"""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