Files
Cognitive_Agent/tools/run_vehicle_hil_suite.py
T

347 lines
16 KiB
Python

#!/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())