286 lines
9.4 KiB
Python
286 lines
9.4 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from collections import deque
|
|
from types import SimpleNamespace
|
|
|
|
from cognitive_agent.application.mqtt_ota_seat_executor import (
|
|
MqttOtaSeatExecutor,
|
|
)
|
|
from cognitive_agent.infrastructure.can.gateway import CanFrame
|
|
from seat_domain import parse_local_intent
|
|
|
|
|
|
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 _FakeOtaBus:
|
|
def __init__(self, initial_frames: list[CanFrame]) -> None:
|
|
self.frames = deque(initial_frames)
|
|
self.sent: list[CanFrame] = []
|
|
self.debug_commands: list[str] = []
|
|
self.closed = False
|
|
|
|
def set_debug_command(self, command: str) -> None:
|
|
self.debug_commands.append(command)
|
|
|
|
def send(
|
|
self,
|
|
arbitration_id: int,
|
|
data: bytes,
|
|
*,
|
|
is_fd: bool = False,
|
|
bitrate_switch: bool = False,
|
|
) -> CanFrame:
|
|
frame = CanFrame(
|
|
arbitration_id,
|
|
bytes(data),
|
|
channel=0,
|
|
is_fd=is_fd,
|
|
bitrate_switch=bitrate_switch,
|
|
)
|
|
self.sent.append(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.frames.append(
|
|
CanFrame(
|
|
0x6A2,
|
|
bytes((0x7C, data[1], opcode, 0))
|
|
+ job_state.to_bytes(4, "little"),
|
|
channel=0,
|
|
)
|
|
)
|
|
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:
|
|
del include_fd
|
|
return self.frames.popleft() if self.frames else None
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class _StatusExecutor:
|
|
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 _function_config(capability: str, parameters: dict[str, object]) -> dict[str, object]:
|
|
return {
|
|
"metadata": {"request_id": "req-mqtt-ota-seat"},
|
|
"spec": {
|
|
"operations": [
|
|
{
|
|
"id": "operation-1",
|
|
"capability": capability,
|
|
"target": {"zone": "front_right", "instance": "passenger"},
|
|
"parameters": parameters,
|
|
}
|
|
]
|
|
},
|
|
}
|
|
|
|
|
|
class MqttOtaSeatExecutorTests(unittest.TestCase):
|
|
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(),
|
|
lambda: bus,
|
|
)
|
|
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.set_longitudinal_position",
|
|
{"position_pct": 50},
|
|
),
|
|
)
|
|
|
|
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(
|
|
bus.debug_commands,
|
|
["seat passenger longitudinal position 50"],
|
|
)
|
|
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,
|
|
},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
report = executor.execute(
|
|
plan,
|
|
_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])
|
|
|
|
def test_wireless_thermal_command_fails_before_publish(self) -> None:
|
|
bus = _FakeOtaBus([])
|
|
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
|
|
_StatusExecutor(),
|
|
lambda: bus,
|
|
)
|
|
plan = {
|
|
"spec": {
|
|
"commands": [
|
|
{
|
|
"operation_ref": "operation-1",
|
|
"message": "T2G_Seat_Thermal_Control_29E",
|
|
"signals": {},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
report = executor.execute(
|
|
plan,
|
|
_function_config("seat.set_heating", {"state": "on", "level": 1}),
|
|
)
|
|
|
|
self.assertEqual(report["spec"]["status"], "failed")
|
|
self.assertIn("does not provide wireless thermal control", report["spec"]["results"][0]["message"])
|
|
self.assertEqual(bus.sent, [])
|
|
|
|
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},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
report = executor.execute(
|
|
plan,
|
|
_function_config(
|
|
"seat.start_dynamic_mode",
|
|
{"mode_id": semantic_mode},
|
|
),
|
|
)
|
|
|
|
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(
|
|
bus.debug_commands,
|
|
[
|
|
"seat passenger dynamic start "
|
|
+ ("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)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|