优化无线/有线CAN,以及控制指令解析
This commit is contained in:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user