Files
Cognitive_Agent/tests/unit/test_mqtt_ota_seat_executor.py
T
2026-08-25 11:39:41 +08:00

427 lines
15 KiB
Python

from __future__ import annotations
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 (
MqttOtaSeatExecutor,
)
from cognitive_agent.infrastructure.can.gateway import CanFrame
from seat_domain import parse_local_intent
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
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)
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.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:
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",
}
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 _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_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(),
lambda: bus,
)
service = SeatDemoService(REPOSITORY_ROOT)
service.vehicle_mqtt_settings = SimpleNamespace(
enabled=True,
configured=True,
authorize=lambda _token: None,
)
validated_events: list[dict[str, object]] = []
with tempfile.TemporaryDirectory() as temporary_directory:
service.artifact_root = Path(temporary_directory)
with (
patch.object(
type(service.hardware_settings),
"validate_function_config",
side_effect=AssertionError(
"wireless CAN0 must not check the local combined profile"
),
),
patch.object(
service.hardware_execution,
"reserve",
side_effect=AssertionError(
"wireless CAN0 must not reserve the local physical bench"
),
),
patch.object(
service,
"_new_hardware_executor",
return_value=executor,
) as executor_factory,
):
result = service.process(
"把副驾座椅水平位置调到35%",
provider="local",
conversation_id="conv-wireless-can0-no-local-hardware",
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"])
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 horizontal 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_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,
)
plan = {
"spec": {
"commands": [
{
"operation_ref": "operation-1",
"message": "T2G_Seat_Thermal_Mode_3CE",
"signals": {"Driver_Seat_Thermal_Mode": 1},
},
{
"operation_ref": "operation-1",
"message": "T2G_Seat_Thermal_Control_29E",
"signals": {
"Driver_Seat_Ventilation_On": 2,
"Driver_Seat_Ventilation_Mode": 1,
"Driver_Seat_Heating_On": 1,
"Driver_Seat_Ventilation_Gear": 0,
"Driver_Seat_Heating_Gear": 1,
},
}
]
}
}
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"))
self.assertEqual(
bus.debug_commands,
["seat passenger heat low", "seat passenger heat low"],
)
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 "
+ ("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()