feat: integrate cabin CAN and verified dynamic seat modes
This commit is contained in:
@@ -16,6 +16,7 @@ if str(SEAT_SOURCE) not in sys.path:
|
||||
|
||||
from cognitive_agent.infrastructure.can.gateway import CanFrame, CanGatewayError # noqa: E402
|
||||
from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402
|
||||
from cognitive_agent.runtime.contracts import ContractValidator # noqa: E402
|
||||
from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
|
||||
HardwareSafetyError,
|
||||
SeatHardwareSettings,
|
||||
@@ -242,6 +243,34 @@ class ReactiveFakeBus:
|
||||
},
|
||||
)
|
||||
)
|
||||
if opcode == 14:
|
||||
self.received.append(
|
||||
self._frame(
|
||||
"T2G_Seat_Dynamic_Mode_Status_306",
|
||||
{
|
||||
"Dynamic_Mode_ID": command["Param_A"],
|
||||
"Dynamic_State": 2,
|
||||
"Dynamic_Phase": 2 if command["Param_A"] == 1 else 5,
|
||||
"Dynamic_Active_Mask": 0x0F,
|
||||
"Dynamic_Cycle_Index": 0,
|
||||
"Dynamic_Phase_Elapsed_Ms": 100,
|
||||
},
|
||||
)
|
||||
)
|
||||
if opcode == 15:
|
||||
self.received.append(
|
||||
self._frame(
|
||||
"T2G_Seat_Dynamic_Mode_Status_306",
|
||||
{
|
||||
"Dynamic_Mode_ID": 0,
|
||||
"Dynamic_State": 0,
|
||||
"Dynamic_Phase": 0,
|
||||
"Dynamic_Active_Mask": 0,
|
||||
"Dynamic_Cycle_Index": 0,
|
||||
"Dynamic_Phase_Elapsed_Ms": 0,
|
||||
},
|
||||
)
|
||||
)
|
||||
if opcode == 0x0B and self.complete_motion:
|
||||
motor = command["Motor_Selector"]
|
||||
self.position_valid = True
|
||||
@@ -447,7 +476,7 @@ class ProtocolSignatureFakeBus:
|
||||
self.received: deque[CanFrame] = deque()
|
||||
self.closed = False
|
||||
if signature:
|
||||
self.received.append(CanFrame(0x301, bytes.fromhex("10 00 00 00 00 00 F0 60")))
|
||||
self.received.append(CanFrame(0x301, bytes.fromhex("10 00 00 00 00 00 F0 D0")))
|
||||
for motor in range(4):
|
||||
self.received.append(
|
||||
CanFrame(0x302, bytes([motor, 0, 0x08, 0, 0, 0, 0, 0]))
|
||||
@@ -464,6 +493,7 @@ class ProtocolSignatureFakeBus:
|
||||
CanFrame(0x304, bytes([motor, 3, 0, 0, 0, 0, 0xF4, 0x01]))
|
||||
)
|
||||
self.received.append(CanFrame(0x305, bytes(8)))
|
||||
self.received.append(CanFrame(0x306, bytes(8)))
|
||||
self.received.append(CanFrame(0x4FA, bytes.fromhex("09 00 00 00 00 00 00 00")))
|
||||
|
||||
def send(self, arbitration_id: int, data: bytes) -> CanFrame:
|
||||
@@ -485,6 +515,29 @@ class FailingProtocolSignatureBus(ProtocolSignatureFakeBus):
|
||||
raise RuntimeError("CAN receive failed during passive protocol probe")
|
||||
|
||||
|
||||
class LegacyProtocolSignatureFakeBus(ProtocolSignatureFakeBus):
|
||||
def __init__(self):
|
||||
super().__init__(False)
|
||||
self.received.append(CanFrame(0x301, bytes.fromhex("10 00 00 00 00 00 F0 00")))
|
||||
for motor in range(4):
|
||||
self.received.append(
|
||||
CanFrame(0x302, bytes([motor, 0, 0x08, 0, 0, 0, 0, 0]))
|
||||
)
|
||||
for motor, maximum in enumerate((480, 374, 917, 647)):
|
||||
self.received.append(
|
||||
CanFrame(
|
||||
0x303,
|
||||
bytes([motor, 0, 0, 0, maximum & 0xFF, maximum >> 8, 0, 0]),
|
||||
)
|
||||
)
|
||||
for motor in range(4):
|
||||
self.received.append(
|
||||
CanFrame(0x304, bytes([motor, 3, 0, 0, 0, 0, 0xF4, 0x01]))
|
||||
)
|
||||
self.received.append(CanFrame(0x305, bytes(8)))
|
||||
self.received.append(CanFrame(0x4FA, bytes(8)))
|
||||
|
||||
|
||||
class FakeProgrammer:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
@@ -511,6 +564,7 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
ROOT / "domains" / "seat" / "can" / "signal-mapping.hardware.yaml",
|
||||
ROOT / "platforms" / "t2g_seat_bench" / "calibration" / "seat.yaml",
|
||||
)
|
||||
self.contracts = ContractValidator(ROOT)
|
||||
|
||||
def test_compiler_emits_verified_heating_payload(self) -> None:
|
||||
config = function_config(
|
||||
@@ -569,6 +623,34 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
self.assertEqual(fallback["signals"]["Param_B"], 900)
|
||||
self.assertEqual(fallback["completion"]["signal"], "Task_State")
|
||||
|
||||
def test_compiler_emits_native_dynamic_mode_command(self) -> None:
|
||||
config = function_config(
|
||||
operation("seat.start_dynamic_mode", {"mode_id": 1})
|
||||
)
|
||||
|
||||
plan = self.compiler.compile(config, {})
|
||||
command = plan["spec"]["commands"][0]
|
||||
frame = self.matrix.encode(command["message"], command["signals"])
|
||||
|
||||
self.assertEqual(frame["frame_id"], "0x300")
|
||||
self.assertEqual(frame["data_hex"], "0E 10 00 00 01 00 00 00")
|
||||
self.assertEqual(command["completion"]["message"], "T2G_Seat_Dynamic_Mode_Status_306")
|
||||
self.assertEqual(command["completion"]["accepted_states"], [1, 2])
|
||||
self.assertEqual(self.contracts.validate(plan), [])
|
||||
|
||||
def test_compiler_emits_native_dynamic_mode_stop_command(self) -> None:
|
||||
config = function_config(operation("seat.stop_dynamic_mode", {}))
|
||||
|
||||
plan = self.compiler.compile(config, {})
|
||||
command = plan["spec"]["commands"][0]
|
||||
frame = self.matrix.encode(command["message"], command["signals"])
|
||||
|
||||
self.assertEqual(frame["frame_id"], "0x300")
|
||||
self.assertEqual(frame["data_hex"], "0F 10 00 00 00 00 00 00")
|
||||
self.assertEqual(command["completion"]["signal"], "Dynamic_State")
|
||||
self.assertEqual(command["completion"]["accepted_states"], [0])
|
||||
self.assertEqual(self.contracts.validate(plan), [])
|
||||
|
||||
def test_compiler_maps_large_relative_backrest_request_to_absolute_target(self) -> None:
|
||||
config = function_config(
|
||||
operation(
|
||||
@@ -696,6 +778,46 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
self.assertEqual([item.data[0] for item in bus.sent], [9, 1])
|
||||
self.assertTrue(bus.closed)
|
||||
|
||||
def test_executor_waits_for_native_dynamic_mode_feedback(self) -> None:
|
||||
config = function_config(
|
||||
operation("seat.start_dynamic_mode", {"mode_id": 2})
|
||||
)
|
||||
plan = self.compiler.compile(config, {})
|
||||
bus = ReactiveFakeBus(self.matrix)
|
||||
|
||||
report = HardwareSeatExecutor(
|
||||
self.matrix, lambda: bus, ack_timeout_ms=20
|
||||
).execute(plan, config)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertEqual(telemetry["dynamic_mode_id"], 2)
|
||||
self.assertEqual(telemetry["dynamic_state"], 2)
|
||||
self.assertEqual(telemetry["dynamic_phase"], 5)
|
||||
self.assertEqual([item.data[0] for item in bus.sent], [9, 14])
|
||||
self.assertTrue(bus.closed)
|
||||
|
||||
def test_executor_stops_dynamic_mode_without_homing_or_pre_stop(self) -> None:
|
||||
config = function_config(operation("seat.stop_dynamic_mode", {}))
|
||||
plan = self.compiler.compile(config, {})
|
||||
bus = ReactiveFakeBus(self.matrix)
|
||||
|
||||
report = HardwareSeatExecutor(
|
||||
self.matrix,
|
||||
lambda: bus,
|
||||
ack_timeout_ms=20,
|
||||
synchronize_position_before_first_motion=True,
|
||||
position_sync_state={"synchronized": False},
|
||||
).execute(plan, config)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertEqual(telemetry["dynamic_mode_id"], 0)
|
||||
self.assertEqual(telemetry["dynamic_state"], 0)
|
||||
self.assertEqual(telemetry["repeat"], "none")
|
||||
self.assertEqual([item.data[0] for item in bus.sent], [15])
|
||||
self.assertTrue(bus.closed)
|
||||
|
||||
def test_firmware_replacement_forces_four_axis_position_memory_sync(self) -> None:
|
||||
config = function_config(
|
||||
operation("seat.set_backrest_recline", {"position_pct": 50})
|
||||
@@ -1453,6 +1575,9 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
settings.validate_function_config(
|
||||
function_config(operation("seat.set_cushion_tilt", {"position_pct": 40}))
|
||||
)
|
||||
settings.validate_function_config(
|
||||
function_config(operation("seat.stop_dynamic_mode", {}))
|
||||
)
|
||||
with self.assertRaises(HardwareSafetyError):
|
||||
settings.validate_function_config(
|
||||
function_config(
|
||||
@@ -1559,7 +1684,7 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
|
||||
self.assertIs(prepared, bus)
|
||||
self.assertFalse(telemetry["programmed_this_request"])
|
||||
self.assertEqual(telemetry["firmware_revision"], 6)
|
||||
self.assertEqual(telemetry["firmware_revision"], 13)
|
||||
self.assertEqual(bus.sent, [])
|
||||
self.assertEqual(telemetry["max_hall_counts"], [480, 374, 917, 647])
|
||||
self.assertFalse(bus.closed)
|
||||
@@ -1617,6 +1742,24 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
guard.prepare_bus()
|
||||
self.assertTrue(bus.closed)
|
||||
|
||||
def test_firmware_guard_reports_legacy_revision_and_missing_dynamic_status(self) -> None:
|
||||
bus = LegacyProtocolSignatureFakeBus()
|
||||
guard = T2GFirmwareGuard(
|
||||
lambda: bus,
|
||||
None,
|
||||
auto_flash=False,
|
||||
probe_timeout_ms=20,
|
||||
)
|
||||
|
||||
with self.assertRaises(FirmwareProgrammingError) as raised:
|
||||
guard.prepare_bus()
|
||||
|
||||
message = str(raised.exception)
|
||||
self.assertIn("firmware_revision=0 (expected 13)", message)
|
||||
self.assertIn("missing_frame_ids=0x306", message)
|
||||
self.assertIn("max_hall_counts=480,374,917,647", message)
|
||||
self.assertTrue(bus.closed)
|
||||
|
||||
def test_programmer_command_downloads_and_hands_off_to_running_target(self) -> None:
|
||||
firmware_root = ROOT / "firmware" / "t2g" / "Seat_Controller_demo"
|
||||
output_image = (
|
||||
|
||||
Reference in New Issue
Block a user