"""Headless multi-channel CAN runtime for orchestration and self-repair. One process owns the ZLG USB device and exposes channel-scoped bus views. The seat protocol engine runs on CH0 in a normal Python thread, while vehicle CAN executors can use CH1 through the same owner. This removes the ZCANPRO window and extension-script runtime from the normal deployment path. """ from __future__ import annotations import importlib.util import json import logging import os from pathlib import Path import threading import time from typing import Any, Callable from cognitive_agent.infrastructure.can.gateway import ( CanFrame, ZlgCanBridgeProcess, ZlgChannelConfig, ) LOGGER = logging.getLogger(__name__) class DirectSeatCanTransport: """Adapt a shared CH0 bus view to the transport-neutral seat bridge.""" def __init__( self, owner: ZlgCanBridgeProcess, channel: int, *, device_type: int, device_index: int, stop_event: threading.Event | None = None, log_callback: Callable[[str], None] | None = None, ) -> None: self.channel = int(channel) self._bus = owner.channel_bus(self.channel) self._stop_event = stop_event self._log_callback = log_callback self.bus = { "backend": "standalone_zlg", "busID": f"zlg:{device_type}:{device_index}:{self.channel}", "devType": int(device_type), "devIndex": int(device_index), "chnIndex": self.channel, "frame_format": self._bus.config.frame_format, "arbitration_bitrate": self._bus.config.arbitration_bitrate, } def log(self, value: str) -> None: text = str(value) LOGGER.info("seat CAN runtime: %s", text) if self._log_callback is not None: self._log_callback(text) def send(self, can_id: int, data: list[int]) -> None: self._bus.send( int(can_id), bytes(int(item) & 0xFF for item in data), is_fd=False, bitrate_switch=False, ) @staticmethod def _public_frame(frame: CanFrame) -> dict[str, Any]: return { "can_id": int(frame.arbitration_id), "data": list(frame.data), "timestamp_us": ( int(float(frame.timestamp) * 1_000_000) if frame.timestamp is not None else 0 ), "is_canfd": int(frame.is_fd), "canfd_brs": int(frame.bitrate_switch), "channel": frame.channel, } def receive(self) -> list[dict[str, Any]]: if self._stop_event is not None and self._stop_event.is_set(): # Raising here unwinds any long-running bridge workflow through # its existing exception-safe STOP_ALL/thermal-off path before # the shared device owner is closed. raise RuntimeError("standalone CAN runtime is stopping") # Non-blocking draining is important: CH1 commands share the one driver # process and must never wait behind a long CH0 receive call. frames: list[dict[str, Any]] = [] for _ in range(64): frame = self._bus.receive(0, include_fd=False) if frame is None: break frames.append(self._public_frame(frame)) return frames class StandaloneCanRuntimeManager: """Supervise one headless ZLG owner and the CH0 seat protocol engine.""" def __init__( self, status_path: Path, seat_bridge_script: Path, *, required_version: str, python_executable: Path, zlg_bridge_script: Path, dll_path: Path, device_type: int, device_index: int, channels: list[ZlgChannelConfig], seat_channel: int, firmware_programmer: Any | None = None, firmware_auto_flash: bool = False, expected_firmware_revision: int | None = None, expected_firmware_build_id: int | None = None, position_sync_enabled: bool = False, firmware_boot_wait_s: float = 1.5, protocol_probe_timeout_s: float = 3.5, protocol_fresh_s: float = 1.5, heartbeat_stale_s: float = 3.0, startup_timeout_s: float = 12.0, poll_s: float = 0.25, auto_start: bool = False, process_factory: Callable[..., ZlgCanBridgeProcess] = ZlgCanBridgeProcess, ) -> None: if not channels: raise ValueError("standalone CAN runtime requires at least one channel") channel_ids = {int(item.channel) for item in channels} if int(seat_channel) not in channel_ids: raise ValueError("seat channel must be included in runtime channels") self.status_path = Path(status_path) self.script_path = Path(seat_bridge_script).resolve() self.required_version = str(required_version) self.python_executable = Path(python_executable) self.zlg_bridge_script = Path(zlg_bridge_script).resolve() self.dll_path = Path(dll_path) self.device_type = int(device_type) self.device_index = int(device_index) self.channels = list(channels) self.seat_channel = int(seat_channel) self.firmware_programmer = firmware_programmer self.firmware_auto_flash = bool(firmware_auto_flash) self.expected_firmware_revision = expected_firmware_revision self.expected_firmware_build_id = expected_firmware_build_id self.position_sync_enabled = bool(position_sync_enabled) self.firmware_boot_wait_s = float(firmware_boot_wait_s) self.protocol_probe_timeout_s = float(protocol_probe_timeout_s) self.protocol_fresh_s = float(protocol_fresh_s) self.heartbeat_stale_s = float(heartbeat_stale_s) self.startup_timeout_s = float(startup_timeout_s) self.poll_s = float(poll_s) self.process_factory = process_factory self.lock = threading.RLock() self.firmware_lock = threading.Lock() self.stop_event = threading.Event() self.wake_event = threading.Event() self.runtime_stop_event = threading.Event() self.owner_ready = threading.Event() self.thread: threading.Thread | None = None self.runtime_thread: threading.Thread | None = None self.owner: ZlgCanBridgeProcess | None = None self.state = "IDLE" self.message = "等待前端服务启动" self.attempts = 0 self.auto_started = False self.last_attempt_epoch: float | None = None self.last_online_epoch: float | None = None self.retry_requested = bool(auto_start) self.last_runtime_error: str | None = None self.firmware_recovery_active = False self.firmware_recovery_count = 0 self.last_firmware_programming: dict[str, Any] | None = None self._position_sync_required = self.position_sync_enabled @property def configured(self) -> bool: return all( path.is_file() for path in ( self.python_executable, self.zlg_bridge_script, self.dll_path, self.script_path, ) ) def start(self) -> None: with self.lock: if self.thread and self.thread.is_alive(): return self.stop_event.clear() self.thread = threading.Thread( target=self._monitor, name="standalone-cabin-can-monitor", daemon=True, ) self.thread.start() def ensure_async(self) -> None: self.start() with self.lock: if self.owner_ready.is_set(): return self.retry_requested = True self.wake_event.set() def stop(self) -> None: self.stop_event.set() self.runtime_stop_event.set() self.wake_event.set() runtime_thread = self.runtime_thread if runtime_thread and runtime_thread is not threading.current_thread(): runtime_thread.join(timeout=10.0) with self.lock: owner = self.owner # Normal shutdown is owned by the protocol thread. Only force-close # the driver process when that thread did not unwind within the safety # window (for example, a wedged vendor call). if owner is not None and runtime_thread and runtime_thread.is_alive(): owner.close() runtime_thread.join(timeout=2.0) monitor = self.thread if monitor and monitor is not threading.current_thread(): monitor.join(timeout=1.0) def snapshot(self) -> dict[str, Any]: age = self._heartbeat_age() with self.lock: return { "state": self.state, "message": self.message, "backend": "standalone_zlg", "attempts": self.attempts, "auto_started": self.auto_started, "last_attempt_epoch": self.last_attempt_epoch, "last_online_epoch": self.last_online_epoch, "heartbeat_age_s": None if age is None else round(age, 2), "retry_armed": self.retry_requested, "required_version": self.required_version, "script_path": str(self.script_path), "python_executable": str(self.python_executable), "dll_path": str(self.dll_path), "device_type": self.device_type, "device_index": self.device_index, "seat_channel": self.seat_channel, "channels": [item.public() for item in self.channels], "owner_ready": self.owner_ready.is_set(), "last_error": self.last_runtime_error, "firmware_auto_flash": self.firmware_auto_flash, "expected_firmware_revision": self.expected_firmware_revision, "expected_firmware_build_id": self.expected_firmware_build_id, "firmware_recovery_active": self.firmware_recovery_active, "firmware_recovery_count": self.firmware_recovery_count, "last_firmware_programming": self.last_firmware_programming, "position_sync_enabled": self.position_sync_enabled, "position_sync_required": self._position_sync_required, # Compatibility fields consumed by the existing status UI. "zcan_exe": None, "script_config_path": None, "zcan_window_found": False, } def channel_bus(self, channel: int, timeout_s: float | None = None): channel = int(channel) if channel not in {item.channel for item in self.channels}: raise RuntimeError(f"CAN channel {channel} is not configured") self.ensure_async() deadline = time.time() + float(timeout_s or self.startup_timeout_s) while time.time() < deadline: if self.owner_ready.wait(timeout=0.05): with self.lock: owner = self.owner if owner is not None: return owner.channel_bus(channel) with self.lock: if self.state == "ERROR" and not self.retry_requested: break raise RuntimeError( "独立CAN Runtime未就绪:%s" % (self.last_runtime_error or self.message) ) def channel_bus_factory(self, channel: int): return lambda: self.channel_bus(channel) def seat_position_sync_required(self) -> bool: with self.lock: return self.position_sync_enabled and self._position_sync_required def mark_seat_position_synchronized(self) -> None: with self.lock: self._position_sync_required = False def _read_status(self) -> dict[str, Any] | None: try: value = json.loads(self.status_path.read_text(encoding="utf-8")) except (OSError, ValueError, TypeError): return None return value if isinstance(value, dict) else None def _protocol_status( self, ) -> tuple[bool, str, dict[str, Any] | None]: status = self._read_status() if status is None: return False, "CAN Runtime尚未发布状态", None now = time.time() try: heartbeat_age = now - float(status.get("heartbeat_epoch", 0) or 0) except (TypeError, ValueError): heartbeat_age = float("inf") if heartbeat_age < 0 or heartbeat_age > self.heartbeat_stale_s: return False, "CAN Runtime心跳已过期", status frame_epochs = status.get("frame_epochs") or {} required_frames = ( "0x301", "0x302", "0x303", "0x304", "0x305", "0x306", "0x307" ) stale_frames = [] for frame_id in required_frames: try: age = now - float(frame_epochs.get(frame_id, 0) or 0) except (TypeError, ValueError): age = float("inf") if age < 0 or age > self.protocol_fresh_s: stale_frames.append(frame_id) if stale_frames: return ( False, "座椅ECU报文缺失或过期:" + ",".join(stale_frames), status, ) system = status.get("system") or {} revision = system.get("firmware_revision") if ( self.expected_firmware_revision is not None and revision != self.expected_firmware_revision ): return ( False, "座椅固件revision不匹配:%s != %s" % (revision, self.expected_firmware_revision), status, ) identity = status.get("firmware_identity") or {} build_id = identity.get("firmware_build_id") if ( self.expected_firmware_build_id is not None and ( build_id != self.expected_firmware_build_id or not identity.get("qualified") ) ): return ( False, "座椅固件build不匹配:%s != %s" % (build_id, self.expected_firmware_build_id), status, ) return True, "座椅ECU协议与仓库固件身份已确认", status def _stop_runtime_owner(self) -> None: self.runtime_stop_event.set() runtime_thread = self.runtime_thread if runtime_thread and runtime_thread is not threading.current_thread(): runtime_thread.join(timeout=10.0) with self.lock: owner = self.owner if owner is not None: owner.close() if runtime_thread and runtime_thread.is_alive(): runtime_thread.join(timeout=2.0) self.owner_ready.clear() def ensure_seat_protocol(self) -> dict[str, Any]: """Return only fresh, repository-qualified seat protocol feedback. Standalone motor scripts intentionally replace the ECU image. When automatic programming is enabled, release the USB CAN owner, restore the repository image through CMSIS-DAP, and reopen the shared runtime before accepting an orchestration request. """ self.ensure_async() deadline = time.time() + self.protocol_probe_timeout_s reason = "座椅ECU协议尚未就绪" while time.time() < deadline: ready, reason, status = self._protocol_status() if ready and status is not None: return status time.sleep(0.1) if not self.firmware_auto_flash or self.firmware_programmer is None: raise RuntimeError(reason + ";自动恢复仓库固件未启用") with self.firmware_lock: ready, reason, status = self._protocol_status() if ready and status is not None: return status if status and status.get("current_request_id"): raise RuntimeError( "CAN Runtime仍有活动任务,不能恢复固件:%s" % status.get("current_request_id") ) with self.lock: self.firmware_recovery_active = True self.state = "FIRMWARE_RECOVERY" self.message = reason + ";正在自动恢复仓库固件" try: self._stop_runtime_owner() programming = dict(self.firmware_programmer.program()) with self.lock: self.firmware_recovery_count += 1 self.last_firmware_programming = programming if self.position_sync_enabled: self._position_sync_required = True time.sleep(self.firmware_boot_wait_s) if self.stop_event.is_set(): raise RuntimeError("CAN Runtime已停止,固件恢复后不再重启") self._attempt_start() verify_deadline = time.time() + self.startup_timeout_s while time.time() < verify_deadline: ready, reason, status = self._protocol_status() if ready and status is not None: return status time.sleep(0.1) raise RuntimeError("仓库固件已烧录但协议校验失败:" + reason) except Exception as exc: self._set_state( "ERROR", "座椅仓库固件自动恢复失败:%s" % exc, last_runtime_error=str(exc), ) raise finally: with self.lock: self.firmware_recovery_active = False def _set_state(self, state: str, message: str, **updates: Any) -> None: with self.lock: self.state = state self.message = message for key, value in updates.items(): setattr(self, key, value) def _heartbeat_age(self) -> float | None: try: value = self._read_status() or {} heartbeat = float(value.get("heartbeat_epoch", 0) or 0) if heartbeat <= 0: return None return max(0.0, time.time() - heartbeat) except (OSError, ValueError, TypeError): return None def _heartbeat_online(self) -> bool: age = self._heartbeat_age() if age is None or age > self.heartbeat_stale_s: return False try: value = self._read_status() or {} return value.get("bridge_version") == self.required_version except (OSError, ValueError, TypeError): return False def _take_retry_request(self) -> bool: with self.lock: if not self.retry_requested: return False self.retry_requested = False return True def _monitor(self) -> None: while not self.stop_event.is_set(): if self.firmware_recovery_active: self.wake_event.wait(self.poll_s) self.wake_event.clear() continue runtime_alive = bool( self.runtime_thread and self.runtime_thread.is_alive() ) if runtime_alive and self._heartbeat_online(): self._set_state( "ONLINE", "双通道CAN Runtime在线", last_online_epoch=time.time(), last_runtime_error=None, ) elif runtime_alive: self._set_state("STARTING", "CAN Runtime已启动,等待桥接心跳") elif self._take_retry_request(): self._attempt_start() elif self.state not in {"ERROR", "STOPPED"}: self._set_state("WAITING", "CAN Runtime未启动,等待硬件请求") self.wake_event.wait(self.poll_s) self.wake_event.clear() def _validate_paths(self) -> None: missing = [ str(path) for path in ( self.python_executable, self.zlg_bridge_script, self.dll_path, self.script_path, ) if not path.is_file() ] if missing: raise RuntimeError("CAN Runtime依赖不存在:" + ", ".join(missing)) def _attempt_start(self) -> None: self._set_state("STARTING", "正在启动独立双通道CAN Runtime") with self.lock: self.attempts += 1 self.last_attempt_epoch = time.time() try: self._validate_paths() owner = self.process_factory( python_executable=self.python_executable, bridge_script=self.zlg_bridge_script, dll_path=self.dll_path, device_type=self.device_type, device_index=self.device_index, channels=self.channels, ) self.runtime_stop_event.clear() with self.lock: self.owner = owner self.last_runtime_error = None self.runtime_thread = threading.Thread( target=self._run_protocol_engine, args=(owner,), name="standalone-seat-protocol-runtime", daemon=True, ) self.runtime_thread.start() if self.runtime_thread.is_alive(): self.owner_ready.set() self.auto_started = True except Exception as exc: # noqa: BLE001 - normalize vendor/runtime errors self.owner_ready.clear() self._set_state( "ERROR", "独立CAN Runtime启动失败:%s" % exc, last_runtime_error=str(exc), ) def _load_bridge_module(self): module_name = "_cognitive_agent_standalone_seat_bridge" spec = importlib.util.spec_from_file_location(module_name, self.script_path) if spec is None or spec.loader is None: raise RuntimeError("无法加载座椅协议桥接模块") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) if getattr(module, "BRIDGE_VERSION", None) != self.required_version: raise RuntimeError( "桥接版本不匹配:%s" % getattr(module, "BRIDGE_VERSION", None) ) return module def _run_protocol_engine(self, owner: ZlgCanBridgeProcess) -> None: try: module = self._load_bridge_module() transport = DirectSeatCanTransport( owner, self.seat_channel, device_type=self.device_type, device_index=self.device_index, stop_event=self.runtime_stop_event, ) module.run_bridge(transport=transport, stop_event=self.runtime_stop_event) except Exception as exc: # noqa: BLE001 - keep manager observable LOGGER.exception("standalone seat protocol runtime failed") self._set_state( "ERROR", "独立座椅协议Runtime异常:%s" % exc, last_runtime_error=str(exc), ) finally: self.owner_ready.clear() owner.close() with self.lock: if self.owner is owner: self.owner = None if self.stop_event.is_set(): self.state = "STOPPED" self.message = "CAN Runtime已停止"