"""Configuration and fail-closed gates for the T2G seat hardware bench.""" from __future__ import annotations import hashlib import hmac import os import threading import time from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Iterator from cognitive_agent.infrastructure.firmware import ( FIRMWARE_PROTOCOL_SIGNATURE, FirmwareProgrammingError, T2GFirmwareGuard, T2GSeatProgrammer, ) from .gateway import ( CanBus, CanGatewayError, PythonCanBus, ZlgCanBridgeBus, ZlgCanBridgeProcess, ZlgChannelConfig, ) TRUE_VALUES = {"1", "true", "yes", "on"} VALID_PROFILES = {"combined"} SUPPORTED_PHYSICAL_TARGET = ("front_right", "passenger") CAPABILITY_PROFILE = { "seat.set_heating": "combined", "seat.set_ventilation": "combined", "seat.set_longitudinal_position": "combined", "seat.set_cushion_tilt": "combined", "seat.set_backrest_recline": "combined", "seat.set_legrest_height": "combined", "seat.start_dynamic_mode": "combined", "seat.stop_dynamic_mode": "combined", } class HardwareSafetyError(ValueError): """Raised before any CAN device is opened when a safety gate fails.""" class HardwareBusyError(RuntimeError): """Raised when another request already owns the single physical bench.""" def __init__(self, retry_after_seconds: int = 5) -> None: self.retry_after_seconds = retry_after_seconds super().__init__( "真实座椅台架正在执行其他请求。为避免多人指令交叉,本次请求未进入CAN链路;" "请等待当前动作完成后重试。" ) class HardwareExecutionCoordinator: """Provide process-wide, fail-fast exclusive ownership of the physical bench.""" def __init__(self, retry_after_seconds: int = 5) -> None: self.retry_after_seconds = retry_after_seconds self._execution_lock = threading.Lock() self._state_lock = threading.Lock() self._active_since: float | None = None @contextmanager def reserve(self) -> Iterator[None]: if not self._execution_lock.acquire(blocking=False): raise HardwareBusyError(self.retry_after_seconds) with self._state_lock: self._active_since = time.monotonic() try: yield finally: with self._state_lock: self._active_since = None self._execution_lock.release() def public_info(self) -> dict[str, Any]: with self._state_lock: active_since = self._active_since return { "busy": active_since is not None, "active_for_ms": ( max(0, int((time.monotonic() - active_since) * 1000)) if active_since is not None else 0 ), "concurrency_policy": "single_flight_fail_fast", "retry_after_seconds": self.retry_after_seconds, } PHYSICAL_BENCH_COORDINATOR = HardwareExecutionCoordinator() def _env_bool(name: str, default: bool = False) -> bool: value = os.getenv(name) return default if value is None else value.strip().lower() in TRUE_VALUES def _env_int(name: str, default: int) -> int: value = os.getenv(name) if value is None or not value.strip(): return default try: return int(value, 0) except ValueError as exc: raise HardwareSafetyError(f"{name} must be an integer") from exc def _profiles_from_env() -> frozenset[str]: raw = os.getenv( "COGNITIVE_AGENT_SEAT_FIRMWARE_PROFILES", os.getenv("COGNITIVE_AGENT_SEAT_FIRMWARE_PROFILE", ""), ) profiles = frozenset( item.strip().lower() for item in raw.replace(";", ",").split(",") if item.strip() ) invalid = profiles - VALID_PROFILES if invalid: raise HardwareSafetyError( "unknown seat firmware profile(s): " + ", ".join(sorted(invalid)) ) return profiles @dataclass(frozen=True) class SeatHardwareSettings: """Environment-owned hardware configuration; secrets are never made public.""" repository_root: Path enabled: bool approval_required: bool approval_token: str profiles: frozenset[str] backend: str bitrate: int termination_enabled: bool channel: str interface: str device_type: int device_index: int zlg_python: Path zlg_dll: Path bridge_script: Path cspybat: Path auto_flash_enabled: bool auto_home_enabled: bool position_sync_enabled: bool home_timeout_s: int firmware_probe_timeout_ms: int firmware_boot_wait_ms: int firmware_program_timeout_ms: int thermal_timeout_ms: int ack_timeout_ms: int firmware_images: dict[str, Path] firmware_debug_files: dict[str, Path] firmware_macro_files: dict[str, Path] @classmethod def from_env(cls, repository_root: Path) -> "SeatHardwareSettings": hardware_enabled = _env_bool("COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED") auto_flash_enabled = _env_bool( "COGNITIVE_AGENT_SEAT_AUTO_FLASH_ENABLED", default=hardware_enabled, ) backend = os.getenv("COGNITIVE_AGENT_SEAT_CAN_BACKEND", "zlgcan").strip().lower() if backend not in {"zlgcan", "python-can"}: raise HardwareSafetyError( "COGNITIVE_AGENT_SEAT_CAN_BACKEND must be zlgcan or python-can" ) firmware_root = ( repository_root / "firmware" / "t2g" / "Seat_Controller_demo" ) images = { "combined": firmware_root / "IAR" / "rev_d" / "Exe" / "Seat_Controller_demo.hex", } debug_files = { "combined": firmware_root / "IAR" / "rev_d" / "Exe" / "Seat_Controller_demo.out", } macro_files = { "combined": firmware_root / "IAR" / "Seat_Controller_program.mac", } return cls( repository_root=repository_root, enabled=hardware_enabled, approval_required=_env_bool( "COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED", default=True, ), approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN", ""), profiles=_profiles_from_env(), backend=backend, bitrate=_env_int("COGNITIVE_AGENT_SEAT_CAN_BITRATE", 500_000), termination_enabled=_env_bool( "COGNITIVE_AGENT_SEAT_CAN_TERMINATION_ENABLED", default=True ), channel=os.getenv("COGNITIVE_AGENT_SEAT_CAN_CHANNEL", "0").strip(), interface=os.getenv("COGNITIVE_AGENT_SEAT_CAN_INTERFACE", "socketcan").strip(), device_type=_env_int("COGNITIVE_AGENT_ZLGCAN_DEVICE_TYPE", 41), device_index=_env_int("COGNITIVE_AGENT_ZLGCAN_DEVICE_INDEX", 0), zlg_python=Path( os.getenv( "COGNITIVE_AGENT_ZLGCAN_PYTHON", r"C:\Program Files (x86)\ZCANPRO\python38\python.exe", ) ), zlg_dll=Path( os.getenv( "COGNITIVE_AGENT_ZLGCAN_DLL", r"C:\Program Files (x86)\ZCANPRO\zlgcan.dll", ) ), bridge_script=repository_root / "src" / "cognitive_agent" / "infrastructure" / "can" / "zlgcan_bridge.py", cspybat=Path( os.getenv( "COGNITIVE_AGENT_IAR_CSPYBAT", r"D:\Program Files\IAR Systems\Embedded Workbench 9.2\common\bin\cspybat.exe", ) ), auto_flash_enabled=auto_flash_enabled, auto_home_enabled=_env_bool("COGNITIVE_AGENT_SEAT_AUTO_HOME_ENABLED"), position_sync_enabled=_env_bool( "COGNITIVE_AGENT_SEAT_POSITION_SYNC_ENABLED", default=hardware_enabled, ), home_timeout_s=_env_int( "COGNITIVE_AGENT_SEAT_HOME_TIMEOUT_S", 120 ), firmware_probe_timeout_ms=_env_int( "COGNITIVE_AGENT_SEAT_FIRMWARE_PROBE_TIMEOUT_MS", 3_500 ), firmware_boot_wait_ms=_env_int( "COGNITIVE_AGENT_SEAT_FIRMWARE_BOOT_WAIT_MS", 1_500 ), firmware_program_timeout_ms=_env_int( "COGNITIVE_AGENT_SEAT_FIRMWARE_PROGRAM_TIMEOUT_MS", 120_000 ), thermal_timeout_ms=_env_int( "COGNITIVE_AGENT_SEAT_THERMAL_TIMEOUT_MS", 2_000 ), ack_timeout_ms=_env_int("COGNITIVE_AGENT_SEAT_ACK_TIMEOUT_MS", 1_500), firmware_images=images, firmware_debug_files=debug_files, firmware_macro_files=macro_files, ) @property def adapter_configured(self) -> bool: if self.bitrate != 500_000: return False if self.backend == "zlgcan": return ( self.zlg_python.is_file() and self.zlg_dll.is_file() and self.bridge_script.is_file() ) return bool(self.interface and self.channel) @property def configured(self) -> bool: return bool( self.enabled and (not self.approval_required or self.approval_token) and self.profiles and self.adapter_configured and all(self.firmware_images[item].is_file() for item in self.profiles) and (not self.auto_flash_enabled or self.programmer_configured) ) @property def programmer_configured(self) -> bool: if not self.profiles: return False try: return all(self._programmer(item).configured for item in self.profiles) except (FirmwareProgrammingError, OSError): return False def authorize(self, supplied_token: str | None) -> None: if not self.enabled: raise HardwareSafetyError( "hardware execution is disabled; set COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED=true" ) if self.approval_required: if not self.approval_token: raise HardwareSafetyError( "COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN is not configured" ) if not supplied_token or not hmac.compare_digest( supplied_token, self.approval_token ): raise HardwareSafetyError("hardware approval token is invalid") if not self.profiles: raise HardwareSafetyError( "the combined IAR seat-controller firmware profile is not selected" ) if self.bitrate != 500_000: raise HardwareSafetyError("the verified T2G seat protocol requires 500000 bit/s") if not 10 <= self.home_timeout_s <= 600: raise HardwareSafetyError( "COGNITIVE_AGENT_SEAT_HOME_TIMEOUT_S must be between 10 and 600" ) if not self.adapter_configured: raise HardwareSafetyError("the configured CAN adapter runtime is not ready") missing = [ str(self.firmware_images[item]) for item in self.profiles if not self.firmware_images[item].is_file() ] if missing: raise HardwareSafetyError( "selected IAR firmware image is missing: " + ", ".join(missing) ) if self.auto_flash_enabled and not self.programmer_configured: raise HardwareSafetyError( "automatic seat-firmware programming is enabled but IAR C-SPY, " "the repository OUT image, or the C-SPY macro is unavailable" ) def validate_function_config(self, function_config: dict[str, Any]) -> None: operations = function_config.get("spec", {}).get("operations", []) if not operations: raise HardwareSafetyError("hardware execution requires at least one seat operation") physical_requests: dict[str, dict[str, Any]] = {} for operation in operations: capability = str(operation.get("capability", "")) target = operation.get("target", {}) target_key = (str(target.get("zone", "")), str(target.get("instance", ""))) if target_key != SUPPORTED_PHYSICAL_TARGET: raise HardwareSafetyError( "the T2G bench accepts only passenger/front_right seat operations" ) try: profile = CAPABILITY_PROFILE[capability] except KeyError as exc: raise HardwareSafetyError( f"the T2G IAR firmware does not implement {capability}" ) from exc if profile not in self.profiles: raise HardwareSafetyError( f"{capability} requires the '{profile}' firmware profile" ) parameters = dict(operation.get("parameters", {})) physical_requests[capability] = parameters def bus_factory(self) -> Callable[[], CanBus]: if self.backend == "zlgcan": try: numeric_channel = int(self.channel, 0) except ValueError as exc: raise CanGatewayError( "ZLG CAN channel must be an integer such as 0" ) from exc def create_zlg_bus() -> CanBus: return ZlgCanBridgeBus( python_executable=self.zlg_python, bridge_script=self.bridge_script, dll_path=self.zlg_dll, device_type=self.device_type, device_index=self.device_index, channel=numeric_channel, bitrate=self.bitrate, termination_enabled=self.termination_enabled, ) return create_zlg_bus def create_python_can_bus() -> CanBus: return PythonCanBus( interface=self.interface, channel=self.channel, bitrate=self.bitrate, ) return create_python_can_bus def _programmer(self, profile: str = "combined") -> T2GSeatProgrammer: return T2GSeatProgrammer( cspybat=self.cspybat, debug_file=self.firmware_debug_files[profile], hex_image=self.firmware_images[profile], macro_file=self.firmware_macro_files[profile], timeout_ms=self.firmware_program_timeout_ms, ) def firmware_guard( self, bus_factory: Callable[[], CanBus] | None = None ) -> T2GFirmwareGuard: return T2GFirmwareGuard( bus_factory or self.bus_factory(), self._programmer() if self.auto_flash_enabled else None, auto_flash=self.auto_flash_enabled, probe_timeout_ms=self.firmware_probe_timeout_ms, boot_wait_ms=self.firmware_boot_wait_ms, ) def public_info(self) -> dict[str, Any]: images = {} for profile, path in self.firmware_images.items(): exists = path.is_file() images[profile] = { "exists": exists, "filename": path.name, "sha256": ( hashlib.sha256(path.read_bytes()).hexdigest() if exists else None ), } return { "enabled": self.enabled, "configured": self.configured, "approval_required": self.approval_required, "approval_configured": bool(self.approval_token), "platform": "t2g_seat_bench", "backend": self.backend, "adapter_configured": self.adapter_configured, "channel": self.channel, "bitrate": self.bitrate, "termination_enabled": self.termination_enabled, "frame_format": "classic_can_11_bit", "firmware_profiles": sorted(self.profiles), "firmware_images": images, "firmware_status": "passive_protocol_verified_before_each_hardware_request", "firmware_protocol_signature": FIRMWARE_PROTOCOL_SIGNATURE, "firmware_source": "repository", "auto_flash_enabled": self.auto_flash_enabled, "programmer_configured": self.programmer_configured, "programmer_backend": "iar_cspy_cmsisdap", "supported_targets": ["front_right"], "supported_seat_instances": ["passenger"], "physical_target": "t2g_primary_seat", "target_aliasing": "disabled_front_passenger_only", "auto_home_enabled": self.auto_home_enabled, "position_sync_enabled": self.position_sync_enabled, "position_sync_policy": ( "four_axis_known_range_home_before_first_process_motion_and_after_firmware_change" if self.position_sync_enabled else "disabled" ), "home_timeout_s": self.home_timeout_s, "limitations": [ "hardware execution requires the unified Seat_Controller_demo image on one development board", "all seat capabilities are restricted to passenger/front_right", "M0/M1/M2/M3 map to horizontal, seat-pan, backrest and legrest on one physical seat", "on-demand recovery of an unexpectedly invalid target axis remains separately disabled by default", "when position synchronization is enabled, the first process motion and every repository firmware replacement rebuild all four physical Hall references", "current diagnostics use conservative bench thresholds and do not replace external power limiting", ], } @dataclass(frozen=True) class VehicleCanSettings: """Fail-closed channel/timing contract imported from the verified CAN_DEV branch.""" repository_root: Path enabled: bool approval_required: bool approval_token: str backend: str channel: int arbitration_bitrate: int data_bitrate: int termination_enabled: bool clock_hz: int arbitration_tseg1: int arbitration_tseg2: int arbitration_sjw: int arbitration_brp: int data_tseg1: int data_tseg2: int data_sjw: int data_brp: int feedback_timeout_ms: int device_type: int device_index: int zlg_python: Path zlg_dll: Path bridge_script: Path @classmethod def from_env(cls, repository_root: Path) -> "VehicleCanSettings": return cls( repository_root=repository_root, enabled=_env_bool( "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED", # The seat bench and the vehicle CAN harness are independent # pieces of hardware. Enabling physical seat execution must # not silently open/transmit on the unconnected vehicle # channel; dual-channel execution is an explicit opt-in. default=False, ), approval_required=_env_bool( "COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED", default=True ), approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN", ""), backend=os.getenv( "COGNITIVE_AGENT_VEHICLE_CAN_BACKEND", "zlgcan" ).strip().lower(), channel=_env_int("COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL", 1), arbitration_bitrate=_env_int( "COGNITIVE_AGENT_VEHICLE_CAN_ARBITRATION_BITRATE", 500_000 ), data_bitrate=_env_int( "COGNITIVE_AGENT_VEHICLE_CAN_DATA_BITRATE", 2_000_000 ), termination_enabled=_env_bool( "COGNITIVE_AGENT_VEHICLE_CAN_TERMINATION_ENABLED", default=False ), clock_hz=60_000_000, arbitration_tseg1=14, arbitration_tseg2=3, arbitration_sjw=2, arbitration_brp=5, data_tseg1=10, data_tseg2=2, data_sjw=2, data_brp=1, feedback_timeout_ms=_env_int( "COGNITIVE_AGENT_VEHICLE_CAN_FEEDBACK_TIMEOUT_MS", 1_500 ), device_type=_env_int("COGNITIVE_AGENT_ZLGCAN_DEVICE_TYPE", 41), device_index=_env_int("COGNITIVE_AGENT_ZLGCAN_DEVICE_INDEX", 0), zlg_python=Path( os.getenv( "COGNITIVE_AGENT_ZLGCAN_PYTHON", r"C:\Program Files (x86)\ZCANPRO\python38\python.exe", ) ), zlg_dll=Path( os.getenv( "COGNITIVE_AGENT_ZLGCAN_DLL", r"C:\Program Files (x86)\ZCANPRO\zlgcan.dll", ) ), bridge_script=repository_root / "src" / "cognitive_agent" / "infrastructure" / "can" / "zlgcan_bridge.py", ) @property def adapter_configured(self) -> bool: return bool( self.backend == "zlgcan" and self.zlg_python.is_file() and self.zlg_dll.is_file() and self.bridge_script.is_file() ) @property def configured(self) -> bool: return bool( self.enabled and (not self.approval_required or self.approval_token) and self.adapter_configured and self.timing_is_verified and self.channel_is_allowed ) @property def required_channel(self) -> int: """Physical body-CAN channel selected by the fail-closed bench policy.""" return _env_int("COGNITIVE_AGENT_VEHICLE_CAN_REQUIRED_CHANNEL", 1) @property def channel_is_allowed(self) -> bool: return self.channel == self.required_channel == 1 @property def timing_is_verified(self) -> bool: return ( self.arbitration_bitrate == 500_000 and self.data_bitrate == 2_000_000 and 60_000_000 // ((self.arbitration_brp + 1) * (self.arbitration_tseg1 + self.arbitration_tseg2 + 3)) == 500_000 and 60_000_000 // ((self.data_brp + 1) * (self.data_tseg1 + self.data_tseg2 + 3)) == 2_000_000 ) def authorize(self, supplied_token: str | None) -> None: if not self.enabled: raise HardwareSafetyError( "vehicle CAN execution is disabled; set COGNITIVE_AGENT_VEHICLE_CAN_ENABLED=true" ) if self.approval_required: if not self.approval_token: raise HardwareSafetyError( "COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN is not configured" ) if not supplied_token or not hmac.compare_digest( supplied_token, self.approval_token ): raise HardwareSafetyError("hardware approval token is invalid") if self.backend != "zlgcan": raise HardwareSafetyError( "vehicle CAN FD currently requires the zlgcan multi-channel backend" ) if not self.channel_is_allowed: raise HardwareSafetyError( "CH1 driver/body-CAN policy requires " "COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL=1 and " "COGNITIVE_AGENT_VEHICLE_CAN_REQUIRED_CHANNEL=1; CH0 is forbidden" ) if not self.timing_is_verified: raise HardwareSafetyError( "vehicle CAN timing must remain ISO CAN FD 500 kbit/s / 2 Mbit/s at 80% sample point" ) if not 100 <= self.feedback_timeout_ms <= 10_000: raise HardwareSafetyError( "COGNITIVE_AGENT_VEHICLE_CAN_FEEDBACK_TIMEOUT_MS must be between 100 and 10000" ) if not self.adapter_configured: raise HardwareSafetyError("the configured ZLG CAN FD runtime is not ready") def channel_config(self) -> ZlgChannelConfig: return ZlgChannelConfig( channel=self.channel, arbitration_bitrate=self.arbitration_bitrate, data_bitrate=self.data_bitrate, can_fd_iso=True, termination_enabled=self.termination_enabled, ) def bridge(self, channels: list[ZlgChannelConfig]) -> ZlgCanBridgeProcess: return ZlgCanBridgeProcess( python_executable=self.zlg_python, bridge_script=self.bridge_script, dll_path=self.zlg_dll, device_type=self.device_type, device_index=self.device_index, channels=channels, ) def public_info(self) -> dict[str, Any]: return { "enabled": self.enabled, "configured": self.configured, "backend": self.backend, "platform": "t2g_cabin_bench", "channel": self.channel, "required_channel": self.required_channel, "channel_is_allowed": self.channel_is_allowed, "target_policy": "ch1_body_can_driver_only", "forbidden_paths": ["ch0", "t2g_seat_bench", "passenger", "front_right"], "mode": 0, "work_mode": "normal", "can_fd_standard": "ISO", "termination_enabled": self.termination_enabled, "clock_hz": self.clock_hz, "arbitration": { "bitrate": self.arbitration_bitrate, "tseg1": self.arbitration_tseg1, "tseg2": self.arbitration_tseg2, "sjw": self.arbitration_sjw, "smp": 0, "brp": self.arbitration_brp, "total_tq": 20, "sample_point_percent": 80, }, "data": { "bitrate": self.data_bitrate, "tseg1": self.data_tseg1, "tseg2": self.data_tseg2, "sjw": self.data_sjw, "smp": 0, "brp": self.data_brp, "total_tq": 15, "sample_point_percent": 80, }, "source_branch": "project-code/CAN_DEV", "source_commit": "6e7ed04a468dfcde7efda9e30bc15144bf02faf9", }