1502 lines
57 KiB
Python
1502 lines
57 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
|
|
mid = 17
|
|
|
|
def is_published(self) -> bool:
|
|
return True
|
|
|
|
def wait_for_publish(self, timeout: float) -> bool:
|
|
raise AssertionError("control publish must never block for acknowledgement")
|
|
|
|
|
|
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_accepts_native_passenger_seat_firmware_status(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(len(frames), 1)
|
|
self.assertEqual(frames[0].channel, 0)
|
|
self.assertEqual(frames[0].arbitration_id, 0x302)
|
|
|
|
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_translates_headunit_6a2_selector_metadata_without_6a0_requests(
|
|
self,
|
|
) -> None:
|
|
values = {
|
|
1: bytes.fromhex("E0 01 E0 01"), # 480 / 480
|
|
2: bytes.fromhex("00 00 75 01"), # 0 / 373
|
|
3: bytes.fromhex("00 00 95 03"), # 0 / 917
|
|
4: bytes.fromhex("BF 01 87 02"), # 447 / 647
|
|
5: bytes.fromhex("11 00 00 00"),
|
|
6: bytes.fromhex("11 00 00 00"),
|
|
7: bytes.fromhex("11 00 02 00"),
|
|
8: bytes.fromhex("11 00 00 00"),
|
|
}
|
|
frames = parse_mqtt_can_payload(
|
|
json.dumps(
|
|
{
|
|
"schema": "n7-can-signals/v1",
|
|
"signals": [
|
|
{
|
|
"channel": "can0",
|
|
"channelIndex": 0,
|
|
"canId": "0x6A2",
|
|
"frameType": "classic",
|
|
"brs": False,
|
|
"dlc": 8,
|
|
"selector": selector,
|
|
"bytes": [0x7C, 0x30 + selector, 0x60, 0, *value],
|
|
"direction": "rx",
|
|
}
|
|
for selector, value in values.items()
|
|
],
|
|
}
|
|
)
|
|
)
|
|
|
|
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],
|
|
[10_000, 0, 0, round(447 * 10_000 / 647)],
|
|
)
|
|
self.assertTrue(all(frame.data[2] & (1 << 3) for frame in positions))
|
|
|
|
def test_rejects_invalid_or_conflicting_headunit_seat_selector_metadata(
|
|
self,
|
|
) -> None:
|
|
with self.assertRaisesRegex(ValueError, "integer from 0 to 14"):
|
|
parse_mqtt_can_payload(
|
|
json.dumps(
|
|
{
|
|
"channel": "can0",
|
|
"canId": "0x6A2",
|
|
"selector": "1",
|
|
"bytes": [0x7C, 1, 0x60, 0, 0xE0, 1, 0xE0, 1],
|
|
}
|
|
)
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "valid 0x6A2"):
|
|
parse_mqtt_can_payload(
|
|
json.dumps(
|
|
{
|
|
"channel": "can0",
|
|
"canId": "0x302",
|
|
"selector": 1,
|
|
"bytes": [0] * 8,
|
|
}
|
|
)
|
|
)
|
|
|
|
frames = parse_mqtt_can_payload(
|
|
json.dumps(
|
|
{
|
|
"signals": [
|
|
{"channel": "can0", "canId": "0x6A0", "bytes":
|
|
[0xC7, 0x21, 0x60, 1, 0, 0, 0, 0]},
|
|
{"channel": "can0", "canId": "0x6A2", "selector": 2,
|
|
"bytes": [0x7C, 0x21, 0x60, 0, 0xE0, 1, 0xE0, 1]},
|
|
]
|
|
}
|
|
)
|
|
)
|
|
self.assertFalse(any(frame.arbitration_id == 0x302 for frame in frames))
|
|
|
|
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],
|
|
[(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/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_native_can0_status_maps_to_passenger_frontend_state(self) -> None:
|
|
self.client.emit(
|
|
"n7/n7-headunit/can/state",
|
|
{
|
|
"schema": "n7-can-signals/v1",
|
|
"signals": [
|
|
{
|
|
"channel": "can0",
|
|
"canId": "0x302",
|
|
"frameType": "classic",
|
|
"bytes": [axis, 0x26, 0x08, 0, 0, value & 0xFF,
|
|
(value >> 8) & 0xFF, 0],
|
|
}
|
|
for axis, value in enumerate((1000, 2500, 5000, 9000))
|
|
] + [
|
|
{
|
|
"channel": "can0",
|
|
"canId": "0x4FA",
|
|
"frameType": "classic",
|
|
"bytes": [0x22, 0, 0, 0, 0, 0, 0, 0],
|
|
},
|
|
{
|
|
"channel": "can0",
|
|
"canId": "0x306",
|
|
"frameType": "classic",
|
|
"bytes": [1, 2, 3, 0x0F, 2, 0, 100, 0],
|
|
},
|
|
],
|
|
},
|
|
)
|
|
service = SeatDemoService(REPOSITORY_ROOT)
|
|
service._vehicle_mqtt_transport = self.transport
|
|
service.vehicle_mqtt_settings = SimpleNamespace(state_configured=True)
|
|
|
|
payload = service._synchronize_hardware_state_once(
|
|
"conv-native-can0-state",
|
|
control_transport="wireless_mqtt",
|
|
)
|
|
|
|
passenger = payload["actuator_state"]["passenger"]
|
|
self.assertEqual(
|
|
{
|
|
axis: value["position_pct"]
|
|
for axis, value in passenger["position"].items()
|
|
if axis in {
|
|
"longitudinal",
|
|
"cushion_tilt",
|
|
"backrest_recline",
|
|
"legrest_height",
|
|
}
|
|
},
|
|
{
|
|
"longitudinal": 10,
|
|
"cushion_tilt": 25,
|
|
"backrest_recline": 50,
|
|
"legrest_height": 90,
|
|
},
|
|
)
|
|
self.assertEqual(passenger["ventilation"]["level"], 1)
|
|
self.assertEqual(passenger["heating"]["level"], 3)
|
|
self.assertEqual(passenger["dynamic_mode"]["mode_id"], 1)
|
|
self.assertEqual(passenger["dynamic_mode"]["state"], "running")
|
|
self.assertTrue(
|
|
all(
|
|
item["feedback_source"] == "wireless_mqtt_ch0"
|
|
for item in passenger["position"].values()
|
|
if item.get("position_valid") is not None
|
|
)
|
|
)
|
|
|
|
def test_headunit_6a2_selector_state_maps_to_passenger_frontend_state(self) -> None:
|
|
values = {
|
|
1: bytes.fromhex("E0 01 E0 01"),
|
|
2: bytes.fromhex("00 00 75 01"),
|
|
3: bytes.fromhex("00 00 95 03"),
|
|
4: bytes.fromhex("BF 01 87 02"),
|
|
5: bytes.fromhex("11 00 00 00"),
|
|
6: bytes.fromhex("11 00 00 00"),
|
|
7: bytes.fromhex("11 00 02 00"),
|
|
8: bytes.fromhex("11 00 00 00"),
|
|
}
|
|
self.client.emit(
|
|
"n7/n7-headunit/can/state",
|
|
{
|
|
"schema": "n7-can-signals/v1",
|
|
"signals": [
|
|
{
|
|
"channel": "can0",
|
|
"canId": "0x6A2",
|
|
"selector": selector,
|
|
"frameType": "classic",
|
|
"bytes": [0x7C, 0x50 + selector, 0x60, 0, *value],
|
|
}
|
|
for selector, value in values.items()
|
|
],
|
|
},
|
|
)
|
|
service = SeatDemoService(REPOSITORY_ROOT)
|
|
service._vehicle_mqtt_transport = self.transport
|
|
service.vehicle_mqtt_settings = SimpleNamespace(state_configured=True)
|
|
|
|
payload = service._synchronize_hardware_state_once(
|
|
"conv-headunit-selector-can0-state",
|
|
control_transport="wireless_mqtt",
|
|
)
|
|
|
|
passenger = payload["actuator_state"]["passenger"]
|
|
self.assertEqual(
|
|
{
|
|
axis: passenger["position"][axis]["position_pct"]
|
|
for axis in (
|
|
"longitudinal",
|
|
"cushion_tilt",
|
|
"backrest_recline",
|
|
"legrest_height",
|
|
)
|
|
},
|
|
{
|
|
"longitudinal": 100,
|
|
"cushion_tilt": 0,
|
|
"backrest_recline": 0,
|
|
"legrest_height": 69,
|
|
},
|
|
)
|
|
self.assertEqual(payload["source"], "wireless_mqtt_ch0")
|
|
self.assertTrue(payload["complete"])
|
|
|
|
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_outlet_switch_feedback_is_available_to_the_can1_executor_bus(self) -> None:
|
|
bus = self.transport.bus(channel=1)
|
|
data = bytearray(64)
|
|
data[41:45] = bytes((0x26, 0x80, 0x80, 0x80))
|
|
self.client.emit(
|
|
"n7/n7-headunit/can/state",
|
|
{
|
|
"signals": [
|
|
{
|
|
"channel": "can1",
|
|
"canId": "0x4BB",
|
|
"frameType": "fd",
|
|
"brs": True,
|
|
"bytes": list(data),
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
feedback = bus.receive(0, include_fd=True)
|
|
self.assertIsNotNone(feedback)
|
|
self.assertEqual(feedback.arbitration_id, 0x4BB)
|
|
self.assertEqual(feedback.data[41], 0x26)
|
|
|
|
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.assertFalse(payload["expectResponse"])
|
|
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["delivery_status"], "published")
|
|
self.assertEqual(publication["message_id"], 17)
|
|
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_publish_returns_queued_without_waiting_for_broker_ack(self) -> None:
|
|
class QueuedPublishResult:
|
|
rc = 0
|
|
mid = 29
|
|
|
|
@staticmethod
|
|
def is_published() -> bool:
|
|
return False
|
|
|
|
@staticmethod
|
|
def wait_for_publish(timeout: float) -> bool:
|
|
raise AssertionError(
|
|
f"wait_for_publish({timeout}) must not be called"
|
|
)
|
|
|
|
with patch.object(
|
|
self.client,
|
|
"publish",
|
|
return_value=QueuedPublishResult(),
|
|
):
|
|
publication = self.transport.publish(
|
|
CanFrame(
|
|
arbitration_id=0x431,
|
|
data=bytes.fromhex("10 00 00 00 00 00 00 00"),
|
|
channel=1,
|
|
is_fd=True,
|
|
bitrate_switch=True,
|
|
),
|
|
command="shade position 20",
|
|
)
|
|
|
|
self.assertEqual(publication["delivery_status"], "queued")
|
|
self.assertEqual(publication["message_id"], 29)
|
|
|
|
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.assertEqual(
|
|
result["telemetry"]["verification_status"],
|
|
"submitted_unverified",
|
|
)
|
|
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_wireless_individual_outlet_plan_publishes_observed_frame_triplet(
|
|
self,
|
|
) -> None:
|
|
service = SeatDemoService(REPOSITORY_ROOT)
|
|
service._vehicle_mqtt_transport = self.transport
|
|
service.vehicle_mqtt_settings = SimpleNamespace(feedback_timeout_ms=1)
|
|
runtime = NativeCanRuntime(REPOSITORY_ROOT)
|
|
config = runtime.build_function_config(
|
|
"outlet",
|
|
[
|
|
{
|
|
"domain": "outlet",
|
|
"capability": "outlet.set_switch_state",
|
|
"target": "passenger_right",
|
|
"operation": "set",
|
|
"parameters": {"state": "close"},
|
|
}
|
|
],
|
|
"req-wireless-outlet-switch",
|
|
"local_rules",
|
|
)
|
|
plan = runtime.compile(config)
|
|
|
|
with patch("cognitive_agent.application.native_can_runtime.time.sleep"):
|
|
reports = service._execute_orchestrated_reports(
|
|
{"native_outlet": config},
|
|
{"native_outlet": plan},
|
|
{"native_outlet"},
|
|
{},
|
|
hardware_request=True,
|
|
simulated_hardware_request=False,
|
|
simulation_fault="normal",
|
|
control_transport="wireless_mqtt",
|
|
)
|
|
|
|
self.assertEqual(reports["native_outlet"]["spec"]["status"], "succeeded")
|
|
payloads = [json.loads(item[1]) for item in self.client.published]
|
|
self.assertEqual(len(payloads), 3)
|
|
self.assertTrue(
|
|
all(payload["command"] == "outlet passenger-right close" for payload in payloads)
|
|
)
|
|
self.assertTrue(all(payload["channel"] == "can1" for payload in payloads))
|
|
self.assertTrue(all(payload["canId"] == "0x4EF" for payload in payloads))
|
|
self.assertTrue(all(payload["frameType"] == "fd" for payload in payloads))
|
|
self.assertTrue(all(payload["brs"] is True for payload in payloads))
|
|
self.assertTrue(
|
|
all(payload["data"] == "03 FF FF FF FF 08 00 00" for payload in payloads)
|
|
)
|
|
|
|
def test_wireless_hardware_can1_service_skips_local_hardware_connection(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,
|
|
)
|
|
validated_events: list[dict[str, object]] = []
|
|
|
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
service.artifact_root = Path(temporary_directory)
|
|
with (
|
|
patch.object(
|
|
service.hardware_execution,
|
|
"reserve",
|
|
side_effect=AssertionError(
|
|
"wireless CAN1 must not reserve the local physical bench"
|
|
),
|
|
),
|
|
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
|
|
):
|
|
result = service.process(
|
|
"空调温度设置为24度",
|
|
provider="local",
|
|
conversation_id="conv-wireless-can1-no-local-hardware",
|
|
execution_mode="hardware",
|
|
control_transport="wireless_mqtt",
|
|
_on_commands_validated=validated_events.append,
|
|
)
|
|
|
|
self.assertEqual(result["status"], "succeeded")
|
|
self.assertEqual(list(result["command_plans"]), ["native_climate"])
|
|
self.assertEqual(len(validated_events), 1)
|
|
self.assertEqual(
|
|
list(validated_events[0]["command_plans"]),
|
|
["native_climate"],
|
|
)
|
|
payloads = [json.loads(item[1]) for item in self.client.published]
|
|
self.assertEqual(len(payloads), 3)
|
|
self.assertTrue(all(item["channel"] == "can1" for item in payloads))
|
|
self.assertTrue(all(item["command"] == "ac temp driver 24" for item in payloads))
|
|
|
|
def test_wireless_hardware_combines_can0_and_can1_without_local_profile(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,
|
|
)
|
|
validated_events: list[dict[str, object]] = []
|
|
seat_execution: dict[str, object] = {}
|
|
|
|
def execute_seat(
|
|
plan: dict[str, object],
|
|
config: dict[str, object],
|
|
_state: dict[str, object],
|
|
) -> dict[str, object]:
|
|
seat_execution["plan"] = plan
|
|
seat_execution["config"] = config
|
|
operation = config["spec"]["operations"][0] # type: ignore[index]
|
|
request_id = config["metadata"]["request_id"] # type: ignore[index]
|
|
return {
|
|
"apiVersion": "cognitive-agent/v1alpha1",
|
|
"kind": "ExecutionReport",
|
|
"metadata": {
|
|
"id": f"report-{request_id}",
|
|
"name": "wireless CAN0 test report",
|
|
"version": "1.0.0",
|
|
"request_id": request_id,
|
|
"labels": {
|
|
"simulation": "false",
|
|
"seat_feedback": "wireless_mqtt_ch0",
|
|
},
|
|
},
|
|
"spec": {
|
|
"mode": "hil",
|
|
"status": "succeeded",
|
|
"started_at": "2026-08-25T00:00:00Z",
|
|
"finished_at": "2026-08-25T00:00:00Z",
|
|
"results": [
|
|
{
|
|
"ref": operation["id"],
|
|
"domain": "seat",
|
|
"status": "succeeded",
|
|
"message": "wireless CAN0 command published",
|
|
"telemetry": {
|
|
"effective_position_pct": 35,
|
|
"position_valid": True,
|
|
"simulated": False,
|
|
},
|
|
}
|
|
],
|
|
},
|
|
}
|
|
|
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
service.artifact_root = Path(temporary_directory)
|
|
with (
|
|
patch.object(
|
|
type(service.hardware_settings),
|
|
"validate_function_config",
|
|
side_effect=AssertionError(
|
|
"wireless mixed CAN0/CAN1 must not check combined profile"
|
|
),
|
|
),
|
|
patch.object(
|
|
service.hardware_execution,
|
|
"reserve",
|
|
side_effect=AssertionError(
|
|
"wireless mixed CAN0/CAN1 must not reserve local hardware"
|
|
),
|
|
),
|
|
patch.object(
|
|
service,
|
|
"_new_hardware_executor",
|
|
return_value=SimpleNamespace(execute=execute_seat),
|
|
) as executor_factory,
|
|
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
|
|
):
|
|
result = service.process(
|
|
"把副驾座椅水平位置调到35%,空调温度设置为24度",
|
|
provider="local",
|
|
conversation_id="conv-wireless-can0-can1-no-local-hardware",
|
|
execution_mode="hardware",
|
|
control_transport="wireless_mqtt",
|
|
_on_commands_validated=validated_events.append,
|
|
)
|
|
|
|
self.assertEqual(result["status"], "succeeded")
|
|
self.assertEqual(
|
|
set(result["command_plans"]),
|
|
{"seat", "native_climate"},
|
|
)
|
|
self.assertEqual(len(validated_events), 1)
|
|
self.assertEqual(
|
|
set(validated_events[0]["command_plans"]),
|
|
{"seat", "native_climate"},
|
|
)
|
|
self.assertEqual(
|
|
seat_execution["plan"]["spec"]["commands"][0]["message"], # type: ignore[index]
|
|
"T2G_Seat_Move_Command_300",
|
|
)
|
|
self.assertTrue(executor_factory.call_args.kwargs["remote_transport"])
|
|
payloads = [json.loads(item[1]) for item in self.client.published]
|
|
self.assertEqual(len(payloads), 3)
|
|
self.assertTrue(all(item["channel"] == "can1" for item in payloads))
|
|
self.assertTrue(all(item["command"] == "ac temp driver 24" for item in payloads))
|
|
|
|
def test_wireless_fixed_controls_and_shade_preset_never_wait_for_state(
|
|
self,
|
|
) -> None:
|
|
service = SeatDemoService(REPOSITORY_ROOT)
|
|
service._vehicle_mqtt_transport = self.transport
|
|
service.vehicle_mqtt_settings = SimpleNamespace(feedback_timeout_ms=1_500)
|
|
runtime = NativeCanRuntime(REPOSITORY_ROOT)
|
|
actions_by_domain = {
|
|
"climate": [
|
|
{
|
|
"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},
|
|
},
|
|
],
|
|
"roof": [
|
|
{
|
|
"domain": "roof",
|
|
"capability": "roof.set_shade_motion",
|
|
"target": "vehicle",
|
|
"operation": "set",
|
|
"parameters": {"motion": "open"},
|
|
}
|
|
],
|
|
"ambient_light": [
|
|
{
|
|
"domain": "ambient_light",
|
|
"capability": "ambient_light.set_power",
|
|
"target": "vehicle",
|
|
"operation": "set",
|
|
"parameters": {"state": "on"},
|
|
}
|
|
],
|
|
}
|
|
configs = {
|
|
f"native_{domain}": runtime.build_function_config(
|
|
domain,
|
|
actions,
|
|
f"req-wireless-no-wait-{domain}",
|
|
"local_rules",
|
|
)
|
|
for domain, actions in actions_by_domain.items()
|
|
}
|
|
plans = {key: runtime.compile(config) for key, config in configs.items()}
|
|
|
|
with (
|
|
patch.object(
|
|
self.transport,
|
|
"_receive_after",
|
|
side_effect=AssertionError(
|
|
"wireless fixed commands must not wait on can/state"
|
|
),
|
|
) as receive_after,
|
|
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
|
|
):
|
|
reports = service._execute_orchestrated_reports(
|
|
configs,
|
|
plans,
|
|
set(configs),
|
|
{},
|
|
hardware_request=True,
|
|
simulated_hardware_request=False,
|
|
simulation_fault="normal",
|
|
control_transport="wireless_mqtt",
|
|
)
|
|
|
|
receive_after.assert_not_called()
|
|
self.assertEqual(len(self.client.published), 10)
|
|
self.assertTrue(
|
|
all(
|
|
report["spec"]["status"] == "succeeded"
|
|
for report in reports.values()
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
all(
|
|
json.loads(payload)["expectResponse"] is False
|
|
for _topic, payload, _qos, _retain in self.client.published
|
|
)
|
|
)
|
|
|
|
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(0x300, bytes.fromhex("01 10 02 00 88 13 30 75"))
|
|
payload = json.loads(self.client.published[-1][1])
|
|
self.assertEqual(sent.channel, 0)
|
|
self.assertEqual(payload["channel"], "can0")
|
|
self.assertEqual(payload["canId"], "0x300")
|
|
self.assertEqual(payload["data"], "01 10 02 00 88 13 30 75")
|
|
self.assertEqual(payload["command"], "")
|
|
self.assertEqual(payload["frameType"], "classic")
|
|
self.assertFalse(payload["brs"])
|
|
bus.set_debug_command("seat passenger heat low")
|
|
bus.send(0x3CE, bytes.fromhex("10 00 00 00 00 00 00 00"))
|
|
bus.send(0x29E, bytes.fromhex("16 00 10 00 00 00 00 00"))
|
|
thermal_payloads = [
|
|
json.loads(item[1]) for item in self.client.published[-2:]
|
|
]
|
|
self.assertEqual(
|
|
[item["canId"] for item in thermal_payloads],
|
|
["0x3CE", "0x29E"],
|
|
)
|
|
self.assertTrue(
|
|
all(item["command"] == "seat passenger heat low" for item in thermal_payloads)
|
|
)
|
|
with self.assertRaisesRegex(Exception, "0x29E thermal control payload"):
|
|
bus.send(0x29E, bytes(8))
|
|
with self.assertRaisesRegex(Exception, "approved CAN0 control set"):
|
|
bus.send(0x431, bytes(8))
|
|
with self.assertRaisesRegex(Exception, "requires classic CAN"):
|
|
bus.send(0x300, bytes.fromhex("01 10 02 00 88 13 30 75"),
|
|
is_fd=True, bitrate_switch=True)
|
|
with self.assertRaisesRegex(Exception, "approved CAN0 control set"):
|
|
bus.send(0x6A0, bytes.fromhex("C7 01 64 00 F4 01 00 00"))
|
|
with self.assertRaisesRegex(Exception, "protocol version 1"):
|
|
bus.send(0x300, bytes(8))
|
|
with self.assertRaisesRegex(Exception, "outside the semantic control whitelist"):
|
|
bus.send(0x300, bytes.fromhex("0A 10 03 00 5A A5 78 00"))
|
|
with self.assertRaisesRegex(Exception, "absolute move payload"):
|
|
bus.send(0x300, bytes.fromhex("01 10 04 00 11 27 30 75"))
|
|
with self.assertRaisesRegex(Exception, "dynamic-mode payload"):
|
|
bus.send(0x300, bytes.fromhex("0E 10 05 00 03 00 00 00"))
|
|
with self.assertRaisesRegex(Exception, "approved channel policy"):
|
|
self.transport.publish(
|
|
CanFrame(arbitration_id=0x431, data=bytes(8), channel=0)
|
|
)
|
|
|
|
def test_can0_native_motion_whitelist_accepts_only_bounded_runtime_opcodes(
|
|
self,
|
|
) -> None:
|
|
bus = self.transport.bus(channel=0)
|
|
valid_payloads = (
|
|
"01 10 01 00 88 13 30 75", # absolute 50%, 30 s
|
|
"02 11 02 00 18 FC 30 75", # native relative -10%, 30 s
|
|
"03 16 03 00 F4 01 84 03", # motor 2 far, 500 ms, 90%
|
|
"08 12 04 00 00 00 00 00", # stop motor 2
|
|
"09 10 05 00 00 00 00 00", # stop all
|
|
"0B 13 06 00 52 4B 78 00", # motor 3 known-range home
|
|
"0E 10 07 00 01 00 00 00", # rocking-horse
|
|
"0F 10 08 00 00 00 00 00", # stop dynamic mode
|
|
)
|
|
|
|
for payload in valid_payloads:
|
|
with self.subTest(payload=payload):
|
|
sent = bus.send(0x300, bytes.fromhex(payload))
|
|
self.assertEqual(sent.arbitration_id, 0x300)
|
|
self.assertFalse(sent.is_fd)
|
|
|
|
with self.assertRaisesRegex(Exception, "requires a runtime sequence"):
|
|
bus.send(0x300, bytes.fromhex("01 10 00 00 88 13 30 75"))
|
|
with self.assertRaisesRegex(Exception, "outside the semantic control whitelist"):
|
|
bus.send(0x300, bytes.fromhex("0D 10 09 00 3C C3 00 00"))
|
|
|
|
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()
|