240 lines
13 KiB
Python
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()
|