优化无线/有线CAN,以及控制指令解析
This commit is contained in:
@@ -22,6 +22,7 @@ from cognitive_agent.application.self_extension import (
|
||||
)
|
||||
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError
|
||||
from cognitive_agent.infrastructure.can.access import HardwareAccessController
|
||||
from cognitive_agent.infrastructure.can.gateway import CanGatewayError
|
||||
from cognitive_agent.server import SeatDemoHandler
|
||||
|
||||
|
||||
@@ -40,6 +41,7 @@ class SeatWebApiTests(unittest.TestCase):
|
||||
{
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
@@ -332,14 +334,14 @@ operations:
|
||||
def test_static_console_and_runtime_endpoint(self) -> None:
|
||||
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
|
||||
html = response.read().decode("utf-8")
|
||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-103", timeout=3) as response:
|
||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-107", timeout=3) as response:
|
||||
app_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
|
||||
) as response:
|
||||
gesture_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/console-v2.js?v=sync-layout-43", timeout=3
|
||||
f"{self.base_url}/console-v2.js?v=task-outcome-47", timeout=3
|
||||
) as response:
|
||||
console_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
@@ -378,11 +380,11 @@ operations:
|
||||
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
|
||||
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
|
||||
self.assertNotIn("透明车体 · 实体设备", html)
|
||||
self.assertIn('src="/app.js?v=dual-seat-103"', html)
|
||||
self.assertIn('src="/app.js?v=dual-seat-107"', html)
|
||||
self.assertIn('href="/styles.css?v=dual-seat-16"', html)
|
||||
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
|
||||
self.assertNotIn('class="vehicle-system-hud"', html)
|
||||
self.assertIn('src="/console-v2.js?v=sync-layout-43"', html)
|
||||
self.assertIn('src="/console-v2.js?v=task-outcome-47"', html)
|
||||
self.assertIn('href="/console-v2.css?v=sync-layout-41"', html)
|
||||
self.assertIn('<body class="console-v2">', html)
|
||||
self.assertIn('id="v2BootScreen"', html)
|
||||
@@ -507,8 +509,10 @@ operations:
|
||||
self.assertNotIn("授权并真实执行", app_script)
|
||||
self.assertIn('const dynamicModeOrder = ["rocking_horse", "dynamic_cradle"]', app_script)
|
||||
self.assertIn('divider.className = "mode-group-divider"', app_script)
|
||||
self.assertIn(
|
||||
'mode === "hardware" && !hardwareExecutionReady',
|
||||
self.assertIn("function isHardwareExecutionReady()", app_script)
|
||||
self.assertIn("function hardwareExecutionIssue(", app_script)
|
||||
self.assertNotIn(
|
||||
'if (mode === "hardware" && !hardwareExecutionReady) return false',
|
||||
app_script,
|
||||
)
|
||||
self.assertIn('data-control-transport="wired_can"', html)
|
||||
@@ -529,7 +533,15 @@ operations:
|
||||
'availableTransports.find((item) => item.state_configured)?.id',
|
||||
app_script,
|
||||
)
|
||||
self.assertIn("hardwareButton.disabled = !hardwareExecutionReady", app_script)
|
||||
self.assertIn("hardwareButton.disabled = false", app_script)
|
||||
self.assertIn(
|
||||
'hardwareButton.dataset.executionState = hardwareExecutionReady ? "available" : "blocked"',
|
||||
app_script,
|
||||
)
|
||||
self.assertNotIn(
|
||||
'id="hardwareMode" data-execution-mode="hardware" disabled',
|
||||
html,
|
||||
)
|
||||
self.assertIn('const seat = twin.selected === "driver" ? "driver" : "passenger"', app_script)
|
||||
self.assertNotIn('data-mode-command="seat driver vent low"', app_script)
|
||||
self.assertIn("CH0 副驾座椅与 CH1 主驾座椅独立收发、独立反馈", app_script)
|
||||
@@ -610,9 +622,15 @@ operations:
|
||||
self.assertIn("const stageHasLayout = () =>", app_script)
|
||||
self.assertIn("window.requestAnimationFrame(initSeatTwin)", app_script)
|
||||
self.assertIn('client_type: "web_console"', app_script)
|
||||
self.assertIn("function executionVerificationStatus(result = {})", app_script)
|
||||
self.assertIn('"submitted_unverified"', app_script)
|
||||
self.assertIn("指令已提交,等待状态确认", app_script)
|
||||
self.assertIn("指令已提交 · 等待状态确认", console_script)
|
||||
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
|
||||
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
||||
self.assertIn('"cognitive-agent:module-change"', app_script)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v204"', service_worker)
|
||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v210"', service_worker)
|
||||
self.assertIn('"/app.js?v=dual-seat-107"', service_worker)
|
||||
self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker)
|
||||
self.assertIn(".then(() => self.skipWaiting())", service_worker)
|
||||
self.assertNotIn("const cached = await caches.match(request)", service_worker)
|
||||
@@ -852,7 +870,7 @@ operations:
|
||||
) as response:
|
||||
extension_script = response.read().decode("utf-8")
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/app.js?v=dual-seat-103", timeout=3
|
||||
f"{self.base_url}/app.js?v=dual-seat-107", timeout=3
|
||||
) as response:
|
||||
app_script = response.read().decode("utf-8")
|
||||
|
||||
@@ -1442,6 +1460,61 @@ operations:
|
||||
self.assertTrue(validated["command_plans"])
|
||||
self.assertEqual(events[1]["payload"]["status"], "succeeded")
|
||||
|
||||
def test_stream_failure_uses_cli_outcome_and_updates_display_sync_terminal_state(
|
||||
self,
|
||||
) -> None:
|
||||
request = urllib.request.Request(
|
||||
f"{self.base_url}/api/requests",
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/x-ndjson",
|
||||
},
|
||||
data=json.dumps(
|
||||
{
|
||||
"utterance": "打开副驾座椅通风",
|
||||
"provider": "local",
|
||||
"conversation_id": "conv-e2e-stream-can-failure",
|
||||
"execution_mode": "hardware",
|
||||
"client_type": "web_console",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8"),
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
self.service,
|
||||
"process",
|
||||
side_effect=CanGatewayError("VCI_OpenDevice failed"),
|
||||
):
|
||||
with self.opener.open(request, timeout=3) as response:
|
||||
events = [
|
||||
json.loads(line)
|
||||
for line in response.read().decode("utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
with self.opener.open(
|
||||
f"{self.base_url}/api/vehicle-display-sync", timeout=3
|
||||
) as response:
|
||||
snapshot = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
self.assertEqual(events, [{
|
||||
"type": "error",
|
||||
"error": "can_transport_failed",
|
||||
"message": "VCI_OpenDevice failed",
|
||||
"outcome": "failed_or_blocked",
|
||||
}])
|
||||
self.assertEqual(snapshot["task"]["status"], "failed")
|
||||
self.assertEqual(snapshot["task"]["utterance"], "打开副驾座椅通风")
|
||||
self.assertEqual(
|
||||
snapshot["task"]["error"],
|
||||
{
|
||||
"code": "can_transport_failed",
|
||||
"message": "VCI_OpenDevice failed",
|
||||
"outcome": "failed_or_blocked",
|
||||
},
|
||||
)
|
||||
|
||||
def test_frontend_suite_covers_every_physical_seat_capability(self) -> None:
|
||||
suite_path = (
|
||||
REPOSITORY_ROOT
|
||||
|
||||
@@ -96,6 +96,44 @@ class MultiDomainServiceTests(unittest.TestCase):
|
||||
self.addCleanup(self.temporary_directory.cleanup)
|
||||
self.service.artifact_root = Path(self.temporary_directory.name)
|
||||
|
||||
def test_driver_massage_mode_and_strength_continuations_compile_locally(
|
||||
self,
|
||||
) -> None:
|
||||
result = self.service.preview_native_can(
|
||||
"打开主驾座椅按摩,模式切换为脉冲模式,强度调到最大",
|
||||
provider="auto",
|
||||
conversation_id="conv-driver-massage-pulse-strong",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["unresolved_clauses"], [])
|
||||
self.assertFalse(result["supplement_required"])
|
||||
commands = [
|
||||
command
|
||||
for plan in result.get("command_plans", {}).values()
|
||||
for command in plan.get("spec", {}).get("commands", [])
|
||||
]
|
||||
self.assertEqual(
|
||||
[command["signals"]["debug_command"] for command in commands],
|
||||
[
|
||||
"seat driver massage mode pulse",
|
||||
"seat driver massage strength strong",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
[command["signals"]["data_hex"] for command in commands],
|
||||
[
|
||||
"12 00 00 03 00 00 00 00",
|
||||
"12 03 00 00 00 00 00 00",
|
||||
],
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
operation["parse_source"] == "local_rule"
|
||||
for config in result.get("function_configs", {}).values()
|
||||
for operation in config.get("spec", {}).get("operations", [])
|
||||
)
|
||||
)
|
||||
|
||||
def test_four_domain_scene_is_generated_executed_and_persisted(self) -> None:
|
||||
model_output = {
|
||||
"detected_domains": ["seat", "climate", "roof", "ambient_light"],
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user