feat: integrate cabin CAN and verified dynamic seat modes
This commit is contained in:
@@ -5,6 +5,7 @@ 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
|
||||
@@ -19,6 +20,9 @@ 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 {
|
||||
@@ -26,13 +30,25 @@ class CanFrame:
|
||||
"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) -> CanFrame: ...
|
||||
def send(
|
||||
self,
|
||||
arbitration_id: int,
|
||||
data: bytes,
|
||||
*,
|
||||
is_fd: bool = False,
|
||||
bitrate_switch: bool = False,
|
||||
) -> CanFrame: ...
|
||||
|
||||
def receive(self, timeout_ms: int) -> CanFrame | None: ...
|
||||
def receive(
|
||||
self, timeout_ms: int, *, include_fd: bool = False
|
||||
) -> CanFrame | None: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
@@ -58,7 +74,16 @@ class PythonCanBus:
|
||||
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) -> CanFrame:
|
||||
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(
|
||||
@@ -71,9 +96,11 @@ class PythonCanBus:
|
||||
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))
|
||||
return CanFrame(arbitration_id, bytes(data), is_fd=False)
|
||||
|
||||
def receive(self, timeout_ms: int) -> CanFrame | None:
|
||||
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
|
||||
@@ -84,14 +111,36 @@ class PythonCanBus:
|
||||
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()
|
||||
|
||||
|
||||
class ZlgCanBridgeBus:
|
||||
"""Use the installed 32-bit ZLG driver from the 64-bit application process."""
|
||||
@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,
|
||||
@@ -100,9 +149,15 @@ class ZlgCanBridgeBus:
|
||||
dll_path: Path,
|
||||
device_type: int,
|
||||
device_index: int,
|
||||
channel: int,
|
||||
bitrate: 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),
|
||||
@@ -112,10 +167,10 @@ class ZlgCanBridgeBus:
|
||||
str(device_type),
|
||||
"--device-index",
|
||||
str(device_index),
|
||||
"--channel",
|
||||
str(channel),
|
||||
"--bitrate",
|
||||
str(bitrate),
|
||||
"--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
|
||||
@@ -135,6 +190,11 @@ class ZlgCanBridgeBus:
|
||||
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")
|
||||
@@ -160,32 +220,6 @@ class ZlgCanBridgeBus:
|
||||
self._process.stdin.flush()
|
||||
return self._read_response()
|
||||
|
||||
def send(self, arbitration_id: int, data: bytes) -> CanFrame:
|
||||
response = self._request(
|
||||
{
|
||||
"operation": "send",
|
||||
"arbitration_id": arbitration_id,
|
||||
"data_hex": data.hex(),
|
||||
}
|
||||
)
|
||||
return CanFrame(
|
||||
int(response["arbitration_id"]),
|
||||
bytes.fromhex(response["data_hex"]),
|
||||
)
|
||||
|
||||
def receive(self, timeout_ms: int) -> CanFrame | None:
|
||||
response = self._request(
|
||||
{"operation": "receive", "timeout_ms": max(0, int(timeout_ms))}
|
||||
)
|
||||
frame = response.get("frame")
|
||||
if frame is None:
|
||||
return None
|
||||
return CanFrame(
|
||||
int(frame["arbitration_id"]),
|
||||
bytes.fromhex(frame["data_hex"]),
|
||||
float(frame["timestamp_us"]) / 1_000_000,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
process = getattr(self, "_process", None)
|
||||
if process is None or process.poll() is not None:
|
||||
@@ -202,6 +236,114 @@ class ZlgCanBridgeBus:
|
||||
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
|
||||
|
||||
|
||||
@@ -19,7 +19,14 @@ from cognitive_agent.infrastructure.firmware import (
|
||||
T2GSeatProgrammer,
|
||||
)
|
||||
|
||||
from .gateway import CanBus, CanGatewayError, PythonCanBus, ZlgCanBridgeBus
|
||||
from .gateway import (
|
||||
CanBus,
|
||||
CanGatewayError,
|
||||
PythonCanBus,
|
||||
ZlgCanBridgeBus,
|
||||
ZlgCanBridgeProcess,
|
||||
ZlgChannelConfig,
|
||||
)
|
||||
|
||||
|
||||
TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||
@@ -32,6 +39,8 @@ CAPABILITY_PROFILE = {
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +141,7 @@ class SeatHardwareSettings:
|
||||
profiles: frozenset[str]
|
||||
backend: str
|
||||
bitrate: int
|
||||
termination_enabled: bool
|
||||
channel: str
|
||||
interface: str
|
||||
device_type: int
|
||||
@@ -196,6 +206,9 @@ class SeatHardwareSettings:
|
||||
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),
|
||||
@@ -234,7 +247,7 @@ class SeatHardwareSettings:
|
||||
"COGNITIVE_AGENT_SEAT_HOME_TIMEOUT_S", 120
|
||||
),
|
||||
firmware_probe_timeout_ms=_env_int(
|
||||
"COGNITIVE_AGENT_SEAT_FIRMWARE_PROBE_TIMEOUT_MS", 1_200
|
||||
"COGNITIVE_AGENT_SEAT_FIRMWARE_PROBE_TIMEOUT_MS", 3_500
|
||||
),
|
||||
firmware_boot_wait_ms=_env_int(
|
||||
"COGNITIVE_AGENT_SEAT_FIRMWARE_BOOT_WAIT_MS", 1_500
|
||||
@@ -368,6 +381,7 @@ class SeatHardwareSettings:
|
||||
device_index=self.device_index,
|
||||
channel=numeric_channel,
|
||||
bitrate=self.bitrate,
|
||||
termination_enabled=self.termination_enabled,
|
||||
)
|
||||
|
||||
return create_zlg_bus
|
||||
@@ -390,9 +404,11 @@ class SeatHardwareSettings:
|
||||
timeout_ms=self.firmware_program_timeout_ms,
|
||||
)
|
||||
|
||||
def firmware_guard(self) -> T2GFirmwareGuard:
|
||||
def firmware_guard(
|
||||
self, bus_factory: Callable[[], CanBus] | None = None
|
||||
) -> T2GFirmwareGuard:
|
||||
return T2GFirmwareGuard(
|
||||
self.bus_factory(),
|
||||
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,
|
||||
@@ -420,6 +436,7 @@ class SeatHardwareSettings:
|
||||
"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,
|
||||
@@ -450,3 +467,209 @@ class SeatHardwareSettings:
|
||||
"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",
|
||||
default=_env_bool("COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED"),
|
||||
),
|
||||
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
|
||||
)
|
||||
|
||||
@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 self.channel < 0:
|
||||
raise HardwareSafetyError("vehicle CAN channel cannot be negative")
|
||||
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,
|
||||
"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",
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""32-bit JSON-line bridge for the ZLG zlgcan.dll API.
|
||||
"""32-bit JSON-line bridge for a multi-channel ZLG CAN FD adapter.
|
||||
|
||||
This module deliberately has no project or third-party imports so it can run under
|
||||
the 32-bit Python bundled with ZCANPRO while the web service uses 64-bit Python.
|
||||
The module deliberately has no project or third-party imports. It runs under
|
||||
the 32-bit Python bundled with ZCANPRO while the application uses 64-bit Python.
|
||||
One bridge owns the physical adapter once and initializes every requested
|
||||
channel, which avoids two processes racing to open the same USB device.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -10,10 +12,12 @@ import argparse
|
||||
import ctypes
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
ZCAN_TYPE_CANFD = 1
|
||||
ZCAN_STATUS_OK = 1
|
||||
CANFD_BRS = 0x01
|
||||
|
||||
|
||||
class ZcanClassicInit(ctypes.Structure):
|
||||
@@ -62,6 +66,17 @@ class ZcanFrame(ctypes.Structure):
|
||||
]
|
||||
|
||||
|
||||
class ZcanFdFrame(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("can_id", ctypes.c_uint32),
|
||||
("len", ctypes.c_uint8),
|
||||
("flags", ctypes.c_uint8),
|
||||
("reserved0", ctypes.c_uint8),
|
||||
("reserved1", ctypes.c_uint8),
|
||||
("data", ctypes.c_uint8 * 64),
|
||||
]
|
||||
|
||||
|
||||
class ZcanTransmitData(ctypes.Structure):
|
||||
_fields_ = [("frame", ZcanFrame), ("transmit_type", ctypes.c_uint32)]
|
||||
|
||||
@@ -70,6 +85,14 @@ class ZcanReceiveData(ctypes.Structure):
|
||||
_fields_ = [("frame", ZcanFrame), ("timestamp", ctypes.c_uint64)]
|
||||
|
||||
|
||||
class ZcanTransmitFdData(ctypes.Structure):
|
||||
_fields_ = [("frame", ZcanFdFrame), ("transmit_type", ctypes.c_uint32)]
|
||||
|
||||
|
||||
class ZcanReceiveFdData(ctypes.Structure):
|
||||
_fields_ = [("frame", ZcanFdFrame), ("timestamp", ctypes.c_uint64)]
|
||||
|
||||
|
||||
class ZcanChannelErrorInfo(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("error_code", ctypes.c_uint32),
|
||||
@@ -87,45 +110,85 @@ class Driver:
|
||||
self.transmit_type = args.transmit_type
|
||||
self.dll = ctypes.WinDLL(args.dll)
|
||||
self._declare_functions()
|
||||
self.device = self.dll.ZCAN_OpenDevice(
|
||||
args.device_type, args.device_index, 0
|
||||
)
|
||||
self.device = self.dll.ZCAN_OpenDevice(args.device_type, args.device_index, 0)
|
||||
if not self.device:
|
||||
raise RuntimeError(
|
||||
f"ZCAN_OpenDevice failed for device_type={args.device_type}, "
|
||||
f"device_index={args.device_index}; connect the adapter and close "
|
||||
"ZCANPRO first"
|
||||
"ZCAN_OpenDevice failed for device_type={}, device_index={}; "
|
||||
"connect the adapter and close ZCANPRO first".format(
|
||||
args.device_type, args.device_index
|
||||
)
|
||||
)
|
||||
self.channel = None
|
||||
self.channels = {}
|
||||
try:
|
||||
bitrate = str(args.bitrate).encode("ascii")
|
||||
channel_prefix = str(args.channel)
|
||||
path = (channel_prefix + "/canfd_abit_baud_rate").encode("ascii")
|
||||
if self.dll.ZCAN_SetValue(self.device, path, bitrate) != ZCAN_STATUS_OK:
|
||||
raise RuntimeError("ZCAN_SetValue(canfd_abit_baud_rate) failed")
|
||||
data_path = (channel_prefix + "/canfd_dbit_baud_rate").encode("ascii")
|
||||
if self.dll.ZCAN_SetValue(self.device, data_path, b"2000000") != ZCAN_STATUS_OK:
|
||||
raise RuntimeError("ZCAN_SetValue(canfd_dbit_baud_rate) failed")
|
||||
init = ZcanChannelInit()
|
||||
# USBCANFD devices require a CANFD channel handle even when the
|
||||
# application only transmits classic CAN frames through ZCAN_Transmit.
|
||||
init.can_type = ZCAN_TYPE_CANFD
|
||||
init.canfd.acc_code = 0
|
||||
init.canfd.acc_mask = 0xFFFFFFFF
|
||||
init.canfd.filter = 0
|
||||
init.canfd.mode = 0
|
||||
self.channel = self.dll.ZCAN_InitCAN(
|
||||
self.device, args.channel, ctypes.byref(init)
|
||||
)
|
||||
if not self.channel:
|
||||
raise RuntimeError("ZCAN_InitCAN failed")
|
||||
if self.dll.ZCAN_StartCAN(self.channel) != ZCAN_STATUS_OK:
|
||||
raise RuntimeError("ZCAN_StartCAN failed")
|
||||
self.dll.ZCAN_ClearBuffer(self.channel)
|
||||
for config in self._channel_configs(args):
|
||||
self._initialize_channel(config)
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _channel_configs(args):
|
||||
if args.channels_json:
|
||||
configs = json.loads(args.channels_json)
|
||||
else:
|
||||
configs = [
|
||||
{
|
||||
"channel": args.channel,
|
||||
"arbitration_bitrate": args.bitrate,
|
||||
"data_bitrate": args.data_bitrate,
|
||||
"can_fd_iso": True,
|
||||
"termination_enabled": True,
|
||||
}
|
||||
]
|
||||
if not isinstance(configs, list) or not configs:
|
||||
raise ValueError("channels-json must be a non-empty JSON array")
|
||||
channel_ids = [int(item["channel"]) for item in configs]
|
||||
if len(channel_ids) != len(set(channel_ids)):
|
||||
raise ValueError("channel numbers must be unique")
|
||||
return configs
|
||||
|
||||
def _set_value(self, path, value):
|
||||
result = self.dll.ZCAN_SetValue(
|
||||
self.device, path.encode("ascii"), str(value).encode("ascii")
|
||||
)
|
||||
if result != ZCAN_STATUS_OK:
|
||||
raise RuntimeError("ZCAN_SetValue({}) failed".format(path))
|
||||
|
||||
def _initialize_channel(self, config):
|
||||
channel_id = int(config["channel"])
|
||||
arbitration_bitrate = int(config.get("arbitration_bitrate", 500000))
|
||||
data_bitrate = int(config.get("data_bitrate", 2000000))
|
||||
can_fd_iso = bool(config.get("can_fd_iso", True))
|
||||
if channel_id < 0:
|
||||
raise ValueError("channel number cannot be negative")
|
||||
if arbitration_bitrate <= 0 or data_bitrate <= 0:
|
||||
raise ValueError("CAN bitrates must be positive")
|
||||
|
||||
prefix = str(channel_id)
|
||||
self._set_value(prefix + "/protocol", 1)
|
||||
self._set_value(prefix + "/canfd_standard", 0 if can_fd_iso else 1)
|
||||
self._set_value(prefix + "/canfd_exp", 1)
|
||||
self._set_value(prefix + "/canfd_abit_baud_rate", arbitration_bitrate)
|
||||
self._set_value(prefix + "/canfd_dbit_baud_rate", data_bitrate)
|
||||
|
||||
init = ZcanChannelInit()
|
||||
init.can_type = ZCAN_TYPE_CANFD
|
||||
init.canfd.acc_code = 0
|
||||
init.canfd.acc_mask = 0xFFFFFFFF
|
||||
init.canfd.filter = 0
|
||||
init.canfd.mode = 0
|
||||
handle = self.dll.ZCAN_InitCAN(self.device, channel_id, ctypes.byref(init))
|
||||
if not handle:
|
||||
raise RuntimeError("ZCAN_InitCAN failed for channel {}".format(channel_id))
|
||||
self.channels[channel_id] = handle
|
||||
self._set_value(
|
||||
prefix + "/initenal_resistance",
|
||||
1 if bool(config.get("termination_enabled", True)) else 0,
|
||||
)
|
||||
if self.dll.ZCAN_StartCAN(handle) != ZCAN_STATUS_OK:
|
||||
raise RuntimeError("ZCAN_StartCAN failed for channel {}".format(channel_id))
|
||||
self.dll.ZCAN_ClearBuffer(handle)
|
||||
|
||||
def _declare_functions(self):
|
||||
handle = ctypes.c_void_p
|
||||
self.dll.ZCAN_OpenDevice.argtypes = [
|
||||
@@ -168,10 +231,60 @@ class Driver:
|
||||
ctypes.c_int32,
|
||||
]
|
||||
self.dll.ZCAN_Receive.restype = ctypes.c_uint32
|
||||
self.dll.ZCAN_TransmitFD.argtypes = [
|
||||
handle,
|
||||
ctypes.POINTER(ZcanTransmitFdData),
|
||||
ctypes.c_uint32,
|
||||
]
|
||||
self.dll.ZCAN_TransmitFD.restype = ctypes.c_uint32
|
||||
self.dll.ZCAN_ReceiveFD.argtypes = [
|
||||
handle,
|
||||
ctypes.POINTER(ZcanReceiveFdData),
|
||||
ctypes.c_uint32,
|
||||
ctypes.c_int32,
|
||||
]
|
||||
self.dll.ZCAN_ReceiveFD.restype = ctypes.c_uint32
|
||||
|
||||
def send(self, arbitration_id, data):
|
||||
def _handle(self, channel):
|
||||
channel_id = int(channel)
|
||||
try:
|
||||
return self.channels[channel_id]
|
||||
except KeyError:
|
||||
raise ValueError("channel {} was not initialized".format(channel_id))
|
||||
|
||||
def _transmit_error(self, handle, operation):
|
||||
error = ZcanChannelErrorInfo()
|
||||
read_ok = self.dll.ZCAN_ReadChannelErrInfo(handle, ctypes.byref(error))
|
||||
detail = (
|
||||
"; channel_error=0x{:08X}".format(error.error_code)
|
||||
if read_ok == ZCAN_STATUS_OK
|
||||
else "; channel error info unavailable"
|
||||
)
|
||||
raise RuntimeError(operation + " did not accept the frame" + detail)
|
||||
|
||||
def send(self, channel, arbitration_id, data, is_fd=False, bitrate_switch=False):
|
||||
if not 0 <= arbitration_id <= 0x7FF:
|
||||
raise ValueError("only 11-bit standard CAN identifiers are allowed")
|
||||
handle = self._handle(channel)
|
||||
if is_fd:
|
||||
valid_lengths = set(range(9)) | {12, 16, 20, 24, 32, 48, 64}
|
||||
if len(data) not in valid_lengths:
|
||||
raise ValueError(
|
||||
"CAN FD payload length must be 0-8, 12, 16, 20, 24, 32, 48 or 64 bytes"
|
||||
)
|
||||
transmit = ZcanTransmitFdData()
|
||||
transmit.frame.can_id = arbitration_id
|
||||
transmit.frame.len = len(data)
|
||||
transmit.frame.flags = CANFD_BRS if bitrate_switch else 0
|
||||
for index, byte in enumerate(data):
|
||||
transmit.frame.data[index] = byte
|
||||
transmit.transmit_type = self.transmit_type
|
||||
if self.dll.ZCAN_TransmitFD(handle, ctypes.byref(transmit), 1) != 1:
|
||||
self._transmit_error(handle, "ZCAN_TransmitFD")
|
||||
return
|
||||
|
||||
if bitrate_switch:
|
||||
raise ValueError("bitrate switching is only valid for CAN FD frames")
|
||||
if len(data) > 8:
|
||||
raise ValueError("classic CAN payload exceeds 8 bytes")
|
||||
transmit = ZcanTransmitData()
|
||||
@@ -180,38 +293,63 @@ class Driver:
|
||||
for index, byte in enumerate(data):
|
||||
transmit.frame.data[index] = byte
|
||||
transmit.transmit_type = self.transmit_type
|
||||
if self.dll.ZCAN_Transmit(self.channel, ctypes.byref(transmit), 1) != 1:
|
||||
error = ZcanChannelErrorInfo()
|
||||
read_ok = self.dll.ZCAN_ReadChannelErrInfo(
|
||||
self.channel, ctypes.byref(error)
|
||||
)
|
||||
detail = (
|
||||
f"; channel_error=0x{error.error_code:08X}"
|
||||
if read_ok == ZCAN_STATUS_OK
|
||||
else "; channel error info unavailable"
|
||||
)
|
||||
raise RuntimeError("ZCAN_Transmit did not accept the frame" + detail)
|
||||
if self.dll.ZCAN_Transmit(handle, ctypes.byref(transmit), 1) != 1:
|
||||
self._transmit_error(handle, "ZCAN_Transmit")
|
||||
|
||||
def receive(self, timeout_ms):
|
||||
receive = ZcanReceiveData()
|
||||
count = self.dll.ZCAN_Receive(
|
||||
self.channel, ctypes.byref(receive), 1, int(timeout_ms)
|
||||
)
|
||||
if count == 0:
|
||||
return None
|
||||
if count != 1:
|
||||
raise RuntimeError("ZCAN_Receive returned an unexpected frame count")
|
||||
@staticmethod
|
||||
def _classic_public(receive):
|
||||
frame = receive.frame
|
||||
return {
|
||||
"arbitration_id": int(frame.can_id & 0x1FFFFFFF),
|
||||
"data_hex": bytes(frame.data[: frame.can_dlc]).hex(),
|
||||
"timestamp_us": int(receive.timestamp),
|
||||
"is_fd": False,
|
||||
"bitrate_switch": False,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _fd_public(receive):
|
||||
frame = receive.frame
|
||||
return {
|
||||
"arbitration_id": int(frame.can_id & 0x1FFFFFFF),
|
||||
"data_hex": bytes(frame.data[: frame.len]).hex(),
|
||||
"timestamp_us": int(receive.timestamp),
|
||||
"is_fd": True,
|
||||
"bitrate_switch": bool(frame.flags & CANFD_BRS),
|
||||
}
|
||||
|
||||
def receive(self, channel, timeout_ms, include_fd=False):
|
||||
handle = self._handle(channel)
|
||||
deadline = time.monotonic() + max(0, int(timeout_ms)) / 1000.0
|
||||
while True:
|
||||
classic = ZcanReceiveData()
|
||||
count = self.dll.ZCAN_Receive(handle, ctypes.byref(classic), 1, 0)
|
||||
if count == 1:
|
||||
return self._classic_public(classic)
|
||||
if count != 0:
|
||||
raise RuntimeError("ZCAN_Receive returned an unexpected frame count")
|
||||
|
||||
if include_fd:
|
||||
fd_receive = ZcanReceiveFdData()
|
||||
count = self.dll.ZCAN_ReceiveFD(
|
||||
handle, ctypes.byref(fd_receive), 1, 0
|
||||
)
|
||||
if count == 1:
|
||||
return self._fd_public(fd_receive)
|
||||
if count != 0:
|
||||
raise RuntimeError(
|
||||
"ZCAN_ReceiveFD returned an unexpected frame count"
|
||||
)
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
time.sleep(min(0.002, remaining))
|
||||
|
||||
def close(self):
|
||||
if self.channel:
|
||||
self.dll.ZCAN_ResetCAN(self.channel)
|
||||
self.channel = None
|
||||
for channel_id in sorted(self.channels, reverse=True):
|
||||
handle = self.channels.pop(channel_id)
|
||||
self.dll.ZCAN_ResetCAN(handle)
|
||||
if self.device:
|
||||
self.dll.ZCAN_CloseDevice(self.device)
|
||||
self.device = None
|
||||
@@ -222,8 +360,10 @@ def parse_args():
|
||||
parser.add_argument("--dll", required=True)
|
||||
parser.add_argument("--device-type", type=int, required=True)
|
||||
parser.add_argument("--device-index", type=int, default=0)
|
||||
parser.add_argument("--channels-json")
|
||||
parser.add_argument("--channel", type=int, default=0)
|
||||
parser.add_argument("--bitrate", type=int, default=500000)
|
||||
parser.add_argument("--data-bitrate", type=int, default=2000000)
|
||||
parser.add_argument(
|
||||
"--transmit-type",
|
||||
type=int,
|
||||
@@ -247,6 +387,8 @@ def main():
|
||||
"ZCAN_StartCAN",
|
||||
"ZCAN_Transmit",
|
||||
"ZCAN_Receive",
|
||||
"ZCAN_TransmitFD",
|
||||
"ZCAN_ReceiveFD",
|
||||
)
|
||||
missing = [name for name in required if not hasattr(dll, name)]
|
||||
emit(
|
||||
@@ -256,9 +398,12 @@ def main():
|
||||
"missing_exports": missing,
|
||||
"structure_sizes": {
|
||||
"init": ctypes.sizeof(ZcanChannelInit),
|
||||
"frame": ctypes.sizeof(ZcanFrame),
|
||||
"tx": ctypes.sizeof(ZcanTransmitData),
|
||||
"rx": ctypes.sizeof(ZcanReceiveData),
|
||||
"classic_frame": ctypes.sizeof(ZcanFrame),
|
||||
"classic_tx": ctypes.sizeof(ZcanTransmitData),
|
||||
"classic_rx": ctypes.sizeof(ZcanReceiveData),
|
||||
"fd_frame": ctypes.sizeof(ZcanFdFrame),
|
||||
"fd_tx": ctypes.sizeof(ZcanTransmitFdData),
|
||||
"fd_rx": ctypes.sizeof(ZcanReceiveFdData),
|
||||
"error": ctypes.sizeof(ZcanChannelErrorInfo),
|
||||
},
|
||||
}
|
||||
@@ -268,27 +413,43 @@ def main():
|
||||
driver = None
|
||||
try:
|
||||
driver = Driver(args)
|
||||
emit({"ok": True, "event": "ready"})
|
||||
emit({"ok": True, "event": "ready", "channels": sorted(driver.channels)})
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
request = json.loads(line)
|
||||
operation = request.get("operation")
|
||||
if operation == "send":
|
||||
channel = int(request.get("channel", args.channel))
|
||||
arbitration_id = int(request["arbitration_id"])
|
||||
data = bytes.fromhex(request["data_hex"])
|
||||
driver.send(arbitration_id, data)
|
||||
is_fd = bool(request.get("is_fd", False))
|
||||
bitrate_switch = bool(request.get("bitrate_switch", False))
|
||||
driver.send(
|
||||
channel,
|
||||
arbitration_id,
|
||||
data,
|
||||
is_fd=is_fd,
|
||||
bitrate_switch=bitrate_switch,
|
||||
)
|
||||
emit(
|
||||
{
|
||||
"ok": True,
|
||||
"channel": channel,
|
||||
"arbitration_id": arbitration_id,
|
||||
"data_hex": data.hex(),
|
||||
"is_fd": is_fd,
|
||||
"bitrate_switch": bitrate_switch,
|
||||
}
|
||||
)
|
||||
elif operation == "receive":
|
||||
emit(
|
||||
{
|
||||
"ok": True,
|
||||
"frame": driver.receive(int(request.get("timeout_ms", 0))),
|
||||
"frame": driver.receive(
|
||||
int(request.get("channel", args.channel)),
|
||||
int(request.get("timeout_ms", 0)),
|
||||
include_fd=bool(request.get("include_fd", False)),
|
||||
),
|
||||
}
|
||||
)
|
||||
elif operation == "close":
|
||||
|
||||
Reference in New Issue
Block a user