feat: integrate cabin CAN and verified dynamic seat modes

This commit is contained in:
vee
2026-08-04 17:51:38 +08:00
parent 155fbb59ba
commit dc7c91800e
99 changed files with 51897 additions and 3618 deletions
+181 -39
View File
@@ -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