feat: integrate vehicle CAN control into self-expand console
This commit is contained in:
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user