优化无线/有线CAN,以及控制指令解析

This commit is contained in:
minghanchen
2026-08-26 20:11:09 +08:00
parent 9ac8fdface
commit 4a0570706f
49 changed files with 8409 additions and 1289 deletions
+43 -1
View File
@@ -105,6 +105,17 @@ class Can1SemanticWhitelistTests(unittest.TestCase):
for temperature in (18, 32)
)
commands.extend(("ac control manual", "ac control auto"))
commands.extend(("ac cycle internal", "ac cycle external"))
commands.extend(
f"outlet {target} {state}"
for target in (
"driver-left",
"driver-right",
"passenger-left",
"passenger-right",
)
for state in ("open", "close")
)
commands.extend(
f"outlet {target} percent {horizontal} {vertical}"
for target in (
@@ -196,7 +207,7 @@ class Can1SemanticWhitelistTests(unittest.TestCase):
def test_every_documented_exact_command_parses_and_compiles(self) -> None:
commands = self._valid_exact_commands()
self.assertEqual(len(commands), 119)
self.assertEqual(len(commands), 129)
for command in commands:
with self.subTest(command=command):
@@ -223,6 +234,7 @@ class Can1SemanticWhitelistTests(unittest.TestCase):
"ac temp driver 33",
"ac temp driver 24.5",
"ac control off",
"outlet driver-left toggle",
"outlet driver-left percent -1 50",
"outlet driver-left percent 101 50",
"seat driver vent medium",
@@ -258,6 +270,32 @@ class Can1SemanticWhitelistTests(unittest.TestCase):
):
self.runtime.compile(config)
def test_individual_outlet_switches_use_observed_byte_exact_triplets(self) -> None:
cases = {
"outlet driver-left open": "02 FF FF FF FF 01 FF FF",
"outlet driver-left close": "02 FF FF FF FF 02 FF FF",
"outlet driver-right open": "02 FF FF FF FF 04 FF FF",
"outlet driver-right close": "02 FF FF FF FF 08 FF FF",
"outlet passenger-left open": "03 FF FF FF FF 01 00 00",
"outlet passenger-left close": "03 FF FF FF FF 02 00 00",
"outlet passenger-right open": "03 FF FF FF FF 04 00 00",
"outlet passenger-right close": "03 FF FF FF FF 08 00 00",
}
for command, expected_payload in cases.items():
with self.subTest(command=command):
plan = self._compile_exact(command)
compiled = plan["spec"]["commands"][0]
self.assertEqual(compiled["signals"]["frame_id"], "0x4EF")
self.assertEqual(compiled["signals"]["data_hex"], expected_payload)
self.assertTrue(compiled["signals"]["is_fd"])
self.assertTrue(compiled["signals"]["bitrate_switch"])
self.assertEqual(
compiled["repeat"], {"count": 3, "period_ms": 100}
)
self.assertEqual(
compiled["signals"]["feedback_ids"], "0x4BB,0x37B"
)
def test_representative_chinese_commands_cover_every_whitelist_family(self) -> None:
cases = (
("后排车门上锁", ["door rear lock"]),
@@ -281,6 +319,10 @@ class Can1SemanticWhitelistTests(unittest.TestCase):
("后排空调温度设为24度", ["ac temp rear 24"]),
("空调设置为自动控制", ["ac control auto"]),
("空调设置为手动控制", ["ac control manual"]),
("打开主驾左风口", ["outlet driver-left open"]),
("关闭主驾右风口", ["outlet driver-right close"]),
("打开副驾左吹面出风口", ["outlet passenger-left open"]),
("关闭副驾右吹面出风口", ["outlet passenger-right close"]),
("主驾左风口水平25%上下75%", ["outlet driver-left percent 25 75"]),
("副驾右风口左右25%垂直75%", ["outlet passenger-right percent 25 75"]),
("主驾左风口标定坐标左右-25上下-40", ["outlet driver-left position -25 -40"]),
+74 -3
View File
@@ -235,6 +235,61 @@ class DriverSeatNativeCanTests(unittest.TestCase):
self.assertEqual((action["capability"], action["parameters"]), expected)
self.assertEqual(action["target"], "driver")
def test_multiclause_massage_preserves_five_modes_and_three_strengths(
self,
) -> None:
modes = {
"波浪": "wave",
"脉冲": "pulse",
"伸展": "stretch",
"松弛": "slack",
"放松": "relax",
}
strengths = {
"最小": 1,
"中等": 2,
"最大": 3,
}
for mode_text, mode in modes.items():
for strength_text, strength in strengths.items():
utterance = (
f"打开主驾座椅按摩,模式切换为{mode_text}模式,"
f"强度调到{strength_text}"
)
with self.subTest(utterance=utterance):
parsed = merge_native_can_intent({}, utterance)
actions = parsed.get("native_can_actions", [])
self.assertEqual(
[item["parameters"] for item in actions],
[
{"action": "mode", "mode": mode},
{"action": "strength", "strength": strength},
],
)
parsed = merge_native_can_intent(
{},
"打开主驾座椅按摩,然后把模式切换为脉冲模式,"
"同时把强度调到最大",
)
config = self.runtime.build_function_config(
"seat",
parsed["native_can_actions"],
"req-driver-massage-multiclause",
"local_rules",
)
plan = self.runtime.compile(config)
self.assertEqual(
[
command["signals"]["debug_command"]
for command in plan["spec"]["commands"]
],
[
"seat driver massage mode pulse",
"seat driver massage strength strong",
],
)
def test_parser_rejects_passenger_right_front_and_unsafe_guesses(self) -> None:
utterances = (
"seat passenger vent low",
@@ -453,6 +508,9 @@ class DriverSeatNativeCanTests(unittest.TestCase):
)
telemetry = report["spec"]["results"][0]["telemetry"]
self.assertFalse(telemetry["physical_effect_confirmed"])
self.assertEqual(
telemetry["verification_status"], "submitted_unverified"
)
self.assertEqual(len(telemetry["sent_frames"]), 20)
uninitialized_bus = _FakeBus(
@@ -596,7 +654,11 @@ class DriverSeatNativeCanTests(unittest.TestCase):
wrong_report = NativeCanExecutor(
lambda: wrong_bus, feedback_timeout_ms=1
).execute(self.runtime, plan, config)
self.assertEqual(wrong_report["spec"]["status"], "failed")
self.assertEqual(wrong_report["spec"]["status"], "succeeded")
self.assertEqual(
wrong_report["spec"]["verification_status"],
"submitted_unverified",
)
self.assertFalse(
wrong_report["spec"]["results"][0]["telemetry"]["feedback_match"]
)
@@ -652,8 +714,13 @@ class DriverSeatNativeCanTests(unittest.TestCase):
self.runtime, plan, config
)
# 0x494 proves only initialization and cannot confirm that the timed
# motion physically occurred, so strict hardware feedback fails closed.
self.assertEqual(report["spec"]["status"], "failed")
# motion physically occurred. Submission succeeds at the orchestration
# layer while physical verification remains explicitly incomplete.
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual(
report["spec"]["verification_status"],
"submitted_unverified",
)
self.assertEqual(len(bus.sent), 8)
self.assertTrue(all(frame.is_fd and frame.bitrate_switch for frame in bus.sent))
self.assertEqual(
@@ -662,6 +729,10 @@ class DriverSeatNativeCanTests(unittest.TestCase):
)
telemetry = report["spec"]["results"][0]["telemetry"]
self.assertIsNone(telemetry["feedback_match"])
self.assertEqual(
telemetry["verification_status"], "submitted_unverified"
)
self.assertFalse(telemetry["physical_effect_confirmed"])
self.assertTrue(telemetry["feedback_required"])
self.assertTrue(telemetry["feedback_observed"])
self.assertEqual(
+255 -8
View File
@@ -21,7 +21,6 @@ from cognitive_agent.infrastructure.can.mqtt_transport import (
parse_mqtt_can_payload,
)
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
@@ -264,6 +263,89 @@ class MqttCanPayloadTests(unittest.TestCase):
)
self.assertTrue(all(frame.data[2] & (1 << 3) for frame in positions))
def test_translates_headunit_6a2_selector_metadata_without_6a0_requests(
self,
) -> None:
values = {
1: bytes.fromhex("E0 01 E0 01"), # 480 / 480
2: bytes.fromhex("00 00 75 01"), # 0 / 373
3: bytes.fromhex("00 00 95 03"), # 0 / 917
4: bytes.fromhex("BF 01 87 02"), # 447 / 647
5: bytes.fromhex("11 00 00 00"),
6: bytes.fromhex("11 00 00 00"),
7: bytes.fromhex("11 00 02 00"),
8: bytes.fromhex("11 00 00 00"),
}
frames = parse_mqtt_can_payload(
json.dumps(
{
"schema": "n7-can-signals/v1",
"signals": [
{
"channel": "can0",
"channelIndex": 0,
"canId": "0x6A2",
"frameType": "classic",
"brs": False,
"dlc": 8,
"selector": selector,
"bytes": [0x7C, 0x30 + selector, 0x60, 0, *value],
"direction": "rx",
}
for selector, value in values.items()
],
}
)
)
positions = [frame for frame in frames if frame.arbitration_id == 0x302]
self.assertEqual([frame.data[0] for frame in positions], [0, 1, 2, 3])
self.assertEqual(
[int.from_bytes(frame.data[5:7], "little") for frame in positions],
[10_000, 0, 0, round(447 * 10_000 / 647)],
)
self.assertTrue(all(frame.data[2] & (1 << 3) for frame in positions))
def test_rejects_invalid_or_conflicting_headunit_seat_selector_metadata(
self,
) -> None:
with self.assertRaisesRegex(ValueError, "integer from 0 to 14"):
parse_mqtt_can_payload(
json.dumps(
{
"channel": "can0",
"canId": "0x6A2",
"selector": "1",
"bytes": [0x7C, 1, 0x60, 0, 0xE0, 1, 0xE0, 1],
}
)
)
with self.assertRaisesRegex(ValueError, "valid 0x6A2"):
parse_mqtt_can_payload(
json.dumps(
{
"channel": "can0",
"canId": "0x302",
"selector": 1,
"bytes": [0] * 8,
}
)
)
frames = parse_mqtt_can_payload(
json.dumps(
{
"signals": [
{"channel": "can0", "canId": "0x6A0", "bytes":
[0xC7, 0x21, 0x60, 1, 0, 0, 0, 0]},
{"channel": "can0", "canId": "0x6A2", "selector": 2,
"bytes": [0x7C, 0x21, 0x60, 0, 0xE0, 1, 0xE0, 1]},
]
}
)
)
self.assertFalse(any(frame.arbitration_id == 0x302 for frame in frames))
def test_rejects_can0_frames_outside_seat_firmware_format(self) -> None:
for label, frame in (
(
@@ -507,6 +589,63 @@ class MqttCanTransportTests(unittest.TestCase):
)
)
def test_headunit_6a2_selector_state_maps_to_passenger_frontend_state(self) -> None:
values = {
1: bytes.fromhex("E0 01 E0 01"),
2: bytes.fromhex("00 00 75 01"),
3: bytes.fromhex("00 00 95 03"),
4: bytes.fromhex("BF 01 87 02"),
5: bytes.fromhex("11 00 00 00"),
6: bytes.fromhex("11 00 00 00"),
7: bytes.fromhex("11 00 02 00"),
8: bytes.fromhex("11 00 00 00"),
}
self.client.emit(
"n7/n7-headunit/can/state",
{
"schema": "n7-can-signals/v1",
"signals": [
{
"channel": "can0",
"canId": "0x6A2",
"selector": selector,
"frameType": "classic",
"bytes": [0x7C, 0x50 + selector, 0x60, 0, *value],
}
for selector, value in values.items()
],
},
)
service = SeatDemoService(REPOSITORY_ROOT)
service._vehicle_mqtt_transport = self.transport
service.vehicle_mqtt_settings = SimpleNamespace(state_configured=True)
payload = service._synchronize_hardware_state_once(
"conv-headunit-selector-can0-state",
control_transport="wireless_mqtt",
)
passenger = payload["actuator_state"]["passenger"]
self.assertEqual(
{
axis: passenger["position"][axis]["position_pct"]
for axis in (
"longitudinal",
"cushion_tilt",
"backrest_recline",
"legrest_height",
)
},
{
"longitudinal": 100,
"cushion_tilt": 0,
"backrest_recline": 0,
"legrest_height": 69,
},
)
self.assertEqual(payload["source"], "wireless_mqtt_ch0")
self.assertTrue(payload["complete"])
def test_vehicle_envelope_four_frames_decode_from_raw_can1_bytes(self) -> None:
self.client.emit(
"n7/n7-headunit/can/state",
@@ -620,6 +759,30 @@ class MqttCanTransportTests(unittest.TestCase):
self.assertGreater(verification["cache_age_ms"], 50_000)
self.assertIsNone(bus.receive(0, include_fd=True))
def test_outlet_switch_feedback_is_available_to_the_can1_executor_bus(self) -> None:
bus = self.transport.bus(channel=1)
data = bytearray(64)
data[41:45] = bytes((0x26, 0x80, 0x80, 0x80))
self.client.emit(
"n7/n7-headunit/can/state",
{
"signals": [
{
"channel": "can1",
"canId": "0x4BB",
"frameType": "fd",
"brs": True,
"bytes": list(data),
}
]
},
)
feedback = bus.receive(0, include_fd=True)
self.assertIsNotNone(feedback)
self.assertEqual(feedback.arbitration_id, 0x4BB)
self.assertEqual(feedback.data[41], 0x26)
def test_bus_publishes_only_headunit_command_contract(self) -> None:
bus = self.transport.bus()
bus.set_debug_command("shade position 20")
@@ -820,6 +983,10 @@ class MqttCanTransportTests(unittest.TestCase):
for result in report["spec"]["results"]:
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["telemetry"]["delivery_status"], "published")
self.assertEqual(
result["telemetry"]["verification_status"],
"submitted_unverified",
)
self.assertFalse(result["telemetry"]["feedback_required"])
self.assertFalse(result["telemetry"]["physical_effect_confirmed"])
self.assertEqual(len(result["telemetry"]["mqtt_messages"]), 3)
@@ -830,6 +997,55 @@ class MqttCanTransportTests(unittest.TestCase):
)
)
def test_wireless_individual_outlet_plan_publishes_observed_frame_triplet(
self,
) -> None:
service = SeatDemoService(REPOSITORY_ROOT)
service._vehicle_mqtt_transport = self.transport
service.vehicle_mqtt_settings = SimpleNamespace(feedback_timeout_ms=1)
runtime = NativeCanRuntime(REPOSITORY_ROOT)
config = runtime.build_function_config(
"outlet",
[
{
"domain": "outlet",
"capability": "outlet.set_switch_state",
"target": "passenger_right",
"operation": "set",
"parameters": {"state": "close"},
}
],
"req-wireless-outlet-switch",
"local_rules",
)
plan = runtime.compile(config)
with patch("cognitive_agent.application.native_can_runtime.time.sleep"):
reports = service._execute_orchestrated_reports(
{"native_outlet": config},
{"native_outlet": plan},
{"native_outlet"},
{},
hardware_request=True,
simulated_hardware_request=False,
simulation_fault="normal",
control_transport="wireless_mqtt",
)
self.assertEqual(reports["native_outlet"]["spec"]["status"], "succeeded")
payloads = [json.loads(item[1]) for item in self.client.published]
self.assertEqual(len(payloads), 3)
self.assertTrue(
all(payload["command"] == "outlet passenger-right close" for payload in payloads)
)
self.assertTrue(all(payload["channel"] == "can1" for payload in payloads))
self.assertTrue(all(payload["canId"] == "0x4EF" for payload in payloads))
self.assertTrue(all(payload["frameType"] == "fd" for payload in payloads))
self.assertTrue(all(payload["brs"] is True for payload in payloads))
self.assertTrue(
all(payload["data"] == "03 FF FF FF FF 08 00 00" for payload in payloads)
)
def test_wireless_hardware_can1_service_skips_local_hardware_connection(self) -> None:
service = SeatDemoService(REPOSITORY_ROOT)
service._vehicle_mqtt_transport = self.transport
@@ -1151,11 +1367,12 @@ class MqttCanTransportTests(unittest.TestCase):
def test_can0_bus_publishes_only_classic_passenger_seat_control(self) -> None:
bus = self.transport.bus(channel=0)
sent = bus.send(0x6A0, bytes.fromhex("C7 01 64 00 F4 01 00 00"))
sent = bus.send(0x300, bytes.fromhex("01 10 02 00 88 13 30 75"))
payload = json.loads(self.client.published[-1][1])
self.assertEqual(sent.channel, 0)
self.assertEqual(payload["channel"], "can0")
self.assertEqual(payload["canId"], "0x6A0")
self.assertEqual(payload["canId"], "0x300")
self.assertEqual(payload["data"], "01 10 02 00 88 13 30 75")
self.assertEqual(payload["command"], "")
self.assertEqual(payload["frameType"], "classic")
self.assertFalse(payload["brs"])
@@ -1177,19 +1394,49 @@ class MqttCanTransportTests(unittest.TestCase):
with self.assertRaisesRegex(Exception, "approved CAN0 control set"):
bus.send(0x431, bytes(8))
with self.assertRaisesRegex(Exception, "requires classic CAN"):
bus.send(0x6A0, bytes.fromhex("C7 01 64 00 F4 01 00 00"),
bus.send(0x300, bytes.fromhex("01 10 02 00 88 13 30 75"),
is_fd=True, bitrate_switch=True)
with self.assertRaisesRegex(Exception, "approved CAN0 control set"):
bus.send(0x6A0, bytes.fromhex("C7 01 64 00 F4 01 00 00"))
with self.assertRaisesRegex(Exception, "protocol version 1"):
bus.send(0x300, bytes(8))
with self.assertRaisesRegex(Exception, "only STOP, MOVE, HEARTBEAT, or DYNAMIC"):
bus.send(0x6A0, bytes.fromhex("C7 02 61 00 00 00 00 00"))
with self.assertRaisesRegex(Exception, "target 0..1000"):
bus.send(0x6A0, bytes.fromhex("C7 03 64 00 E9 03 00 00"))
with self.assertRaisesRegex(Exception, "outside the semantic control whitelist"):
bus.send(0x300, bytes.fromhex("0A 10 03 00 5A A5 78 00"))
with self.assertRaisesRegex(Exception, "absolute move payload"):
bus.send(0x300, bytes.fromhex("01 10 04 00 11 27 30 75"))
with self.assertRaisesRegex(Exception, "dynamic-mode payload"):
bus.send(0x300, bytes.fromhex("0E 10 05 00 03 00 00 00"))
with self.assertRaisesRegex(Exception, "approved channel policy"):
self.transport.publish(
CanFrame(arbitration_id=0x431, data=bytes(8), channel=0)
)
def test_can0_native_motion_whitelist_accepts_only_bounded_runtime_opcodes(
self,
) -> None:
bus = self.transport.bus(channel=0)
valid_payloads = (
"01 10 01 00 88 13 30 75", # absolute 50%, 30 s
"02 11 02 00 18 FC 30 75", # native relative -10%, 30 s
"03 16 03 00 F4 01 84 03", # motor 2 far, 500 ms, 90%
"08 12 04 00 00 00 00 00", # stop motor 2
"09 10 05 00 00 00 00 00", # stop all
"0B 13 06 00 52 4B 78 00", # motor 3 known-range home
"0E 10 07 00 01 00 00 00", # rocking-horse
"0F 10 08 00 00 00 00 00", # stop dynamic mode
)
for payload in valid_payloads:
with self.subTest(payload=payload):
sent = bus.send(0x300, bytes.fromhex(payload))
self.assertEqual(sent.arbitration_id, 0x300)
self.assertFalse(sent.is_fd)
with self.assertRaisesRegex(Exception, "requires a runtime sequence"):
bus.send(0x300, bytes.fromhex("01 10 00 00 88 13 30 75"))
with self.assertRaisesRegex(Exception, "outside the semantic control whitelist"):
bus.send(0x300, bytes.fromhex("0D 10 09 00 3C C3 00 00"))
def test_bus_receive_views_are_isolated_by_channel(self) -> None:
can0 = self.transport.bus(channel=0)
can1 = self.transport.bus(channel=1)
+435 -257
View File
@@ -1,53 +1,123 @@
from __future__ import annotations
import tempfile
import unittest
from collections import deque
from pathlib import Path
import tempfile
from types import SimpleNamespace
from unittest.mock import patch
from cognitive_agent.application.seat_service import SeatDemoService
from cognitive_agent.application.mqtt_ota_seat_executor import (
MqttNativeSeatExecutor,
MqttOtaSeatExecutor,
)
from cognitive_agent.application.seat_service import SeatDemoService
from cognitive_agent.infrastructure.can.gateway import CanFrame
from seat_domain import parse_local_intent
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
MAX_HALL = {0: 480, 1: 374, 2: 917, 3: 647}
def _axis_frame(axis: int, value_x100: int, *, valid: bool = True) -> CanFrame:
flags = (1 << 3) if valid else 0
return CanFrame(
0x302,
bytes(
(
axis,
0,
flags,
0,
0,
value_x100 & 0xFF,
(value_x100 >> 8) & 0xFF,
0,
)
),
channel=0,
)
class _NativeFirmwareBus:
"""Deterministic firmware twin; it never opens or writes a physical bus."""
class _FakeOtaBus:
def __init__(self, initial_frames: list[CanFrame]) -> None:
self.frames = deque(initial_frames)
def __init__(self, matrix) -> None:
self.matrix = matrix
self.frames: deque[CanFrame] = deque()
self.sent: list[CanFrame] = []
self.debug_commands: list[str] = []
self._debug_command = ""
self._published_messages: list[dict[str, object]] = []
self.closed = False
@property
def published_messages(self) -> tuple[dict[str, object], ...]:
return tuple(self._published_messages)
def set_debug_command(self, command: str) -> None:
self._debug_command = command
self.debug_commands.append(command)
def _frame(self, message: str, values: dict[str, int]) -> CanFrame:
encoded = self.matrix.encode(message, values)
return CanFrame(
int(encoded["frame_id"], 16),
bytes.fromhex(encoded["data_hex"]),
channel=0,
)
def _system_ack(self, sequence: int, opcode: int, ack: int) -> CanFrame:
return self._frame(
"T2G_Seat_Move_System_Status_301",
{
"Ack": ack,
"System_State": 1,
"Reject_Reason": 0,
"Last_Sequence": sequence,
"Last_Opcode": opcode,
"Active_Mask": 0,
"Safety_Inhibit": 0,
"Calibration_Busy": 0,
"Fault_Summary": 0,
"Faulted_Mask": 0,
"Position_Valid_Mask": 0x0F,
"Alive": 1,
"Firmware_Revision": 1,
},
)
def _motor_status(
self,
motor: int,
*,
sequence: int = 0,
value_x100: int = 4_000,
complete: bool = False,
) -> CanFrame:
return self._frame(
"T2G_Seat_Move_Motor_Status_302",
{
"Motor_Sub_ID": motor,
"Task_State": 6 if complete else 0,
"Result": 2 if complete else 0,
"Direction": 0,
"Active": 0,
"Position_Valid": 1,
"Near_Endpoint": int(value_x100 == 0),
"Far_Endpoint": int(value_x100 == 10_000),
"Stall": 0,
"Driver_Fault": 0,
"Motor_Sequence": sequence,
"Value_X100": value_x100,
"Duty_Percent": 90,
},
)
def _diagnostic(self, motor: int, value_x100: int = 4_000) -> CanFrame:
maximum = MAX_HALL[motor]
hall = round(maximum * value_x100 / 10_000)
return self._frame(
"T2G_Seat_Move_Diagnostic_303",
{
"Motor_Sub_ID": motor,
"Last_Motor_Event": 1,
"Fault_Flags": 0,
"Hall_Count": hall,
"Max_Hall_Count": maximum,
"Hall_Speed_Half_Pps": 0,
"Retry_Count": 0,
"Diagnostic_Alive": 1,
},
)
def _queue_preflight(self) -> None:
for motor in range(4):
self.frames.append(self._motor_status(motor))
for motor in range(4):
self.frames.append(self._diagnostic(motor))
def send(
self,
arbitration_id: int,
@@ -64,27 +134,79 @@ class _FakeOtaBus:
bitrate_switch=bitrate_switch,
)
self.sent.append(frame)
if arbitration_id != 0x6A0:
return frame
opcode = data[2]
job_state = (
1 | (4 << 4) | (9 << 8)
if opcode == 0x66
else (1 | (3 << 4) | (7 << 8))
if opcode in {0x64, 0x65}
else (1 << 1) | (1 << 2)
self._published_messages.append(
{
"topic": "n7/n7-headunit/can/command",
"qos": 1,
"retain": False,
"payload": {
"command": self._debug_command,
"channel": "can0",
"canId": f"0x{arbitration_id:03X}",
"frameType": "classic",
"brs": False,
"data": data.hex(" ").upper(),
},
}
)
self.frames.append(
CanFrame(
0x6A2,
bytes((0x7C, data[1], opcode, 0))
+ job_state.to_bytes(4, "little"),
channel=0,
if arbitration_id == 0x300:
opcode = data[0]
motor = data[1] & 0x03
sequence = int.from_bytes(data[2:4], "little")
self.frames.append(
self._system_ack(sequence, opcode, 4 if opcode == 0x09 else 1)
)
if opcode == 0x09:
self._queue_preflight()
elif opcode in {0x01, 0x0B}:
target = int.from_bytes(data[4:6], "little") if opcode == 0x01 else 0
self.frames.append(
self._motor_status(
motor,
sequence=sequence,
value_x100=target,
complete=True,
)
)
self.frames.append(self._diagnostic(motor, target))
elif opcode == 0x03:
direction = (data[1] >> 2) & 0x03
target = 5_000 if direction == 1 else 3_000
self.frames.append(
self._motor_status(
motor,
sequence=sequence,
value_x100=target,
complete=True,
)
)
self.frames.append(self._diagnostic(motor, target))
elif opcode in {0x0E, 0x0F}:
mode = int.from_bytes(data[4:6], "little") if opcode == 0x0E else 0
self.frames.append(
self._frame(
"T2G_Seat_Dynamic_Mode_Status_306",
{
"Dynamic_Mode_ID": mode,
"Dynamic_State": 1 if opcode == 0x0E else 0,
"Dynamic_Phase": 1 if opcode == 0x0E else 0,
"Dynamic_Active_Mask": 0x0F if opcode == 0x0E else 0,
"Dynamic_Fault_Mask": 0,
"Dynamic_Cycle_Index": 0,
"Dynamic_Phase_Elapsed_Ms": 10,
},
)
)
elif arbitration_id == 0x29E:
self.frames.append(
self._frame(
"T2G_Seat_Thermal_Status_4FA",
{
"Driver_Seat_Ventilation_Status": 1,
"Driver_Seat_Heating_Status": 2,
},
)
)
)
if opcode == 0x64:
target_x100 = int.from_bytes(data[4:8], "little") * 10
self.frames.append(_axis_frame(data[3], target_x100))
return frame
def receive(self, _timeout_ms: int, *, include_fd: bool = False) -> CanFrame | None:
@@ -95,96 +217,87 @@ class _FakeOtaBus:
self.closed = True
class _StatusExecutor:
class _Matrix:
@staticmethod
def encode(message: str, signals: dict[str, int]) -> dict[str, str]:
if message == "T2G_Seat_Thermal_Mode_3CE":
return {
"frame_id": "0x3CE",
"data_hex": f"{int(signals['Driver_Seat_Thermal_Mode']) << 4:02X} 00 00 00 00 00 00 00",
}
byte0 = (
int(signals["Driver_Seat_Ventilation_On"])
| (int(signals["Driver_Seat_Ventilation_Mode"]) << 2)
| (int(signals["Driver_Seat_Heating_On"]) << 4)
)
byte2 = (
int(signals["Driver_Seat_Ventilation_Gear"])
| (int(signals["Driver_Seat_Heating_Gear"]) << 4)
)
return {
"frame_id": "0x29E",
"data_hex": f"{byte0:02X} 00 {byte2:02X} 00 00 00 00 00",
}
class _PublishOnlyNativeFirmwareBus(_NativeFirmwareBus):
"""Accept every Tx publication but expose no synchronous state feedback."""
matrix = _Matrix()
def read_physical_state(self) -> dict[str, object]:
return {"position": {}, "complete": False}
def _capture_physical_state(
self,
_bus: _FakeOtaBus,
_results: list[dict[str, object]],
) -> dict[str, object]:
return {"position": {}, "complete": True, "missing": []}
def _execute_thermal(
self,
bus: _FakeOtaBus,
command: dict[str, object],
operations: list[dict[str, object]],
) -> list[dict[str, object]]:
encoded = self.matrix.encode(
str(command["message"]),
command["signals"], # type: ignore[arg-type]
)
sent = bus.send(
int(encoded["frame_id"], 16),
bytes.fromhex(encoded["data_hex"]),
)
feedback = bus.receive(100)
if feedback is None or feedback.arbitration_id != 0x4FA:
raise RuntimeError("missing thermal feedback")
return [
{
"ref": str(operation["id"]),
"domain": "seat",
"status": "succeeded",
"message": "thermal feedback confirmed",
"telemetry": {
"command_frame": sent.public(),
"feedback_frame": feedback.public(),
},
}
for operation in operations
]
def receive(self, _timeout_ms: int, *, include_fd: bool = False) -> CanFrame | None:
del include_fd
return None
def _function_config(capability: str, parameters: dict[str, object]) -> dict[str, object]:
def _function_config(
capability: str,
parameters: dict[str, object],
*,
motion: dict[str, object] | None = None,
) -> dict[str, object]:
operation = {
"id": "operation-1",
"capability": capability,
"target": {"zone": "front_right", "instance": "passenger"},
"parameters": parameters,
}
if motion is not None:
operation["motion"] = motion
return {
"metadata": {"request_id": "req-mqtt-ota-seat"},
"spec": {
"operations": [
{
"id": "operation-1",
"capability": capability,
"target": {"zone": "front_right", "instance": "passenger"},
"parameters": parameters,
}
]
},
"metadata": {"request_id": "req-mqtt-native-seat"},
"spec": {"operations": [operation]},
}
class MqttOtaSeatExecutorTests(unittest.TestCase):
def test_wireless_hardware_service_skips_local_t2g_profile_and_bench(self) -> None:
bus = _FakeOtaBus([_axis_frame(0, 4_000)])
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
_StatusExecutor(),
def _motion_plan(
opcode: int,
motor: int,
param_a: int,
param_b: int,
*,
direction: int = 0,
target: int = 5_000,
) -> dict[str, object]:
return {
"spec": {
"commands": [
{
"id": "native-motion-1",
"operation_ref": "operation-1",
"message": "T2G_Seat_Move_Command_300",
"signals": {
"Opcode": opcode,
"Motor_Selector": motor,
"Direction": direction,
"Protocol_Version": 1,
"Sequence": 0,
"Param_A": param_a,
"Param_B": param_b,
},
"send_offset_ms": 0,
"completion": {
"value": target,
"tolerance": 100,
"timeout_ms": 1_000,
},
}
]
}
}
class MqttNativeSeatExecutorTests(unittest.TestCase):
def _executor(self) -> tuple[MqttNativeSeatExecutor, _NativeFirmwareBus]:
service = SeatDemoService(REPOSITORY_ROOT)
bus = _NativeFirmwareBus(service.hardware_matrix)
executor = service._new_hardware_executor(
lambda: bus,
remote_transport=True,
)
self.assertIsInstance(executor, MqttNativeSeatExecutor)
self.assertIs(MqttOtaSeatExecutor, MqttNativeSeatExecutor)
return executor, bus
def test_wireless_hardware_service_skips_local_profile_and_publishes_native_300(
self,
) -> None:
executor, bus = self._executor()
service = SeatDemoService(REPOSITORY_ROOT)
service.vehicle_mqtt_settings = SimpleNamespace(
enabled=True,
@@ -219,58 +332,111 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
result = service.process(
"把副驾座椅水平位置调到35%",
provider="local",
conversation_id="conv-wireless-can0-no-local-hardware",
conversation_id="conv-wireless-native-can0",
execution_mode="hardware",
control_transport="wireless_mqtt",
_on_commands_validated=validated_events.append,
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(list(result["command_plans"]), ["seat"])
self.assertEqual(len(validated_events), 1)
self.assertEqual(list(validated_events[0]["command_plans"]), ["seat"])
self.assertEqual(
result["command_plans"]["seat"]["spec"]["commands"][0]["message"],
"T2G_Seat_Move_Command_300",
)
self.assertEqual(bus.sent[0].arbitration_id, 0x6A0)
self.assertEqual(bus.sent[0].data[2], 0x64)
self.assertEqual(int.from_bytes(bus.sent[0].data[4:8], "little"), 350)
self.assertTrue(executor_factory.call_args.kwargs["remote_transport"])
self.assertTrue(bus.sent)
self.assertTrue(all(frame.arbitration_id != 0x6A0 for frame in bus.sent))
move = next(frame for frame in bus.sent if frame.data[0] == 0x01)
self.assertEqual(move.arbitration_id, 0x300)
self.assertEqual(move.data[1] & 0x03, 0)
self.assertEqual(int.from_bytes(move.data[4:6], "little"), 3_500)
def test_seat_height_language_maps_to_internal_axis_one_capability(self) -> None:
parsed = parse_local_intent("副驾座椅高度调到60%")
self.assertEqual(parsed["ambiguities"], [])
self.assertEqual(parsed["actions"][0]["axis"], "cushion_tilt")
self.assertEqual(parsed["actions"][0]["position_pct"], 60)
self.assertIn("目标:副驾座椅高度位置60%。", parsed["decision_summary"])
def test_absolute_move_uses_runtime_move_and_fresh_position_confirmation(self) -> None:
bus = _FakeOtaBus([_axis_frame(0, 4_000)])
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
_StatusExecutor(),
def test_publish_without_feedback_is_orchestration_success_not_vehicle_state(
self,
) -> None:
service = SeatDemoService(REPOSITORY_ROOT)
bus = _PublishOnlyNativeFirmwareBus(service.hardware_matrix)
executor = service._new_hardware_executor(
lambda: bus,
remote_transport=True,
)
service.vehicle_mqtt_settings = SimpleNamespace(
enabled=True,
configured=True,
authorize=lambda _token: None,
)
conversation_id = "conv-wireless-submit-no-feedback"
before = service.conversations.snapshot(conversation_id)["devices"][
"passenger"
]["ventilation"]
with tempfile.TemporaryDirectory() as temporary_directory:
service.artifact_root = Path(temporary_directory)
with patch.object(
service,
"_new_hardware_executor",
return_value=executor,
):
result = service.process(
"打开副驾座椅通风",
provider="local",
conversation_id=conversation_id,
execution_mode="hardware",
control_transport="wireless_mqtt",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["execution_outcome"], "submitted_unverified")
self.assertEqual(
result["execution_report"]["spec"]["verification_status"],
"submitted_unverified",
)
operation = result["execution_report"]["spec"]["results"][-1]
self.assertEqual(operation["status"], "succeeded")
self.assertEqual(
operation["telemetry"]["verification_status"],
"submitted_unverified",
)
self.assertFalse(operation["telemetry"]["physical_effect_confirmed"])
self.assertEqual(
result["actuator_state"]["passenger"]["ventilation"],
before,
)
def test_publish_only_dynamic_sends_requested_frame_after_sequence_barrier(
self,
) -> None:
service = SeatDemoService(REPOSITORY_ROOT)
bus = _PublishOnlyNativeFirmwareBus(service.hardware_matrix)
executor = service._new_hardware_executor(
lambda: bus,
remote_transport=True,
)
plan = _motion_plan(0x0E, 0, 2, 0, target=2)
plan["spec"]["commands"][0]["completion"].update(
{"accepted_states": [1, 2], "timeout_ms": 1_000}
)
plan = {
"spec": {
"commands": [
{
"operation_ref": "operation-1",
"message": "T2G_Seat_Move_Command_300",
"signals": {"Opcode": 1, "Motor_Selector": 0},
"completion": {
"value": 5_000,
"tolerance": 100,
"timeout_ms": 1_000,
},
}
]
}
}
report = executor.execute(
plan,
_function_config("seat.start_dynamic_mode", {"mode_id": 2}),
)
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual(report["spec"]["verification_status"], "submitted_unverified")
self.assertEqual([frame.data[0] for frame in bus.sent], [0x09, 0x0E])
requested = bus.sent[-1]
self.assertEqual(requested.arbitration_id, 0x300)
self.assertEqual(int.from_bytes(requested.data[4:6], "little"), 2)
result = report["spec"]["results"][-1]
self.assertFalse(result["telemetry"]["feedback_required"])
self.assertFalse(result["telemetry"]["physical_effect_confirmed"])
self.assertEqual(
[item["payload"]["canId"] for item in result["telemetry"]["mqtt_messages"]],
["0x300", "0x300"],
)
def test_absolute_move_uses_native_300_publish_only_contract(self) -> None:
executor, bus = self._executor()
report = executor.execute(
_motion_plan(0x01, 0, 5_000, 30_000),
_function_config(
"seat.set_longitudinal_position",
{"position_pct": 50},
@@ -278,71 +444,78 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
)
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual(bus.sent[0].arbitration_id, 0x6A0)
self.assertEqual(bus.sent[0].data[2], 0x64)
self.assertEqual(bus.sent[0].data[3], 0)
self.assertEqual(int.from_bytes(bus.sent[0].data[4:8], "little"), 500)
self.assertEqual([frame.data[0] for frame in bus.sent], [0x09, 0x01])
self.assertTrue(all(frame.arbitration_id == 0x300 for frame in bus.sent))
move = bus.sent[-1]
self.assertEqual(move.data[0], 0x01)
self.assertEqual(int.from_bytes(move.data[4:6], "little"), 5_000)
telemetry = report["spec"]["results"][0]["telemetry"]
self.assertEqual(telemetry["verification_status"], "submitted_unverified")
self.assertFalse(telemetry["feedback_required"])
self.assertEqual(
bus.debug_commands,
["seat passenger horizontal position 50"],
[item["payload"]["canId"] for item in telemetry["mqtt_messages"]],
["0x300", "0x300"],
)
self.assertEqual(
report["spec"]["results"][0]["telemetry"]["effective_position_pct"],
50,
)
self.assertTrue(bus.closed)
def test_legacy_zero_target_maps_to_move_not_homing(self) -> None:
bus = _FakeOtaBus([_axis_frame(3, 2_000)])
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
_StatusExecutor(),
lambda: bus,
)
plan = {
"spec": {
"commands": [
{
"operation_ref": "operation-1",
"message": "T2G_Seat_Move_Command_300",
"signals": {"Opcode": 0x0B, "Motor_Selector": 3},
"completion": {
"value": 0,
"tolerance": 100,
"timeout_ms": 1_000,
},
}
]
}
}
self.assertEqual(report["metadata"]["labels"]["feedback"], "wireless_mqtt_ch0")
def test_zero_position_preserves_firmware_known_range_home_opcode(self) -> None:
executor, bus = self._executor()
report = executor.execute(
plan,
_motion_plan(0x0B, 3, 0x4B52, 120, target=0),
_function_config("seat.set_legrest_height", {"position_pct": 0}),
)
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual(bus.sent[0].data[2], 0x64)
self.assertEqual(int.from_bytes(bus.sent[0].data[4:8], "little"), 0)
self.assertNotIn(0x61, [frame.data[2] for frame in bus.sent])
self.assertNotIn(0x62, [frame.data[2] for frame in bus.sent])
home = bus.sent[-1]
self.assertEqual(home.arbitration_id, 0x300)
self.assertEqual(home.data[0], 0x0B)
self.assertEqual(int.from_bytes(home.data[4:6], "little"), 0x4B52)
self.assertEqual(int.from_bytes(home.data[6:8], "little"), 120)
def test_wireless_thermal_command_uses_native_firmware_frames(self) -> None:
bus = _FakeOtaBus(
[CanFrame(0x4FA, bytes((0x11, 0, 0, 0, 0, 0, 0, 0)), channel=0)]
)
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
_StatusExecutor(),
lambda: bus,
def test_relative_fallback_uses_bounded_native_timed_move(self) -> None:
executor, bus = self._executor()
report = executor.execute(
_motion_plan(
0x03,
2,
500,
900,
direction=1,
target=6,
),
_function_config(
"seat.set_backrest_recline",
{"position_pct": 50},
motion={
"mode": "relative",
"direction": "increase",
"delta_position_pct": 10,
},
),
)
self.assertEqual(report["spec"]["status"], "succeeded")
move = bus.sent[-1]
self.assertEqual(move.arbitration_id, 0x300)
self.assertEqual(move.data[0], 0x03)
self.assertEqual((move.data[1] >> 2) & 0x03, 1)
self.assertEqual(int.from_bytes(move.data[4:6], "little"), 500)
self.assertEqual(int.from_bytes(move.data[6:8], "little"), 900)
def test_wireless_thermal_uses_native_3ce_29e(self) -> None:
executor, bus = self._executor()
plan = {
"spec": {
"commands": [
{
"id": "thermal-mode",
"operation_ref": "operation-1",
"message": "T2G_Seat_Thermal_Mode_3CE",
"signals": {"Driver_Seat_Thermal_Mode": 1},
"send_offset_ms": 0,
},
{
"id": "thermal-control",
"operation_ref": "operation-1",
"message": "T2G_Seat_Thermal_Control_29E",
"signals": {
@@ -352,74 +525,79 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
"Driver_Seat_Ventilation_Gear": 0,
"Driver_Seat_Heating_Gear": 1,
},
}
"send_offset_ms": 20,
},
]
}
}
report = executor.execute(
plan,
_function_config("seat.set_heating", {"state": "on", "level": 1}),
)
with patch("seat_domain.hardware_executor.time.sleep"):
report = executor.execute(
plan,
_function_config("seat.set_heating", {"state": "on", "level": 1}),
)
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual([frame.arbitration_id for frame in bus.sent], [0x3CE, 0x29E])
self.assertEqual(bus.sent[0].data, bytes.fromhex("10 00 00 00 00 00 00 00"))
self.assertEqual(bus.sent[1].data, bytes.fromhex("16 00 10 00 00 00 00 00"))
result = next(
item for item in report["spec"]["results"] if item["ref"] == "operation-1"
)
self.assertEqual(
bus.debug_commands,
["seat passenger heat low", "seat passenger heat low"],
[item["payload"]["canId"] for item in result["telemetry"]["mqtt_messages"]],
["0x3CE", "0x29E"],
)
def test_dynamic_semantic_modes_use_seat_ota_firmware_selectors(self) -> None:
for semantic_mode, firmware_selector, mode_name in (
(1, 2, "木马模式"),
(2, 1, "动态摇篮模式"),
):
with self.subTest(semantic_mode=semantic_mode):
bus = _FakeOtaBus([])
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
_StatusExecutor(),
lambda: bus,
)
plan = {
"spec": {
"commands": [
{
"operation_ref": "operation-1",
"message": "T2G_Seat_Move_Command_300",
"signals": {"Opcode": 0x0E, "Motor_Selector": 0},
}
]
def test_dynamic_modes_use_native_300_mode_ids_without_ota_selector_swap(self) -> None:
for mode_id, mode_name in ((1, "rocking-horse"), (2, "cradle")):
with self.subTest(mode_id=mode_id):
executor, bus = self._executor()
plan = _motion_plan(0x0E, 0, mode_id, 0, target=mode_id)
plan["spec"]["commands"][0]["completion"].update(
{
"accepted_states": [1, 2],
"timeout_ms": 1_000,
}
}
)
report = executor.execute(
plan,
_function_config(
"seat.start_dynamic_mode",
{"mode_id": semantic_mode},
),
_function_config("seat.start_dynamic_mode", {"mode_id": mode_id}),
)
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual(bus.sent[0].data[2], 0x66)
self.assertEqual(bus.sent[0].data[3], firmware_selector)
self.assertEqual(
dynamic = bus.sent[-1]
self.assertEqual(dynamic.arbitration_id, 0x300)
self.assertEqual(dynamic.data[0], 0x0E)
self.assertEqual(int.from_bytes(dynamic.data[4:6], "little"), mode_id)
self.assertIn(
f"seat passenger dynamic {mode_name}",
bus.debug_commands,
[
"seat passenger dynamic "
+ ("rocking-horse" if semantic_mode == 1 else "cradle")
],
)
self.assertEqual(
int.from_bytes(bus.sent[0].data[4:8], "little"),
300,
)
telemetry = report["spec"]["results"][0]["telemetry"]
self.assertEqual(telemetry["dynamic_mode_id"], semantic_mode)
self.assertEqual(telemetry["ota_dynamic_selector"], firmware_selector)
self.assertEqual(telemetry["dynamic_mode_name"], mode_name)
self.assertTrue(all(frame.arbitration_id != 0x6A0 for frame in bus.sent))
def test_dynamic_stop_uses_native_300_opcode_0f(self) -> None:
executor, bus = self._executor()
plan = _motion_plan(0x0F, 0, 0, 0, target=0)
plan["spec"]["commands"][0]["completion"].update(
{"accepted_states": [0], "timeout_ms": 1_000}
)
report = executor.execute(
plan,
_function_config("seat.stop_dynamic_mode", {}),
)
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual(len(bus.sent), 1)
self.assertEqual(bus.sent[0].arbitration_id, 0x300)
self.assertEqual(bus.sent[0].data[0], 0x0F)
self.assertEqual(bus.sent[0].data[4:8], bytes(4))
def test_seat_height_language_maps_to_internal_axis_one_capability(self) -> None:
parsed = parse_local_intent("副驾座椅高度调到60%")
self.assertEqual(parsed["ambiguities"], [])
self.assertEqual(parsed["actions"][0]["axis"], "cushion_tilt")
self.assertEqual(parsed["actions"][0]["position_pct"], 60)
if __name__ == "__main__":
+39
View File
@@ -47,6 +47,45 @@ class NativeCanFeedbackTests(unittest.TestCase):
self.assertTrue(feedback_matches("window_position:front_left:50", feedback))
self.assertFalse(feedback_matches("window_position:front_left:80", feedback))
def test_individual_outlet_switch_matches_observed_4bb_signatures(self) -> None:
data = bytearray(64)
data[41:45] = bytes((0x26, 0x80, 0x80, 0x26))
data[45:49] = bytes((0x2D, 0x85, 0x9E, 0xD3))
feedback = CanFrame(0x4BB, bytes(data), channel=1, is_fd=True)
switches = decode_feedback(feedback)["domain_state"]["outlet"][
"switches"
]
self.assertEqual(switches["driver_left"]["state"], "close")
self.assertEqual(switches["driver_right"]["state"], "open")
self.assertEqual(switches["passenger_right"]["state"], "close")
self.assertTrue(
feedback_matches("outlet_switch:driver_left:close", feedback)
)
self.assertFalse(
feedback_matches("outlet_switch:driver_left:open", feedback)
)
def test_individual_outlet_switch_supports_direct_37b_feedback(self) -> None:
data = bytearray(64)
data[0] = 0x50
data[8] = 0b00001001 # driver-left open, driver-right close
data[14] = 0b10010000 # passenger-left open, passenger-right close
feedback = CanFrame(0x37B, bytes(data), channel=1, is_fd=True)
self.assertTrue(
feedback_matches("outlet_switch:driver_left:open", feedback)
)
self.assertTrue(
feedback_matches("outlet_switch:driver_right:close", feedback)
)
self.assertTrue(
feedback_matches("outlet_switch:passenger_left:open", feedback)
)
self.assertTrue(
feedback_matches("outlet_switch:passenger_right:close", feedback)
)
def test_light_feedback_requires_target_value_not_only_frame_id(self) -> None:
passive_feedback = self.frame(0x42E, "11140a03880200c6")
self.assertEqual(
+9 -2
View File
@@ -111,7 +111,7 @@ class NativeCanIntegrationTests(unittest.TestCase):
self.assertTrue(command["signals"]["is_fd"])
self.assertTrue(command["signals"]["bitrate_switch"])
def test_executor_requires_configured_feedback_for_vehicle_success(self) -> None:
def test_executor_distinguishes_submission_from_vehicle_feedback(self) -> None:
config, plan = self._compile(
"climate",
[
@@ -139,7 +139,14 @@ class NativeCanIntegrationTests(unittest.TestCase):
report = NativeCanExecutor(
lambda: no_feedback_bus, feedback_timeout_ms=1
).execute(self.runtime, plan, config)
self.assertEqual(report["spec"]["status"], "failed")
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual(
report["spec"]["verification_status"],
"submitted_unverified",
)
telemetry = report["spec"]["results"][0]["telemetry"]
self.assertEqual(telemetry["delivery_status"], "submitted")
self.assertFalse(telemetry["physical_effect_confirmed"])
def test_executor_ignores_stale_wrong_value_until_target_feedback_arrives(self) -> None:
config, plan = self._compile(
@@ -231,11 +231,12 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
"light ambient brightness ${level}",
"shade position ${position}",
"seat passenger ${axis[1]} position ${position}",
"seat passenger dynamic ${dynamic.name}",
"seat passenger dynamic ${dynamic}",
"seat passenger dynamic stop",
"otaCan0Frame(0x64, axis[2], targetPermille)",
"otaCan0Frame(0x66, dynamic.selector, 300)",
"otaCan0Frame(0x63, 0xFF, 0)",
"nativeCan0Frame(0x01, axis[2], 0, position * 100, 30000)",
"nativeCan0Frame(0x0B, axis[2], 0, 0x4B52, 120)",
"nativeCan0Frame(0x0E, 0, 0, mode, 0)",
"nativeCan0Frame(0x0F, 0, 0, 0, 0)",
):
assert command_contract in console
@@ -303,7 +304,7 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
"channel": "CAN0",
"category": "副驾座椅靠背调节",
"command": "seat passenger backrest position 36",
"frame": "0x6A0 · C7 <SEQ> 64 02 68 01 00 00",
"frame": "0x300 · 01 12 <SEQ_LO> <SEQ_HI> 10 0E 30 75",
"parseSource": "local_rule",
},
{
@@ -532,3 +533,19 @@ def test_twin_device_hotspots_use_responsive_hover_and_leader_labels() -> None:
assert "--v2-hotspot-line-angle: -150deg" in styles
assert '.v2-twin-device-popover[data-pinned="false"]' in styles
assert "pointer-events: none;" in styles
def test_validated_can_commands_survive_same_task_running_poll_and_keep_error_detail() -> None:
console = source("console-v2.js")
app = source("app.js")
assert "function validatedCommandsBelongToTask(task = {})" in console
assert "if (!validatedCommandsBelongToTask(task))" in console
assert "A stale" in console
assert "must not erase those commands" in console
assert "rememberValidatedCommandContext(result);" in console
assert "resetValidatedCommandContext();" in console
assert "error.detail || error.message" in console
assert "failed_or_blocked: \"失败或已阻止\"" in console
assert "requestError.outcome = phaseEvent.outcome;" in app
assert "requestError.detail = phaseEvent.detail;" in app
@@ -0,0 +1,93 @@
from __future__ import annotations
import subprocess
import sys
import unittest
from pathlib import Path
from cognitive_agent.application.native_can_runtime import NativeCanRuntime
from cognitive_agent.generated.can_semantic_protocol import (
CAN0_SUPPORTED_CAPABILITIES,
CAN1_SUPPORTED_CAPABILITIES,
PROTOCOL_SOURCE_SHA256,
frame,
match_semantic_command,
payload_variant,
)
from seat_domain import T2GSeatCompiler
ROOT = Path(__file__).resolve().parents[2]
class SharedCanSemanticProtocolTests(unittest.TestCase):
def test_checked_in_python_and_cpp_views_are_current(self) -> None:
completed = subprocess.run(
[sys.executable, "tools/generate_can_semantic_protocol.py", "--check"],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertEqual(len(PROTOCOL_SOURCE_SHA256), 64)
def test_generated_matcher_routes_both_channels_and_rejects_bad_axes(self) -> None:
cases = {
"seat passenger basin relative up 10": ("can0.seat.relative", 0),
"seat passenger legrest position 100": ("can0.seat.position", 0),
"ac fan 2": ("can1.climate.fan", 1),
"outlet passenger-right close": ("can1.outlet.switch", 1),
"seat driver horizontal forward 500": ("can1.seat.driver_axis", 1),
}
for command, expected in cases.items():
with self.subTest(command=command):
matched = match_semantic_command(command)
self.assertIsNotNone(matched)
assert matched is not None
self.assertEqual((matched["id"], matched["channel"]), expected)
for command in (
"seat passenger basin relative forward 10",
"seat driver height forward 500",
"ac fan 8",
"door vehicle lock",
):
with self.subTest(command=command):
self.assertIsNone(match_semantic_command(command))
def test_outlet_static_payload_and_transport_have_one_generated_source(self) -> None:
metadata = frame("can1.outlet.switch")
self.assertEqual(metadata["frame_id"], "0x4EF")
self.assertEqual(metadata["format"], "can_fd")
self.assertTrue(metadata["brs"])
self.assertEqual(metadata["repeat"], {"kind": "fixed", "count": 3, "period_ms": 100})
self.assertEqual(metadata["feedback_ids"], ["0x4BB", "0x37B"])
self.assertEqual(
payload_variant("can1.outlet.switch", "passenger-right:close"),
bytes.fromhex("03 FF FF FF FF 08 00 00"),
)
runtime = NativeCanRuntime(ROOT)
spec = runtime._outlet_switch_frame( # noqa: SLF001 - protocol contract test
"passenger_right", {"state": "close"}
)
self.assertEqual(spec.frame_id, 0x4EF)
self.assertEqual(spec.payload, bytes.fromhex("03 FF FF FF FF 08 00 00"))
self.assertEqual(spec.repeat_count, 3)
self.assertEqual(spec.period_ms, 100)
self.assertEqual(spec.feedback_ids, (0x4BB, 0x37B))
def test_python_capability_sets_and_can0_plan_contract_are_generated(self) -> None:
runtime = NativeCanRuntime(ROOT)
self.assertEqual(runtime.supported_capabilities, CAN1_SUPPORTED_CAPABILITIES)
self.assertIn("seat.set_longitudinal_position", CAN0_SUPPORTED_CAPABILITIES)
contract = T2GSeatCompiler._protocol_contract( # noqa: SLF001
"can0.seat.position", "move"
)
self.assertEqual(contract["frame_id"], "0x300")
self.assertEqual(contract["repeat"]["count"], 1)
self.assertEqual(contract["feedback_ids"], ["0x301", "0x302"])
if __name__ == "__main__":
unittest.main()
+241
View File
@@ -0,0 +1,241 @@
from __future__ import annotations
import os
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
def _report(domain: str, request_id: str) -> dict[str, object]:
return {
"apiVersion": "cognitive-agent/v1alpha1",
"kind": "ExecutionReport",
"metadata": {
"id": f"report-{domain}-{request_id}",
"name": f"{domain} test report",
"version": "1.0.0",
"request_id": request_id,
"labels": {"simulation": "false"},
},
"spec": {
"mode": "hil",
"status": "succeeded",
"results": [
{
"ref": f"{domain}-operation",
"domain": domain,
"status": "succeeded",
"message": "test",
"telemetry": {},
}
],
},
}
class _ChannelBus:
def __init__(self, channel: int) -> None:
self.channel = channel
def close(self) -> None:
return None
class _Bridge:
def __init__(self) -> None:
self.buses = {0: _ChannelBus(0), 1: _ChannelBus(1)}
self.closed = False
def channel_bus(self, channel: int) -> _ChannelBus:
return self.buses[channel]
def close(self) -> None:
self.closed = True
class _NativeExecutor:
def __init__(self, bus_factory, **_kwargs) -> None:
self.bus_factory = bus_factory
def execute(self, _runtime, _plan, config):
if self.bus_factory().channel != 1:
raise AssertionError("vehicle commands must use CAN1")
return _report(config["spec"]["domain"], config["metadata"]["request_id"])
class _SeatExecutor:
def __init__(self, bus_factory) -> None:
self.bus_factory = bus_factory
def execute(self, _plan, config, _state):
if self.bus_factory().channel != 0:
raise AssertionError("passenger-seat commands must use CAN0")
report = _report("seat", config["metadata"]["request_id"])
report["spec"]["physical_state"] = {
"source": "physical_can",
"channel": 0,
"complete": False,
"missing": ["longitudinal"],
"position": {},
}
return report
class WiredDualChannelCanTests(unittest.TestCase):
@staticmethod
def _linux_environment() -> dict[str, str]:
return {
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "true",
"COGNITIVE_AGENT_VEHICLE_CAN_BACKEND": "zlgcan-linux",
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "true",
"COGNITIVE_AGENT_SEAT_CAN_CHANNEL": "0",
"COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL": "1",
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED": "false",
"COGNITIVE_AGENT_ZLGCAN_PYTHON": os.sys.executable,
"COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY": str(
REPOSITORY_ROOT.parent / "project-code" / "canfd" / "libusbcanfd.so"
),
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED": "false",
}
def test_runtime_reports_manual_wired_dual_channel_policy(self) -> None:
with patch.dict(os.environ, self._linux_environment(), clear=True):
service = SeatDemoService(REPOSITORY_ROOT)
runtime = service.runtime_info()
wired = next(
item
for item in runtime["vehicle_transports"]["options"]
if item["id"] == "wired_can"
)
self.assertEqual(
wired["target_policy"],
"dual_channel_wired_can0_passenger_can1_vehicle",
)
self.assertEqual(wired["channels"], {"seat": 0, "vehicle": 1})
self.assertEqual(
runtime["multi_channel_can"]["active_policy"],
"dual_channel_wired_can0_passenger_can1_vehicle",
)
def test_linked_wired_execution_routes_seat_to_can0_and_vehicle_to_can1(self) -> None:
with patch.dict(os.environ, self._linux_environment(), clear=True):
service = SeatDemoService(Path(REPOSITORY_ROOT))
bridge = _Bridge()
request_id = "req-wired-dual-channel"
seat_config = {
"metadata": {"request_id": request_id},
"spec": {"domain": "seat", "operations": []},
}
native_config = {
"metadata": {"request_id": request_id},
"spec": {"domain": "climate", "operations": []},
}
configs = {"seat": seat_config, "native_climate": native_config}
plans = {
"seat": {"spec": {"commands": []}},
"native_climate": {"spec": {"commands": []}},
}
captured_channel_configs = []
def create_bridge(channel_configs):
captured_channel_configs.extend(channel_configs)
return bridge
def create_seat_executor(bus_factory, *, remote_transport=False):
self.assertFalse(remote_transport)
return _SeatExecutor(bus_factory)
with (
patch.dict(
os.environ,
{"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED": "false"},
clear=False,
),
patch.object(
type(service.vehicle_can_settings),
"bridge",
side_effect=create_bridge,
),
patch.object(
service,
"_new_hardware_executor",
side_effect=create_seat_executor,
),
patch(
"cognitive_agent.application.seat_service.NativeCanExecutor",
_NativeExecutor,
),
patch(
"cognitive_agent.application.seat_service.observe_vehicle_safety",
return_value={"stationary": True},
),
patch(
"cognitive_agent.application.seat_service.require_stationary_vehicle"
),
):
reports = service._execute_orchestrated_reports(
configs,
plans,
{"native_climate"},
{"devices": {}},
hardware_request=True,
simulated_hardware_request=False,
simulation_fault="normal",
control_transport="wired_can",
)
self.assertEqual(set(reports), {"seat", "native_climate"})
self.assertEqual(
[(item.channel, item.frame_format) for item in captured_channel_configs],
[(0, "classic_can"), (1, "can_fd")],
)
self.assertTrue(bridge.closed)
def test_wired_passenger_status_uses_monitor_only_can0_bridge(self) -> None:
with patch.dict(os.environ, self._linux_environment(), clear=True):
service = SeatDemoService(Path(REPOSITORY_ROOT))
bridge = _Bridge()
executor = SimpleNamespace(
read_physical_state=lambda: {
"position": {},
"complete": False,
"missing": ["longitudinal"],
"captured_at": "2026-08-25T00:00:00Z",
}
)
with (
patch.object(
type(service.vehicle_can_settings),
"bridge",
return_value=bridge,
) as create_bridge,
patch.object(
service,
"_new_hardware_executor",
return_value=executor,
) as create_executor,
):
result = service._synchronize_hardware_state_once(
"conv-wired-monitor-only",
control_transport="wired_can",
)
channel_configs = create_bridge.call_args.args[0]
self.assertTrue(create_bridge.call_args.kwargs["monitor_only"])
self.assertEqual(
[(item.channel, item.frame_format) for item in channel_configs],
[(0, "classic_can")],
)
bus_factory = create_executor.call_args.args[0]
self.assertEqual(bus_factory().channel, 0)
self.assertEqual(result["source"], "physical_can")
self.assertTrue(bridge.closed)
if __name__ == "__main__":
unittest.main()
+32 -1
View File
@@ -12,7 +12,10 @@ from cognitive_agent.infrastructure.can.gateway import (
ZlgCanBridgeProcess,
ZlgChannelConfig,
)
from cognitive_agent.infrastructure.can.hardware import VehicleCanSettings
from cognitive_agent.infrastructure.can.hardware import (
SeatHardwareSettings,
VehicleCanSettings,
)
from cognitive_agent.infrastructure.can.zlgcan_linux_bridge import (
EXPECTED_ABI_SIZES,
Driver,
@@ -113,6 +116,34 @@ class LinuxZlgBridgeTests(unittest.TestCase):
self.assertTrue(settings.bridge_script.name.endswith("linux_bridge.py"))
self.assertTrue(settings.public_info()["state_configured"])
def test_linux_wired_vehicle_enables_native_passenger_can0_profile(self) -> None:
with tempfile.TemporaryDirectory() as directory:
library = Path(directory) / "libusbcanfd.so"
library.write_bytes(b"test")
environment = {
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "true",
"COGNITIVE_AGENT_VEHICLE_CAN_BACKEND": "zlgcan-linux",
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "true",
"COGNITIVE_AGENT_SEAT_CAN_CHANNEL": "0",
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED": "false",
"COGNITIVE_AGENT_ZLGCAN_PYTHON": sys.executable,
"COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY": str(library),
}
with patch.dict(os.environ, environment, clear=True):
settings = SeatHardwareSettings.from_env(REPOSITORY_ROOT)
self.assertTrue(settings.native_passenger_can0)
self.assertTrue(settings.configured)
self.assertEqual(settings.backend, "zlgcan-linux")
self.assertEqual(settings.channel, "0")
self.assertEqual(settings.device_type, 33)
self.assertFalse(settings.termination_enabled)
self.assertFalse(settings.auto_flash_enabled)
self.assertFalse(settings.position_sync_enabled)
self.assertEqual(
settings.public_info()["protocol_profile"],
"passenger-seat-native-can0-v1",
)
def test_process_passes_monitor_and_trace_flags_to_bridge(self) -> None:
process = Mock()
process.stdout = io.StringIO('{"ok":true,"event":"ready"}\n')