Files
Cognitive_Agent/tests/unit/test_vehicle_can_integration.py
T

240 lines
13 KiB
Python

from __future__ import annotations
import importlib.util
import json
import sys
import threading
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
APP_ROOT = ROOT / "apps" / "ir-toy-console"
sys.path.insert(0, str(APP_ROOT))
from vehicle_can import ( # noqa: E402
NativeCanClient,
NativeCanSettings,
commands_for_operation,
extract_native_commands,
)
gateway_spec = importlib.util.spec_from_file_location(
"zlg_vehicle_gateway", APP_ROOT / "zlg_vehicle_gateway.py"
)
assert gateway_spec and gateway_spec.loader
gateway = importlib.util.module_from_spec(gateway_spec)
sys.modules[gateway_spec.name] = gateway
gateway_spec.loader.exec_module(gateway)
def operation(capability: str, target: str, **parameters: object) -> dict:
return {
"capability": capability,
"target": {"zone": target},
"parameters": parameters,
}
class VehicleCanMappingTests(unittest.TestCase):
def test_current_frontend_capabilities_map_to_expected_whitelist_commands(self) -> None:
cases = [
(operation("seat.set_driver_ventilation_native", "driver", state="on", level=1), ["seat driver vent high"]),
(operation("seat.set_driver_heating_native", "driver", state="on", level=1), ["seat driver heat high"]),
(operation("seat.set_driver_ventilation_native", "driver", state="off"), ["seat driver vent off"]),
(operation("seat.set_driver_heating_native", "driver", state="off"), ["seat driver heat off"]),
(operation("climate.set_power", "vehicle", state="on"), ["ac on"]),
(operation("climate.set_temperature", "vehicle", temperature_c=24), ["ac temp driver 24", "ac temp passenger 24"]),
(operation("climate.set_fan_level", "vehicle", fan_level=3), ["ac fan 3"]),
(operation("climate.set_control_mode", "vehicle", mode="auto"), ["ac control auto"]),
(operation("outlet.set_position_percent", "driver_left", horizontal_pct=50, vertical_pct=50), ["outlet driver-left percent 50 50"]),
(operation("outlet.set_position_percent", "driver_right", horizontal_pct=50, vertical_pct=50), ["outlet driver-right percent 50 50"]),
(operation("outlet.set_position_percent", "passenger_left", horizontal_pct=50, vertical_pct=50), ["outlet passenger-left percent 50 50"]),
(operation("outlet.set_position_percent", "passenger_right", horizontal_pct=50, vertical_pct=50), ["outlet passenger-right percent 50 50"]),
(operation("roof.set_shade_motion", "vehicle", motion="open"), ["shade open"]),
(operation("roof.set_shade_motion", "vehicle", motion="close"), ["shade close"]),
(operation("roof.set_shade_motion", "vehicle", motion="half"), ["shade half"]),
(operation("roof.set_shade_motion", "vehicle", motion="stop"), ["shade stop"]),
(operation("interior_light.set_indoor_power", "vehicle", state="on"), ["light indoor on"]),
(operation("interior_light.set_reading_power", "all", state="on"), ["light reading all on"]),
(operation("ambient_light.set_power", "vehicle", state="on"), ["light ambient on"]),
(operation("ambient_light.set_brightness_level_native", "vehicle", level=3), ["light ambient brightness 3"]),
(operation("window.set_motion", "all", motion="open"), ["window all open"]),
(operation("window.set_motion", "all", motion="close"), ["window all close"]),
(operation("window.set_position", "front_left", position_pct=50), ["window front-left position 50"]),
(operation("door.set_rear_lock", "rear", state="lock"), ["door rear lock"]),
]
for item, expected in cases:
with self.subTest(expected=expected):
self.assertEqual(commands_for_operation(item), expected)
def test_passenger_t2g_seat_alias_is_not_misreported_as_native_vehicle_control(self) -> None:
self.assertEqual(
commands_for_operation(operation("seat.set_ventilation", "front_right", state="on", level=3)),
[],
)
def test_extracts_function_configs_and_native_deferred_requests_without_duplicates(self) -> None:
result = {
"function_configs": {
"climate": {"spec": {"operations": [operation("climate.set_power", "vehicle", state="on")]}}
},
"deferred_requests": [
{"capability": "door.set_rear_lock", "target_scope": "rear", "parameters": {"state": "lock"}},
{"capability": "door.set_rear_lock", "target_scope": "rear", "parameters": {"state": "lock"}},
],
}
self.assertEqual(extract_native_commands(result), (["ac on", "door rear lock"], []))
class GatewayCompilerTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.calibration = json.loads((APP_ROOT / "air_outlet_calibration.json").read_text(encoding="utf-8"))["axes"]
cls.latest = {
0x388: {
"positions": [0, 0, 0, 0],
"observed_at": __import__("time").time(),
}
}
def test_frame_encodings_match_can_dev_reference_vectors(self) -> None:
cases = {
"window all open": (0x431, "0000000000000909", True, 3),
"window all close": (0x431, "0000000000001212", True, 3),
"ac on": (0x1DE, "0000001000000000", False, 3),
"ac fan 3": (0x1DE, "3000000000000000", False, 3),
"door rear lock": (0x32B, "0a40000000000000", False, 1),
"seat driver vent high": (0x431, "0001000000000000", True, 3),
"seat driver heat high": (0x431, "0010000000000000", True, 3),
"light reading all on": (0x4F5, "000003ffff000800", True, 3),
"light ambient brightness 3": (0x3FC, "0034007500000000", True, 3),
"shade half": (0x431, "3000000000000000", False, 1),
}
for command, expected in cases.items():
with self.subTest(command=command):
plan = gateway.compile_command(command, self.latest, self.calibration)
step = plan.steps[0]
self.assertEqual((step.arbitration_id, step.data.hex(), step.fd, step.repeat), expected)
def test_all_frontend_native_commands_pass_strict_compilation(self) -> None:
commands = [
"seat driver vent high", "seat driver heat high", "seat driver vent off", "seat driver heat off",
"ac on", "ac temp driver 24", "ac temp passenger 24", "ac fan 3", "ac control auto",
"outlet driver-left percent 50 50", "outlet driver-right percent 50 50",
"outlet passenger-left percent 50 50", "outlet passenger-right percent 50 50",
"shade open", "shade close", "shade half", "shade stop",
"light indoor on", "light reading all on", "light ambient on", "light ambient brightness 3",
"window all open", "window all close", "window front-left position 50", "door rear lock",
]
for command in commands:
with self.subTest(command=command):
self.assertEqual(gateway.compile_command(command, self.latest, self.calibration).command, command)
def test_rejects_raw_frames_meta_commands_and_out_of_range_values(self) -> None:
for command in ["send 431 00", "quit", "window all explode", "ac fan 9", "light ambient color 128"]:
with self.subTest(command=command), self.assertRaises(gateway.GatewayError):
gateway.compile_command(command, self.latest, self.calibration)
def test_motion_commands_have_bounded_automatic_stop(self) -> None:
window = gateway.compile_command("window front-left position 50", self.latest, self.calibration)
seat = gateway.compile_command("seat driver backrest backward 500", self.latest, self.calibration)
self.assertEqual(window.final_steps[0].data.hex(), "0000000000000300")
self.assertEqual(window.final_steps[0].repeat, 3)
self.assertEqual(seat.final_steps[0].data.hex(), "0500000000000000")
self.assertEqual(seat.final_steps[0].repeat, 3)
def test_observed_unmatched_feedback_is_not_target_confirmation(self) -> None:
driver = object.__new__(gateway.ZlgDriver)
driver.condition = threading.Condition()
driver.latest = {0x2F5: {"sequence": 2, "frame_id": "0x2F5"}}
plan = gateway.CompiledCommand(
command="outlet driver-left percent 50 50",
domain="outlet",
steps=[],
expected_ids=(0x2F5,),
timeout_ms=100,
matcher=None,
)
observed, matched, feedback = driver.wait_feedback(plan, baseline=1)
self.assertTrue(observed)
self.assertFalse(matched)
self.assertIn("0x2F5", feedback)
def test_decodes_vehicle_interlocks_and_outlet_feedback(self) -> None:
gear = gateway._decode_state(0x342, bytes([0, 0, 0, 1, 0, 0, 0, 0]))
speed = gateway._decode_state(0x4EB, bytes([0x07, 16, 0, 0, 0, 0, 0, 0]))
outlet = gateway._decode_state(0x2F5, bytes([0x01, 0, 0, 0, 0x34, 0x12, 0, 0]))
self.assertEqual(gear["gear"], "P")
self.assertEqual(speed["speed_kmh"], 1.1)
self.assertEqual((outlet["outlet"], outlet["axis"]), ("driver-left", "left-right"))
self.assertEqual(outlet["position"], 0x1234)
self.assertFalse(outlet["fault"])
class FakeNativeClient(NativeCanClient):
def __init__(self, mode: str, responses: dict[tuple[str, str | None], tuple[int, dict]]):
super().__init__(NativeCanSettings("http://native.test", mode, "secret-token", 1))
self.responses = responses
self.calls: list[tuple[str, str | None, bool]] = []
def _request(self, path: str, *, body: str | None = None, token: bool = False, timeout: float | None = None):
self.calls.append((path, body, token))
return self.responses[(path, body)]
class NativeCanExecutionTests(unittest.TestCase):
def test_dry_run_validates_but_never_calls_command_endpoint(self) -> None:
result = {"function_configs": {"climate": {"spec": {"operations": [operation("climate.set_power", "vehicle", state="on")]}}}}
client = FakeNativeClient("dry_run", {("/api/can/validate", "ac on"): (200, {"valid": True, "transmitted": False, "command": "ac on"})})
report = client.process(result)
self.assertEqual(report["status"], "validated")
self.assertFalse(any(path == "/api/can/command" for path, _, _ in client.calls))
def test_batch_validation_is_all_or_nothing(self) -> None:
result = {"function_configs": {"climate": {"spec": {"operations": [operation("climate.set_temperature", "vehicle", temperature_c=24)]}}}}
client = FakeNativeClient("hardware", {
("/api/can/validate", "ac temp driver 24"): (200, {"valid": True, "transmitted": False, "command": "ac temp driver 24"}),
("/api/can/validate", "ac temp passenger 24"): (400, {"valid": False, "message": "rejected"}),
})
report = client.process(result)
self.assertEqual(report["status"], "blocked")
self.assertFalse(any(path == "/api/can/command" for path, _, _ in client.calls))
def test_hardware_report_distinguishes_adapter_acceptance_from_feedback(self) -> None:
result = {"function_configs": {"climate": {"spec": {"operations": [operation("climate.set_power", "vehicle", state="on")]}}}}
responses = {
("/api/can/validate", "ac on"): (200, {"valid": True, "transmitted": False, "command": "ac on"}),
("/api/can/command", "ac on"): (200, {"accepted": True, "command": "ac on", "execution": {"feedback_matched": False}}),
("/api/can/status", None): (200, {"mode": "control"}),
}
report = FakeNativeClient("hardware", responses).process(result)
self.assertEqual(report["status"], "accepted")
self.assertFalse(report["target_state_confirmed"])
self.assertFalse(report["physical_state_confirmed"])
def test_bus_target_feedback_is_not_reported_as_physical_confirmation(self) -> None:
result = {"function_configs": {"climate": {"spec": {"operations": [operation("climate.set_power", "vehicle", state="on")]}}}}
responses = {
("/api/can/validate", "ac on"): (200, {"valid": True, "transmitted": False, "command": "ac on"}),
("/api/can/command", "ac on"): (200, {
"accepted": True,
"command": "ac on",
"execution": {"feedback_matched": True, "target_state_confirmed": True, "physical_state_confirmed": False},
}),
("/api/can/status", None): (200, {"mode": "control"}),
}
report = FakeNativeClient("hardware", responses).process(result)
self.assertEqual(report["status"], "confirmed")
self.assertTrue(report["target_state_confirmed"])
self.assertFalse(report["physical_state_confirmed"])
if __name__ == "__main__":
unittest.main()