"""Small CAN transport abstraction used by approved hardware executors.""" from __future__ import annotations import json import subprocess import threading import time from dataclasses import dataclass from pathlib import Path from typing import Any, Protocol class CanGatewayError(RuntimeError): """Raised when a CAN adapter cannot be opened or used safely.""" @dataclass(frozen=True) class CanFrame: arbitration_id: int data: bytes timestamp: float | None = None channel: int | None = None is_fd: bool = False bitrate_switch: bool = False def public(self) -> dict[str, Any]: return { "frame_id": f"0x{self.arbitration_id:03X}", "payload_size": len(self.data), "data_hex": " ".join(f"{byte:02X}" for byte in self.data), "timestamp": self.timestamp, "channel": self.channel, "is_fd": self.is_fd, "bitrate_switch": self.bitrate_switch, } class CanBus(Protocol): def send( self, arbitration_id: int, data: bytes, *, is_fd: bool = False, bitrate_switch: bool = False, ) -> CanFrame: ... def receive( self, timeout_ms: int, *, include_fd: bool = False ) -> CanFrame | None: ... def close(self) -> None: ... class PythonCanBus: """Adapter for SocketCAN, PCAN and Vector through the optional python-can package.""" def __init__(self, interface: str, channel: str, bitrate: int): try: import can # type: ignore[import-not-found] except ImportError as exc: raise CanGatewayError( "python-can is not installed; install the project 'can' extra" ) from exc self._can = can try: self._bus = can.Bus( interface=interface, channel=channel, bitrate=bitrate, fd=False, ) except Exception as exc: # noqa: BLE001 - normalize vendor exceptions raise CanGatewayError(f"cannot open {interface} CAN channel {channel}: {exc}") from exc def send( self, arbitration_id: int, data: bytes, *, is_fd: bool = False, bitrate_switch: bool = False, ) -> CanFrame: if is_fd: raise CanGatewayError("PythonCanBus was opened for classic CAN only") if len(data) > 8: raise CanGatewayError("the T2G bench accepts classic CAN frames of at most 8 bytes") message = self._can.Message( arbitration_id=arbitration_id, data=data, is_extended_id=False, is_fd=False, ) try: self._bus.send(message, timeout=0.5) except Exception as exc: # noqa: BLE001 raise CanGatewayError(f"CAN transmit failed: {exc}") from exc return CanFrame(arbitration_id, bytes(data), is_fd=False) def receive( self, timeout_ms: int, *, include_fd: bool = False ) -> CanFrame | None: try: message = self._bus.recv(max(0, timeout_ms) / 1000) except Exception as exc: # noqa: BLE001 raise CanGatewayError(f"CAN receive failed: {exc}") from exc if message is None: return None return CanFrame( int(message.arbitration_id), bytes(message.data), float(message.timestamp) if message.timestamp is not None else None, is_fd=bool(getattr(message, "is_fd", False)), bitrate_switch=bool(getattr(message, "bitrate_switch", False)), ) def close(self) -> None: self._bus.shutdown() @dataclass(frozen=True) class ZlgChannelConfig: """Configuration for one channel on a shared ZLG CAN FD adapter.""" channel: int arbitration_bitrate: int = 500_000 data_bitrate: int = 2_000_000 can_fd_iso: bool = True termination_enabled: bool = True def public(self) -> dict[str, Any]: return { "channel": self.channel, "arbitration_bitrate": self.arbitration_bitrate, "data_bitrate": self.data_bitrate, "can_fd_iso": self.can_fd_iso, "termination_enabled": self.termination_enabled, } class ZlgCanBridgeProcess: """Own one device handle and expose several independently addressed channels.""" def __init__( self, python_executable: Path, bridge_script: Path, dll_path: Path, device_type: int, device_index: int, channels: list[ZlgChannelConfig], transmit_type: int = 0, ): if not channels: raise CanGatewayError("at least one ZLG channel must be configured") channel_ids = [item.channel for item in channels] if len(channel_ids) != len(set(channel_ids)): raise CanGatewayError("ZLG channel numbers must be unique") self._channels = frozenset(channel_ids) command = [ str(python_executable), str(bridge_script), "--dll", str(dll_path), "--device-type", str(device_type), "--device-index", str(device_index), "--channels-json", json.dumps([item.public() for item in channels], separators=(",", ":")), "--transmit-type", str(transmit_type), ] try: self._process = subprocess.Popen( # noqa: S603 - paths are trusted server config command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", bufsize=1, ) except OSError as exc: raise CanGatewayError(f"cannot start ZLG CAN bridge: {exc}") from exc self._lock = threading.Lock() ready = self._read_response() if ready.get("event") != "ready": self.close() raise CanGatewayError(str(ready.get("message", "ZLG CAN bridge did not start"))) def channel_bus(self, channel: int) -> "ZlgCanChannelBus": if channel not in self._channels: raise CanGatewayError(f"ZLG channel {channel} was not initialized") return ZlgCanChannelBus(self, channel) def _read_response(self) -> dict[str, Any]: if self._process.stdout is None: raise CanGatewayError("ZLG CAN bridge stdout is unavailable") line = self._process.stdout.readline() if not line: error = "" if self._process.stderr is not None: error = self._process.stderr.read().strip() raise CanGatewayError(error or "ZLG CAN bridge exited unexpectedly") try: response = json.loads(line) except json.JSONDecodeError as exc: raise CanGatewayError(f"invalid ZLG CAN bridge response: {line!r}") from exc if response.get("ok") is False: raise CanGatewayError(str(response.get("message", "ZLG CAN operation failed"))) return response def _request(self, payload: dict[str, Any]) -> dict[str, Any]: with self._lock: if self._process.poll() is not None or self._process.stdin is None: raise CanGatewayError("ZLG CAN bridge is not running") self._process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") self._process.stdin.flush() return self._read_response() def close(self) -> None: process = getattr(self, "_process", None) if process is None or process.poll() is not None: return try: if process.stdin is not None: process.stdin.write('{"operation":"close"}\n') process.stdin.flush() process.wait(timeout=2) except (OSError, subprocess.TimeoutExpired): process.terminate() try: process.wait(timeout=1) except subprocess.TimeoutExpired: process.kill() def __enter__(self) -> "ZlgCanBridgeProcess": return self def __exit__(self, *_: object) -> None: self.close() class ZlgCanChannelBus: """Channel-scoped view over a shared :class:`ZlgCanBridgeProcess`.""" def __init__(self, bridge: ZlgCanBridgeProcess, channel: int): self._bridge = bridge self.channel = channel def send( self, arbitration_id: int, data: bytes, *, is_fd: bool = False, bitrate_switch: bool = False, ) -> CanFrame: response = self._bridge._request( { "operation": "send", "channel": self.channel, "arbitration_id": arbitration_id, "data_hex": data.hex(), "is_fd": is_fd, "bitrate_switch": bitrate_switch, } ) return CanFrame( int(response["arbitration_id"]), bytes.fromhex(response["data_hex"]), channel=self.channel, is_fd=bool(response.get("is_fd", False)), bitrate_switch=bool(response.get("bitrate_switch", False)), ) def receive( self, timeout_ms: int, *, include_fd: bool = False ) -> CanFrame | None: requested_ms = max(0, int(timeout_ms)) deadline = time.monotonic() + requested_ms / 1000 while True: remaining_ms = max(0, int((deadline - time.monotonic()) * 1000)) response = self._bridge._request( { "operation": "receive", "channel": self.channel, # Short slices let another channel transmit or receive while # this synchronous JSON bridge is waiting for feedback. "timeout_ms": min(20, remaining_ms), "include_fd": include_fd, } ) frame = response.get("frame") if frame is not None: return CanFrame( int(frame["arbitration_id"]), bytes.fromhex(frame["data_hex"]), float(frame["timestamp_us"]) / 1_000_000, channel=self.channel, is_fd=bool(frame.get("is_fd", False)), bitrate_switch=bool(frame.get("bitrate_switch", False)), ) if requested_ms == 0 or time.monotonic() >= deadline: return None def close(self) -> None: # The owner closes all channel handles together. Executors may safely close views. return None class ZlgCanBridgeBus(ZlgCanChannelBus): """Backward-compatible single-channel ZLG bus.""" def __init__( self, python_executable: Path, bridge_script: Path, dll_path: Path, device_type: int, device_index: int, channel: int, bitrate: int, termination_enabled: bool = True, ): self._owner = ZlgCanBridgeProcess( python_executable=python_executable, bridge_script=bridge_script, dll_path=dll_path, device_type=device_type, device_index=device_index, channels=[ ZlgChannelConfig( channel=channel, arbitration_bitrate=bitrate, termination_enabled=termination_enabled, ) ], ) super().__init__(self._owner, channel) def close(self) -> None: self._owner.close() def __enter__(self) -> "ZlgCanBridgeBus": return self def __exit__(self, *_: object) -> None: self.close()