Files
Cognitive_Agent/tests/unit/test_mqtt_can_transport.py
T

885 lines
34 KiB
Python

from __future__ import annotations
import json
import tempfile
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from cognitive_agent.application.native_can_runtime import NativeCanRuntime
from cognitive_agent.application.seat_service import SeatDemoService
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,
)
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
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/n7-headunit/can/command",
"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/n7-headunit/can/command",
}
return SimpleNamespace(**values)
class MqttCanPayloadTests(unittest.TestCase):
def test_default_topics_and_public_contracts_match_headunit(self) -> None:
with patch.dict("os.environ", {}, clear=True):
settings = VehicleMqttSettings.from_env()
info = settings.public_info()
self.assertEqual(settings.condition_topic, "n7/n7-headunit/can/state")
self.assertEqual(settings.control_topic, "n7/n7-headunit/can/command")
self.assertFalse(settings.approval_required)
self.assertEqual(info["state_contract"], "n7-can-signals/v1")
self.assertEqual(info["control_contract"], "n7-headunit-can-command/v1")
def test_explicit_shared_approval_policy_still_locks_mqtt_control(self) -> None:
with patch.dict(
"os.environ",
{"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED": "true"},
clear=True,
):
settings = VehicleMqttSettings.from_env()
self.assertTrue(settings.approval_required)
self.assertFalse(settings.configured)
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_ignores_legacy_can0_state_outside_seat_ota_wire_protocol(self) -> None:
frames = parse_mqtt_can_payload(
json.dumps(
{
"schema": "n7-can-signals/v1",
"can0Seat": {
"signals": [
{
"canId": "0x302",
"frameType": "classic",
"bytes": [0] * 8,
}
]
},
}
)
)
self.assertEqual(frames, [])
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,
}
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": "0x6A0",
"frameType": "fd",
"bytes": [0] * 8,
},
),
(
"brs",
{
"channel": "can0",
"canId": "0x6A2",
"frameType": "classic",
"brs": True,
"bytes": [0] * 8,
},
),
(
"dlc",
{
"channel": "can0",
"canId": "0x6A2",
"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],
[(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/n7-headunit/can/command",
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.assertTrue(
all(
state["source"] == "wireless_mqtt_ch1"
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_vehicle_envelope_four_frames_decode_from_raw_can1_bytes(self) -> None:
self.client.emit(
"n7/n7-headunit/can/state",
{
"schema": "n7-can-signals/v1",
"sequence": 335,
"signals": [
{"channel": "can1", "canId": frame_id, "frameType": "fd",
"brs": True, "data": data}
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"),
)
],
# This summary is deliberately inconsistent. Only the raw CAN
# bytes above are allowed to update authoritative UI state.
"can1Vehicle": {"parsed": {"speedKph": 123, "gear": 4}},
},
)
snapshot = self.transport.snapshot()
self.assertEqual(
snapshot.observed_ids,
frozenset({0x388, 0x3FF, 0x41A, 0x42E}),
)
by_id = {state["frame_id"]: state for state in snapshot.feedback_states}
self.assertEqual(
by_id["0x388"]["domain_state"]["window"]["positions"]["rear_left"]["position_pct"],
2,
)
self.assertEqual(
by_id["0x41A"]["domain_state"]["roof"]["shade_position_pct"],
92,
)
self.assertIsNone(
by_id["0x42E"]["domain_state"]["ambient_light"]["front_color_index"]
)
self.assertEqual(
by_id["0x3FF"]["domain_state"]["interior_light"]["ambient_illuminance_lux"],
510,
)
self.assertTrue(
all(state["source"] == "wireless_mqtt_ch1" for state in by_id.values())
)
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_headunit_command_contract(self) -> None:
bus = self.transport.bus()
bus.set_debug_command("shade position 20")
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/n7-headunit/can/command")
self.assertEqual(qos, 1)
self.assertFalse(retain)
self.assertRegex(payload["requestId"], r"^cognitive-agent-[0-9a-f]{32}$")
self.assertEqual(payload["command"], "shade position 20")
self.assertEqual(payload["channel"], "can1")
self.assertEqual(payload["canId"], "0x431")
self.assertEqual(payload["frameType"], "fd")
self.assertTrue(payload["brs"])
self.assertFalse(payload["extended"])
self.assertFalse(payload["remote"])
self.assertEqual(payload["data"], "10 00 00 00 00 00 00 00")
self.assertEqual(payload["repeat"], 1)
self.assertEqual(payload["intervalMs"], 0)
self.assertEqual(payload["responseTimeoutMs"], 100)
publication = bus.published_messages[0]
self.assertEqual(publication["topic"], topic)
self.assertEqual(publication["qos"], qos)
self.assertEqual(publication["retain"], retain)
self.assertEqual(publication["payload"], payload)
self.assertTrue(
{
"schema",
"arbitration_id",
"frame_id",
"data_hex",
"is_fd",
"bitrate_switch",
}.isdisjoint(payload)
)
bus.send(
0x431,
bytes.fromhex("10 00 00 00 00 00 00 00"),
is_fd=True,
bitrate_switch=True,
)
second_payload = json.loads(self.client.published[1][1])
self.assertNotEqual(payload["requestId"], second_payload["requestId"])
with self.assertRaisesRegex(Exception, "FD command requires BRS"):
bus.send(0x431, bytes(8), is_fd=True, bitrate_switch=False)
with self.assertRaisesRegex(Exception, "classic CAN command cannot enable BRS"):
bus.send(0x431, bytes(8), is_fd=False, bitrate_switch=True)
with self.assertRaisesRegex(Exception, "approved CAN1 control set"):
bus.send(0x555, bytes(8), is_fd=True)
with self.assertRaisesRegex(Exception, "at most 256"):
bus.set_debug_command("x" * 257)
self.assertEqual(len(self.client.published), 2)
self.assertEqual(len(bus.published_messages), 2)
def test_can1_whitelist_publishes_rear_lock_candidate_with_debug_command(self) -> None:
bus = self.transport.bus(channel=1)
bus.set_debug_command("door rear lock")
sent = bus.send(0x32B, bytes.fromhex("0A 40 00 00 00 00 00 00"))
self.assertEqual(sent.arbitration_id, 0x32B)
self.assertFalse(sent.is_fd)
topic, raw_payload, qos, retain = self.client.published[-1]
payload = json.loads(raw_payload)
self.assertEqual(topic, "n7/n7-headunit/can/command")
self.assertEqual(qos, 1)
self.assertFalse(retain)
self.assertEqual(payload["command"], "door rear lock")
self.assertEqual(payload["channel"], "can1")
self.assertEqual(payload["canId"], "0x32B")
self.assertEqual(payload["frameType"], "classic")
self.assertFalse(payload["brs"])
self.assertEqual(payload["data"], "0A 40 00 00 00 00 00 00")
def test_wireless_whitelisted_climate_plan_publishes_without_safety_or_feedback_frames(
self,
) -> None:
service = SeatDemoService(REPOSITORY_ROOT)
service._vehicle_mqtt_transport = self.transport
service.vehicle_mqtt_settings = SimpleNamespace(feedback_timeout_ms=1)
actions = [
{
"domain": "climate",
"capability": "climate.set_temperature",
"target": "driver",
"operation": "set",
"parameters": {"temperature_c": 24},
},
{
"domain": "climate",
"capability": "climate.set_fan_level",
"target": "vehicle",
"operation": "set",
"parameters": {"level": 2},
},
]
runtime = NativeCanRuntime(REPOSITORY_ROOT)
config = runtime.build_function_config(
"climate", actions, "req-wireless-climate", "local_rules"
)
plan = runtime.compile(config)
with (
patch(
"cognitive_agent.application.seat_service.observe_vehicle_safety",
side_effect=AssertionError(
"wireless whitelist publish must not require 0x342/0x4EB"
),
),
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
):
reports = service._execute_orchestrated_reports(
{"native_climate": config},
{"native_climate": plan},
{"native_climate"},
{},
hardware_request=True,
simulated_hardware_request=False,
simulation_fault="normal",
control_transport="wireless_mqtt",
)
report = reports["native_climate"]
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual(
report["spec"]["vehicle_safety_preflight"]["policy"],
"mqtt_whitelist_publish",
)
self.assertEqual(len(self.client.published), 6)
payloads = [json.loads(item[1]) for item in self.client.published]
self.assertTrue(
all(
topic == "n7/n7-headunit/can/command"
for topic, _payload, _qos, _retain in self.client.published
)
)
self.assertTrue(all(payload["channel"] == "can1" for payload in payloads))
self.assertTrue(all(payload["canId"] == "0x1DE" for payload in payloads))
self.assertEqual(
[payload["command"] for payload in payloads],
["ac temp driver 24"] * 3 + ["ac fan 2"] * 3,
)
self.assertEqual(
[payload["data"] for payload in payloads],
[
"00 00 00 00 18 00 00 00",
"00 00 00 00 18 00 00 00",
"00 00 00 00 18 00 00 00",
"20 00 00 00 00 00 00 00",
"20 00 00 00 00 00 00 00",
"20 00 00 00 00 00 00 00",
],
)
for result in report["spec"]["results"]:
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["telemetry"]["delivery_status"], "published")
self.assertFalse(result["telemetry"]["feedback_required"])
self.assertFalse(result["telemetry"]["physical_effect_confirmed"])
self.assertEqual(len(result["telemetry"]["mqtt_messages"]), 3)
self.assertTrue(
all(
message["topic"] == "n7/n7-headunit/can/command"
for message in result["telemetry"]["mqtt_messages"]
)
)
def test_model_added_nonwhitelisted_action_does_not_block_whitelisted_publish(
self,
) -> None:
service = SeatDemoService(REPOSITORY_ROOT)
service._vehicle_mqtt_transport = self.transport
service.vehicle_mqtt_settings = SimpleNamespace(
enabled=True,
configured=True,
feedback_timeout_ms=1,
authorize=lambda _token: None,
)
model_output = {
"detected_domains": ["climate", "ambient_light"],
"requests": [
{
"domain": "ambient_light",
"capability": "ambient_light.set_mode",
"target_scope": "vehicle",
"operation": "set",
"parameters": [{"name": "mode", "value": "cinema"}],
"reason": "模型根据观影状态补充了氛围灯模式。",
},
{
"domain": "climate",
"capability": "climate.set_temperature",
"target_scope": "driver",
"operation": "set",
"parameters": [{"name": "temperature_c", "value": 24}],
"reason": "用户明确指定温度。",
},
{
"domain": "climate",
"capability": "climate.set_fan_level",
"target_scope": "vehicle",
"operation": "set",
"parameters": [{"name": "level", "value": 2}],
"reason": "用户明确指定风量。",
},
],
"ambiguities": [],
"decision_summary": ["执行用户明确指定的观影空调设置。"],
"confidence": 0.96,
}
with tempfile.TemporaryDirectory() as temporary_directory:
service.artifact_root = Path(temporary_directory)
service.openai.api_key = "test-key"
with (
patch.object(service.openai, "interpret", return_value=model_output),
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
):
result = service.process(
"进入观影状态:空调24度、风量2档,",
provider="openai",
conversation_id="conv-wireless-movie-climate",
execution_mode="hardware",
control_transport="wireless_mqtt",
)
self.assertEqual(result["status"], "partially_succeeded")
self.assertEqual(
[item["capability"] for item in result["deferred_requests"]],
["ambient_light.set_mode"],
)
self.assertEqual(
result["deferred_requests"][0]["rejection_code"],
"WIRELESS_MQTT_CONTROL_NOT_WHITELISTED",
)
self.assertEqual(len(self.client.published), 6)
self.assertTrue(
all(
topic == "n7/n7-headunit/can/command"
and json.loads(payload)["canId"] == "0x1DE"
for topic, payload, _qos, _retain in self.client.published
)
)
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"], "can0")
self.assertEqual(payload["canId"], "0x6A0")
self.assertEqual(payload["command"], "")
self.assertEqual(payload["frameType"], "classic")
self.assertFalse(payload["brs"])
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": "0x6A0", "bytes":
[0xC7, 0x21, 0x60, 1, 0, 0, 0, 0]},
{"channel": "can0", "canId": "0x6A2", "bytes":
[0x7C, 0x21, 0x60, 0, 0xE0, 0x01, 0xE0, 0x01]},
{
"channel": "can1",
"canId": "0x342",
"frameType": "fd",
"bytes": [0] * 8,
},
]
},
)
self.assertEqual(can0.receive(0).arbitration_id, 0x6A2)
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,
}.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()