添加无线模块
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
@@ -21,6 +23,8 @@ class Ch1VehicleSafetyTests(unittest.TestCase):
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_REQUIRED_CHANNEL": "1",
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED": "true",
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN": "approved",
|
||||
"COGNITIVE_AGENT_ZLGCAN_PYTHON": sys.executable,
|
||||
"COGNITIVE_AGENT_ZLGCAN_DLL": str(Path(__file__).resolve()),
|
||||
}
|
||||
with patch.dict(os.environ, environment, clear=False):
|
||||
return VehicleCanSettings.from_env(REPOSITORY_ROOT)
|
||||
|
||||
@@ -225,9 +225,18 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
|
||||
"async function synchronizePhysicalSeatState() {",
|
||||
"\nasync function loadAuthStatus()",
|
||||
)
|
||||
self.assertIn("state.runtime?.hardware?.configured", passenger_source)
|
||||
self.assertIn('requestedTransport === "wireless_mqtt"', passenger_source)
|
||||
self.assertIn("selectedVehicleTransport()?.state_configured", passenger_source)
|
||||
self.assertIn("control_transport: requestedTransport", passenger_source)
|
||||
self.assertIn("requestedTransport !== state.controlTransport", passenger_source)
|
||||
self.assertIn('payload.source === "wireless_mqtt_ch0"', passenger_source)
|
||||
self.assertIn("/api/hardware/seat-state", passenger_source)
|
||||
self.assertIn("selectedVehicleTransport()?.state_configured", driver_source)
|
||||
self.assertIn("control_transport: requestedTransport", driver_source)
|
||||
self.assertIn("requestedTransport !== state.controlTransport", driver_source)
|
||||
self.assertIn("/api/hardware/vehicle-state", driver_source)
|
||||
self.assertIn('state.controlTransport === "wireless_mqtt"', combined_source)
|
||||
self.assertIn("state.passengerStateSyncPromise", combined_source)
|
||||
self.assertLess(
|
||||
combined_source.index("synchronizePassengerSeatState()"),
|
||||
combined_source.index("synchronizeDriverSeatState()"),
|
||||
@@ -239,6 +248,18 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
|
||||
)
|
||||
self.assertNotIn("passenger: mergedActuatorState.driver", render_source)
|
||||
|
||||
def test_persistent_linux_monitor_uses_backend_selected_fast_polling(self) -> None:
|
||||
interval_source = function_source(
|
||||
self.app_source,
|
||||
"function physicalStatePollIntervalMs() {",
|
||||
"\nfunction refreshIcons",
|
||||
)
|
||||
self.assertIn("selectedVehicleTransport()?.state_sync?.poll_interval_ms", interval_source)
|
||||
self.assertIn("Math.max(250", interval_source)
|
||||
self.assertIn("DEFAULT_PHYSICAL_STATE_POLL_INTERVAL_MS = 5000", self.app_source)
|
||||
self.assertIn("}, physicalStatePollIntervalMs());", self.app_source)
|
||||
self.assertIn("restartPhysicalStatePolling();", self.app_source)
|
||||
|
||||
def test_vehicle_selection_cannot_replay_unfiltered_state_in_ch1_mode(self) -> None:
|
||||
select_source = function_source(
|
||||
self.app_source,
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.infrastructure.can.gateway import CanFrame
|
||||
from cognitive_agent.infrastructure.can.hardware import (
|
||||
HardwareSafetyError,
|
||||
VehicleMqttSettings,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.mqtt_transport import (
|
||||
MQTT_CAN_SCHEMA,
|
||||
MqttCanTransport,
|
||||
parse_mqtt_can_payload,
|
||||
)
|
||||
|
||||
|
||||
class _PublishResult:
|
||||
rc = 0
|
||||
|
||||
def wait_for_publish(self, timeout: float) -> bool:
|
||||
return timeout > 0
|
||||
|
||||
|
||||
class _FakeMqttClient:
|
||||
def __init__(self) -> None:
|
||||
self.on_connect = None
|
||||
self.on_disconnect = None
|
||||
self.on_message = None
|
||||
self.subscriptions: list[tuple[str, int]] = []
|
||||
self.published: list[tuple[str, str, int, bool]] = []
|
||||
|
||||
def reconnect_delay_set(self, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
def connect_async(self, host: str, port: int, keepalive: int) -> None:
|
||||
self.connection = (host, port, keepalive)
|
||||
|
||||
def loop_start(self) -> None:
|
||||
self.on_connect(self, None, {}, 0, None)
|
||||
|
||||
def loop_stop(self) -> None:
|
||||
return None
|
||||
|
||||
def disconnect(self) -> None:
|
||||
return None
|
||||
|
||||
def subscribe(self, topic: str, qos: int):
|
||||
self.subscriptions.append((topic, qos))
|
||||
return (0, 1)
|
||||
|
||||
def publish(self, topic: str, payload: str, qos: int, retain: bool):
|
||||
self.published.append((topic, payload, qos, retain))
|
||||
return _PublishResult()
|
||||
|
||||
def emit(self, topic: str, payload: object) -> None:
|
||||
message = SimpleNamespace(
|
||||
topic=topic,
|
||||
payload=json.dumps(payload).encode("utf-8"),
|
||||
)
|
||||
self.on_message(self, None, message)
|
||||
|
||||
|
||||
def _settings():
|
||||
values = {
|
||||
"enabled": True,
|
||||
"state_configured": True,
|
||||
"configured": True,
|
||||
"host": "127.0.0.1",
|
||||
"port": 1883,
|
||||
"condition_topic": "n7/n7-headunit/can/state",
|
||||
"control_topic": "n7/can_control",
|
||||
"qos": 1,
|
||||
"keepalive_s": 30,
|
||||
"username": None,
|
||||
"password": None,
|
||||
"tls_enabled": False,
|
||||
"feedback_timeout_ms": 100,
|
||||
}
|
||||
values["public_info"] = lambda: {
|
||||
"enabled": True,
|
||||
"configured": True,
|
||||
"state_configured": True,
|
||||
"backend": "mqtt",
|
||||
"condition_topic": "n7/n7-headunit/can/state",
|
||||
"control_topic": "n7/can_control",
|
||||
}
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
class MqttCanPayloadTests(unittest.TestCase):
|
||||
def test_accepts_one_canonical_frame_and_common_receive_aliases(self) -> None:
|
||||
canonical = parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": MQTT_CAN_SCHEMA,
|
||||
"arbitration_id": 0x342,
|
||||
"channel": 1,
|
||||
"data_hex": "00 00 00 01 00 00 00 00",
|
||||
"is_fd": True,
|
||||
"bitrate_switch": True,
|
||||
}
|
||||
)
|
||||
)[0]
|
||||
alias = parse_mqtt_can_payload(
|
||||
json.dumps({"canId": "0x4FA", "data": [0] * 8})
|
||||
)[0]
|
||||
self.assertEqual(canonical.arbitration_id, 0x342)
|
||||
self.assertEqual(canonical.channel, 1)
|
||||
self.assertTrue(canonical.is_fd)
|
||||
self.assertEqual(alias.arbitration_id, 0x4FA)
|
||||
|
||||
def test_accepts_vehicle_n7_can_signals_envelope(self) -> None:
|
||||
frames = parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "n7-can-signals/v1",
|
||||
"sequence": 335,
|
||||
"timestamp": 1787037724090,
|
||||
"online": True,
|
||||
"signals": [
|
||||
{
|
||||
"channel": "can1",
|
||||
"channelIndex": 1,
|
||||
"canId": "0x388",
|
||||
"canIdDecimal": 904,
|
||||
"frameType": "fd",
|
||||
"brs": True,
|
||||
"dlc": 8,
|
||||
"data": "00 00 00 02 01 00 59 00",
|
||||
"bytes": [0, 0, 0, 2, 1, 0, 89, 0],
|
||||
"timestamp": 1787037667665,
|
||||
"direction": "rx",
|
||||
}
|
||||
],
|
||||
"can1Vehicle": {
|
||||
"parsed": {"speedKph": 0, "gear": 1}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertEqual(len(frames), 1)
|
||||
self.assertEqual(frames[0].arbitration_id, 0x388)
|
||||
self.assertEqual(frames[0].channel, 1)
|
||||
self.assertEqual(frames[0].data, bytes.fromhex("00 00 00 02 01 00 59 00"))
|
||||
self.assertTrue(frames[0].is_fd)
|
||||
self.assertTrue(frames[0].bitrate_switch)
|
||||
self.assertEqual(frames[0].timestamp, 1787037667.665)
|
||||
|
||||
def test_accepts_nested_can1_vehicle_signals_when_top_level_is_absent(self) -> None:
|
||||
frame = parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "n7-can-signals/v1",
|
||||
"can1Vehicle": {
|
||||
"signals": [
|
||||
{
|
||||
"channel": "can1",
|
||||
"canId": "0x42E",
|
||||
"frameType": "fd",
|
||||
"brs": True,
|
||||
"bytes": [17, 100, 50, 3, 144, 2, 0, 198],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)[0]
|
||||
self.assertEqual(frame.arbitration_id, 0x42E)
|
||||
self.assertTrue(frame.is_fd)
|
||||
|
||||
def test_accepts_nested_can0_seat_signals_with_can0_default(self) -> None:
|
||||
frame = parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "n7-can-signals/v1",
|
||||
"can0Seat": {
|
||||
"signals": [
|
||||
{
|
||||
"canId": "0x302",
|
||||
"frameType": "classic",
|
||||
"bytes": [0] * 8,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)[0]
|
||||
self.assertEqual(frame.arbitration_id, 0x302)
|
||||
self.assertEqual(frame.channel, 0)
|
||||
self.assertFalse(frame.is_fd)
|
||||
|
||||
def test_translates_seat_ota_info_responses_into_four_position_states(self) -> None:
|
||||
values = {
|
||||
1: (480 << 16) | 407,
|
||||
2: (373 << 16) | 266,
|
||||
3: (917 << 16) | 451,
|
||||
4: (647 << 16) | 575,
|
||||
5: 0x11,
|
||||
6: 0x11,
|
||||
7: 0x11,
|
||||
8: 0x11,
|
||||
}
|
||||
signals = []
|
||||
for selector, value in values.items():
|
||||
sequence = 0x20 + selector
|
||||
signals.extend(
|
||||
(
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x6A0",
|
||||
"frameType": "classic",
|
||||
"direction": "tx",
|
||||
"bytes": [0xC7, sequence, 0x60, selector, 0, 0, 0, 0],
|
||||
},
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x6A2",
|
||||
"frameType": "classic",
|
||||
"direction": "rx",
|
||||
"bytes": [0x7C, sequence, 0x60, 0, *value.to_bytes(4, "little")],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
frames = parse_mqtt_can_payload(json.dumps({"signals": signals}))
|
||||
positions = [frame for frame in frames if frame.arbitration_id == 0x302]
|
||||
|
||||
self.assertEqual([frame.data[0] for frame in positions], [0, 1, 2, 3])
|
||||
self.assertEqual(
|
||||
[int.from_bytes(frame.data[5:7], "little") for frame in positions],
|
||||
[round(407 * 10_000 / 480), round(266 * 10_000 / 373),
|
||||
round(451 * 10_000 / 917), round(575 * 10_000 / 647)],
|
||||
)
|
||||
self.assertTrue(all(frame.data[2] & (1 << 3) for frame in positions))
|
||||
|
||||
def test_rejects_can0_frames_outside_seat_firmware_format(self) -> None:
|
||||
for label, frame in (
|
||||
(
|
||||
"fd",
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x302",
|
||||
"frameType": "fd",
|
||||
"bytes": [0] * 8,
|
||||
},
|
||||
),
|
||||
(
|
||||
"brs",
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x302",
|
||||
"frameType": "classic",
|
||||
"brs": True,
|
||||
"bytes": [0] * 8,
|
||||
},
|
||||
),
|
||||
(
|
||||
"dlc",
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x302",
|
||||
"frameType": "classic",
|
||||
"bytes": [0] * 7,
|
||||
},
|
||||
),
|
||||
):
|
||||
with self.subTest(label=label), self.assertRaisesRegex(
|
||||
ValueError, "classic CAN, no BRS, and DLC 8"
|
||||
):
|
||||
parse_mqtt_can_payload(json.dumps(frame))
|
||||
|
||||
def test_top_level_signals_can_mix_can0_and_can1_without_nested_duplicates(self) -> None:
|
||||
frames = parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "n7-can-signals/v1",
|
||||
"signals": [
|
||||
{"channel": "can0", "canId": "0x302", "bytes": [0] * 8},
|
||||
{"channel": "can1", "canId": "0x342", "bytes": [0] * 8},
|
||||
],
|
||||
"can0Seat": {
|
||||
"signals": [
|
||||
{"channel": "can0", "canId": "0x302", "bytes": [0] * 8}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
[(frame.channel, frame.arbitration_id) for frame in frames],
|
||||
[(0, 0x302), (1, 0x342)],
|
||||
)
|
||||
|
||||
def test_rejects_decoded_state_invalid_channels_and_channel_conflicts(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "requires an ID and data"):
|
||||
parse_mqtt_can_payload(json.dumps({"gear": "P", "speed_kmh": 0}))
|
||||
with self.assertRaisesRegex(ValueError, "only CAN0 or CAN1"):
|
||||
parse_mqtt_can_payload(
|
||||
json.dumps({"id": 0x342, "channel": 2, "data": [0] * 8})
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "declarations conflict"):
|
||||
parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
{
|
||||
"id": 0x342,
|
||||
"channel": "can1",
|
||||
"channelIndex": 0,
|
||||
"data": [0] * 8,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_control_remains_locked_without_existing_hardware_approval(self) -> None:
|
||||
settings = VehicleMqttSettings(
|
||||
enabled=True,
|
||||
approval_required=True,
|
||||
approval_token="",
|
||||
host="127.0.0.1",
|
||||
port=1883,
|
||||
condition_topic="n7/n7-headunit/can/state",
|
||||
control_topic="n7/can_control",
|
||||
qos=1,
|
||||
keepalive_s=30,
|
||||
username=None,
|
||||
password=None,
|
||||
tls_enabled=False,
|
||||
feedback_timeout_ms=1_500,
|
||||
)
|
||||
with patch(
|
||||
"cognitive_agent.infrastructure.can.hardware._module_available",
|
||||
return_value=True,
|
||||
):
|
||||
self.assertTrue(settings.state_configured)
|
||||
self.assertFalse(settings.configured)
|
||||
with self.assertRaisesRegex(HardwareSafetyError, "not configured"):
|
||||
settings.authorize(None)
|
||||
|
||||
|
||||
class MqttCanTransportTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.client = _FakeMqttClient()
|
||||
self.transport = MqttCanTransport(
|
||||
_settings(),
|
||||
client_factory=lambda: self.client,
|
||||
)
|
||||
self.transport.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.transport.close()
|
||||
|
||||
def test_condition_frames_use_existing_decoder_and_snapshot_contract(self) -> None:
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
{
|
||||
"frames": [
|
||||
{"id": 0x342, "data": [0, 0, 0, 1, 0, 0, 0, 0]},
|
||||
{"id": 0x4EB, "data": [7, 0, 0, 0, 0, 0, 0, 0]},
|
||||
{"id": 0x407, "data": [0] * 8},
|
||||
{"id": 0x494, "data": [0] * 8},
|
||||
{"id": 0x4FA, "data": [0] * 8},
|
||||
{"id": 0x555, "data": [255] * 8},
|
||||
]
|
||||
},
|
||||
)
|
||||
snapshot = self.transport.snapshot()
|
||||
self.assertEqual(
|
||||
snapshot.observed_ids,
|
||||
frozenset({0x342, 0x4EB, 0x407, 0x494, 0x4FA}),
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
state["verification"]["transport"] == "wireless_mqtt"
|
||||
for state in snapshot.feedback_states
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.subscriptions,
|
||||
[("n7/n7-headunit/can/state", 1)],
|
||||
)
|
||||
|
||||
def test_first_partial_live_vehicle_snapshot_does_not_wait_for_missing_ids(self) -> None:
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
{
|
||||
"signals": [
|
||||
{
|
||||
"channel": "can1",
|
||||
"canId": "0x388",
|
||||
"frameType": "fd",
|
||||
"brs": True,
|
||||
"bytes": [0, 0, 0, 2, 1, 0, 89, 0],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
started = time.perf_counter()
|
||||
snapshot = self.transport.snapshot()
|
||||
|
||||
self.assertLess(time.perf_counter() - started, 0.05)
|
||||
self.assertEqual(snapshot.observed_ids, frozenset({0x388}))
|
||||
|
||||
def test_stale_safety_signal_cannot_enter_snapshot_or_executor_bus(self) -> None:
|
||||
bus = self.transport.bus(channel=1)
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
{
|
||||
"schema": "n7-can-signals/v1",
|
||||
"signals": [
|
||||
{
|
||||
"channel": "can1",
|
||||
"channelIndex": 1,
|
||||
"canId": "0x342",
|
||||
"frameType": "fd",
|
||||
"brs": True,
|
||||
"data": "00 00 00 01 00 00 00 00",
|
||||
"timestamp": round((time.time() - 60) * 1_000),
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
snapshot = self.transport.snapshot()
|
||||
info = self.transport.public_info()["state_sync"]
|
||||
self.assertEqual(info["capture_sequence"], 1)
|
||||
self.assertEqual(info["stale_frames"], 1)
|
||||
self.assertEqual(info["observed_frame_ids"], [])
|
||||
self.assertEqual(snapshot.observed_ids, frozenset())
|
||||
self.assertIsNone(bus.receive(0, include_fd=True))
|
||||
|
||||
def test_stale_non_safety_can1_frames_are_display_only(self) -> None:
|
||||
bus = self.transport.bus(channel=1)
|
||||
cached_timestamp = round((time.time() - 60) * 1_000)
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
{
|
||||
"schema": "n7-can-signals/v1",
|
||||
"signals": [
|
||||
{
|
||||
"channel": "can1",
|
||||
"canId": frame_id,
|
||||
"frameType": "fd",
|
||||
"brs": True,
|
||||
"data": data,
|
||||
"timestamp": cached_timestamp,
|
||||
}
|
||||
for frame_id, data in (
|
||||
("0x388", "00 00 00 02 01 00 59 00"),
|
||||
("0x41A", "00 5C 85 08 20 42 C1 FD"),
|
||||
("0x42E", "11 64 32 03 90 02 00 C6"),
|
||||
("0x3FF", "5B 09 FF 0F 32 61 04 12"),
|
||||
)
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
snapshot = self.transport.snapshot()
|
||||
self.assertEqual(
|
||||
snapshot.observed_ids,
|
||||
frozenset({0x388, 0x3FF, 0x41A, 0x42E}),
|
||||
)
|
||||
for state in snapshot.feedback_states:
|
||||
verification = state["verification"]
|
||||
self.assertFalse(verification["fresh"])
|
||||
self.assertTrue(verification["display_only"])
|
||||
self.assertFalse(verification["control_eligible"])
|
||||
self.assertGreater(verification["cache_age_ms"], 50_000)
|
||||
self.assertIsNone(bus.receive(0, include_fd=True))
|
||||
|
||||
def test_bus_publishes_only_canonical_frame_contract(self) -> None:
|
||||
bus = self.transport.bus()
|
||||
sent = bus.send(
|
||||
0x431,
|
||||
bytes.fromhex("10 00 00 00 00 00 00 00"),
|
||||
is_fd=True,
|
||||
bitrate_switch=True,
|
||||
)
|
||||
self.assertEqual(sent.channel, 1)
|
||||
self.assertEqual(len(self.client.published), 1)
|
||||
topic, raw_payload, qos, retain = self.client.published[0]
|
||||
payload = json.loads(raw_payload)
|
||||
self.assertEqual(topic, "n7/can_control")
|
||||
self.assertEqual(qos, 1)
|
||||
self.assertFalse(retain)
|
||||
self.assertEqual(payload["schema"], MQTT_CAN_SCHEMA)
|
||||
self.assertEqual(payload["arbitration_id"], 0x431)
|
||||
self.assertEqual(payload["data_hex"], "10 00 00 00 00 00 00 00")
|
||||
self.assertTrue(payload["is_fd"])
|
||||
self.assertTrue(payload["bitrate_switch"])
|
||||
with self.assertRaisesRegex(Exception, "approved CAN1 control set"):
|
||||
bus.send(0x555, bytes(8), is_fd=True)
|
||||
self.assertEqual(len(self.client.published), 1)
|
||||
|
||||
def test_can0_bus_publishes_only_classic_passenger_seat_control(self) -> None:
|
||||
bus = self.transport.bus(channel=0)
|
||||
sent = bus.send(0x6A0, bytes.fromhex("C7 01 64 00 F4 01 00 00"))
|
||||
payload = json.loads(self.client.published[-1][1])
|
||||
self.assertEqual(sent.channel, 0)
|
||||
self.assertEqual(payload["channel"], 0)
|
||||
self.assertFalse(payload["is_fd"])
|
||||
with self.assertRaisesRegex(Exception, "approved CAN0 control set"):
|
||||
bus.send(0x431, bytes(8))
|
||||
with self.assertRaisesRegex(Exception, "requires classic CAN"):
|
||||
bus.send(0x6A0, bytes.fromhex("C7 01 64 00 F4 01 00 00"),
|
||||
is_fd=True, bitrate_switch=True)
|
||||
with self.assertRaisesRegex(Exception, "approved CAN0 control set"):
|
||||
bus.send(0x300, bytes(8))
|
||||
with self.assertRaisesRegex(Exception, "only STOP, MOVE, HEARTBEAT, or DYNAMIC"):
|
||||
bus.send(0x6A0, bytes.fromhex("C7 02 61 00 00 00 00 00"))
|
||||
with self.assertRaisesRegex(Exception, "target 0..1000"):
|
||||
bus.send(0x6A0, bytes.fromhex("C7 03 64 00 E9 03 00 00"))
|
||||
with self.assertRaisesRegex(Exception, "approved channel policy"):
|
||||
self.transport.publish(
|
||||
CanFrame(arbitration_id=0x431, data=bytes(8), channel=0)
|
||||
)
|
||||
|
||||
def test_bus_receive_views_are_isolated_by_channel(self) -> None:
|
||||
can0 = self.transport.bus(channel=0)
|
||||
can1 = self.transport.bus(channel=1)
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
{
|
||||
"signals": [
|
||||
{"channel": "can0", "canId": "0x302", "bytes": [0] * 8},
|
||||
{
|
||||
"channel": "can1",
|
||||
"canId": "0x342",
|
||||
"frameType": "fd",
|
||||
"bytes": [0] * 8,
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
self.assertEqual(can0.receive(0).arbitration_id, 0x302)
|
||||
self.assertEqual(can1.receive(0, include_fd=True).arbitration_id, 0x342)
|
||||
self.assertIsNone(can0.receive(0))
|
||||
self.assertIsNone(can1.receive(0, include_fd=True))
|
||||
|
||||
def test_can0_status_bus_can_replay_latest_translated_ota_positions(self) -> None:
|
||||
signals = []
|
||||
for selector, value in {
|
||||
1: (480 << 16) | 407,
|
||||
2: (373 << 16) | 266,
|
||||
3: (917 << 16) | 451,
|
||||
4: (647 << 16) | 575,
|
||||
5: 0x11,
|
||||
6: 0x11,
|
||||
7: 0x11,
|
||||
8: 0x11,
|
||||
}.items():
|
||||
sequence = 0x40 + selector
|
||||
signals.extend(
|
||||
(
|
||||
{"channel": "can0", "canId": "0x6A0", "bytes":
|
||||
[0xC7, sequence, 0x60, selector, 0, 0, 0, 0]},
|
||||
{"channel": "can0", "canId": "0x6A2", "bytes":
|
||||
[0x7C, sequence, 0x60, 0, *value.to_bytes(4, "little")]},
|
||||
)
|
||||
)
|
||||
self.client.emit("n7/n7-headunit/can/state", {"signals": signals})
|
||||
|
||||
bus = self.transport.bus(channel=0, replay_latest=True)
|
||||
replayed = [bus.receive(0) for _ in range(4)]
|
||||
|
||||
self.assertEqual(
|
||||
[frame.data[0] for frame in replayed if frame is not None],
|
||||
[0, 1, 2, 3],
|
||||
)
|
||||
self.assertEqual(bus.receive(0).arbitration_id, 0x6A2)
|
||||
self.assertIsNone(bus.receive(0))
|
||||
self.assertEqual(
|
||||
self.transport.public_info()["state_sync"]["observed_frame_ids_by_channel"]["0"],
|
||||
["0x302", "0x6A2"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,218 @@
|
||||
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
|
||||
|
||||
|
||||
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.closed = False
|
||||
|
||||
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_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(
|
||||
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, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -54,6 +54,31 @@ class NativeCanFeedbackTests(unittest.TestCase):
|
||||
self.assertFalse(feedback_matches("indoor_light:on", feedback))
|
||||
self.assertTrue(feedback_matches("ambient_power:off", feedback))
|
||||
|
||||
def test_can1_matrix_rejects_reserved_ambient_color_values(self) -> None:
|
||||
decoded = decode_feedback(
|
||||
self.frame(0x42E, "11 64 32 03 90 02 00 C6")
|
||||
)["domain_state"]["ambient_light"]
|
||||
|
||||
self.assertIsNone(decoded["front_color_index"])
|
||||
self.assertIsNone(decoded["rear_color_index"])
|
||||
|
||||
def test_3ff_matrix_decodes_lighting_and_illuminance(self) -> None:
|
||||
light = decode_feedback(
|
||||
self.frame(0x3FF, "5B 09 FF 0F 32 61 04 12")
|
||||
)["domain_state"]["interior_light"]
|
||||
|
||||
self.assertEqual(light["power"], "unknown")
|
||||
self.assertEqual(light["footwell_power"], "off")
|
||||
self.assertEqual(light["ambient_illuminance_lux"], 510)
|
||||
|
||||
def test_3f3_matrix_decodes_wall_and_ambient_switches(self) -> None:
|
||||
decoded = decode_feedback(
|
||||
self.frame(0x3F3, "00 00 00 00 00 00 00 84")
|
||||
)["domain_state"]
|
||||
|
||||
self.assertEqual(decoded["interior_light"]["wall_light_power"], "on")
|
||||
self.assertEqual(decoded["ambient_light"]["power"], "on")
|
||||
|
||||
def test_driver_seat_feedback_uses_0x4fa_not_0x42a(self) -> None:
|
||||
passive_feedback = self.frame(0x4FA, "0980aa0000810000")
|
||||
passive_driver = decode_feedback(passive_feedback)["actuator_state"]["driver"]
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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()
|
||||
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from cognitive_agent.infrastructure.can.gateway import (
|
||||
ZlgCanBridgeProcess,
|
||||
ZlgChannelConfig,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.hardware import VehicleCanSettings
|
||||
from cognitive_agent.infrastructure.can.zlgcan_linux_bridge import (
|
||||
EXPECTED_ABI_SIZES,
|
||||
Driver,
|
||||
_acceptance_filters,
|
||||
_verified_init,
|
||||
abi_sizes,
|
||||
)
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class LinuxZlgBridgeTests(unittest.TestCase):
|
||||
def test_ctypes_layout_matches_vendor_packed_abi(self) -> None:
|
||||
self.assertEqual(abi_sizes(), EXPECTED_ABI_SIZES)
|
||||
|
||||
def test_verified_60mhz_timing_matches_can_dev_baseline(self) -> None:
|
||||
init = _verified_init(
|
||||
{
|
||||
"arbitration_bitrate": 500_000,
|
||||
"data_bitrate": 2_000_000,
|
||||
"can_fd_iso": True,
|
||||
}
|
||||
)
|
||||
self.assertEqual(init.clk, 60_000_000)
|
||||
self.assertEqual(init.mode, 0)
|
||||
self.assertEqual(
|
||||
(init.aset.tseg1, init.aset.tseg2, init.aset.sjw, init.aset.brp),
|
||||
(14, 3, 2, 5),
|
||||
)
|
||||
self.assertEqual(
|
||||
(init.dset.tseg1, init.dset.tseg2, init.dset.sjw, init.dset.brp),
|
||||
(10, 2, 2, 1),
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "locked"):
|
||||
_verified_init(
|
||||
{"arbitration_bitrate": 1_000_000, "data_bitrate": 4_000_000}
|
||||
)
|
||||
|
||||
def test_passive_state_filter_uses_exact_standard_identifiers(self) -> None:
|
||||
frame_ids = frozenset({0x342, 0x407, 0x4EB})
|
||||
filters = _acceptance_filters({"receive_ids": frame_ids})
|
||||
|
||||
self.assertEqual(filters.size, 3 * 12)
|
||||
self.assertEqual(
|
||||
[
|
||||
(
|
||||
filters.table[index].type,
|
||||
filters.table[index].sid,
|
||||
filters.table[index].eid,
|
||||
)
|
||||
for index in range(3)
|
||||
],
|
||||
[(0, 0x342, 0x342), (0, 0x407, 0x407), (0, 0x4EB, 0x4EB)],
|
||||
)
|
||||
|
||||
def test_channel_config_serializes_receive_filter_only_when_requested(self) -> None:
|
||||
filtered = ZlgChannelConfig(channel=1, receive_ids=(0x342, 0x4EB))
|
||||
unfiltered = ZlgChannelConfig(channel=1)
|
||||
|
||||
self.assertEqual(filtered.public()["receive_ids"], [0x342, 0x4EB])
|
||||
self.assertNotIn("receive_ids", unfiltered.public())
|
||||
|
||||
def test_monitor_only_rejects_send_before_touching_sdk(self) -> None:
|
||||
driver = object.__new__(Driver)
|
||||
driver.monitor_only = True
|
||||
driver.configs = {1: {"channel": 1, "frame_format": "can_fd"}}
|
||||
driver.sdk = Mock()
|
||||
|
||||
with self.assertRaisesRegex(PermissionError, "monitor-only"):
|
||||
driver.send(
|
||||
1,
|
||||
0x123,
|
||||
bytes.fromhex("0001020304050607"),
|
||||
is_fd=True,
|
||||
bitrate_switch=True,
|
||||
)
|
||||
|
||||
driver.sdk.VCI_TransmitFD.assert_not_called()
|
||||
driver.sdk.VCI_Transmit.assert_not_called()
|
||||
|
||||
def test_linux_state_can_be_ready_while_transmit_stays_locked(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
library = Path(directory) / "libusbcanfd.so"
|
||||
library.write_bytes(b"test")
|
||||
environment = {
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "true",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_BACKEND": "zlgcan-linux",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL": "1",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_REQUIRED_CHANNEL": "1",
|
||||
"COGNITIVE_AGENT_ZLGCAN_PYTHON": sys.executable,
|
||||
"COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY": str(library),
|
||||
}
|
||||
with patch.dict(os.environ, environment, clear=True):
|
||||
settings = VehicleCanSettings.from_env(REPOSITORY_ROOT)
|
||||
self.assertEqual(settings.device_type, 33)
|
||||
self.assertTrue(settings.state_configured)
|
||||
self.assertFalse(settings.configured)
|
||||
self.assertTrue(settings.bridge_script.name.endswith("linux_bridge.py"))
|
||||
self.assertTrue(settings.public_info()["state_configured"])
|
||||
|
||||
def test_process_passes_monitor_and_trace_flags_to_bridge(self) -> None:
|
||||
process = Mock()
|
||||
process.stdout = io.StringIO('{"ok":true,"event":"ready"}\n')
|
||||
process.stderr = io.StringIO()
|
||||
process.stdin = io.StringIO()
|
||||
process.poll.return_value = 0
|
||||
with patch(
|
||||
"cognitive_agent.infrastructure.can.gateway.subprocess.Popen",
|
||||
return_value=process,
|
||||
) as popen:
|
||||
bridge = ZlgCanBridgeProcess(
|
||||
python_executable=Path(sys.executable),
|
||||
bridge_script=Path("bridge.py"),
|
||||
dll_path=Path("libusbcanfd.so"),
|
||||
device_type=33,
|
||||
device_index=0,
|
||||
channels=[ZlgChannelConfig(channel=1, termination_enabled=False)],
|
||||
monitor_only=True,
|
||||
trace_all=True,
|
||||
)
|
||||
command = popen.call_args.args[0]
|
||||
self.assertIn("--monitor-only", command)
|
||||
self.assertIn("--trace-all", command)
|
||||
bridge.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user