feat: integrate vehicle CAN control into self-expand console
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SERVER_PATH = ROOT / "apps" / "ir-toy-console" / "server.py"
|
||||
SPEC = importlib.util.spec_from_file_location("ir_toy_console_server_timing_test", SERVER_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
SERVER = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = SERVER
|
||||
SPEC.loader.exec_module(SERVER)
|
||||
|
||||
|
||||
class IRToyConsoleTimingTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.catalog = [
|
||||
{
|
||||
"id": "cap-power",
|
||||
"status": "learned",
|
||||
"device_id": "cactus",
|
||||
"device_name": "跳舞仙人掌玩具",
|
||||
"action": "电源开关",
|
||||
"risk": "low",
|
||||
},
|
||||
{
|
||||
"id": "cap-song",
|
||||
"status": "learned",
|
||||
"device_id": "cactus",
|
||||
"device_name": "跳舞仙人掌玩具",
|
||||
"action": "儿歌",
|
||||
"risk": "low",
|
||||
},
|
||||
]
|
||||
|
||||
@patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=False)
|
||||
def test_delay_is_required_and_preserved_in_normalized_plan(self) -> None:
|
||||
proposed = {
|
||||
"summary": "先开机,5 秒后播放儿歌",
|
||||
"needs_confirmation": False,
|
||||
"question": "",
|
||||
"steps": [
|
||||
{
|
||||
"kind": "peripheral",
|
||||
"capability_id": "cap-power",
|
||||
"command": "",
|
||||
"label": "仙人掌·电源开关",
|
||||
"delay_before_ms": 0,
|
||||
},
|
||||
{
|
||||
"kind": "peripheral",
|
||||
"capability_id": "cap-song",
|
||||
"command": "",
|
||||
"label": "仙人掌·儿歌",
|
||||
"delay_before_ms": 5000,
|
||||
},
|
||||
],
|
||||
}
|
||||
with patch.object(
|
||||
SERVER, "request_gpt_stream", return_value=json.dumps(proposed, ensure_ascii=False)
|
||||
) as request_gpt:
|
||||
result = SERVER.plan_with_gpt(
|
||||
{"utterance": "开启仙人掌,5秒后播放儿歌", "peripherals": self.catalog}
|
||||
)
|
||||
|
||||
self.assertEqual([0, 5000], [step["delay_before_ms"] for step in result["steps"]])
|
||||
request_payload = request_gpt.call_args.args[0]
|
||||
step_schema = request_payload["text"]["format"]["schema"]["properties"]["steps"][
|
||||
"items"
|
||||
]
|
||||
self.assertIn("delay_before_ms", step_schema["required"])
|
||||
self.assertEqual(60_000, step_schema["properties"]["delay_before_ms"]["maximum"])
|
||||
|
||||
@patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=False)
|
||||
def test_out_of_range_delay_is_rejected(self) -> None:
|
||||
proposed = {
|
||||
"summary": "等待时间超限",
|
||||
"needs_confirmation": False,
|
||||
"question": "",
|
||||
"steps": [
|
||||
{
|
||||
"kind": "peripheral",
|
||||
"capability_id": "cap-power",
|
||||
"command": "",
|
||||
"label": "仙人掌·电源开关",
|
||||
"delay_before_ms": 60_001,
|
||||
}
|
||||
],
|
||||
}
|
||||
with patch.object(
|
||||
SERVER, "request_gpt_stream", return_value=json.dumps(proposed, ensure_ascii=False)
|
||||
):
|
||||
with self.assertRaisesRegex(SERVER.IntegrationError, "单步等待时间"):
|
||||
SERVER.plan_with_gpt({"utterance": "稍后开机", "peripherals": self.catalog})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,239 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user