feat: validate real-bench seat orchestration and self-repair

This commit is contained in:
vee
2026-08-09 19:00:16 +08:00
parent 3bceb0d5a1
commit 3701192a11
41 changed files with 10403 additions and 3392 deletions
@@ -62,6 +62,9 @@ from cognitive_agent.application.vehicle_profile import ( # noqa: E402
VehicleProfile,
VehicleProfileRegistry,
)
from cognitive_agent.application.zcan_seat_executor import ( # noqa: E402
ZcanBridgeSeatExecutor,
)
from cognitive_agent.infrastructure.llm.openai_responses import ( # noqa: E402
OpenAIIntentError,
OpenAIIntentInterpreter,
@@ -217,7 +220,7 @@ class SeatDemoService:
"execution_modes": ["dry_run", "simulated_hardware", "hardware"],
"hardware_execution_allowed": (
self.hardware_settings.configured
and self.vehicle_can_settings.configured
or self.vehicle_can_settings.configured
),
"hardware": hardware_info,
"vehicle_can": self.vehicle_can_settings.public_info(),
@@ -334,6 +337,29 @@ class SeatDemoService:
def _new_hardware_executor(
self, bus_factory: Any | None = None
) -> HardwareSeatExecutor:
shared_bridge_enabled = os.getenv(
"COGNITIVE_AGENT_SEAT_SHARED_ZCANPRO_BRIDGE_ENABLED", "true"
).strip().lower() in {"1", "true", "yes", "on"}
if bus_factory is None and shared_bridge_enabled:
# Import lazily so the ordinary dry-run service does not initialize
# any self-repair runtime state. Both modules then share the one
# process that is allowed to own the Windows ZLG adapter.
from cognitive_agent.application.self_repair.service import (
BRIDGE_MANAGER,
PENDING_DIR,
REQUIRED_BRIDGE_VERSION,
STATUS_PATH,
ZCANPRO_EXE,
)
if ZCANPRO_EXE.is_file():
return ZcanBridgeSeatExecutor(
self.hardware_matrix,
BRIDGE_MANAGER,
STATUS_PATH,
PENDING_DIR,
required_version=REQUIRED_BRIDGE_VERSION,
auto_home=self.hardware_settings.auto_home_enabled,
)
resolved_bus_factory = bus_factory or self.hardware_settings.bus_factory()
return HardwareSeatExecutor(
self.hardware_matrix,
@@ -554,7 +580,8 @@ class SeatDemoService:
)
parsed = merge_native_can_intent(parsed, utterance)
parsed = self._partition_native_actions(
parsed, promote_supported=hardware_request
parsed,
promote_supported=(hardware_request and self.vehicle_can_settings.enabled),
)
if hardware_request and not control_plane_request:
if parsed.get("actions"):
@@ -4,7 +4,8 @@ The original HIL demo mixed HTTP hosting, business state, firmware tooling and
ZCANPRO process control in one script. This module keeps the proven state
machine and diagnostic rules while exposing them through the repository's
existing server. Every machine-specific path is supplied through environment
configuration; importing the module never launches hardware tooling.
configuration or discovered standard installations; importing the module never
launches hardware tooling.
"""
from __future__ import unicode_literals
@@ -29,6 +30,9 @@ from .signal_diagnostic import (
from .zcan_bridge import (
ZcanBridgeManager, run_self_test as run_bridge_manager_self_test,
)
from cognitive_agent.infrastructure.firmware.t2g_seat import (
EXPECTED_FIRMWARE_REVISION,
)
REPOSITORY_ROOT = Path(__file__).resolve().parents[4]
@@ -39,6 +43,17 @@ def _env_path(name, default):
return Path(value).expanduser() if value else Path(default)
def _env_or_installed_path(name, candidates, fallback):
value = os.getenv(name, "").strip()
if value:
return Path(value).expanduser()
for candidate in candidates:
path = Path(candidate)
if path.exists():
return path
return Path(fallback)
def _env_flag(name, default=False):
value = os.getenv(name)
if value is None:
@@ -71,16 +86,28 @@ HARDWARE_LOG_ROOT = _env_path(
REPOSITORY_ROOT / "artifacts" / "self-repair" / "hardware",
)
PROJECT_DIR = _env_path(
"SELF_REPAIR_FIRMWARE_ROOT", REPOSITORY_ROOT / "__unconfigured_firmware__")
"SELF_REPAIR_FIRMWARE_ROOT",
REPOSITORY_ROOT / "firmware" / "t2g" / "Seat_Controller_demo")
IAR_PROJECT_PATH = PROJECT_DIR / "IAR" / "Seat_Controller_demo.ewp"
FIRMWARE_OUT_PATH = PROJECT_DIR / "IAR" / "rev_d" / "Exe" / \
"Seat_Controller_demo.out"
FIRMWARE_HEX_PATH = PROJECT_DIR / "IAR" / "rev_d" / "Exe" / \
"Seat_Controller_demo.hex"
IAR_BUILD_PATH = _env_path(
"SELF_REPAIR_IAR_BUILD_PATH", REPOSITORY_ROOT / "__unconfigured_iar_build__")
CSPY_BAT_PATH = _env_path(
"SELF_REPAIR_CSPY_BAT_PATH", REPOSITORY_ROOT / "__unconfigured_cspy__")
IAR_INSTALL_ROOTS = (
Path(r"D:\Program Files\IAR Systems\Embedded Workbench 9.2"),
Path(r"C:\Program Files\IAR Systems\Embedded Workbench 9.2"),
Path(r"C:\Program Files\IAR Systems\Embedded Workbench 9.4"),
)
IAR_BUILD_PATH = _env_or_installed_path(
"SELF_REPAIR_IAR_BUILD_PATH",
tuple(root / "common" / "bin" / "iarbuild.exe"
for root in IAR_INSTALL_ROOTS),
REPOSITORY_ROOT / "__unconfigured_iar_build__")
CSPY_BAT_PATH = _env_or_installed_path(
"SELF_REPAIR_CSPY_BAT_PATH",
tuple(root / "common" / "bin" / "CSpyBat.exe"
for root in IAR_INSTALL_ROOTS),
REPOSITORY_ROOT / "__unconfigured_cspy__")
CSPY_GENERAL_PATH = _env_path(
"SELF_REPAIR_CSPY_GENERAL_PATH",
PROJECT_DIR / "IAR" / "settings" / "Seat_Controller_demo.rev_d.general.xcl",
@@ -96,13 +123,19 @@ FIRMWARE_LOG_ROOT = _env_path(
FIRMWARE_SOURCE_ROOTS = tuple(
PROJECT_DIR / name for name in ("App", "Bsp", "Config", "Driver"))
FIRMWARE_SOURCE_SUFFIXES = frozenset((".c", ".h", ".s", ".asm"))
REQUIRED_BRIDGE_VERSION = "2026-08-07-reset-home-far-target-v15"
ZCANPRO_EXE = _env_path(
"SELF_REPAIR_ZCANPRO_EXE", REPOSITORY_ROOT / "__unconfigured_zcanpro__")
ZCANPRO_SCRIPT_CONFIG = _env_path(
"SELF_REPAIR_ZCANPRO_SCRIPT_CONFIG",
REPOSITORY_ROOT / "__unconfigured_zcanpro_script_config__",
REQUIRED_BRIDGE_VERSION = "2026-08-09-real-self-heal-v32"
ZCANPRO_ROOTS = (
Path(r"C:\Program Files (x86)\ZCANPRO"),
Path(r"C:\Program Files\ZCANPRO"),
)
ZCANPRO_EXE = _env_or_installed_path(
"SELF_REPAIR_ZCANPRO_EXE",
tuple(root / "ZCANPRO.exe" for root in ZCANPRO_ROOTS),
REPOSITORY_ROOT / "__unconfigured_zcanpro__")
ZCANPRO_SCRIPT_CONFIG = _env_or_installed_path(
"SELF_REPAIR_ZCANPRO_SCRIPT_CONFIG",
tuple(root / "config" / "extend_script.ini" for root in ZCANPRO_ROOTS),
REPOSITORY_ROOT / "__unconfigured_zcanpro_script_config__")
BRIDGE_MANAGER = ZcanBridgeManager(
STATUS_PATH, BRIDGE_SCRIPT_PATH,
required_version=REQUIRED_BRIDGE_VERSION,
@@ -268,6 +301,13 @@ class ConsoleState(object):
"CAN链路有流量但缺少0x301~0x305,判定为未烧录/固件不匹配类问题" if
bridge.get("bus_traffic") else
"桥接在线但座椅协议无报文,判定为未烧录/固件未运行类问题")
elif not bridge.get("firmware_revision_ok"):
reason = "FIRMWARE_REVISION_MISMATCH"
detail = (
"Seat_Controller_demo固件revision=%s,期望revision=%s,"
"需要自动重新构建并烧录" %
(bridge.get("firmware_revision", "未知"),
EXPECTED_FIRMWARE_REVISION))
else:
return {
"ready": True, "auto_flash": False,
@@ -334,7 +374,8 @@ class ConsoleState(object):
last_bridge = self._bridge_summary(last_status, time.time())
if (last_bridge.get("online") and
(not require_protocol or
last_bridge.get("protocol_online"))):
(last_bridge.get("protocol_online") and
last_bridge.get("firmware_revision_ok")))):
return last_status, last_bridge
time.sleep(0.25)
return last_status, last_bridge or {}
@@ -385,7 +426,8 @@ class ConsoleState(object):
trigger_reason=qualification.get("reason_code"))
status, bridge = self._wait_bridge(require_protocol=True,
timeout_s=12.0)
if not bridge.get("protocol_online"):
if (not bridge.get("protocol_online") or
not bridge.get("firmware_revision_ok")):
detail = ("自动烧录已执行,但仍未收到0x301~0x305;"
"硬件诊断未启动,请检查ECU供电或烧录日志")
with self.lock:
@@ -430,6 +472,7 @@ class ConsoleState(object):
online = age is not None and age < 3.0 and version_ok
system = {} if not isinstance(status, dict) else (
status.get("system") or {})
firmware_revision = system.get("firmware_revision")
frame_epochs = {} if not isinstance(status, dict) else (
status.get("frame_epochs") or {})
recent_frame_ids = []
@@ -460,6 +503,10 @@ class ConsoleState(object):
"current_request_id": None if not isinstance(status, dict) else status.get(
"current_request_id"),
"position_valid_mask": int(system.get("position_valid_mask", 0)),
"firmware_revision": firmware_revision,
"expected_firmware_revision": EXPECTED_FIRMWARE_REVISION,
"firmware_revision_ok":
firmware_revision == EXPECTED_FIRMWARE_REVISION,
"active_mask": int(system.get("active_mask", 0)),
"faulted_mask": int(system.get("faulted_mask", 0)),
"bus": {} if not isinstance(status, dict) else (
@@ -618,6 +665,16 @@ class ConsoleState(object):
self.hardware_active = False
if not self.hardware_run_id or self.hardware_report_path is not None:
return
active_test = (self.hardware_result or {}).get("active_test") or {}
test_phase = active_test.get("phase")
if test_phase in ("PASSED", "FAILED"):
self.hardware_prepare.update({
"state": "DIAGNOSTIC_%s" % test_phase,
"detail": active_test.get("detail") or
active_test.get("title") or
"21针主动检测已结束",
"finished_epoch": now,
})
report = {
"schema": "seat-hardware-diagnostic-report-v1",
"run_id": self.hardware_run_id,
@@ -625,8 +682,7 @@ class ConsoleState(object):
"events": list(self.hardware_events),
"diagnosis": self.hardware_result,
"startup_rule_check": self.hardware_self_test,
"active_pin_test": (self.hardware_result or {}).get(
"active_test"),
"active_pin_test": active_test,
"bridge": self._bridge_summary(status, now),
}
path = HARDWARE_LOG_ROOT / self.hardware_run_id / \
@@ -839,10 +895,11 @@ class ConsoleState(object):
"firmware": self._firmware_snapshot(),
}
def start(self, case_id, execution_mode="shadow"):
def start(self, case_id, execution_mode="shadow", allow_batch=False):
with self.lock:
if execution_mode == "bench":
if self.hardware_active or self.batch.get("active"):
if (self.hardware_active or
(self.batch.get("active") and not allow_batch)):
raise ValueError("硬件诊断或批量验证正在运行,不能启动实物故障用例")
scenario = SCENARIO_BY_ID.get(case_id) or {}
if not (scenario.get("bench") or {}).get("supported", False):
@@ -850,8 +907,9 @@ class ConsoleState(object):
"该用例没有安全的实物注入手段,请使用影子仿真")
self.bridge_manager.ensure_async()
now = time.time()
status = _read_json(STATUS_PATH)
bridge = self._bridge_summary(status, now)
status, bridge = self._wait_bridge(
require_protocol=True, timeout_s=12.0)
now = time.time()
if not bridge.get("online"):
raise ValueError("ZCANPRO桥接未在线,不能向实物ECU写入故障")
if not bridge.get("protocol_online"):
@@ -888,7 +946,8 @@ class ConsoleState(object):
self.bench_request_id = None
return self.engine.start(case_id, execution_mode="shadow")
def reset(self, execution_mode="shadow"):
def reset(self, execution_mode="shadow", allow_batch=False,
force_all_axes=True, home_nonzero_axes=False):
with self.lock:
if (self.engine.execution_mode == "bench" and
self.engine.phase not in ("IDLE", PHASE_PASSED,
@@ -907,12 +966,14 @@ class ConsoleState(object):
})
return {"healing": self.engine.snapshot(),
"bench_reset": dict(self.bench_reset)}
if self.hardware_active or self.batch.get("active"):
if (self.hardware_active or
(self.batch.get("active") and not allow_batch)):
raise ValueError("硬件诊断或批量验证正在运行,不能执行四轴归零")
self.bridge_manager.ensure_async()
now = time.time()
status = _read_json(STATUS_PATH)
bridge = self._bridge_summary(status, now)
status, bridge = self._wait_bridge(
require_protocol=True, timeout_s=12.0)
now = time.time()
if not bridge.get("online") or not bridge.get("protocol_online"):
raise ValueError("CAN桥接或座椅ECU协议未在线,不能执行四轴归零")
if (status or {}).get("current_request_id"):
@@ -925,7 +986,10 @@ class ConsoleState(object):
"armed_until_epoch": now + 600.0,
"source": "seat_self_healing_console",
"action": "initialize_known_ranges",
"payload": {"force_all_axes": True},
"payload": {
"force_all_axes": bool(force_all_axes),
"home_nonzero_axes": bool(home_nonzero_axes),
},
}
zero = self._bench_zero_snapshot(status)
self.bench_reset = {
@@ -938,13 +1002,64 @@ class ConsoleState(object):
return {"healing": self.engine.snapshot(),
"bench_reset": dict(self.bench_reset)}
def _wait_bench_reset(self, timeout_s=620.0):
"""Wait for the bridge-owned four-axis homing request to finish."""
deadline = time.time() + float(timeout_s)
last = None
while time.time() < deadline:
last = self.snapshot()
reset = last.get("bench_reset") or {}
if reset.get("phase") in ("PASSED", "FAILED"):
return reset
time.sleep(0.2)
with self.lock:
self.bench_reset.update({
"phase": "FAILED",
"finished_epoch": time.time(),
"summary": "四轴归零等待超时,批量验证已停止",
})
return dict(self.bench_reset)
@staticmethod
def _wait_bench_bridge_idle(timeout_s=8.0):
"""Wait until the bridge clears its just-published request result."""
deadline = time.time() + float(timeout_s)
while time.time() < deadline:
status = _read_json(STATUS_PATH) or {}
if not status.get("current_request_id"):
return True
time.sleep(0.05)
return False
def _batch_worker(self, execution_mode):
try:
for scenario in public_scenarios():
with self.lock:
self.batch["current_case_id"] = scenario["id"]
self.latest_report_path = None
self.start(scenario["id"], execution_mode)
if execution_mode == "bench":
if not self._wait_bench_bridge_idle():
raise ValueError(
"%s前台架桥接任务未在安全窗口内结束" % scenario["id"])
reset = {}
for reset_attempt in range(2):
self.reset(
"bench", allow_batch=True,
force_all_axes=False,
home_nonzero_axes=True)
reset = self._wait_bench_reset()
if (reset.get("phase") == "PASSED" and
reset.get("all_zero")):
break
if reset_attempt == 0:
time.sleep(1.0)
if (reset.get("phase") != "PASSED" or
not reset.get("all_zero")):
raise ValueError(
"%s前四轴自动归零失败:%s" %
(scenario["id"], reset.get("summary", "未知错误")))
self.start(
scenario["id"], execution_mode, allow_batch=True)
while True:
value = self.snapshot()
phase = value["healing"]["phase"]
@@ -961,9 +1076,12 @@ class ConsoleState(object):
"duration_s": value["healing"].get("elapsed_s"),
"diagnosed_code": value["healing"].get("diagnosed_code"),
"execution_mode": execution_mode,
"snapshot_path": (None if self.latest_report_path is None else
str(self.latest_report_path.parent /
"diagnostic_snapshot_success.json")),
"report_path": (None if self.latest_report_path is None else
str(self.latest_report_path)),
"snapshot_path": (
None if self.latest_report_path is None or not passed else
str(self.latest_report_path.parent /
"diagnostic_snapshot_success.json")),
}
self.batch["results"].append(result)
self.batch["completed"] += 1
@@ -990,8 +1108,9 @@ class ConsoleState(object):
if execution_mode not in ("shadow", "bench"):
raise ValueError("未知批量验证方式")
if execution_mode == "bench":
raise ValueError(
"实物台架需逐项验证:每个用例前请人工点击重置并确认四轴归零")
if self.hardware_active:
raise ValueError("硬件诊断正在运行,不能启动实物批量验证")
self.bridge_manager.ensure_async()
self.batch = self._new_batch_state(execution_mode)
self.batch["active"] = True
self.batch["started_epoch"] = time.time()
@@ -1376,7 +1495,10 @@ class SelfRepairService(object):
return self.state.start_batch(payload.get("execution_mode", "shadow"))
def reset_healing(self, payload):
return self.state.reset(payload.get("execution_mode", "shadow"))
return self.state.reset(
payload.get("execution_mode", "shadow"),
force_all_axes=bool(payload.get("force_all_axes", True)),
home_nonzero_axes=bool(payload.get("home_nonzero_axes", False)))
def start_hardware(self):
return self.state.start_hardware()
@@ -24,6 +24,7 @@ class ZcanBridgeManager(object):
def __init__(self, status_path, script_path, heartbeat_stale_s=3.0,
poll_s=1.0, retry_cooldown_s=15.0,
busy_heartbeat_grace_s=45.0,
required_version=None, zcanpro_exe=None,
script_config_path=None, auto_start=False):
self.status_path = Path(status_path)
@@ -34,6 +35,7 @@ class ZcanBridgeManager(object):
self.heartbeat_stale_s = float(heartbeat_stale_s)
self.poll_s = float(poll_s)
self.retry_cooldown_s = float(retry_cooldown_s)
self.busy_heartbeat_grace_s = float(busy_heartbeat_grace_s)
self.required_version = required_version
self.lock = threading.RLock()
self.stop_event = threading.Event()
@@ -79,6 +81,7 @@ class ZcanBridgeManager(object):
"last_attempt_epoch": self.last_attempt_epoch,
"last_online_epoch": self.last_online_epoch,
"heartbeat_age_s": None if age is None else round(age, 2),
"busy_heartbeat_grace_s": self.busy_heartbeat_grace_s,
"zcan_window_found": self.zcan_window_found,
"retry_armed": self.retry_requested,
"zcan_exe": str(self.zcanpro_exe),
@@ -120,6 +123,20 @@ class ZcanBridgeManager(object):
return False
return True
def _bridge_busy_within_grace(self):
try:
import json
with self.status_path.open("r", encoding="utf-8") as stream:
status = json.load(stream)
heartbeat = float(status.get("heartbeat_epoch", 0) or 0)
age = max(0.0, time.time() - heartbeat)
return bool(
status.get("current_request_id") and
status.get("bridge_state") == "RUNNING" and
heartbeat > 0 and age <= self.busy_heartbeat_grace_s)
except (OSError, ValueError, TypeError):
return False
def _take_retry_request(self):
with self.lock:
if not self.retry_requested:
@@ -130,15 +147,22 @@ class ZcanBridgeManager(object):
def _monitor(self):
while not self.stop_event.is_set():
if self._heartbeat_online():
with self.lock:
# A defensive ensure request is already satisfied by a
# healthy bridge. Do not carry it into a later live task.
self.retry_requested = False
self._set_state(
"ONLINE", "CAN桥接在线,由ZCANPRO扩展脚本持续提供数据",
last_online_epoch=time.time())
elif self._bridge_busy_within_grace():
self._set_state(
"BUSY", "CAN bridge request is active; restart is deferred")
else:
with self.lock:
# A bridge that was genuinely online gets one automatic
# recovery attempt. A failed recovery is not repeated
# until an explicit Start action calls ``ensure_async``.
if self.state == "ONLINE":
if self.state in ("ONLINE", "BUSY"):
self.retry_requested = True
if self._take_retry_request():
self._attempt_start()
@@ -0,0 +1,254 @@
"""Execute normal seat command plans through the shared ZCANPRO bridge.
ZLG's Windows driver gives one process exclusive ownership of a USBCAN device.
Self-repair already keeps that ownership inside ZCANPRO, so opening the same
adapter again from the web server makes both user and admin orchestration fail.
This executor keeps one owner and exchanges bounded, armed JSON requests with
the bridge instead.
"""
from __future__ import annotations
from datetime import UTC, datetime
import json
import os
from pathlib import Path
import time
from typing import Any
import uuid
def _now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _read_json(path: Path) -> dict[str, Any] | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return None
return value if isinstance(value, dict) else None
def _atomic_json(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(
f".{path.name}.{os.getpid()}.{time.time_ns()}.tmp"
)
temporary.write_text(
json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8"
)
os.replace(temporary, path)
class ZcanBridgeSeatExecutor:
"""Hardware-seat executor compatible with ``HardwareSeatExecutor``."""
def __init__(
self,
matrix: Any,
bridge_manager: Any,
status_path: Path,
pending_dir: Path,
*,
required_version: str,
auto_home: bool,
startup_timeout_s: float = 12.0,
request_timeout_s: float = 660.0,
) -> None:
self.matrix = matrix
self.bridge_manager = bridge_manager
self.status_path = Path(status_path)
self.pending_dir = Path(pending_dir)
self.required_version = required_version
self.auto_home = bool(auto_home)
self.startup_timeout_s = float(startup_timeout_s)
self.request_timeout_s = float(request_timeout_s)
def _online_status(self, *, require_protocol: bool) -> dict[str, Any] | None:
value = _read_json(self.status_path)
if value is None or value.get("bridge_version") != self.required_version:
return None
try:
fresh = time.time() - float(value.get("heartbeat_epoch", 0)) < 3.0
except (TypeError, ValueError):
fresh = False
if not fresh:
return None
if require_protocol and not (value.get("system") and value.get("frame_epochs")):
return None
return value
def _wait_online(self, *, require_protocol: bool) -> dict[str, Any]:
self.bridge_manager.ensure_async()
deadline = time.time() + self.startup_timeout_s
while time.time() < deadline:
value = self._online_status(require_protocol=require_protocol)
if value is not None:
return value
time.sleep(0.1)
qualifier = "及座椅ECU协议" if require_protocol else ""
raise RuntimeError(f"ZCANPRO桥接{qualifier}未在线")
def _payload(
self,
command_plan: dict[str, Any],
function_config: dict[str, Any],
) -> dict[str, Any]:
commands = []
for command in command_plan["spec"]["commands"]:
encoded = self.matrix.encode(command["message"], command["signals"])
item = dict(command)
item["frame_id"] = int(encoded["frame_id"], 16)
item["data"] = list(bytes.fromhex(encoded["data_hex"]))
commands.append(item)
return {
"name": command_plan.get("metadata", {}).get(
"name", "自然语言座椅控制"
),
"commands": commands,
"operations": list(function_config["spec"]["operations"]),
"allow_auto_home": self.auto_home,
}
def _submit(self, payload: dict[str, Any]) -> dict[str, Any]:
status = self._wait_online(require_protocol=True)
idle_deadline = time.time() + self.startup_timeout_s
while status.get("current_request_id") and time.time() < idle_deadline:
time.sleep(0.1)
status = self._wait_online(require_protocol=True)
if status.get("current_request_id"):
raise RuntimeError(
"ZCANPRO桥接正在执行其他台架任务:%s" %
status.get("current_request_id")
)
request_id = "SEAT_ORCH_%d_%s" % (
int(time.time() * 1000), uuid.uuid4().hex[:6]
)
request = {
"schema": "seat-function-request-v1",
"request_id": request_id,
"created_epoch": time.time(),
"armed_until_epoch": time.time() + self.request_timeout_s + 30.0,
"source": "cabin_cognitive_orchestrator",
"action": "execute_command_plan",
"payload": payload,
}
_atomic_json(self.pending_dir / f"{request_id}.json", request)
deadline = time.time() + self.request_timeout_s
while time.time() < deadline:
status = self._online_status(require_protocol=False)
if status is None:
time.sleep(0.1)
continue
result = status.get("last_result") or {}
if result.get("request_id") == request_id:
return result
time.sleep(0.1)
raise RuntimeError("ZCANPRO桥接执行座椅请求超时,已停止等待")
@staticmethod
def _report(
function_config: dict[str, Any],
started_at: str,
bridge_result: dict[str, Any] | None,
error: Exception | None,
) -> dict[str, Any]:
request_id = function_config["metadata"]["request_id"]
passed = error is None and (bridge_result or {}).get("result") == "PASS"
detail = (bridge_result or {}).get("details") or {}
message = (
detail.get("summary")
or (bridge_result or {}).get("summary")
or (str(error) if error is not None else "ZCANPRO桥接未返回通过结果")
)
results = [
{
"ref": operation["id"],
"domain": "seat",
"status": "succeeded" if passed else "failed",
"message": message,
"telemetry": {
"transport": "zcanpro_shared_bridge",
"hardware_feedback": passed,
"simulated": False,
"bridge_result": bridge_result,
"error_type": type(error).__name__ if error is not None else None,
"stop_all_attempted": bool(detail.get("stop_all_attempted")),
},
}
for operation in function_config["spec"]["operations"]
]
return {
"apiVersion": "cognitive-agent/v1alpha1",
"kind": "ExecutionReport",
"metadata": {
"id": f"report-{request_id}",
"name": "T2G seat ZCANPRO bridge execution report",
"version": "1.0.0",
"request_id": request_id,
"labels": {"simulation": "false", "feedback": "physical_can"},
},
"spec": {
"mode": "hil",
"status": "succeeded" if passed else "failed",
"started_at": started_at,
"finished_at": _now(),
"results": results,
},
}
def execute(
self,
command_plan: dict[str, Any],
function_config: dict[str, Any],
actuator_state: dict[str, Any] | None = None,
) -> dict[str, Any]:
del actuator_state
started_at = _now()
try:
result = self._submit(self._payload(command_plan, function_config))
except Exception as exc: # normalize bridge/startup failures for the API
return self._report(function_config, started_at, None, exc)
return self._report(function_config, started_at, result, None)
def read_physical_state(self) -> dict[str, Any]:
status = self._wait_online(require_protocol=True)
axes = ("longitudinal", "cushion_tilt", "backrest_recline", "legrest_height")
positions: dict[str, Any] = {}
missing = []
motors = status.get("motors") or []
for motor, axis in enumerate(axes):
item = motors[motor] if motor < len(motors) else None
if not isinstance(item, dict):
missing.append(axis)
continue
positions[axis] = {
"position_pct": round(int(item.get("position", 0)) / 100),
"position_valid": bool(item.get("position_valid")),
"motion_state": "moving" if item.get("active") else "stopped",
}
thermal = status.get("thermal") or []
thermal_byte = int(thermal[0]) if thermal else 0
ventilation_raw = thermal_byte & 0x07
heating_raw = (thermal_byte >> 3) & 0x07
return {
"position": positions,
"ventilation": {
"state": "off" if ventilation_raw <= 1 else "on",
"level": max(0, ventilation_raw - 1),
},
"heating": {
"state": "off" if heating_raw <= 1 else "on",
"level": max(0, heating_raw - 1),
},
"captured_at": _now(),
"source": "physical_can",
"target": {
"device": "seat", "zone": "front_right", "instance": "passenger"
},
"complete": not missing,
"missing": missing,
}
@@ -504,7 +504,11 @@ class VehicleCanSettings:
repository_root=repository_root,
enabled=_env_bool(
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED",
default=_env_bool("COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED"),
# The seat bench and the vehicle CAN harness are independent
# pieces of hardware. Enabling physical seat execution must
# not silently open/transmit on the unconnected vehicle
# channel; dual-channel execution is an explicit opt-in.
default=False,
),
approval_required=_env_bool(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED", default=True
@@ -7,16 +7,17 @@ import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from typing import TYPE_CHECKING, Any, Callable
from cognitive_agent.infrastructure.can.gateway import CanBus
if TYPE_CHECKING:
from cognitive_agent.infrastructure.can.gateway import CanBus
FIRMWARE_PROTOCOL_SIGNATURE = "seat-controller-smv8-dynamic-modes-v13"
FIRMWARE_PROTOCOL_FRAME_IDS = frozenset(
{0x301, 0x302, 0x303, 0x304, 0x305, 0x306, 0x4FA}
)
EXPECTED_FIRMWARE_REVISION = 13
EXPECTED_FIRMWARE_REVISION = 15
EXPECTED_MOTOR_MAX_HALL = (480, 374, 917, 647)