352 lines
12 KiB
Python
Executable File
352 lines
12 KiB
Python
Executable File
"""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()
|