Files
Cognitive_Agent/src/cognitive_agent/application/self_repair/service.py
T

1636 lines
72 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Seat Controller self-repair application service.
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 or discovered standard installations; importing the module never
launches hardware tooling.
"""
from __future__ import unicode_literals
import json
import os
from pathlib import Path
import hashlib
import subprocess
import threading
import time
from .healing_model import (
PHASE_FAILED, PHASE_PASSED, SCENARIO_BY_ID, SelfHealingEngine,
public_scenarios,
run_self_test as run_healing_self_test,
)
from .signal_diagnostic import (
SignalDiagnosticModel, load_config,
run_self_test as run_signal_self_test,
)
from .zcan_bridge import (
ZcanBridgeManager, run_self_test as run_bridge_manager_self_test,
)
from .can_runtime import StandaloneCanRuntimeManager
from cognitive_agent.infrastructure.can.gateway import ZlgChannelConfig
from cognitive_agent.infrastructure.can.hardware import (
SeatHardwareSettings,
VehicleCanSettings,
)
from cognitive_agent.infrastructure.firmware.t2g_seat import (
EXPECTED_FIRMWARE_BUILD_ID,
EXPECTED_FIRMWARE_REVISION,
)
REPOSITORY_ROOT = Path(__file__).resolve().parents[4]
def _env_path(name, default):
value = os.getenv(name, "").strip()
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:
return bool(default)
return value.strip().lower() in {"1", "true", "yes", "on"}
RUNTIME_DIR = _env_path(
"SELF_REPAIR_RUNTIME_DIR",
REPOSITORY_ROOT / "artifacts" / "tmp" / "self-repair" / "runtime",
)
PENDING_DIR = RUNTIME_DIR / "requests" / "pending"
STATUS_PATH = _env_path(
"SELF_REPAIR_BRIDGE_STATUS_PATH", RUNTIME_DIR / "bridge_status.json")
BRIDGE_SCRIPT_PATH = _env_path(
"SELF_REPAIR_BRIDGE_SCRIPT_PATH",
REPOSITORY_ROOT / "tools" / "zcan" / "seat_self_repair_bridge.py",
)
EMERGENCY_PATH = RUNTIME_DIR / "emergency_stop.json"
CONFIG_PATH = _env_path(
"SELF_REPAIR_DIAGNOSTIC_CONFIG",
REPOSITORY_ROOT / "config" / "self-repair" / "signal_diagnostic_config.json",
)
LOG_ROOT = _env_path(
"SELF_REPAIR_LOG_DIR",
REPOSITORY_ROOT / "artifacts" / "self-repair" / "software",
)
HARDWARE_LOG_ROOT = _env_path(
"SELF_REPAIR_HARDWARE_LOG_DIR",
REPOSITORY_ROOT / "artifacts" / "self-repair" / "hardware",
)
PROJECT_DIR = _env_path(
"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_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",
)
CSPY_DRIVER_PATH = _env_path(
"SELF_REPAIR_CSPY_DRIVER_PATH",
PROJECT_DIR / "IAR" / "settings" / "Seat_Controller_demo.rev_d.driver.xcl",
)
FIRMWARE_LOG_ROOT = _env_path(
"SELF_REPAIR_FIRMWARE_LOG_DIR",
REPOSITORY_ROOT / "artifacts" / "self-repair" / "firmware",
)
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-10-legrest-runtime-v36"
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__")
CAN_RUNTIME_BACKEND = os.getenv(
"COGNITIVE_AGENT_CAN_RUNTIME_BACKEND", "standalone-zlg"
).strip().lower()
def _build_bridge_manager():
auto_start = _env_flag("SELF_REPAIR_AUTO_START_BRIDGE", False)
if CAN_RUNTIME_BACKEND == "zcanpro":
return ZcanBridgeManager(
STATUS_PATH, BRIDGE_SCRIPT_PATH,
required_version=REQUIRED_BRIDGE_VERSION,
zcanpro_exe=ZCANPRO_EXE,
script_config_path=ZCANPRO_SCRIPT_CONFIG,
auto_start=auto_start,
)
if CAN_RUNTIME_BACKEND != "standalone-zlg":
raise ValueError(
"COGNITIVE_AGENT_CAN_RUNTIME_BACKEND must be standalone-zlg or zcanpro"
)
seat = SeatHardwareSettings.from_env(REPOSITORY_ROOT)
vehicle = VehicleCanSettings.from_env(REPOSITORY_ROOT)
seat_channel = int(seat.channel, 0)
channels = [
ZlgChannelConfig(
channel=seat_channel,
frame_format="classic_can",
arbitration_bitrate=seat.bitrate,
data_bitrate=vehicle.data_bitrate,
can_fd_iso=True,
termination_enabled=seat.termination_enabled,
)
]
if vehicle.enabled:
if vehicle.channel == seat_channel:
raise ValueError("seat CH0 and vehicle CH1 must use different channels")
if (
vehicle.device_type != seat.device_type
or vehicle.device_index != seat.device_index
or vehicle.zlg_python != seat.zlg_python
or vehicle.zlg_dll != seat.zlg_dll
):
raise ValueError(
"seat and vehicle channels must share one configured ZLG device runtime"
)
channels.append(vehicle.channel_config())
return StandaloneCanRuntimeManager(
STATUS_PATH,
BRIDGE_SCRIPT_PATH,
required_version=REQUIRED_BRIDGE_VERSION,
python_executable=seat.zlg_python,
zlg_bridge_script=seat.bridge_script,
dll_path=seat.zlg_dll,
device_type=seat.device_type,
device_index=seat.device_index,
channels=channels,
seat_channel=seat_channel,
firmware_programmer=(
seat._programmer() if seat.auto_flash_enabled else None
),
firmware_auto_flash=seat.auto_flash_enabled,
expected_firmware_revision=EXPECTED_FIRMWARE_REVISION,
expected_firmware_build_id=EXPECTED_FIRMWARE_BUILD_ID,
position_sync_enabled=seat.position_sync_enabled,
firmware_boot_wait_s=seat.firmware_boot_wait_ms / 1000.0,
auto_start=auto_start,
)
BRIDGE_MANAGER = _build_bridge_manager()
def _read_json(path):
try:
with path.open("r", encoding="utf-8") as stream:
return json.load(stream)
except (OSError, ValueError, TypeError):
return None
def _atomic_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(
"%s.%s.%s.%s.tmp" %
(path.name, os.getpid(), threading.get_ident(), time.time_ns()))
try:
with temporary.open("w", encoding="utf-8") as stream:
json.dump(value, stream, ensure_ascii=False, indent=2,
sort_keys=True)
stream.flush()
os.fsync(stream.fileno())
for attempt in range(8):
try:
os.replace(str(temporary), str(path))
return
except PermissionError:
if attempt >= 7:
raise
# Windows may briefly deny replacement while the ZCAN bridge
# or an HTTP polling thread is closing the previous file.
time.sleep(0.01 * (attempt + 1))
finally:
try:
temporary.unlink()
except OSError:
pass
class ConsoleState(object):
def __init__(self, bridge_manager):
self.lock = threading.RLock()
self.bridge_manager = bridge_manager
self.engine = SelfHealingEngine()
self.signal_model = SignalDiagnosticModel(load_config(CONFIG_PATH))
self.latest_report_path = None
self.batch = self._new_batch_state()
self.batch_thread = None
self.hardware_active = False
self.hardware_run_id = None
self.hardware_started_epoch = None
self.hardware_events = []
self.hardware_signatures = {}
self.hardware_result = self.signal_model.analyze(None)
self.hardware_report_path = None
self.hardware_self_test = None
self.hardware_request_id = None
self.hardware_prepare = {
"state": "IDLE",
"detail": "等待启动;诊断前将自动检查并按需烧录Seat_Controller_demo固件",
"automatic_flash": False,
"reason_code": None,
}
self.bench_request_id = None
self.bench_reset = {
"phase": "IDLE", "request_id": None,
"started_epoch": None, "finished_epoch": None,
"summary": "实物用例启动前须点击重置并完成四轴归零",
"details": None, "axes": [], "zero_count": 0,
"all_zero": False,
}
self.firmware_operation = {
"state": "IDLE", "message": "尚未执行固件烧录",
"hex_path": str(FIRMWARE_HEX_PATH), "probe_checked_epoch": 0.0,
}
self.cspy_process = None
@staticmethod
def _run_hidden(command, timeout_s):
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
return subprocess.run(
[str(item) for item in command], cwd=str(PROJECT_DIR),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace",
timeout=timeout_s, creationflags=creationflags, check=False)
@classmethod
def _programmer_status(cls):
script = (
"$items=Get-PnpDevice -PresentOnly -ErrorAction SilentlyContinue | "
"Where-Object {$_.FriendlyName -match "
"'CMSIS-DAP|DAPLink|fireDAP|KitProg|MiniProg|J-Link' "
"-or $_.InstanceId -match "
"'CMSIS|CYPRESS|INFINEON|SEGGER|VID_1FC9&PID_5601'}; "
"$items | ForEach-Object {"
"$bus=(Get-PnpDeviceProperty -InstanceId $_.InstanceId "
"-KeyName DEVPKEY_Device_BusReportedDeviceDesc "
"-ErrorAction SilentlyContinue).Data; "
"$name=if($bus){$bus}else{$_.FriendlyName}; "
"if($name -match "
"'CMSIS-DAP|DAPLink|fireDAP CMSIS-DAP|KitProg|MiniProg|J-Link'){"
"$_.Status + '|' + $name + '|' + $_.InstanceId}}")
try:
result = cls._run_hidden(
["powershell", "-NoProfile", "-Command", script], 10)
devices = [line.strip() for line in result.stdout.splitlines()
if line.strip()]
except (OSError, subprocess.SubprocessError):
devices = []
return {"detected": bool(devices), "devices": devices,
"required": "CMSIS-DAP"}
def _firmware_snapshot(self):
now = time.time()
if now - float(self.firmware_operation.get(
"probe_checked_epoch", 0.0)) > 5.0:
self.firmware_operation["programmer"] = \
self._programmer_status()
self.firmware_operation["probe_checked_epoch"] = now
value = dict(self.firmware_operation)
if FIRMWARE_HEX_PATH.exists():
value["hex_sha256"] = hashlib.sha256(
FIRMWARE_HEX_PATH.read_bytes()).hexdigest()
value["hex_mtime"] = FIRMWARE_HEX_PATH.stat().st_mtime
return value
@staticmethod
def _classify_firmware_issue(bridge, hex_exists, source_newer,
probe_detected):
"""Classify only failures that can reasonably be fixed by flashing.
CAN traffic alone proves the adapter and physical pair are alive; it
does not prove that the connected ECU runs Seat_Controller_demo. A
live bridge without fresh 0x301~0x305, a missing local image, or local
firmware sources newer than that image are treated as recoverable
firmware-qualification problems. A missing bridge is not: flashing
cannot repair a closed adapter or a stopped CAN runtime.
"""
bridge = bridge or {}
if not bridge.get("online"):
return {
"ready": False, "auto_flash": False,
"reason_code": "BRIDGE_OFFLINE",
"detail": "CAN Runtime未在线;该问题不能通过烧录修复",
}
if not hex_exists:
reason = "FIRMWARE_IMAGE_MISSING"
detail = "未找到Seat_Controller_demo固件镜像,需要自动构建并烧录"
elif source_newer:
reason = "FIRMWARE_SOURCE_NEWER"
detail = "检测到固件源码晚于当前镜像,需要自动重新构建并烧录"
elif not bridge.get("protocol_online"):
reason = ("CAN_TRAFFIC_PROTOCOL_MISSING" if
bridge.get("bus_traffic") else
"SEAT_PROTOCOL_MISSING")
detail = (
"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))
elif not bridge.get("firmware_build_id_ok"):
reason = "FIRMWARE_BUILD_MISMATCH"
detail = (
"Seat_Controller_demo firmware build=%s, expected build=%s; "
"the repository image must be restored" %
(bridge.get("firmware_build_id", "unknown"),
EXPECTED_FIRMWARE_BUILD_ID))
else:
return {
"ready": True, "auto_flash": False,
"reason_code": "FIRMWARE_QUALIFIED",
"detail": "Seat_Controller_demo固件协议在线,无需重复烧录",
}
return {
"ready": False,
"auto_flash": bool(probe_detected),
"reason_code": reason,
"detail": (detail if probe_detected else
detail + ";未检测到CMSIS-DAP,无法自动烧录"),
}
@staticmethod
def _firmware_source_newer_than_image():
if not FIRMWARE_HEX_PATH.is_file():
return False, None
image_mtime = FIRMWARE_HEX_PATH.stat().st_mtime
newest_path = None
newest_mtime = 0.0
for root in FIRMWARE_SOURCE_ROOTS:
if not root.is_dir():
continue
for path in root.rglob("*"):
if (not path.is_file() or
path.suffix.lower() not in FIRMWARE_SOURCE_SUFFIXES):
continue
try:
modified = path.stat().st_mtime
except OSError:
continue
if modified > newest_mtime:
newest_path, newest_mtime = path, modified
return newest_mtime > image_mtime, newest_path
def _firmware_qualification(self, status=None, now=None):
now = time.time() if now is None else float(now)
status = _read_json(STATUS_PATH) if status is None else status
bridge = self._bridge_summary(status, now)
source_newer, newest_source = \
self._firmware_source_newer_than_image()
programmer = self._programmer_status()
value = self._classify_firmware_issue(
bridge, FIRMWARE_HEX_PATH.is_file(), source_newer,
programmer.get("detected", False))
value.update({
"checked_epoch": now,
"programmer": programmer,
"source_newer": source_newer,
"newest_source": (None if newest_source is None else
str(newest_source)),
"hex_path": str(FIRMWARE_HEX_PATH),
"bridge": bridge,
})
return value
def _wait_bridge(self, require_protocol=False, timeout_s=10.0):
deadline = time.time() + float(timeout_s)
last_status = None
last_bridge = None
while time.time() < deadline:
last_status = _read_json(STATUS_PATH)
last_bridge = self._bridge_summary(last_status, time.time())
if (last_bridge.get("online") and
(not require_protocol or
(last_bridge.get("protocol_online") and
last_bridge.get("firmware_revision_ok") and
last_bridge.get("firmware_build_id_ok")))):
return last_status, last_bridge
time.sleep(0.25)
return last_status, last_bridge or {}
def _prepare_firmware_for_hardware_diagnostic(self):
with self.lock:
self.hardware_prepare = {
"state": "CHECKING_FIRMWARE",
"detail": "正在检查CAN桥接、Seat_Controller_demo协议和固件镜像",
"automatic_flash": False,
"reason_code": None,
"started_epoch": time.time(),
}
self.bridge_manager.ensure_async()
status, bridge = self._wait_bridge(require_protocol=False,
timeout_s=10.0)
qualification = self._firmware_qualification(status, time.time())
with self.lock:
self.hardware_prepare.update({
"reason_code": qualification.get("reason_code"),
"detail": qualification.get("detail"),
"qualification": qualification,
})
if qualification.get("ready"):
with self.lock:
self.hardware_prepare.update({
"state": "READY",
"finished_epoch": time.time(),
})
return status, bridge
if not qualification.get("auto_flash"):
with self.lock:
self.hardware_prepare.update({
"state": "FAILED",
"finished_epoch": time.time(),
})
raise ValueError(qualification.get("detail"))
with self.lock:
self.hardware_prepare.update({
"state": "AUTO_FLASHING",
"automatic_flash": True,
"detail": qualification.get("detail") +
";正在自动构建并烧录",
})
firmware = self.build_and_flash_firmware(
confirmed_bench_clear=True, automatic=True,
trigger_reason=qualification.get("reason_code"))
status, bridge = self._wait_bridge(require_protocol=True,
timeout_s=12.0)
if (not bridge.get("protocol_online") or
not bridge.get("firmware_revision_ok") or
not bridge.get("firmware_build_id_ok")):
detail = ("自动烧录已执行,但仍未收到0x301~0x305;"
"硬件诊断未启动,请检查ECU供电或烧录日志")
with self.lock:
self.hardware_prepare.update({
"state": "FAILED", "detail": detail,
"firmware": firmware,
"finished_epoch": time.time(),
})
raise ValueError(detail)
with self.lock:
self.hardware_prepare.update({
"state": "READY",
"detail": "自动烧录完成,0x301~0x305已上线,开始21针检测",
"firmware": firmware,
"finished_epoch": time.time(),
})
return status, bridge
@staticmethod
def _new_batch_state(execution_mode="shadow"):
return {
"active": False, "total": len(public_scenarios()),
"execution_mode": execution_mode,
"completed": 0, "passed": 0, "failed": 0,
"current_case_id": None, "started_epoch": None,
"finished_epoch": None, "results": [],
}
@staticmethod
def _bridge_summary(status, now):
heartbeat = None if not isinstance(status, dict) else status.get(
"heartbeat_epoch")
age = None
if heartbeat is not None:
try:
age = max(0.0, now - float(heartbeat))
except (TypeError, ValueError):
age = None
version_ok = (isinstance(status, dict) and
status.get("bridge_version") ==
REQUIRED_BRIDGE_VERSION)
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")
firmware_identity = {} if not isinstance(status, dict) else (
status.get("firmware_identity") or {})
firmware_build_id = firmware_identity.get("firmware_build_id")
frame_epochs = {} if not isinstance(status, dict) else (
status.get("frame_epochs") or {})
recent_frame_ids = []
for frame_id, epoch in frame_epochs.items():
if ":" in str(frame_id):
continue
try:
if max(0.0, now - float(epoch)) <= 1.5:
recent_frame_ids.append(str(frame_id))
except (TypeError, ValueError):
continue
recent_frame_ids.sort()
protocol_required = {
"0x301", "0x302", "0x303", "0x304", "0x305", "0x306", "0x307"
}
protocol_online = online and protocol_required.issubset(
set(recent_frame_ids)
)
return {
"online": online,
"age_s": None if age is None else round(age, 2),
"state": "ONLINE" if online else "OFFLINE",
"bridge_state": None if not isinstance(status, dict) else status.get(
"bridge_state"),
"bridge_version": None if not isinstance(status, dict) else status.get(
"bridge_version"),
"bridge_version_ok": version_ok,
"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,
"firmware_build_id": firmware_build_id,
"expected_firmware_build_id": EXPECTED_FIRMWARE_BUILD_ID,
"firmware_build_id_ok": (
firmware_build_id == EXPECTED_FIRMWARE_BUILD_ID
and bool(firmware_identity.get("qualified"))
),
"active_mask": int(system.get("active_mask", 0)),
"faulted_mask": int(system.get("faulted_mask", 0)),
"bus": {} if not isinstance(status, dict) else (
status.get("bus") or {}),
"bus_traffic": bool(recent_frame_ids),
"protocol_online": protocol_online,
"recent_frame_ids": recent_frame_ids,
"expected_frame_ids": [
"0x301", "0x302", "0x303", "0x304", "0x305", "0x306", "0x307"
],
"frame_epochs": frame_epochs,
}
@staticmethod
def _bench_twin_snapshot(status, now):
"""Convert raw 0x301~0x305 observations into a UI-safe live twin."""
if not isinstance(status, dict):
return {"online": False, "source": "NO_BENCH_DATA", "axes": [],
"thermal": {"fan_running": False, "heater_level": 0}}
frame_epochs = status.get("frame_epochs") or {}
system = status.get("system") or {}
motors = status.get("motors") or []
diagnostics = status.get("diagnostics") or []
currents = status.get("currents") or []
axes = []
labels = ("水平滑轨", "坐盆升降", "靠背角度", "腿托角度")
for motor in range(4):
state = motors[motor] if motor < len(motors) and \
isinstance(motors[motor], dict) else {}
diagnostic = diagnostics[motor] if motor < len(diagnostics) and \
isinstance(diagnostics[motor], dict) else {}
current = currents[motor] if motor < len(currents) and \
isinstance(currents[motor], dict) else {}
raw_position = state.get("position")
position = raw_position
hall = diagnostic.get("hall")
max_hall = diagnostic.get("max_hall")
# The production protocol deliberately publishes Position=0xFFFF
# while PositionValid is false. The mechanism still has live
# Hall feedback, so use Hall/MaxHall for the visual twin instead
# of making the model jump to 100% during fault stop and homing.
if (position is None or not state.get("position_valid", False) or
int(position) > 10000) and hall is not None and max_hall:
position = int(round(10000.0 * float(hall) /
float(max_hall)))
epoch = frame_epochs.get("0x302:M%d" % motor)
age = None
try:
age = max(0.0, now - float(epoch))
except (TypeError, ValueError):
pass
axes.append({
"motor": motor, "label": labels[motor],
"position": position,
"raw_position": raw_position,
"position_pct": None if position is None else round(
max(0.0, min(100.0, float(position) / 100.0)), 1),
"position_valid": bool(state.get("position_valid", False)),
"active": bool(state.get("active", False)),
"direction": int(state.get("direction", 0) or 0),
"hall": diagnostic.get("hall"),
"max_hall": diagnostic.get("max_hall"),
"speed_pps_x100": diagnostic.get("speed_pps_x100"),
"current_raw": current.get("raw"),
"fault": bool(state.get("stall") or
state.get("driver_fault") or
diagnostic.get("fault_flags")),
"fresh": age is not None and age <= 1.0,
"age_s": None if age is None else round(age, 2),
})
thermal = status.get("thermal_current") or {}
thermal_feedback = status.get("thermal") or []
thermal_epoch = frame_epochs.get("0x305")
try:
thermal_age = max(0.0, now - float(thermal_epoch))
except (TypeError, ValueError):
thermal_age = None
protocol_epoch = frame_epochs.get("0x301")
try:
online = max(0.0, now - float(protocol_epoch)) <= 0.8
except (TypeError, ValueError):
online = False
progress = status.get("self_healing_progress") or {}
progress_telemetry = progress.get("telemetry") or {}
return {
"online": online,
"source": "REAL_BENCH_CAN_0x301_0x305",
"generated_epoch": now,
"axes": axes,
"system": {
"active_mask": int(system.get("active_mask", 0) or 0),
"faulted_mask": int(system.get("faulted_mask", 0) or 0),
"position_valid_mask": int(
system.get("position_valid_mask", 0) or 0),
},
"thermal": {
"fan_running": bool(thermal.get("fan_running", False)) and
thermal_age is not None and thermal_age <= 1.0,
"heater_level": (int(thermal.get("heater_level", 0) or 0)
if thermal_age is not None and
thermal_age <= 1.0 else 0),
"fan_raw": thermal.get("fan_raw"),
"heater_raw": thermal.get("heater_raw"),
"fresh": thermal_age is not None and thermal_age <= 1.0,
"age_s": (None if thermal_age is None else
round(thermal_age, 2)),
"feedback_byte0": (thermal_feedback[0]
if thermal_feedback else None),
},
"task": {
"stage": progress_telemetry.get("operation_stage"),
"name": progress_telemetry.get("normal_task"),
"reaction": progress_telemetry.get("bench_reaction"),
"target_positions": progress_telemetry.get(
"task_target_positions"),
"restored": bool(progress_telemetry.get("task_restored")),
},
}
def _save_completed_report(self, healing, hardware, bridge):
if healing["phase"] not in (PHASE_PASSED, PHASE_FAILED):
return
if self.engine.report_saved:
return
run_id = healing.get("run_id") or "RUN-UNKNOWN"
report_dir = LOG_ROOT / run_id
report_path = report_dir / "self_healing_report.json"
report = {
"schema": "seat-self-healing-report-v1",
"generated_epoch": time.time(),
"healing": healing,
"bridge": bridge,
"hardware_diagnosis": hardware,
}
_atomic_json(report_path, report)
if healing["phase"] == PHASE_PASSED:
_atomic_json(report_dir / "diagnostic_snapshot_success.json", report)
self.engine.report_saved = True
self.latest_report_path = report_path
def _record_hardware_transitions(self, result, now):
for group in result.get("groups", []):
signature = (group.get("state"), group.get("summary"))
previous = self.hardware_signatures.get(group.get("key"))
self.hardware_signatures[group.get("key")] = signature
if previous == signature:
continue
self.hardware_events.append({
"time": time.strftime("%H:%M:%S", time.localtime(now)),
"epoch": now, "key": group.get("key"),
"name": group.get("name"), "state": group.get("state"),
"summary": group.get("summary"),
"evidence": list(group.get("evidence") or []),
})
self.hardware_events = self.hardware_events[-200:]
def _save_hardware_report(self, status, now):
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,
"generated_epoch": now,
"events": list(self.hardware_events),
"diagnosis": self.hardware_result,
"startup_rule_check": self.hardware_self_test,
"active_pin_test": active_test,
"bridge": self._bridge_summary(status, now),
}
path = HARDWARE_LOG_ROOT / self.hardware_run_id / \
"hardware_diagnostic_report.json"
_atomic_json(path, report)
self.hardware_report_path = path
def _hardware_snapshot(self, status, now):
# Keep the read-only link summary current even before a diagnostic
# session starts. Event recording and report generation remain gated
# by ``hardware_active``.
self.hardware_result = self.signal_model.analyze(status, now=now)
active_test = {} if not isinstance(status, dict) else (
status.get("hardware_diagnostic") or {})
if (self.hardware_request_id and
active_test.get("request_id") == self.hardware_request_id):
pin_results = active_test.get("pin_results") or {}
group_pins = {
"can": ("JC1-1", "JC1-21"),
"m0": ("JA1-4", "JA1-5", "JC1-9"),
"m1": ("JB1-6", "JB1-8", "JC1-15"),
"m2": ("JB1-12", "JB1-15", "JC1-17"),
"m3": ("JB1-1", "JB1-3", "JC1-12"),
"fan": ("JA1-13",), "heater": ("JB1-18",),
"common": ("JA1-1", "JA1-10", "JA1-11",
"JC1-6", "JC1-18"),
}
ranks = {"OK": 0, "UNKNOWN": 1, "IDLE": 1,
"UNMONITORED": 1, "WARN": 2, "FAULT": 3}
for group in self.hardware_result.get("groups", []):
ids = group_pins.get(group.get("key"))
values = [pin_results.get(pin_id) for pin_id in (ids or ())]
values = [item for item in values if item]
if not values:
continue
group["state"] = max(
(item.get("state", "UNKNOWN") for item in values),
key=lambda state: ranks.get(state, 1))
group["summary"] = "主动检测:%s" % ";".join(
"%s %s" % (pin_id,
(pin_results.get(pin_id) or {}).get(
"state", "UNKNOWN"))
for pin_id in ids)
group["evidence"] = [
item.get("evidence") for item in values
if item.get("evidence")]
for connector, pins in self.hardware_result.get(
"connectors", {}).items():
for pin in pins:
pin_id = "%s-%s" % (connector, pin.get("number"))
evidence = pin_results.get(pin_id)
if not evidence:
continue
pin["state"] = evidence.get("state", pin.get("state"))
pin["active_evidence"] = evidence.get("evidence")
pin["evidence_kind"] = evidence.get("evidence_kind")
enabled = [pin for pins in self.hardware_result.get(
"connectors", {}).values() for pin in pins
if pin.get("enabled", True)]
pending = ("UNKNOWN", "IDLE", "UNMONITORED")
self.hardware_result["connector_ok_count"] = sum(
1 for pin in enabled if pin.get("state") == "OK")
self.hardware_result["connector_warn_count"] = sum(
1 for pin in enabled if pin.get("state") == "WARN")
self.hardware_result["connector_fault_count"] = sum(
1 for pin in enabled if pin.get("state") == "FAULT")
self.hardware_result["connector_pending_count"] = sum(
1 for pin in enabled if pin.get("state") in pending)
if self.hardware_result["connector_fault_count"]:
self.hardware_result["overall_state"] = "FAULT"
elif self.hardware_result["connector_warn_count"]:
self.hardware_result["overall_state"] = "WARN"
elif self.hardware_result["connector_pending_count"] == 0:
self.hardware_result["overall_state"] = "OK"
self.hardware_result["active_test"] = active_test
if self.hardware_active:
self._record_hardware_transitions(self.hardware_result, now)
if (active_test.get("request_id") == self.hardware_request_id and
active_test.get("phase") in ("PASSED", "FAILED")):
self._save_hardware_report(status, now)
return self.hardware_result
def _sync_bench_healing(self, status, now):
if self.engine.execution_mode != "bench" or not self.bench_request_id:
return
progress = {} if not isinstance(status, dict) else (
status.get("self_healing_progress") or {})
if progress.get("request_id") != self.bench_request_id:
return
self.engine.apply_external(
progress.get("phase"), telemetry=progress.get("telemetry"),
diagnosed_code=progress.get("diagnosed_code"),
title=progress.get("title"), detail=progress.get("detail"),
now=progress.get("epoch", now))
@staticmethod
def _bench_zero_snapshot(status):
status = status if isinstance(status, dict) else {}
motors = status.get("motors") or []
by_motor = {
int(value.get("motor", -1)): value for value in motors
if isinstance(value, dict)
}
axes = []
for motor in range(4):
value = by_motor.get(motor) or {}
position = int(value.get("position", -1))
at_zero = bool(
value.get("position_valid") and value.get("near") and
0 <= position <= 100 and not value.get("active"))
axes.append({
"motor": motor, "position": position,
"position_valid": bool(value.get("position_valid")),
"near": bool(value.get("near")),
"active": bool(value.get("active")),
"at_zero": at_zero,
})
system = status.get("system") or {}
all_zero = bool(
all(axis["at_zero"] for axis in axes) and
int(system.get("position_valid_mask", 0)) == 0x0F and
int(system.get("active_mask", 0)) == 0 and
int(system.get("faulted_mask", 0)) == 0)
return {
"axes": axes,
"zero_count": sum(1 for axis in axes if axis["at_zero"]),
"all_zero": all_zero,
}
def _sync_bench_reset(self, status, now):
zero = self._bench_zero_snapshot(status)
self.bench_reset.update(zero)
request_id = self.bench_reset.get("request_id")
if not request_id or self.bench_reset.get("phase") not in (
"QUEUED", "HOMING"):
return
current_request_id = None if not isinstance(status, dict) else \
status.get("current_request_id")
if current_request_id == request_id:
self.bench_reset["phase"] = "HOMING"
self.bench_reset["summary"] = \
"四轴依次归零中,当前%d/4轴已在零位" % zero["zero_count"]
return
last_result = {} if not isinstance(status, dict) else \
(status.get("last_result") or {})
if last_result.get("request_id") != request_id:
if now - float(self.bench_reset.get("started_epoch") or now) > 600.0:
self.bench_reset.update({
"phase": "FAILED", "finished_epoch": now,
"summary": "四轴归零等待超时,台架保持停止",
})
return
passed = last_result.get("result") == "PASS" and zero["all_zero"]
self.bench_reset.update({
"phase": "PASSED" if passed else "FAILED",
"finished_epoch": now,
"summary": ("四轴已归零,可启动一项实物故障用例" if passed else
(last_result.get("summary") or
"四轴归零反馈不完整,禁止故障注入")),
"details": last_result.get("details"),
})
def snapshot(self):
with self.lock:
now = time.time()
status = _read_json(STATUS_PATH)
self._sync_bench_reset(status, now)
self._sync_bench_healing(status, now)
hardware = self._hardware_snapshot(status, now)
bridge = self._bridge_summary(status, now)
healing = self.engine.snapshot(now)
self._save_completed_report(healing, hardware, bridge)
return {
"schema": "seat-self-healing-console-state-v1",
"generated_epoch": now,
"project": {
"name": "Seat_Controller_demo",
"controller": "T2G Seat Controller",
"ai_engine": "Seat-Heal Evidence Engine v1",
"execution_target": "影子故障模型 + 受控实物CAN写入闭环",
},
"scenarios": public_scenarios(),
"bridge": bridge,
"bench_twin": self._bench_twin_snapshot(status, now),
"bridge_manager": self.bridge_manager.snapshot(),
"healing": healing,
"bench_reset": dict(self.bench_reset),
"hardware": hardware,
"batch": dict(self.batch),
"hardware_session": {
"active": self.hardware_active,
"run_id": self.hardware_run_id,
"started_epoch": self.hardware_started_epoch,
"events": list(self.hardware_events),
"report_path": (None if self.hardware_report_path is None
else str(self.hardware_report_path)),
"self_test": self.hardware_self_test,
"request_id": self.hardware_request_id,
"progress": (self.hardware_result or {}).get(
"active_test"),
"prepare": dict(self.hardware_prepare),
},
"model": {
"available": True,
"type": "PROCEDURAL_SEAT",
"name": "Cognitive_Agent程序化座椅孪生",
},
"latest_report_path": (None if self.latest_report_path is None
else str(self.latest_report_path)),
"firmware": self._firmware_snapshot(),
}
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") and not allow_batch)):
raise ValueError("硬件诊断或批量验证正在运行,不能启动实物故障用例")
scenario = SCENARIO_BY_ID.get(case_id) or {}
if not (scenario.get("bench") or {}).get("supported", False):
raise ValueError(
"该用例没有安全的实物注入手段,请使用影子仿真")
self.bridge_manager.ensure_async()
now = time.time()
status, bridge = self._wait_bridge(
require_protocol=True, timeout_s=12.0)
now = time.time()
if not bridge.get("online"):
raise ValueError("CAN Runtime未在线,不能向实物ECU写入故障")
if not bridge.get("protocol_online"):
raise ValueError("未识别到本工程座椅ECU协议,不能执行台架故障注入")
self._sync_bench_reset(status, now)
zero = self._bench_zero_snapshot(status)
if (self.bench_reset.get("phase") != "PASSED" or
not zero.get("all_zero")):
raise ValueError(
"请先点击‘重置并四轴归零’,确认四轴均为零位后再启动该用例")
request_id = "SELF_HEAL_BENCH_%d" % int(now * 1000)
request = {
"schema": "seat-function-request-v1",
"request_id": request_id,
"created_epoch": now,
"armed_until_epoch": now + 60.0,
"source": "seat_self_healing_console",
"action": "execute_self_healing_case",
"payload": {"case_id": case_id},
}
self.latest_report_path = None
self.bench_request_id = request_id
value = self.engine.start(
case_id, now=now, execution_mode="bench")
self.engine.telemetry["bench_request_id"] = request_id
_atomic_json(PENDING_DIR / (request_id + ".json"), request)
self.bench_reset.update({
"phase": "CONSUMED",
"summary": "本次归零许可已用于%s;下一个用例前须重新归零" %
case_id,
})
return value
self.latest_report_path = None
self.bench_request_id = None
return self.engine.start(case_id, 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,
PHASE_FAILED)):
raise ValueError("实物台架执行中不能重置,请使用STOP ALL")
if execution_mode not in ("shadow", "bench"):
raise ValueError("未知重置方式")
self.engine.reset()
self.bench_request_id = None
if execution_mode != "bench":
self.bench_reset.update({
"phase": "IDLE", "request_id": None,
"started_epoch": None, "finished_epoch": None,
"summary": "影子验证已重置",
"details": None,
})
return {"healing": self.engine.snapshot(),
"bench_reset": dict(self.bench_reset)}
if (self.hardware_active or
(self.batch.get("active") and not allow_batch)):
raise ValueError("硬件诊断或批量验证正在运行,不能执行四轴归零")
self.bridge_manager.ensure_async()
now = time.time()
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"):
raise ValueError("台架已有任务正在执行,不能开始四轴归零")
request_id = "RESET_HOME_ALL_%d" % int(now * 1000)
request = {
"schema": "seat-function-request-v1",
"request_id": request_id,
"created_epoch": now,
"armed_until_epoch": now + 600.0,
"source": "seat_self_healing_console",
"action": "initialize_known_ranges",
"payload": {
"force_all_axes": bool(force_all_axes),
"home_nonzero_axes": bool(home_nonzero_axes),
},
}
zero = self._bench_zero_snapshot(status)
self.bench_reset = {
"phase": "QUEUED", "request_id": request_id,
"started_epoch": now, "finished_epoch": None,
"summary": "已停止全部输出,等待四轴依次归零",
"details": None, **zero,
}
_atomic_json(PENDING_DIR / (request_id + ".json"), request)
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
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"]
if phase in (PHASE_PASSED, PHASE_FAILED):
break
time.sleep(0.12)
with self.lock:
passed = phase == PHASE_PASSED
result = {
"case_id": scenario["id"],
"title": scenario["short_title"],
"result": "PASS" if passed else "FAIL",
"run_id": value["healing"].get("run_id"),
"duration_s": value["healing"].get("elapsed_s"),
"diagnosed_code": value["healing"].get("diagnosed_code"),
"execution_mode": execution_mode,
"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
self.batch["passed" if passed else "failed"] += 1
with self.lock:
self.batch["active"] = False
self.batch["current_case_id"] = None
self.batch["finished_epoch"] = time.time()
except Exception as error:
with self.lock:
self.batch["active"] = False
self.batch["failed"] += 1
self.batch["current_case_id"] = None
self.batch["finished_epoch"] = time.time()
self.batch["results"].append({
"case_id": "BATCH", "title": "批量验证执行器",
"result": "FAIL", "reason": str(error),
})
def start_batch(self, execution_mode="shadow"):
with self.lock:
if self.batch.get("active"):
raise ValueError("六项自动验证正在运行")
if execution_mode not in ("shadow", "bench"):
raise ValueError("未知批量验证方式")
if execution_mode == "bench":
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()
self.batch_thread = threading.Thread(
target=self._batch_worker, name="seat-self-healing-batch",
args=(execution_mode,), daemon=True)
self.batch_thread.start()
return dict(self.batch)
def start_hardware(self):
try:
status, bridge = self._prepare_firmware_for_hardware_diagnostic()
self_test = self.run_hardware_self_test()
if self_test.get("result") != "PASS":
raise ValueError("内置诊断规则校验失败,未启动实物诊断:%s" %
self_test.get("reason", "未知错误"))
with self.lock:
now = time.time()
status = _read_json(STATUS_PATH) or status
bridge = self._bridge_summary(status, now)
if not bridge.get("online"):
raise ValueError(
"固件资格检查后CAN Runtime离线,未启动21针主动检测")
if not bridge.get("protocol_online"):
raise ValueError(
"固件资格检查后仍未识别0x301~0x305,未启动21针主动检测")
self.signal_model = SignalDiagnosticModel(
load_config(CONFIG_PATH))
self.hardware_active = True
self.hardware_started_epoch = now
self.hardware_run_id = \
"HW-RUN-%s" % time.strftime("%Y%m%d-%H%M%S")
self.hardware_request_id = \
"HARDWARE_PIN_%d" % int(now * 1000)
self.hardware_events = []
self.hardware_signatures = {}
self.hardware_report_path = None
self.hardware_result = self.signal_model.analyze(
status, now=now)
self._record_hardware_transitions(self.hardware_result, now)
request = {
"schema": "seat-function-request-v1",
"request_id": self.hardware_request_id,
"created_epoch": now,
"armed_until_epoch": now + 180.0,
"source": "seat_self_healing_console",
"action": "execute_hardware_diagnostic",
"payload": {
"enabled_pin_count": 21,
"repeat_per_direction": 2,
"bounded_output": True,
},
}
_atomic_json(
PENDING_DIR / (self.hardware_request_id + ".json"),
request)
self.hardware_prepare.update({
"state": "DIAGNOSTIC_RUNNING",
"detail": ("固件资格检查通过;21针主动检测已启动" if
not self.hardware_prepare.get(
"automatic_flash") else
"自动烧录与协议校验通过;21针主动检测已启动"),
})
return self.hardware_session()
except Exception as error:
with self.lock:
self.hardware_prepare.update({
"state": "FAILED", "detail": str(error),
"finished_epoch": time.time(),
})
if isinstance(error, ValueError):
raise
raise ValueError("硬件诊断启动前的自动固件处理失败:%s" % error)
def hardware_session(self):
return {
"active": self.hardware_active, "run_id": self.hardware_run_id,
"started_epoch": self.hardware_started_epoch,
"events": list(self.hardware_events),
"report_path": (None if self.hardware_report_path is None else
str(self.hardware_report_path)),
"self_test": self.hardware_self_test,
"request_id": self.hardware_request_id,
"progress": (self.hardware_result or {}).get("active_test"),
"prepare": dict(self.hardware_prepare),
}
def stop_hardware(self):
with self.lock:
now = time.time()
status = _read_json(STATUS_PATH)
self._save_hardware_report(status, now)
return self.hardware_session()
def run_hardware_self_test(self):
started = time.time()
try:
run_signal_self_test()
result = "PASS"
reason = ("启动前内部校验通过:JA1/JB1/JC1共72针映射、"
"21个启用针脚及检测阈值一致")
except Exception as error:
result = "FAIL"
reason = str(error)
value = {
"result": result, "reason": reason,
"duration_ms": round((time.time() - started) * 1000.0, 2),
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
}
with self.lock:
self.hardware_self_test = value
path = HARDWARE_LOG_ROOT / "self_test_latest.json"
_atomic_json(path, value)
return value
def emergency_stop(self):
request_id = "SELF_HEAL_STOP_%d" % int(time.time() * 1000)
value = {
"schema": "seat-function-emergency-v1",
"request_id": request_id,
"created_epoch": time.time(),
"armed_until_epoch": time.time() + 5.0,
"stop_all": True,
"thermal_off": True,
"source": "seat_self_healing_console",
}
_atomic_json(EMERGENCY_PATH, value)
return value
def build_and_flash_firmware(self, confirmed_bench_clear=False,
automatic=False, trigger_reason=None):
if not confirmed_bench_clear:
raise ValueError("请先确认台架运动区域无人、座椅机构无遮挡且急停可用")
with self.lock:
if self.batch.get("active") or self.hardware_active:
raise ValueError("有台架验证正在运行,不能烧录固件")
if self.firmware_operation.get("state") in ("BUILDING", "FLASHING"):
raise ValueError("固件构建/烧录已在进行")
if (self.cspy_process is not None and
self.cspy_process.poll() is None):
raise ValueError("台架测试固件已由C-SPY运行,无需重复烧录")
if not IAR_BUILD_PATH.exists() or not CSPY_BAT_PATH.exists():
raise ValueError("未找到IAR构建或C-SPY命令行工具")
stamp = time.strftime("%Y%m%d_%H%M%S")
run_dir = FIRMWARE_LOG_ROOT / stamp
run_dir.mkdir(parents=True, exist_ok=True)
self.firmware_operation = {
"state": "BUILDING", "message": "正在构建台架测试固件",
"started_epoch": time.time(), "log_dir": str(run_dir),
"hex_path": str(FIRMWARE_HEX_PATH),
"probe_checked_epoch": 0.0,
"automatic": bool(automatic),
"trigger_reason": trigger_reason,
}
build = self._run_hidden(
[IAR_BUILD_PATH, IAR_PROJECT_PATH, "-build", "rev_d",
"-log", "errors"], 180)
build_log = run_dir / "iar_build.log"
build_log.write_text(build.stdout, encoding="utf-8")
if build.returncode != 0 or not FIRMWARE_OUT_PATH.exists():
with self.lock:
self.firmware_operation.update({
"state": "BUILD_FAILED", "message": "IAR构建失败",
"build_returncode": build.returncode,
"build_log": str(build_log),
})
raise ValueError("IAR构建失败,详见%s" % build_log)
programmer = self._programmer_status()
with self.lock:
self.firmware_operation.update({
"programmer": programmer,
"probe_checked_epoch": time.time(),
"build_returncode": build.returncode,
"build_log": str(build_log),
"hex_sha256": hashlib.sha256(
FIRMWARE_HEX_PATH.read_bytes()).hexdigest(),
})
if not programmer.get("detected"):
with self.lock:
self.firmware_operation.update({
"state": "BUILT_NO_PROBE",
"message": "固件已构建;未检测到CMSIS-DAP,未执行烧录",
})
raise ValueError(
"测试固件已成功构建,但Windows未枚举到CMSIS-DAP下载器;"
"连接下载器后可再次点击烧录")
self.emergency_stop()
time.sleep(0.5)
with self.lock:
self.firmware_operation.update({
"state": "FLASHING", "message": "正在通过CMSIS-DAP烧录并校验",
})
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
flash = None
flash_log = None
flash_logs = []
flash_returncode = None
flash_error = None
protocol_online = False
max_flash_attempts = 3
for attempt in range(1, max_flash_attempts + 1):
flash_error = None
flash_log = run_dir / ("cspy_flash.log" if attempt == 1 else
"cspy_flash_attempt_%d.log" % attempt)
flash_logs.append(str(flash_log))
with self.lock:
self.firmware_operation.update({
"state": "FLASHING",
"message": "正在通过CMSIS-DAP烧录并校验(%d/%d)" %
(attempt, max_flash_attempts),
"flash_attempt": attempt,
"flash_attempts_max": max_flash_attempts,
"flash_logs": list(flash_logs),
})
try:
# Leave the flashed ECU running without an attached debugger.
# Otherwise an on-chip watchdog reset can be intercepted before
# the restarted firmware publishes recovery evidence. A macro
# parameter marks this as an authorized clean detach; unexpected
# debugger loss still forces the ECU safe.
with flash_log.open("w", encoding="utf-8") as stream:
flash = subprocess.Popen([
str(CSPY_BAT_PATH), "--silent", "--timeout", "3000",
"--leave_target_running", "--macro_param",
"seat_hil_leave_target_running=1",
"-f", str(CSPY_GENERAL_PATH), "--backend", "-f",
str(CSPY_DRIVER_PATH)], cwd=str(PROJECT_DIR),
stdout=stream, stderr=subprocess.STDOUT,
creationflags=creationflags)
self.cspy_process = flash
except OSError as error:
flash_error = str(error)
flash_returncode = None
else:
self.bridge_manager.ensure_async()
deadline = time.time() + 20.0
while time.time() < deadline:
flash_returncode = flash.poll()
if flash_returncode not in (None, 0):
break
bridge = self._bridge_summary(
_read_json(STATUS_PATH), time.time())
if (bridge.get("protocol_online") and
flash_returncode == 0):
protocol_online = True
break
time.sleep(0.25)
flash_returncode = flash.poll()
if flash_returncode is not None:
self.cspy_process = None
if protocol_online:
break
# Retry only a genuine probe/CPU connection failure. A zero
# return code means the image was downloaded successfully; if CAN
# is still silent, another download cannot repair the bus path.
# Likewise, do not launch a second debugger while the first one
# still owns the probe.
if flash_error is None and flash_returncode in (None, 0):
break
if attempt < max_flash_attempts:
with self.lock:
self.firmware_operation.update({
"message": ("CMSIS-DAP目标握手失败,%0.1f秒后自动重试" %
float(attempt)),
"flash_returncode": flash_returncode,
"flash_error": flash_error,
})
time.sleep(float(attempt))
if flash_returncode not in (None, 0) and not protocol_online:
with self.lock:
self.firmware_operation.update({
"state": "FLASH_FAILED",
"message": "C-SPY多次无法连接目标CPU",
"flash_returncode": flash_returncode,
"flash_error": flash_error,
"flash_log": str(flash_log),
"flash_logs": list(flash_logs),
"finished_epoch": time.time(),
})
raise ValueError(
"CMSIS-DAP已枚举,但%d次均无法连接目标CPU;"
"请检查座椅ECU 12V供电和SWD接线,日志:%s" %
(max_flash_attempts, flash_log))
with self.lock:
if flash_returncode is not None:
self.cspy_process = None
self.firmware_operation.update({
"state": "READY" if protocol_online else "FLASHED_WAITING_CAN",
"message": ("测试固件烧录成功且0x301~0x305已在线" if
protocol_online else
"C-SPY仍在运行,但20秒内未收到座椅协议"),
"flash_returncode": flash_returncode,
"flash_log": str(flash_log),
"flash_logs": list(flash_logs),
"cspy_pid": (flash.pid if flash is not None and
flash_returncode is None else None),
"target_mode": ("STANDALONE" if flash_returncode == 0 else
"CSPY_ATTACHED"),
"finished_epoch": time.time(),
"protocol_online": protocol_online,
})
return dict(self.firmware_operation)
def save_snapshot(self):
value = self.snapshot()
stamp = time.strftime("%Y%m%d_%H%M%S")
path = LOG_ROOT / ("snapshot_%s.json" % stamp)
_atomic_json(path, value)
self.latest_report_path = path
return path
def open_log_dir(self):
LOG_ROOT.mkdir(parents=True, exist_ok=True)
try:
os.startfile(str(LOG_ROOT))
return True
except (AttributeError, OSError):
return False
class SelfRepairService(object):
"""Repository-facing facade for the migrated self-repair runtime."""
def __init__(self):
self.bridge_manager = BRIDGE_MANAGER
self.state = ConsoleState(self.bridge_manager)
@staticmethod
def _configuration():
firmware_root_configured = PROJECT_DIR.is_dir()
return {
"runtime_dir": str(RUNTIME_DIR),
"diagnostic_config": str(CONFIG_PATH),
"bridge_status_path": str(STATUS_PATH),
"bridge_script_path": str(BRIDGE_SCRIPT_PATH),
"can_runtime_backend": CAN_RUNTIME_BACKEND,
"zcanpro_exe": str(ZCANPRO_EXE),
"firmware_root": str(PROJECT_DIR),
"iar_build_path": str(IAR_BUILD_PATH),
"cspy_bat_path": str(CSPY_BAT_PATH),
"bridge_script_available": BRIDGE_SCRIPT_PATH.is_file(),
"diagnostic_config_available": CONFIG_PATH.is_file(),
"zcanpro_configured": ZCANPRO_EXE.is_file(),
"standalone_can_runtime_configured": bool(
CAN_RUNTIME_BACKEND == "standalone-zlg"
and getattr(BRIDGE_MANAGER, "configured", False)
),
"can_runtime_configured": bool(
getattr(BRIDGE_MANAGER, "configured", False)
if CAN_RUNTIME_BACKEND == "standalone-zlg"
else ZCANPRO_EXE.is_file()
),
"firmware_root_configured": firmware_root_configured,
"firmware_tools_configured": (
firmware_root_configured
and IAR_BUILD_PATH.is_file()
and CSPY_BAT_PATH.is_file()
),
"auto_start_bridge": _env_flag("SELF_REPAIR_AUTO_START_BRIDGE", False),
}
def start(self):
self.bridge_manager.start()
def close(self):
self.bridge_manager.stop()
def health(self):
configuration = self._configuration()
return {
"ok": configuration["diagnostic_config_available"],
"service": "seat-self-repair",
"configuration": configuration,
"bridge_manager": self.bridge_manager.snapshot(),
}
def snapshot(self):
value = self.state.snapshot()
value["integration"] = self._configuration()
return value
def start_healing(self, payload):
return self.state.start(
payload.get("case_id"), payload.get("execution_mode", "shadow"))
def start_batch(self, payload):
return self.state.start_batch(payload.get("execution_mode", "shadow"))
def reset_healing(self, payload):
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()
def stop_hardware(self):
return self.state.stop_hardware()
def run_hardware_self_test(self):
return self.state.run_hardware_self_test()
def emergency_stop(self):
return self.state.emergency_stop()
def build_and_flash_firmware(self, payload):
return self.state.build_and_flash_firmware(
bool(payload.get("confirmed_bench_clear", False)))
def save_snapshot(self):
return self.state.save_snapshot()
def open_log_dir(self):
return self.state.open_log_dir()
@staticmethod
def run_self_test():
run_healing_self_test()
run_signal_self_test()
run_bridge_manager_self_test(
STATUS_PATH,
BRIDGE_SCRIPT_PATH,
zcanpro_exe=ZCANPRO_EXE,
script_config_path=ZCANPRO_SCRIPT_CONFIG,
)
return True