597 lines
23 KiB
Python
597 lines
23 KiB
Python
"""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已停止"
|