210 lines
6.7 KiB
Python
210 lines
6.7 KiB
Python
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from collections import deque
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
from cognitive_agent.application.seat_service import SeatDemoService
|
|
from cognitive_agent.application.vehicle_state_monitor import (
|
|
OBSERVABLE_IDS,
|
|
REQUIRED_UI_IDS,
|
|
VehicleStateMonitor,
|
|
VehicleStateSnapshot,
|
|
)
|
|
from cognitive_agent.infrastructure.can.gateway import (
|
|
CanFrame,
|
|
CanGatewayError,
|
|
ZlgChannelConfig,
|
|
)
|
|
|
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
class _FakeBus:
|
|
def __init__(self, frames: list[CanFrame]) -> None:
|
|
self._frames = deque(frames)
|
|
self._condition = threading.Condition()
|
|
|
|
def push(self, frame: CanFrame) -> None:
|
|
with self._condition:
|
|
self._frames.append(frame)
|
|
self._condition.notify_all()
|
|
|
|
def receive(self, timeout_ms: int, *, include_fd: bool = False) -> CanFrame | None:
|
|
if not include_fd:
|
|
raise AssertionError("vehicle monitor must receive classic CAN and CAN FD")
|
|
with self._condition:
|
|
if not self._frames:
|
|
self._condition.wait(max(0, timeout_ms) / 1000)
|
|
return self._frames.popleft() if self._frames else None
|
|
|
|
|
|
class _FakeBridge:
|
|
def __init__(self, bus: _FakeBus) -> None:
|
|
self.bus = bus
|
|
self.closed = False
|
|
|
|
def channel_bus(self, channel: int) -> _FakeBus:
|
|
if channel != 1:
|
|
raise AssertionError("vehicle monitor must stay on CH1")
|
|
return self.bus
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class _FakeSettings:
|
|
channel = 1
|
|
feedback_timeout_ms = 100
|
|
|
|
def __init__(self, bus: _FakeBus) -> None:
|
|
self.bus = bus
|
|
self.bridge_instance = _FakeBridge(bus)
|
|
self.monitor_only: bool | None = None
|
|
|
|
def channel_config(self) -> ZlgChannelConfig:
|
|
return ZlgChannelConfig(channel=1, termination_enabled=False)
|
|
|
|
def bridge(
|
|
self,
|
|
channels: list[ZlgChannelConfig],
|
|
*,
|
|
monitor_only: bool = False,
|
|
) -> _FakeBridge:
|
|
if len(channels) != 1:
|
|
raise AssertionError("vehicle monitor must own exactly one channel")
|
|
self.monitor_only = monitor_only
|
|
if channels[0].receive_ids != tuple(sorted(OBSERVABLE_IDS)):
|
|
raise AssertionError("vehicle monitor must filter to observable identifiers")
|
|
return self.bridge_instance
|
|
|
|
|
|
def _frame(frame_id: int, data: bytes | None = None) -> CanFrame:
|
|
return CanFrame(
|
|
frame_id,
|
|
data or bytes(8),
|
|
channel=1,
|
|
is_fd=True,
|
|
bitrate_switch=True,
|
|
)
|
|
|
|
|
|
def _baseline_frames() -> list[CanFrame]:
|
|
gear_p = bytearray(8)
|
|
gear_p[3] = 1
|
|
return [
|
|
_frame(0x342, bytes(gear_p)),
|
|
_frame(0x4EB, bytes.fromhex("0700000000000000")),
|
|
_frame(0x407),
|
|
_frame(0x4FA),
|
|
_frame(0x494),
|
|
]
|
|
|
|
|
|
class VehicleStateMonitorTests(unittest.TestCase):
|
|
def test_persistent_monitor_returns_cached_state_without_reopening_adapter(self) -> None:
|
|
bus = _FakeBus(_baseline_frames())
|
|
settings = _FakeSettings(bus)
|
|
monitor = VehicleStateMonitor(settings, reconnect_delay_s=0.01) # type: ignore[arg-type]
|
|
self.addCleanup(monitor.close)
|
|
|
|
first = monitor.snapshot()
|
|
started = time.perf_counter()
|
|
second = monitor.snapshot()
|
|
cached_latency_s = time.perf_counter() - started
|
|
|
|
self.assertEqual(first.observed_ids, REQUIRED_UI_IDS)
|
|
self.assertEqual(second.observed_ids, REQUIRED_UI_IDS)
|
|
self.assertLess(cached_latency_s, 0.05)
|
|
self.assertTrue(settings.monitor_only)
|
|
self.assertEqual(monitor.public_info()["mode"], "persistent_monitor")
|
|
|
|
def test_monitor_updates_sequence_for_each_new_feedback_frame(self) -> None:
|
|
bus = _FakeBus(_baseline_frames())
|
|
monitor = VehicleStateMonitor(
|
|
_FakeSettings(bus), # type: ignore[arg-type]
|
|
reconnect_delay_s=0.01,
|
|
)
|
|
self.addCleanup(monitor.close)
|
|
baseline = monitor.snapshot()
|
|
|
|
gear_d = bytearray(8)
|
|
gear_d[3] = 4
|
|
bus.push(_frame(0x342, bytes(gear_d)))
|
|
deadline = time.monotonic() + 0.5
|
|
updated = baseline
|
|
while updated.capture_sequence == baseline.capture_sequence:
|
|
if time.monotonic() >= deadline:
|
|
self.fail("monitor did not consume the new gear frame")
|
|
time.sleep(0.005)
|
|
updated = monitor.snapshot()
|
|
|
|
gear = next(
|
|
state["vehicle_safety"]["gear"]
|
|
for state in updated.feedback_states
|
|
if state["frame_id"] == "0x342"
|
|
)
|
|
self.assertEqual(gear, "D")
|
|
|
|
def test_monitor_surfaces_adapter_open_failure(self) -> None:
|
|
settings = _FakeSettings(_FakeBus([]))
|
|
|
|
def fail_bridge(*_args: object, **_kwargs: object) -> _FakeBridge:
|
|
raise RuntimeError("adapter open failed")
|
|
|
|
settings.bridge = fail_bridge # type: ignore[method-assign]
|
|
monitor = VehicleStateMonitor(settings, reconnect_delay_s=0.01) # type: ignore[arg-type]
|
|
self.addCleanup(monitor.close)
|
|
|
|
with self.assertRaisesRegex(CanGatewayError, "adapter open failed"):
|
|
monitor.snapshot()
|
|
|
|
def test_service_merges_a_cached_monitor_snapshot(self) -> None:
|
|
service = SeatDemoService(REPOSITORY_ROOT)
|
|
gear_state = {
|
|
"source": "physical_can_ch1",
|
|
"channel": 1,
|
|
"frame_id": "0x342",
|
|
"feedback_frame_id": "0x342",
|
|
"valid": True,
|
|
"captured_at": "2026-08-18T00:00:00Z",
|
|
"vehicle_safety": {"gear": "P"},
|
|
"verification": {
|
|
"authoritative": True,
|
|
"channel": 1,
|
|
"simulated": False,
|
|
"read_only": True,
|
|
"frame_id": "0x342",
|
|
"payload_size": 8,
|
|
},
|
|
}
|
|
service.vehicle_can_settings = SimpleNamespace(
|
|
state_configured=True,
|
|
backend="zlgcan-linux",
|
|
)
|
|
service._vehicle_state_monitor = SimpleNamespace(
|
|
snapshot=lambda: VehicleStateSnapshot(
|
|
feedback_states=(gear_state,),
|
|
observed_ids=REQUIRED_UI_IDS,
|
|
captured_at="2026-08-18T00:00:00Z",
|
|
capture_sequence=42,
|
|
)
|
|
)
|
|
|
|
result = service._synchronize_vehicle_state_once(
|
|
"conv-monitor-test",
|
|
"denza_n7",
|
|
)
|
|
|
|
self.assertEqual(result["capture_sequence"], 42)
|
|
self.assertEqual(result["domain_state"]["vehicle_status"]["gear"], "P")
|
|
self.assertTrue(result["complete"])
|
|
self.assertTrue(result["body_state_complete"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|