feat: integrate vehicle CAN control into self-expand console

This commit is contained in:
Miayui
2026-08-07 17:16:52 +08:00
parent cd3f68a8f7
commit f0e359698f
17 changed files with 3639 additions and 48 deletions
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""Run the self-expand vehicle frontend safety and integration matrix.
The default suite is deliberately non-transmitting: it expects the frontend in
``dry_run`` mode and the native gateway in ``monitor_only`` mode. It verifies
every visible vehicle preset from natural-language request through CAN_DEV and
the native whitelist, exercises rejection paths, checks live bus reception,
and proves that the adapter transmit counter did not change.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
@dataclass(frozen=True)
class FrontendCase:
case_id: str
utterance: str
commands: tuple[str, ...]
FRONTEND_CASES = (
FrontendCase("seat-vent-on", "打开主驾座椅通风", ("seat driver vent low",)),
FrontendCase("seat-heat-on", "打开主驾座椅加热", ("seat driver heat low",)),
FrontendCase("seat-vent-off", "关闭主驾座椅通风", ("seat driver vent off",)),
FrontendCase("seat-heat-off", "关闭主驾座椅加热", ("seat driver heat off",)),
FrontendCase("climate-on", "打开空调", ("ac on",)),
FrontendCase("climate-24", "把空调温度调到24度", ("ac temp driver 24", "ac temp passenger 24")),
FrontendCase("climate-fan3", "把空调风量调到3档", ("ac fan 3",)),
FrontendCase("climate-auto", "把空调设置为自动模式", ("ac control auto",)),
FrontendCase("outlet-driver-left", "把主驾左风口调到中间位置", ("outlet driver-left percent 50 50",)),
FrontendCase("outlet-driver-right", "把主驾右风口调到中间位置", ("outlet driver-right percent 50 50",)),
FrontendCase("outlet-passenger-left", "把副驾左风口调到中间位置", ("outlet passenger-left percent 50 50",)),
FrontendCase("outlet-passenger-right", "把副驾右风口调到中间位置", ("outlet passenger-right percent 50 50",)),
FrontendCase("shade-open", "打开遮阳帘", ("shade open",)),
FrontendCase("shade-close", "关闭遮阳帘", ("shade close",)),
FrontendCase("shade-half", "把遮阳帘打开一半", ("shade half",)),
FrontendCase("shade-stop", "停止遮阳帘", ("shade stop",)),
FrontendCase("light-indoor", "打开车内室内灯", ("light indoor on",)),
FrontendCase("light-reading", "打开全部阅读灯", ("light reading all on",)),
FrontendCase("light-ambient", "打开车内氛围灯", ("light ambient on",)),
FrontendCase("light-brightness", "把氛围灯亮度调到3档", ("light ambient brightness 3",)),
FrontendCase("window-open", "打开全部车窗", ("window all open",)),
FrontendCase("window-close", "关闭全部车窗", ("window all close",)),
FrontendCase("window-front-left-50", "把左前车窗打开到50%", ("window front-left position 50",)),
FrontendCase("door-rear-lock", "锁上后排门锁", ("door rear lock",)),
)
REJECTED_COMMANDS = (
"raw 123#0102",
"status",
"help",
"quit",
"window front-left position 101",
"ac fan 8",
"ac temp driver 17",
"seat driver horizontal forward 99",
"light ambient color 128",
"outlet driver-left percent 101 50",
"shade position 101",
)
EXPECTED_FEEDBACK_IDS = {"0x388", "0x404", "0x407", "0x41A", "0x42E", "0x4FA", "0x494"}
def request_json(
url: str,
*,
payload: dict[str, Any] | None = None,
text: str | None = None,
headers: dict[str, str] | None = None,
timeout: float = 30,
) -> tuple[int, dict[str, Any]]:
request_headers = {"Accept": "application/json", **(headers or {})}
data = None
if payload is not None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
request_headers["Content-Type"] = "application/json; charset=utf-8"
elif text is not None:
data = text.encode("utf-8")
request_headers["Content-Type"] = "text/plain; charset=utf-8"
request = Request(url, data=data, headers=request_headers, method="POST" if data is not None else "GET")
try:
with urlopen(request, timeout=timeout) as response: # noqa: S310 - operator-selected local endpoints
raw, status = response.read(), int(response.status)
except HTTPError as exc:
raw, status = exc.read(), int(exc.code)
except (URLError, TimeoutError, OSError) as exc:
raise RuntimeError(f"cannot reach {url}: {exc}") from exc
try:
decoded = json.loads(raw.decode("utf-8")) if raw else {}
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RuntimeError(f"non-JSON response from {url}") from exc
if not isinstance(decoded, dict):
raise RuntimeError(f"non-object JSON response from {url}")
return status, decoded
def record(results: list[dict[str, Any]], case_id: str, passed: bool, detail: str, **extra: Any) -> None:
results.append({"id": case_id, "passed": passed, "detail": detail, **extra})
def run(args: argparse.Namespace) -> dict[str, Any]:
frontend = args.frontend_url.rstrip("/")
native = args.native_url.rstrip("/")
results: list[dict[str, Any]] = []
started = time.time()
status, health = request_json(f"{frontend}/api/health", timeout=5)
vehicle = health.get("vehicle") or {}
native_health = vehicle.get("native_can") or {}
health_ok = (
200 <= status < 300
and health.get("success") is True
and vehicle.get("connected") is True
and native_health.get("connected") is True
and vehicle.get("execution_mode") == "dry_run"
)
record(results, "frontend-health", health_ok, "frontend, CAN_DEV and native gateway are connected", status=status)
status, before = request_json(f"{native}/api/can/runtime", timeout=5)
before_tx = int(before.get("tx_count", -1))
live_ids = set((before.get("states") or {}).keys())
runtime_ok = (
200 <= status < 300
and before.get("mode") == "monitor_only"
and before_tx >= 0
and EXPECTED_FEEDBACK_IDS <= live_ids
and int(before.get("last_receive_age_ms", 999999)) < args.max_receive_age_ms
)
record(
results,
"native-monitor-runtime",
runtime_ok,
"adapter is receiving all required feedback families in monitor-only mode",
tx_count=before_tx,
feedback_ids=sorted(live_ids),
)
app_source = (Path(__file__).resolve().parents[1] / "apps" / "ir-toy-console" / "app.js").read_text(encoding="utf-8")
source_missing = [case.case_id for case in FRONTEND_CASES if case.utterance not in app_source]
record(results, "frontend-source-matrix", not source_missing, "all 24 tested actions are present in the visible frontend", missing=source_missing)
for case in FRONTEND_CASES:
status, response = request_json(
f"{frontend}/api/vehicle/execute",
payload={"command": case.utterance, "vehicle_id": args.vehicle_id},
timeout=args.action_timeout,
)
report = response.get("native_can_execution") or {}
actual_commands = tuple(
str(item.get("command"))
for item in report.get("commands", [])
if isinstance(item, dict)
)
passed = (
200 <= status < 300
and response.get("success") is True
and report.get("status") == "validated"
and actual_commands == case.commands
and report.get("physical_state_confirmed") is False
)
record(
results,
f"preset:{case.case_id}",
passed,
case.utterance,
expected_commands=list(case.commands),
actual_commands=list(actual_commands),
http_status=status,
report_status=report.get("status"),
)
for index, command in enumerate(REJECTED_COMMANDS, start=1):
status, response = request_json(f"{native}/api/can/validate", text=command, timeout=5)
passed = status >= 400 and response.get("valid") is not True and response.get("transmitted") is not True
record(results, f"reject:{index:02d}", passed, command, http_status=status)
status, response = request_json(
f"{native}/api/can/command",
text="ac on",
headers={"X-CAN-Approval-Token": "intentionally-invalid"},
timeout=5,
)
record(
results,
"monitor-only-command-gate",
status in {401, 403} and response.get("accepted") is not True,
"monitor-only gateway rejects command execution",
http_status=status,
)
_, after = request_json(f"{native}/api/can/runtime", timeout=5)
after_tx = int(after.get("tx_count", -1))
record(
results,
"zero-transmit-invariant",
before_tx == after_tx,
"dry-run suite did not transmit CAN frames",
before_tx=before_tx,
after_tx=after_tx,
)
passed_count = sum(item["passed"] for item in results)
return {
"suite": "vehicle_frontend_dry_run",
"passed": passed_count == len(results),
"summary": {"passed": passed_count, "failed": len(results) - passed_count, "total": len(results)},
"duration_ms": round((time.time() - started) * 1000),
"frontend_url": frontend,
"native_url": native,
"results": results,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--frontend-url", default="http://127.0.0.1:8010")
parser.add_argument("--native-url", default="http://127.0.0.1:8765")
parser.add_argument("--vehicle-id", default="denza_n7")
parser.add_argument("--action-timeout", type=float, default=30)
parser.add_argument("--max-receive-age-ms", type=int, default=2000)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
try:
report = run(args)
except Exception as exc: # noqa: BLE001 - command-line diagnostic boundary
report = {"suite": "vehicle_frontend_dry_run", "passed": False, "fatal_error": str(exc)}
rendered = json.dumps(report, ensure_ascii=False, indent=2)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(rendered + "\n", encoding="utf-8")
print(rendered)
return 0 if report.get("passed") else 1
if __name__ == "__main__":
sys.exit(main())
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env python3
"""Run a bounded live-vehicle test through the self-expand frontend.
This suite intentionally has an explicit confirmation switch because it sends
real CAN frames. It snapshots the decoded vehicle state, tests one bounded
change per supported family, restores the original state immediately, and
records whether the frontend reported target-state feedback. Physical motion
is never inferred from CAN feedback alone.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
LIVE_CONFIRMATION = "YES-I-UNDERSTAND"
@dataclass(frozen=True)
class HilCase:
case_id: str
action: str
restore: str | None
expected_status: str = "confirmed"
def request_json(url: str, *, payload: dict[str, Any] | None = None, timeout: float = 45) -> tuple[int, dict[str, Any]]:
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json; charset=utf-8"
request = Request(url, data=data, headers=headers, method="POST" if data is not None else "GET")
try:
with urlopen(request, timeout=timeout) as response: # noqa: S310 - operator-selected local endpoint
raw, status = response.read(), int(response.status)
except HTTPError as exc:
raw, status = exc.read(), int(exc.code)
except (URLError, TimeoutError, OSError) as exc:
raise RuntimeError(f"cannot reach {url}: {exc}") from exc
decoded = json.loads(raw.decode("utf-8")) if raw else {}
if not isinstance(decoded, dict):
raise RuntimeError(f"non-object JSON response from {url}")
return status, decoded
def state(runtime: dict[str, Any], frame_id: str) -> dict[str, Any]:
return dict((runtime.get("states") or {}).get(frame_id) or {})
def power_utterance(prefix: str, raw: int) -> str:
return f"{'打开' if raw == 2 else '关闭'}{prefix}"
def thermal_utterance(feature: str, raw: int) -> str:
if raw == 1:
return f"关闭主驾座椅{feature}"
if raw == 3:
return f"主驾座椅{feature}高档"
return f"打开主驾座椅{feature}"
def build_cases(runtime: dict[str, Any], include_outlets: bool) -> list[HilCase]:
climate = state(runtime, "0x404")
seat = state(runtime, "0x4FA")
light = state(runtime, "0x42E")
shade = state(runtime, "0x41A")
window = state(runtime, "0x388")
door = state(runtime, "0x407")
required = {"climate": climate, "seat": seat, "light": light, "shade": shade, "window": window, "door": door}
missing = [name for name, value in required.items() if not value]
if missing:
raise RuntimeError(f"missing live state needed for restoration: {', '.join(missing)}")
positions = window.get("positions") or []
if len(positions) != 4 or any(value is None for value in positions):
raise RuntimeError("window positions are not available for safe restoration")
front_left_restore = "关闭全部车窗" if int(positions[0]) <= 3 else f"把左前车窗打开到{int(positions[0])}%"
shade_position = int(shade.get("position"))
if shade_position <= 2:
shade_restore = "关闭遮阳帘"
elif shade_position >= 98:
shade_restore = "打开遮阳帘"
elif 47 <= shade_position <= 53:
shade_restore = "把遮阳帘打开一半"
else:
raise RuntimeError("shade is not at a restorable 0/50/100 position")
power_restore = "打开空调" if int(climate.get("power")) == 1 else "关闭空调"
control_restore = "把空调设置为手动模式" if int(climate.get("control")) == 1 else "把空调设置为自动模式"
ambient_raw = int(light.get("front_brightness"))
ambient_restore = "关闭车内氛围灯" if ambient_raw == 1 else "打开车内氛围灯"
indoor_restore = power_utterance("车内室内灯", int(light.get("indoor")))
door_restore = "解锁后排门锁" if int(door.get("rear_left")) == 1 else "锁上后排门锁"
cases = [
HilCase("climate-power", "关闭空调" if int(climate.get("power")) == 1 else "打开空调", power_restore),
HilCase("climate-temperature", "把空调温度调到24度", f"把空调温度调到{int(climate.get('driver_temp'))}度"),
HilCase("climate-fan", "把空调风量调到3档", f"把空调风量调到{int(climate.get('fan'))}档"),
HilCase("climate-control", "把空调设置为自动模式" if int(climate.get("control")) != 2 else "把空调设置为手动模式", control_restore),
HilCase("seat-ventilation", "关闭主驾座椅通风" if int(seat.get("vent")) != 1 else "打开主驾座椅通风", thermal_utterance("通风", int(seat.get("vent")))),
HilCase("seat-heating", "关闭主驾座椅加热" if int(seat.get("heat")) != 1 else "打开主驾座椅加热", thermal_utterance("加热", int(seat.get("heat")))),
HilCase("light-indoor", "关闭车内室内灯" if int(light.get("indoor")) == 2 else "打开车内室内灯", indoor_restore),
HilCase("light-ambient", "关闭车内氛围灯" if ambient_raw != 1 else "打开车内氛围灯", ambient_restore),
# The current vehicle acknowledges the trusted rear-lock request on the
# adapter but may inhibit the state transition. The UI must preserve
# that distinction as "accepted", not fabricate target confirmation.
HilCase("door-rear-lock", "锁上后排门锁" if int(door.get("rear_left")) == 1 else "解锁后排门锁", door_restore, "accepted"),
HilCase("window-front-left", "把左前车窗打开到50%", front_left_restore),
HilCase("shade", "把遮阳帘打开一半", shade_restore),
]
if include_outlets:
cases.extend(
HilCase(f"outlet-{zone}", f"把{name}风口调到中间位置", None, "accepted")
for zone, name in (
("driver-left", "主驾左"),
("driver-right", "主驾右"),
("passenger-left", "副驾左"),
("passenger-right", "副驾右"),
)
)
return cases
def final_restore_commands(runtime: dict[str, Any]) -> list[str]:
climate = state(runtime, "0x404")
seat = state(runtime, "0x4FA")
light = state(runtime, "0x42E")
shade = state(runtime, "0x41A")
window = state(runtime, "0x388")
door = state(runtime, "0x407")
climate_modes = {
1: "空调吹面",
2: "空调吹面吹脚",
3: "空调吹脚",
4: "空调吹脚并除霜",
5: "空调除霜",
6: "空调吹面吹脚并除霜",
7: "空调吹面并除霜",
}
commands = [
"打开空调" if int(climate["power"]) == 1 else "关闭空调",
f"把空调温度调到{int(climate['driver_temp'])}度",
f"把空调风量调到{int(climate['fan'])}档",
"把空调设置为手动模式" if int(climate["control"]) == 1 else "把空调设置为自动模式",
climate_modes[int(climate["mode"])],
thermal_utterance("通风", int(seat["vent"])),
thermal_utterance("加热", int(seat["heat"])),
power_utterance("车内室内灯", int(light["indoor"])),
"关闭车内氛围灯" if int(light["front_brightness"]) == 1 else "打开车内氛围灯",
"解锁后排门锁" if int(door["rear_left"]) == 1 else "锁上后排门锁",
"关闭遮阳帘" if int(shade["position"]) <= 2 else "打开遮阳帘" if int(shade["position"]) >= 98 else "把遮阳帘打开一半",
]
if int(light["front_brightness"]) > 1:
commands.append(f"把氛围灯亮度调到{int(light['front_brightness']) - 1}档")
window_names = ("左前", "左后", "右前", "右后")
for name, position in zip(window_names, window["positions"]):
commands.append("关闭全部车窗" if all(int(value) <= 3 for value in window["positions"]) else f"把{name}车窗打开到{int(position)}%")
if commands[-1] == "关闭全部车窗":
break
return commands
def restored_state_matches(before: dict[str, Any], after: dict[str, Any]) -> tuple[bool, dict[str, Any]]:
fields = {
"0x404": ("power", "control", "mode", "fan", "driver_temp", "passenger_temp"),
"0x4FA": ("vent", "heat"),
"0x388": ("positions", "closed"),
"0x42E": ("front_brightness", "rear_brightness", "front_color", "rear_color", "indoor"),
"0x41A": ("position",),
"0x407": ("rear_left", "rear_right", "logical"),
}
differences: dict[str, Any] = {}
for frame_id, names in fields.items():
before_state, after_state = state(before, frame_id), state(after, frame_id)
for name in names:
if before_state.get(name) != after_state.get(name):
differences[f"{frame_id}.{name}"] = {
"before": before_state.get(name),
"after": after_state.get(name),
}
return not differences, differences
def execute(frontend: str, vehicle_id: str, utterance: str, timeout: float) -> tuple[int, dict[str, Any]]:
return request_json(
f"{frontend}/api/vehicle/execute",
payload={"command": utterance, "vehicle_id": vehicle_id},
timeout=timeout,
)
def summarize_response(status: int, response: dict[str, Any]) -> dict[str, Any]:
report = response.get("native_can_execution") or {}
return {
"http_status": status,
"success": response.get("success"),
"report_status": report.get("status"),
"target_state_confirmed": report.get("target_state_confirmed"),
"physical_state_confirmed": report.get("physical_state_confirmed"),
"commands": [item.get("command") for item in report.get("commands", []) if isinstance(item, dict)],
"message": report.get("message"),
}
def run(args: argparse.Namespace) -> dict[str, Any]:
if args.confirm_live_control != LIVE_CONFIRMATION:
raise RuntimeError(f"live control requires --confirm-live-control {LIVE_CONFIRMATION}")
frontend = args.frontend_url.rstrip("/")
native = args.native_url.rstrip("/")
started = time.time()
_, health = request_json(f"{frontend}/api/health", timeout=5)
vehicle = health.get("vehicle") or {}
native_health = vehicle.get("native_can") or {}
if not (
health.get("success") is True
and vehicle.get("execution_mode") == "hardware"
and vehicle.get("control_ready") is True
and native_health.get("gateway_mode") == "control"
):
raise RuntimeError("frontend is not connected to a control-ready hardware gateway")
_, before = request_json(f"{native}/api/can/runtime", timeout=5)
gear = state(before, "0x342").get("gear")
speed = state(before, "0x4EB").get("speed_kmh")
if gear != "P" or (speed is not None and float(speed) > 0.1):
raise RuntimeError(f"live motion tests require gear P and zero speed; gear={gear}, speed={speed}")
before_tx = int(before.get("tx_count", -1))
cases = build_cases(before, args.include_outlets)
results: list[dict[str, Any]] = []
for case in cases:
action_status, action_response = execute(frontend, args.vehicle_id, case.action, args.action_timeout)
action_summary = summarize_response(action_status, action_response)
action_ok = (
action_summary["http_status"] < 300
and action_summary["success"] is True
and action_summary["report_status"] == case.expected_status
and action_summary["physical_state_confirmed"] is False
and (
action_summary["target_state_confirmed"] is True
if case.expected_status == "confirmed"
else action_summary["target_state_confirmed"] is False
)
)
restore_summary: dict[str, Any] | None = None
restore_ok = case.restore is None
if case.restore is not None:
restore_status, restore_response = execute(frontend, args.vehicle_id, case.restore, args.action_timeout)
restore_summary = summarize_response(restore_status, restore_response)
restore_ok = (
restore_summary["http_status"] < 300
and restore_summary["success"] is True
and restore_summary["report_status"] == "confirmed"
and restore_summary["physical_state_confirmed"] is False
)
results.append(
{
"id": case.case_id,
"passed": action_ok and restore_ok,
"action": case.action,
"action_result": action_summary,
"restore": case.restore,
"restore_result": restore_summary,
}
)
time.sleep(args.inter_case_delay)
restore_results = []
for utterance in final_restore_commands(before):
restore_status, restore_response = execute(frontend, args.vehicle_id, utterance, args.action_timeout)
restore_results.append({"utterance": utterance, **summarize_response(restore_status, restore_response)})
time.sleep(0.5)
_, after = request_json(f"{native}/api/can/runtime", timeout=5)
after_tx = int(after.get("tx_count", -1))
results.append(
{
"id": "live-transmit-proof",
"passed": after_tx > before_tx >= 0,
"before_tx": before_tx,
"after_tx": after_tx,
}
)
state_restored, differences = restored_state_matches(before, after)
results.append(
{
"id": "final-state-restored",
"passed": state_restored and all(item["success"] is True for item in restore_results),
"differences": differences,
"restore_results": restore_results,
}
)
passed_count = sum(item["passed"] for item in results)
return {
"suite": "vehicle_frontend_hil",
"passed": passed_count == len(results),
"summary": {"passed": passed_count, "failed": len(results) - passed_count, "total": len(results)},
"duration_ms": round((time.time() - started) * 1000),
"frontend_url": frontend,
"native_url": native,
"baseline": {
"tx_count": before_tx,
"states": before.get("states", {}),
},
"final": {
"tx_count": after_tx,
"states": after.get("states", {}),
},
"results": results,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--frontend-url", default="http://127.0.0.1:8010")
parser.add_argument("--native-url", default="http://127.0.0.1:8765")
parser.add_argument("--vehicle-id", default="denza_n7")
parser.add_argument("--confirm-live-control", default="")
parser.add_argument("--include-outlets", action="store_true")
parser.add_argument("--action-timeout", type=float, default=45)
parser.add_argument("--inter-case-delay", type=float, default=0.3)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
try:
report = run(args)
except Exception as exc: # noqa: BLE001 - command-line diagnostic boundary
report = {"suite": "vehicle_frontend_hil", "passed": False, "fatal_error": str(exc)}
rendered = json.dumps(report, ensure_ascii=False, indent=2)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(rendered + "\n", encoding="utf-8")
print(rendered)
return 0 if report.get("passed") else 1
if __name__ == "__main__":
sys.exit(main())