添加无线模块

This commit is contained in:
minghanchen
2026-08-19 09:59:36 +08:00
parent 344f662970
commit 52c4dd08f9
33 changed files with 4740 additions and 246 deletions
@@ -129,13 +129,26 @@ class ZlgChannelConfig:
data_bitrate: int = 2_000_000
can_fd_iso: bool = True
termination_enabled: bool = True
receive_ids: tuple[int, ...] | None = None
def __post_init__(self) -> None:
if self.frame_format not in {"classic_can", "can_fd"}:
raise ValueError("frame_format must be classic_can or can_fd")
if self.receive_ids is not None:
if not 1 <= len(self.receive_ids) <= 64:
raise ValueError("receive_ids must contain between 1 and 64 identifiers")
if len(self.receive_ids) != len(set(self.receive_ids)):
raise ValueError("receive_ids must not contain duplicates")
if any(
isinstance(frame_id, bool)
or not isinstance(frame_id, int)
or not 0 <= frame_id <= 0x7FF
for frame_id in self.receive_ids
):
raise ValueError("receive_ids must contain only 11-bit identifiers")
def public(self) -> dict[str, Any]:
return {
value = {
"channel": self.channel,
"frame_format": self.frame_format,
"arbitration_bitrate": self.arbitration_bitrate,
@@ -143,6 +156,9 @@ class ZlgChannelConfig:
"can_fd_iso": self.can_fd_iso,
"termination_enabled": self.termination_enabled,
}
if self.receive_ids is not None:
value["receive_ids"] = list(self.receive_ids)
return value
class ZlgCanBridgeProcess:
@@ -157,6 +173,8 @@ class ZlgCanBridgeProcess:
device_index: int,
channels: list[ZlgChannelConfig],
transmit_type: int = 0,
monitor_only: bool = False,
trace_all: bool = False,
):
if not channels:
raise CanGatewayError("at least one ZLG channel must be configured")
@@ -179,6 +197,10 @@ class ZlgCanBridgeProcess:
"--transmit-type",
str(transmit_type),
]
if monitor_only or trace_all:
command.append("--monitor-only")
if trace_all:
command.append("--trace-all")
try:
self._process = subprocess.Popen( # noqa: S603 - paths are trusted server config
command,
@@ -4,7 +4,9 @@ from __future__ import annotations
import hashlib
import hmac
import importlib.util
import os
import sys
import threading
import time
from contextlib import contextmanager
@@ -114,6 +116,13 @@ def _env_int(name: str, default: int) -> int:
raise HardwareSafetyError(f"{name} must be an integer") from exc
def _module_available(name: str) -> bool:
try:
return importlib.util.find_spec(name) is not None
except ModuleNotFoundError:
return False
def _profiles_from_env() -> frozenset[str]:
raw = os.getenv(
"COGNITIVE_AGENT_SEAT_FIRMWARE_PROFILES",
@@ -405,12 +414,19 @@ class SeatHardwareSettings:
)
def firmware_guard(
self, bus_factory: Callable[[], CanBus] | None = None
self,
bus_factory: Callable[[], CanBus] | None = None,
*,
allow_programming: bool = True,
) -> T2GFirmwareGuard:
return T2GFirmwareGuard(
bus_factory or self.bus_factory(),
self._programmer() if self.auto_flash_enabled else None,
auto_flash=self.auto_flash_enabled,
(
self._programmer()
if self.auto_flash_enabled and allow_programming
else None
),
auto_flash=self.auto_flash_enabled and allow_programming,
probe_timeout_ms=self.firmware_probe_timeout_ms,
boot_wait_ms=self.firmware_boot_wait_ms,
)
@@ -500,6 +516,52 @@ class VehicleCanSettings:
@classmethod
def from_env(cls, repository_root: Path) -> "VehicleCanSettings":
backend = os.getenv(
"COGNITIVE_AGENT_VEHICLE_CAN_BACKEND", "zlgcan"
).strip().lower()
linux_vci = backend == "zlgcan-linux"
default_linux_library = (
repository_root.parent / "project-code" / "canfd" / "libusbcanfd.so"
)
if linux_vci:
zlg_python = Path(
os.getenv("COGNITIVE_AGENT_ZLGCAN_PYTHON", sys.executable)
)
zlg_library = Path(
os.getenv(
"COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY",
str(default_linux_library),
)
)
bridge_script = (
repository_root
/ "src"
/ "cognitive_agent"
/ "infrastructure"
/ "can"
/ "zlgcan_linux_bridge.py"
)
else:
zlg_python = Path(
os.getenv(
"COGNITIVE_AGENT_ZLGCAN_PYTHON",
r"C:\Program Files (x86)\ZCANPRO\python38\python.exe",
)
)
zlg_library = 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"
)
return cls(
repository_root=repository_root,
enabled=_env_bool(
@@ -514,9 +576,7 @@ class VehicleCanSettings:
"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(),
backend=backend,
channel=_env_int("COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL", 1),
arbitration_bitrate=_env_int(
"COGNITIVE_AGENT_VEHICLE_CAN_ARBITRATION_BITRATE", 500_000
@@ -539,47 +599,42 @@ class VehicleCanSettings:
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_type=_env_int(
"COGNITIVE_AGENT_ZLGCAN_DEVICE_TYPE", 33 if linux_vci else 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",
zlg_python=zlg_python,
zlg_dll=zlg_library,
bridge_script=bridge_script,
)
@property
def adapter_configured(self) -> bool:
return bool(
self.backend == "zlgcan"
self.backend in {"zlgcan", "zlgcan-linux"}
and self.zlg_python.is_file()
and self.zlg_dll.is_file()
and self.bridge_script.is_file()
)
@property
def configured(self) -> bool:
def state_configured(self) -> bool:
"""Whether authoritative read-only CH1 synchronization can run."""
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 configured(self) -> bool:
return bool(
self.state_configured
and (not self.approval_required or self.approval_token)
)
@property
def required_channel(self) -> int:
"""Physical body-CAN channel selected by the fail-closed bench policy."""
@@ -617,9 +672,9 @@ class VehicleCanSettings:
supplied_token, self.approval_token
):
raise HardwareSafetyError("hardware approval token is invalid")
if self.backend != "zlgcan":
if self.backend not in {"zlgcan", "zlgcan-linux"}:
raise HardwareSafetyError(
"vehicle CAN FD currently requires the zlgcan multi-channel backend"
"vehicle CAN FD requires the zlgcan or zlgcan-linux backend"
)
if not self.channel_is_allowed:
raise HardwareSafetyError(
@@ -648,7 +703,13 @@ class VehicleCanSettings:
termination_enabled=self.termination_enabled,
)
def bridge(self, channels: list[ZlgChannelConfig]) -> ZlgCanBridgeProcess:
def bridge(
self,
channels: list[ZlgChannelConfig],
*,
monitor_only: bool = False,
trace_all: bool = False,
) -> ZlgCanBridgeProcess:
return ZlgCanBridgeProcess(
python_executable=self.zlg_python,
bridge_script=self.bridge_script,
@@ -656,12 +717,15 @@ class VehicleCanSettings:
device_type=self.device_type,
device_index=self.device_index,
channels=channels,
monitor_only=monitor_only,
trace_all=trace_all,
)
def public_info(self) -> dict[str, Any]:
return {
"enabled": self.enabled,
"configured": self.configured,
"state_configured": self.state_configured,
"backend": self.backend,
"platform": "t2g_cabin_bench",
"channel": self.channel,
@@ -699,3 +763,125 @@ class VehicleCanSettings:
"source_branch": "project-code/CAN_DEV",
"source_commit": "6e7ed04a468dfcde7efda9e30bc15144bf02faf9",
}
@dataclass(frozen=True)
class VehicleMqttSettings:
"""Wireless CAN0/CAN1 relay settings with the same physical approval gate."""
enabled: bool
approval_required: bool
approval_token: str
host: str
port: int
condition_topic: str
control_topic: str
qos: int
keepalive_s: int
username: str | None
password: str | None
tls_enabled: bool
feedback_timeout_ms: int
@classmethod
def from_env(cls) -> "VehicleMqttSettings":
username = os.getenv("COGNITIVE_AGENT_VEHICLE_MQTT_USERNAME")
return cls(
enabled=_env_bool("COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED", default=True),
approval_required=_env_bool(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED", default=True
),
approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN", ""),
host=os.getenv("COGNITIVE_AGENT_VEHICLE_MQTT_HOST", "127.0.0.1").strip(),
port=_env_int("COGNITIVE_AGENT_VEHICLE_MQTT_PORT", 1883),
condition_topic=os.getenv(
"COGNITIVE_AGENT_VEHICLE_MQTT_CONDITION_TOPIC",
"n7/n7-headunit/can/state",
).strip(),
control_topic=os.getenv(
"COGNITIVE_AGENT_VEHICLE_MQTT_CONTROL_TOPIC",
"n7/can_control",
).strip(),
qos=_env_int("COGNITIVE_AGENT_VEHICLE_MQTT_QOS", 1),
keepalive_s=_env_int("COGNITIVE_AGENT_VEHICLE_MQTT_KEEPALIVE_S", 30),
username=username.strip() if username and username.strip() else None,
password=os.getenv("COGNITIVE_AGENT_VEHICLE_MQTT_PASSWORD"),
tls_enabled=_env_bool("COGNITIVE_AGENT_VEHICLE_MQTT_TLS"),
feedback_timeout_ms=_env_int(
"COGNITIVE_AGENT_VEHICLE_MQTT_FEEDBACK_TIMEOUT_MS", 1_500
),
)
@property
def adapter_configured(self) -> bool:
return bool(
self.host
and 1 <= self.port <= 65_535
and self.condition_topic
and self.control_topic
and self.condition_topic != self.control_topic
and self.qos in {0, 1, 2}
and 5 <= self.keepalive_s <= 3_600
and 100 <= self.feedback_timeout_ms <= 10_000
and _module_available("paho.mqtt.client")
)
@property
def state_configured(self) -> bool:
return self.enabled and self.adapter_configured
@property
def configured(self) -> bool:
return bool(
self.state_configured
and (not self.approval_required or self.approval_token)
)
def authorize(self, supplied_token: str | None) -> None:
if not self.enabled:
raise HardwareSafetyError(
"vehicle MQTT execution is disabled; set "
"COGNITIVE_AGENT_VEHICLE_MQTT_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.adapter_configured:
raise HardwareSafetyError("the configured MQTT CAN relay is not ready")
def public_info(self) -> dict[str, Any]:
return {
"enabled": self.enabled,
"configured": self.configured,
"state_configured": self.state_configured,
"backend": "mqtt",
"host": self.host,
"port": self.port,
"condition_topic": self.condition_topic,
"control_topic": self.control_topic,
"qos": self.qos,
"tls_enabled": self.tls_enabled,
"authentication_configured": bool(self.username),
"channels": {"seat": 0, "vehicle": 1},
"target_policy": "dual_channel_mqtt_can0_passenger_can1_vehicle",
"frame_contract": "n7.can.frame.v1",
"can0_seat_protocol": "seat-ota-runtime",
"can0_state_contract": "0x6A0-SEAT_INFO-plus-0x6A2-to-0x302",
"can0_control_id": "0x6A0",
"can0_control_opcodes": ["0x63", "0x64", "0x65", "0x66"],
"can0_forbidden_workflows": [
"flash",
"bootloader_entry",
"test",
"calibration",
"homing",
],
"approval_required": self.approval_required,
"approval_configured": bool(self.approval_token),
}
@@ -0,0 +1,836 @@
"""MQTT transport for relaying validated raw CAN0/CAN1 frames.
The MQTT topics carry CAN frames, never decoded vehicle-state patches. Every
incoming frame is therefore subjected to the same allow-list and byte decoder
as a frame received from the wired ZLG adapter.
CAN0 is the classic-CAN passenger-seat protocol defined by
``/home/orin/project511/seat-code-file/seat-ota`` and remains raw for the seat
domain decoder. CAN1 is the rest-of-vehicle protocol defined by
``/home/orin/project511/project-code/canfd/CAN矩阵表_filled.xlsx`` and is
decoded by :mod:`cognitive_agent.application.native_can_feedback`.
"""
from __future__ import annotations
import json
import math
import threading
import time
import uuid
from collections import deque
from collections.abc import Callable
from copy import deepcopy
from datetime import UTC, datetime
from typing import Any
from cognitive_agent.application.native_can_feedback import decode_feedback
from cognitive_agent.application.vehicle_state_monitor import (
OBSERVABLE_IDS,
REQUIRED_UI_IDS,
VehicleStateSnapshot,
)
from cognitive_agent.infrastructure.can.gateway import (
CanFrame,
CanGatewayError,
)
from cognitive_agent.infrastructure.can.hardware import VehicleMqttSettings
MQTT_CAN_SCHEMA = "n7.can.frame.v1"
MQTT_POLL_INTERVAL_MS = 500
MQTT_SAFETY_FEEDBACK_IDS = frozenset({0x342, 0x4EB})
MQTT_VEHICLE_FEEDBACK_IDS = OBSERVABLE_IDS | frozenset({0x2F5, 0x37B})
MQTT_SEAT_FEEDBACK_IDS = frozenset({*range(0x301, 0x308), 0x4FA, 0x6A2})
MQTT_ACCEPTED_FEEDBACK_IDS = {
0: MQTT_SEAT_FEEDBACK_IDS,
1: MQTT_VEHICLE_FEEDBACK_IDS,
}
MQTT_APPROVED_CONTROL_IDS = {
0: frozenset({0x6A0}),
1: frozenset({0x1DE, 0x3FC, 0x431, 0x4C1, 0x4EF, 0x4F5}),
}
MQTT_OTA_SEAT_RUNTIME_OPCODES = frozenset({0x63, 0x64, 0x65, 0x66})
_ID_KEYS = ("arbitration_id", "can_id", "canId", "frame_id", "frameId", "id")
_DATA_KEYS = ("data_hex", "dataHex", "data", "payload", "bytes")
def _now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _parse_frame_id(value: Any) -> int:
if isinstance(value, bool):
raise ValueError("CAN frame ID must not be boolean") # noqa: TRY004
if isinstance(value, int):
frame_id = value
elif isinstance(value, str):
raw = value.strip()
if not raw:
raise ValueError("CAN frame ID is empty")
if raw.lower().startswith("0x") or any(character in "abcdefABCDEF" for character in raw):
frame_id = int(raw, 16)
else:
frame_id = int(raw, 10)
else:
raise ValueError("CAN frame ID must be an integer or string") # noqa: TRY004
if not 0 <= frame_id <= 0x7FF:
raise ValueError("only 11-bit CAN frame IDs are accepted")
return frame_id
def _parse_data(value: Any) -> bytes:
if isinstance(value, list):
if not value or len(value) > 64:
raise ValueError("CAN data must contain between 1 and 64 bytes")
if any(
isinstance(item, bool) or not isinstance(item, int) or not 0 <= item <= 255
for item in value
):
raise ValueError("CAN data array must contain only byte integers")
return bytes(value)
if not isinstance(value, str):
raise ValueError("CAN data must be a hex string or byte array") # noqa: TRY004
compact = value.strip().replace("0x", "").replace("0X", "")
for separator in (" ", ":", "-", ",", "_"):
compact = compact.replace(separator, "")
if not compact or len(compact) % 2 or len(compact) > 128:
raise ValueError("CAN data hex must encode between 1 and 64 bytes")
try:
return bytes.fromhex(compact)
except ValueError as exc:
raise ValueError("CAN data contains invalid hexadecimal bytes") from exc
def _parse_channel(value: Any) -> int:
if isinstance(value, bool):
raise ValueError("CAN channel must not be boolean") # noqa: TRY004
if isinstance(value, str):
raw = value.strip().lower().removeprefix("can")
value = raw
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError("CAN channel must be an integer or can1-style name") from exc
def _parse_one_frame(value: Any, *, default_channel: int) -> CanFrame:
if not isinstance(value, dict):
raise ValueError("each MQTT CAN frame must be a JSON object") # noqa: TRY004
frame_id_value = next((value[key] for key in _ID_KEYS if key in value), None)
data_value = next((value[key] for key in _DATA_KEYS if key in value), None)
if frame_id_value is None or data_value is None:
raise ValueError("MQTT CAN frame requires an ID and data")
frame_id = _parse_frame_id(frame_id_value)
data = _parse_data(data_value)
channel_values = [
value[key]
for key in ("channelIndex", "channel_index", "channel")
if key in value
] or [default_channel]
channels = {_parse_channel(channel_value) for channel_value in channel_values}
if len(channels) != 1:
raise ValueError("MQTT CAN frame channel declarations conflict")
channel = channels.pop()
if channel not in MQTT_ACCEPTED_FEEDBACK_IDS:
raise ValueError("wireless CAN state accepts only CAN0 or CAN1 frames")
frame_type = str(value.get("frameType", value.get("frame_type", ""))).strip().lower()
is_fd = bool(
value.get(
"is_fd",
value.get(
"isFd",
frame_type in {"fd", "canfd", "can_fd"} if frame_type else len(data) > 8,
),
)
)
bitrate_switch = bool(
value.get("bitrate_switch", value.get("bitrateSwitch", value.get("brs", False)))
)
if not is_fd and len(data) > 8:
raise ValueError("classic CAN data must not exceed 8 bytes")
if channel == 0 and (is_fd or bitrate_switch or len(data) != 8):
raise ValueError(
"CAN0 passenger-seat state requires classic CAN, no BRS, and DLC 8"
)
timestamp_value = value.get("timestamp", value.get("timestamp_s"))
timestamp: float | None = None
if timestamp_value is not None:
if isinstance(timestamp_value, bool):
raise ValueError("CAN timestamp must not be boolean")
try:
timestamp = float(timestamp_value)
except (TypeError, ValueError) as exc:
raise ValueError("CAN timestamp must be numeric") from exc
if not math.isfinite(timestamp) or timestamp < 0:
raise ValueError("CAN timestamp must be a finite non-negative number")
# n7-can-signals/v1 uses Unix milliseconds while the canonical frame
# contract uses Unix seconds.
if timestamp >= 100_000_000_000:
timestamp /= 1_000
return CanFrame(
arbitration_id=frame_id,
data=data,
timestamp=timestamp,
channel=channel,
is_fd=is_fd,
bitrate_switch=bitrate_switch,
)
def _ota_seat_status_frames(frames: list[CanFrame]) -> list[CanFrame]:
"""Translate read-only 0x6A0/0x6A2 seat telemetry into 0x302 states.
The seat-ota runtime returns a selector value in a generic response frame:
0x6A0 is ``C7 sequence 60 selector ...`` and 0x6A2 is
``7C sequence 60 status uint32-le``. Only the non-mutating SEAT_INFO
opcode is paired here. Neither OTA identifier is added to an outbound
allow-list or exposed to the ordinary seat executor.
"""
selectors_by_request: dict[tuple[int, int], int] = {}
for frame in frames:
if (
frame.channel == 0
and frame.arbitration_id == 0x6A0
and len(frame.data) == 8
and frame.data[0] == 0xC7
and frame.data[2] == 0x60
and frame.data[3] <= 14
):
selectors_by_request[(frame.data[1], frame.data[2])] = frame.data[3]
values: dict[int, tuple[int, CanFrame]] = {}
for frame in frames:
if (
frame.channel != 0
or frame.arbitration_id != 0x6A2
or len(frame.data) != 8
or frame.data[0] != 0x7C
or frame.data[2] != 0x60
or frame.data[3] != 0x00
):
continue
selector = selectors_by_request.get((frame.data[1], frame.data[2]))
if selector is None:
continue
values[selector] = (int.from_bytes(frame.data[4:8], "little"), frame)
translated: list[CanFrame] = []
for axis in range(4):
position_entry = values.get(1 + axis)
flags_entry = values.get(5 + axis)
if position_entry is None or flags_entry is None:
continue
packed_position, position_frame = position_entry
flags, flags_frame = flags_entry
position = packed_position & 0xFFFF
hall_range = (packed_position >> 16) & 0xFFFF
if hall_range <= 0 or position > hall_range:
continue
value_x100 = min(10_000, round(position * 10_000 / hall_range))
moving = bool(flags & (1 << 1))
position_valid = bool(flags & (1 << 0))
driver_fault = bool(flags & (1 << 3))
direction = (flags >> 8) & 0x03
task_state = 1 if moving else 0
result = 7 if driver_fault else 0
state_flags = direction
if moving:
state_flags |= 1 << 2
if position_valid:
state_flags |= 1 << 3
if position == 0:
state_flags |= 1 << 4
if position == hall_range:
state_flags |= 1 << 5
if driver_fault:
state_flags |= 1 << 7
sequence = flags_frame.data[1]
payload = bytes(
(
axis,
task_state | (result << 4),
state_flags,
sequence,
0,
value_x100 & 0xFF,
(value_x100 >> 8) & 0xFF,
0,
)
)
translated.append(
CanFrame(
arbitration_id=0x302,
data=payload,
timestamp=max(
value
for value in (position_frame.timestamp, flags_frame.timestamp)
if value is not None
)
if position_frame.timestamp is not None
or flags_frame.timestamp is not None
else None,
channel=0,
is_fd=False,
bitrate_switch=False,
)
)
return translated
def parse_mqtt_can_payload(payload: bytes | str) -> list[CanFrame]:
"""Parse one frame or a bounded frame batch from the condition topic."""
if isinstance(payload, bytes):
try:
text = payload.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError("MQTT CAN payload is not UTF-8") from exc
else:
text = payload
if len(text.encode("utf-8")) > 256 * 1024:
raise ValueError("MQTT CAN payload exceeds 256 KiB")
try:
decoded = json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError("MQTT CAN payload must be JSON") from exc
batches: list[tuple[Any, int]]
if isinstance(decoded, dict) and "frames" in decoded:
batches = [(decoded["frames"], 1)]
elif isinstance(decoded, dict) and "signals" in decoded:
# Vehicle-side n7-can-signals/v1 envelope. Its decoded ``parsed``
# summary is intentionally ignored: raw frames still pass through the
# same per-channel allow-list and decoders as wired traffic. When the
# top-level batch is present, nested copies are intentionally ignored.
batches = [(decoded["signals"], 1)]
elif isinstance(decoded, dict) and any(
isinstance(decoded.get(key), dict) and "signals" in decoded[key]
for key in ("can0Seat", "can1Vehicle")
):
batches = []
for key, default_channel in (("can0Seat", 0), ("can1Vehicle", 1)):
block = decoded.get(key)
if isinstance(block, dict) and "signals" in block:
batches.append((block["signals"], default_channel))
elif isinstance(decoded, list):
batches = [(decoded, 1)]
else:
batches = [([decoded], 1)]
if any(not isinstance(items, list) for items, _ in batches):
raise ValueError("MQTT CAN frame batch must be an array")
item_count = sum(len(items) for items, _ in batches)
if not 1 <= item_count <= 256:
raise ValueError("MQTT CAN frame batch must contain between 1 and 256 frames")
frames = [
_parse_one_frame(item, default_channel=default_channel)
for items, default_channel in batches
for item in items
]
return [*frames, *_ota_seat_status_frames(frames)]
def mqtt_control_payload(frame: CanFrame) -> str:
"""Serialize one compiled outbound frame using the documented wire contract."""
return json.dumps(
{
"schema": MQTT_CAN_SCHEMA,
"arbitration_id": frame.arbitration_id,
"frame_id": f"0x{frame.arbitration_id:03X}",
"channel": frame.channel,
"data_hex": frame.data.hex(" ").upper(),
"data": list(frame.data),
"payload_size": len(frame.data),
"is_fd": frame.is_fd,
"bitrate_switch": frame.bitrate_switch,
"timestamp": time.time(),
},
ensure_ascii=False,
separators=(",", ":"),
)
def _validate_ota_seat_runtime_control(data: bytes) -> None:
"""Reject every CAN0 OTA opcode except normal bounded seat runtime control."""
if len(data) != 8 or data[0] != 0xC7:
raise CanGatewayError("CAN0 seat-ota control requires C7 and DLC 8")
opcode = data[2]
selector = data[3]
argument = int.from_bytes(data[4:8], "little")
if opcode not in MQTT_OTA_SEAT_RUNTIME_OPCODES:
raise CanGatewayError(
"CAN0 seat-ota control allows only STOP, MOVE, HEARTBEAT, or DYNAMIC"
)
if opcode == 0x63 and (selector not in {*range(4), 0xFF} or argument != 0):
raise CanGatewayError("CAN0 seat STOP selector or argument is invalid")
if opcode == 0x64 and (selector not in range(4) or argument > 1_000):
raise CanGatewayError("CAN0 seat MOVE requires axis 0..3 and target 0..1000")
if opcode == 0x65 and (selector not in {*range(4), 0xFF} or argument != 0):
raise CanGatewayError("CAN0 seat HEARTBEAT selector or argument is invalid")
if opcode == 0x66 and (
selector not in {1, 2}
or (argument != 0 and not 5 <= argument <= 300)
):
raise CanGatewayError(
"CAN0 seat DYNAMIC requires mode 1/2 and duration 5..300 seconds"
)
class MqttCanTransport:
"""One process-wide MQTT connection serving snapshots and executor buses."""
def __init__(
self,
settings: VehicleMqttSettings,
*,
client_factory: Callable[[], Any] | None = None,
decoder: Callable[[CanFrame], dict[str, Any]] = decode_feedback,
) -> None:
self.settings = settings
self.client_factory = client_factory
self.decoder = decoder
self.initial_snapshot_timeout_s = settings.feedback_timeout_ms / 1_000
self.freshness_s = max(3.0, self.initial_snapshot_timeout_s * 2)
self._condition = threading.Condition(threading.RLock())
self._client: Any | None = None
self._started = False
self._state = "disabled" if not settings.enabled else "idle"
self._last_error: str | None = None
self._parse_errors = 0
self._stale_frames = 0
self._capture_sequence = 0
self._initial_snapshot_attempted = False
self._frames: deque[tuple[int, CanFrame]] = deque(maxlen=4_096)
self._latest: dict[int, tuple[float, dict[str, Any]]] = {}
self._latest_seen: dict[tuple[int, int], float] = {}
self._latest_bus_frames: dict[
tuple[int, int, int], tuple[float, CanFrame]
] = {}
def _build_client(self) -> Any:
if self.client_factory is not None:
return self.client_factory()
try:
import paho.mqtt.client as mqtt # type: ignore[import-not-found]
except ImportError as exc:
raise CanGatewayError("paho-mqtt is not installed") from exc
client_id = f"n7-cognitive-agent-{uuid.uuid4().hex[:12]}"
try:
return mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=client_id,
protocol=mqtt.MQTTv311,
)
except (AttributeError, TypeError):
return mqtt.Client(client_id=client_id, protocol=mqtt.MQTTv311)
def start(self) -> None:
with self._condition:
if self._started or not self.settings.state_configured:
return
self._started = True
self._state = "connecting"
try:
client = self._build_client()
client.on_connect = self._on_connect
client.on_disconnect = self._on_disconnect
client.on_message = self._on_message
if self.settings.username:
client.username_pw_set(self.settings.username, self.settings.password)
if self.settings.tls_enabled:
client.tls_set()
if hasattr(client, "reconnect_delay_set"):
client.reconnect_delay_set(min_delay=1, max_delay=10)
with self._condition:
self._client = client
client.connect_async(
self.settings.host,
self.settings.port,
self.settings.keepalive_s,
)
client.loop_start()
except Exception as exc: # noqa: BLE001 - normalize broker/client failures
with self._condition:
self._state = "error"
self._last_error = str(exc)
self._condition.notify_all()
def close(self) -> None:
with self._condition:
client = self._client
self._client = None
self._started = False
if client is not None:
try:
client.disconnect()
except Exception: # noqa: BLE001, S110 - best-effort service shutdown
pass
try:
client.loop_stop()
except Exception: # noqa: BLE001, S110 - best-effort service shutdown
pass
with self._condition:
self._state = "stopped"
self._condition.notify_all()
@staticmethod
def _reason_code_value(reason_code: Any) -> int:
try:
return int(reason_code)
except (TypeError, ValueError):
return int(getattr(reason_code, "value", 1))
def _on_connect(
self,
client: Any,
_userdata: Any,
_flags: Any,
reason_code: Any,
_properties: Any = None,
) -> None:
code = self._reason_code_value(reason_code)
if code != 0:
with self._condition:
self._state = "error"
self._last_error = f"MQTT connection refused with reason {code}"
self._condition.notify_all()
return
result = client.subscribe(self.settings.condition_topic, qos=self.settings.qos)
result_code = result[0] if isinstance(result, tuple) else getattr(result, "rc", 0)
with self._condition:
if int(result_code) == 0:
self._state = "online"
self._last_error = None
else:
self._state = "error"
self._last_error = f"MQTT subscribe failed with code {result_code}"
self._condition.notify_all()
def _on_disconnect(
self,
_client: Any,
_userdata: Any,
*args: Any,
) -> None:
reason_code = args[-2] if len(args) >= 2 else args[-1] if args else 0
code = self._reason_code_value(reason_code)
with self._condition:
if self._started:
self._state = "connecting"
self._last_error = None if code == 0 else f"MQTT disconnected: {code}"
else:
self._state = "stopped"
self._condition.notify_all()
def _on_message(self, _client: Any, _userdata: Any, message: Any) -> None:
if str(getattr(message, "topic", "")) != self.settings.condition_topic:
return
try:
frames = parse_mqtt_can_payload(bytes(message.payload))
for frame in frames:
self._record_frame(frame)
except (TypeError, ValueError) as exc:
with self._condition:
self._parse_errors += 1
self._last_error = str(exc)
self._condition.notify_all()
def _record_frame(self, frame: CanFrame) -> None:
if (
frame.channel not in MQTT_ACCEPTED_FEEDBACK_IDS
or frame.arbitration_id not in MQTT_ACCEPTED_FEEDBACK_IDS[frame.channel]
or len(frame.data) < 8
):
return
cache_age_s = (
max(0.0, time.time() - frame.timestamp)
if frame.timestamp is not None and frame.timestamp >= 1_000_000_000
else 0.0
)
stale = cache_age_s > self.freshness_s
with self._condition:
self._capture_sequence += 1
sequence = self._capture_sequence
self._latest_seen[(frame.channel, frame.arbitration_id)] = time.monotonic()
if stale:
self._stale_frames += 1
else:
self._frames.append((sequence, frame))
discriminator = (
frame.data[0]
if frame.channel == 0
and frame.arbitration_id == 0x302
and frame.data
else 0
)
self._latest_bus_frames[
(frame.channel, frame.arbitration_id, discriminator)
] = (time.monotonic(), frame)
self._condition.notify_all()
if stale and (
frame.channel != 1
or frame.arbitration_id in MQTT_SAFETY_FEEDBACK_IDS
or frame.arbitration_id not in OBSERVABLE_IDS
):
# Cached frames never enter an executor bus. In particular, old
# gear/speed evidence cannot satisfy the stationary-vehicle gate.
return
if frame.channel != 1 or frame.arbitration_id not in OBSERVABLE_IDS:
return
decoded = self.decoder(frame)
if not any(
key in decoded
for key in ("actuator_state", "domain_state", "vehicle_safety")
):
return
frame_id = str(decoded.get("frame_id", ""))
captured_at = (
datetime.fromtimestamp(frame.timestamp, UTC)
.isoformat()
.replace("+00:00", "Z")
if frame.timestamp is not None and frame.timestamp >= 1_000_000_000
else _now()
)
decoded.update(
{
"valid": True,
"feedback_frame_id": frame_id,
"captured_at": captured_at,
"verification": {
"authoritative": True,
"channel": 1,
"simulated": False,
"read_only": True,
"transport": "wireless_mqtt",
"topic": self.settings.condition_topic,
"frame_id": frame_id,
"payload_size": len(frame.data),
"fresh": not stale,
"display_only": stale,
"control_eligible": not stale,
"cache_age_ms": round(cache_age_s * 1_000),
},
}
)
with self._condition:
self._latest[frame.arbitration_id] = (time.monotonic(), decoded)
self._condition.notify_all()
def _fresh_ids(self, now: float) -> set[int]:
return {
frame_id
for frame_id, (captured_monotonic, _) in self._latest.items()
if now - captured_monotonic <= self.freshness_s
}
def snapshot(self) -> VehicleStateSnapshot:
self.start()
deadline = time.monotonic() + self.initial_snapshot_timeout_s
with self._condition:
while not self._initial_snapshot_attempted:
fresh_ids = self._fresh_ids(time.monotonic())
# A partial live body snapshot is immediately useful to the
# dashboard. Completeness remains explicit in the API; only a
# completely empty first subscription waits for its bounded
# initial frame window.
if fresh_ids:
break
if self._state == "error":
raise CanGatewayError(self._last_error or "MQTT CAN relay is unavailable")
remaining_s = deadline - time.monotonic()
if remaining_s <= 0:
break
self._condition.wait(timeout=remaining_s)
self._initial_snapshot_attempted = True
observed_ids = self._fresh_ids(time.monotonic())
feedback_states = tuple(
deepcopy(self._latest[frame_id][1])
for frame_id in sorted(observed_ids)
)
captured_at = max(
(
str(state.get("captured_at"))
for state in feedback_states
if state.get("captured_at")
),
default=None,
)
return VehicleStateSnapshot(
feedback_states=feedback_states,
observed_ids=frozenset(observed_ids),
captured_at=captured_at,
capture_sequence=self._capture_sequence,
)
def bus(self, channel: int = 1, *, replay_latest: bool = False) -> MqttCanBus:
if channel not in MQTT_ACCEPTED_FEEDBACK_IDS:
raise CanGatewayError("MQTT CAN bus channel must be 0 or 1")
self.start()
with self._condition:
cursor = self._capture_sequence
now = time.monotonic()
initial_frames = tuple(
frame
for (seen_channel, _frame_id, _discriminator),
(captured_monotonic, frame) in sorted(self._latest_bus_frames.items())
if replay_latest
and seen_channel == channel
and now - captured_monotonic <= self.freshness_s
)
return MqttCanBus(self, cursor, channel, initial_frames=initial_frames)
def publish(self, frame: CanFrame) -> None:
if (
frame.channel not in MQTT_APPROVED_CONTROL_IDS
or frame.arbitration_id not in MQTT_APPROVED_CONTROL_IDS[frame.channel]
):
raise CanGatewayError("MQTT CAN frame is outside the approved channel policy")
if frame.channel == 0 and (frame.is_fd or frame.bitrate_switch):
raise CanGatewayError("CAN0 passenger-seat control requires classic CAN")
if frame.channel == 0:
_validate_ota_seat_runtime_control(frame.data)
with self._condition:
client = self._client
state = self._state
if client is None or state != "online":
raise CanGatewayError("MQTT CAN relay is not connected")
result = client.publish(
self.settings.control_topic,
mqtt_control_payload(frame),
qos=self.settings.qos,
retain=False,
)
if int(getattr(result, "rc", 0)) != 0:
raise CanGatewayError(f"MQTT CAN publish failed with code {result.rc}")
if hasattr(result, "wait_for_publish"):
published = result.wait_for_publish(timeout=2.0)
if published is False:
raise CanGatewayError("MQTT CAN publish confirmation timed out")
def _receive_after(
self,
cursor: int,
timeout_ms: int,
*,
channel: int,
include_fd: bool,
) -> tuple[int, CanFrame | None]:
deadline = time.monotonic() + max(0, timeout_ms) / 1_000
with self._condition:
while True:
for sequence, frame in self._frames:
if sequence <= cursor:
continue
cursor = sequence
if frame.channel == channel and (include_fd or not frame.is_fd):
return cursor, frame
remaining_s = deadline - time.monotonic()
if timeout_ms <= 0 or remaining_s <= 0:
return cursor, None
self._condition.wait(timeout=remaining_s)
def public_info(self) -> dict[str, Any]:
with self._condition:
info = self.settings.public_info()
now = time.monotonic()
observed_by_channel = {
str(channel): [
f"0x{frame_id:03X}"
for (seen_channel, frame_id), captured_monotonic in sorted(
self._latest_seen.items()
)
if seen_channel == channel
and now - captured_monotonic <= self.freshness_s
]
for channel in MQTT_ACCEPTED_FEEDBACK_IDS
}
info["state_sync"] = {
"mode": "persistent_mqtt_subscription",
"state": self._state,
"read_only": True,
"poll_interval_ms": MQTT_POLL_INTERVAL_MS,
"snapshot_freshness_ms": round(self.freshness_s * 1_000),
"capture_sequence": self._capture_sequence,
"observed_frame_ids": [
f"0x{frame_id:03X}"
for frame_id in sorted(self._fresh_ids(now))
],
"observed_frame_ids_by_channel": observed_by_channel,
"parse_errors": self._parse_errors,
"stale_frames": self._stale_frames,
"last_error": self._last_error,
}
return info
class MqttCanBus:
"""Cursor-based bus view over the shared MQTT subscription."""
def __init__(
self,
transport: MqttCanTransport,
cursor: int,
channel: int,
*,
initial_frames: tuple[CanFrame, ...] = (),
) -> None:
self._transport = transport
self._cursor = cursor
self.channel = channel
self._initial_frames = deque(initial_frames)
self._closed = False
def send(
self,
arbitration_id: int,
data: bytes,
*,
is_fd: bool = False,
bitrate_switch: bool = False,
) -> CanFrame:
if self._closed:
raise CanGatewayError("MQTT CAN bus is closed")
if arbitration_id not in MQTT_APPROVED_CONTROL_IDS[self.channel]:
raise CanGatewayError(
f"CAN frame ID is not in the approved CAN{self.channel} control set"
)
if not 1 <= len(data) <= 64 or (not is_fd and len(data) > 8):
raise CanGatewayError("invalid CAN payload length")
if self.channel == 0 and (is_fd or bitrate_switch):
raise CanGatewayError("CAN0 passenger-seat control requires classic CAN")
frame = CanFrame(
arbitration_id=arbitration_id,
data=bytes(data),
channel=self.channel,
is_fd=is_fd,
bitrate_switch=bitrate_switch,
)
self._transport.publish(frame)
return frame
def receive(
self,
timeout_ms: int,
*,
include_fd: bool = False,
) -> CanFrame | None:
if self._closed:
raise CanGatewayError("MQTT CAN bus is closed")
while self._initial_frames:
frame = self._initial_frames.popleft()
if include_fd or not frame.is_fd:
return frame
self._cursor, frame = self._transport._receive_after(
self._cursor,
timeout_ms,
channel=self.channel,
include_fd=include_fd,
)
return frame
def close(self) -> None:
self._closed = True
@@ -108,6 +108,7 @@ def emit(value):
class Driver:
def __init__(self, args):
self.transmit_type = args.transmit_type
self.monitor_only = bool(args.monitor_only or args.trace_all)
self.dll = ctypes.WinDLL(args.dll)
self._declare_functions()
self.device = self.dll.ZCAN_OpenDevice(args.device_type, args.device_index, 0)
@@ -270,6 +271,8 @@ class Driver:
raise RuntimeError(operation + " did not accept the frame" + detail)
def send(self, channel, arbitration_id, data, is_fd=False, bitrate_switch=False):
if self.monitor_only:
raise PermissionError("ZLG bridge is monitor-only; transmit rejected")
if not 0 <= arbitration_id <= 0x7FF:
raise ValueError("only 11-bit standard CAN identifiers are allowed")
handle = self._handle(channel)
@@ -389,6 +392,8 @@ def parse_args():
help="0 for normal transmission; 2 is diagnostic self-reception",
)
parser.add_argument("--self-test", action="store_true")
parser.add_argument("--monitor-only", action="store_true")
parser.add_argument("--trace-all", action="store_true")
return parser.parse_args()
@@ -430,7 +435,14 @@ def main():
driver = None
try:
driver = Driver(args)
emit({"ok": True, "event": "ready", "channels": sorted(driver.channels)})
emit(
{
"ok": True,
"event": "ready",
"channels": sorted(driver.channels),
"monitor_only": driver.monitor_only,
}
)
for line in sys.stdin:
try:
request = json.loads(line)
@@ -0,0 +1,625 @@
"""JSON-line bridge for ZLG's Linux ``libusbcanfd`` VCI SDK.
The process owns one USBCANFD-200U and exposes the same small protocol as the
Windows ``zlgcan_bridge.py``. Passive vehicle-state synchronization launches
this bridge with ``--monitor-only`` so every send request is rejected inside
the hardware-owning process, not merely hidden by the browser.
The ABI below follows the vendor ``zcan.h`` delivered with the verified
``libusbcanfd.so.1.0.12``. Both classic CAN and CAN FD queues are polled: the
SDK does not merge them unless a separate receive mode is explicitly enabled.
"""
from __future__ import annotations
import argparse
import ctypes
import json
import os
import sys
import time
from collections import deque
from pathlib import Path
from typing import Any
CMD_CAN_FILTER = 0x14
CMD_CAN_TRES = 0x18
ZCAN_TX_NORM = 0
INFO_FMT_SHIFT = 4
INFO_REMOTE = 1 << 8
INFO_EXTENDED = 1 << 9
INFO_ERROR = 1 << 10
INFO_BRS = 1 << 11
MAX_BATCH = 64
CAN_FD_LENGTHS = frozenset((*range(9), 12, 16, 20, 24, 32, 48, 64))
class ZcanTiming(ctypes.LittleEndianStructure):
_pack_ = 1
_fields_ = [ # noqa: RUF012 - ctypes declares ABI fields as a class list
("tseg1", ctypes.c_uint8),
("tseg2", ctypes.c_uint8),
("sjw", ctypes.c_uint8),
("smp", ctypes.c_uint8),
("brp", ctypes.c_uint16),
]
class ZcanInit(ctypes.LittleEndianStructure):
_pack_ = 1
_fields_ = [ # noqa: RUF012 - ctypes declares ABI fields as a class list
("clk", ctypes.c_uint32),
("mode", ctypes.c_uint32),
("aset", ZcanTiming),
("dset", ZcanTiming),
]
class ZcanFilter(ctypes.LittleEndianStructure):
_pack_ = 1
_fields_ = [ # noqa: RUF012 - ctypes declares ABI fields as a class list
("type", ctypes.c_uint8),
("pad", ctypes.c_uint8 * 3),
("sid", ctypes.c_uint32),
("eid", ctypes.c_uint32),
]
class ZcanFilterTable(ctypes.LittleEndianStructure):
_pack_ = 1
_fields_ = [ # noqa: RUF012 - ctypes declares ABI fields as a class list
("size", ctypes.c_uint32),
("table", ZcanFilter * 64),
]
class ZcanMessageHeader(ctypes.LittleEndianStructure):
_pack_ = 1
_fields_ = [ # noqa: RUF012 - ctypes declares ABI fields as a class list
("ts", ctypes.c_uint32),
("id", ctypes.c_uint32),
("inf", ctypes.c_uint32),
("pad", ctypes.c_uint16),
("chn", ctypes.c_uint8),
("len", ctypes.c_uint8),
]
class ZcanClassicMessage(ctypes.LittleEndianStructure):
_pack_ = 1
_fields_ = [ # noqa: RUF012 - ctypes declares ABI fields as a class list
("hdr", ZcanMessageHeader),
("dat", ctypes.c_uint8 * 8),
]
class ZcanFdMessage(ctypes.LittleEndianStructure):
_pack_ = 1
_fields_ = [ # noqa: RUF012 - ctypes declares ABI fields as a class list
("hdr", ZcanMessageHeader),
("dat", ctypes.c_uint8 * 64),
]
EXPECTED_ABI_SIZES = {
"timing": 6,
"init": 20,
"filter": 12,
"header": 16,
"classic": 24,
"fd": 80,
}
_PROTOCOL_STDOUT: Any | None = None
def _separate_protocol_stdout() -> None:
"""Keep vendor SDK prints out of the JSON-line protocol stream."""
global _PROTOCOL_STDOUT
if _PROTOCOL_STDOUT is not None:
return
_PROTOCOL_STDOUT = os.fdopen(os.dup(sys.stdout.fileno()), "w", buffering=1)
# libusbcanfd writes diagnostics directly to fd 1. Route those messages to
# stderr while retaining the duplicated original stdout for JSON replies.
os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
def emit(value: dict[str, Any]) -> None:
stream = _PROTOCOL_STDOUT or sys.stdout
print(
json.dumps(value, ensure_ascii=False, separators=(",", ":")),
file=stream,
flush=True,
)
def log(value: str) -> None:
print(value, file=sys.stderr, flush=True)
def abi_sizes() -> dict[str, int]:
return {
"timing": ctypes.sizeof(ZcanTiming),
"init": ctypes.sizeof(ZcanInit),
"filter": ctypes.sizeof(ZcanFilter),
"header": ctypes.sizeof(ZcanMessageHeader),
"classic": ctypes.sizeof(ZcanClassicMessage),
"fd": ctypes.sizeof(ZcanFdMessage),
}
def validate_abi() -> None:
actual = abi_sizes()
if actual != EXPECTED_ABI_SIZES:
raise RuntimeError(
f"Linux ZLG SDK ABI size mismatch: expected {EXPECTED_ABI_SIZES}, got {actual}"
)
def _load_sdk(path: str) -> ctypes.CDLL:
validate_abi()
sdk = ctypes.CDLL(path)
required = (
"VCI_OpenDevice",
"VCI_CloseDevice",
"VCI_InitCAN",
"VCI_SetReference",
"VCI_ClearBuffer",
"VCI_StartCAN",
"VCI_ResetCAN",
"VCI_Transmit",
"VCI_TransmitFD",
"VCI_Receive",
"VCI_ReceiveFD",
)
missing = [name for name in required if not hasattr(sdk, name)]
if missing:
raise RuntimeError("Linux ZLG SDK is missing symbols: " + ", ".join(missing))
return sdk
def _declare_functions(sdk: ctypes.CDLL) -> None:
u32 = ctypes.c_uint32
sdk.VCI_OpenDevice.argtypes = [u32, u32, u32]
sdk.VCI_OpenDevice.restype = u32
sdk.VCI_CloseDevice.argtypes = [u32, u32]
sdk.VCI_CloseDevice.restype = u32
sdk.VCI_InitCAN.argtypes = [u32, u32, u32, ctypes.POINTER(ZcanInit)]
sdk.VCI_InitCAN.restype = u32
sdk.VCI_SetReference.argtypes = [u32, u32, u32, u32, ctypes.c_void_p]
sdk.VCI_SetReference.restype = u32
sdk.VCI_ClearBuffer.argtypes = [u32, u32, u32]
sdk.VCI_ClearBuffer.restype = u32
sdk.VCI_StartCAN.argtypes = [u32, u32, u32]
sdk.VCI_StartCAN.restype = u32
sdk.VCI_ResetCAN.argtypes = [u32, u32, u32]
sdk.VCI_ResetCAN.restype = u32
sdk.VCI_Transmit.argtypes = [
u32,
u32,
u32,
ctypes.POINTER(ZcanClassicMessage),
u32,
]
sdk.VCI_Transmit.restype = u32
sdk.VCI_TransmitFD.argtypes = [
u32,
u32,
u32,
ctypes.POINTER(ZcanFdMessage),
u32,
]
sdk.VCI_TransmitFD.restype = u32
sdk.VCI_Receive.argtypes = [
u32,
u32,
u32,
ctypes.POINTER(ZcanClassicMessage),
u32,
u32,
]
sdk.VCI_Receive.restype = u32
sdk.VCI_ReceiveFD.argtypes = [
u32,
u32,
u32,
ctypes.POINTER(ZcanFdMessage),
u32,
u32,
]
sdk.VCI_ReceiveFD.restype = u32
def _channel_configs(raw: str) -> list[dict[str, Any]]:
value = json.loads(raw)
if not isinstance(value, list) or not value:
raise ValueError("channels-json must be a non-empty JSON array")
configs: list[dict[str, Any]] = []
seen: set[int] = set()
for candidate in value:
if not isinstance(candidate, dict):
raise TypeError("each channel configuration must be an object")
config = dict(candidate)
channel = int(config["channel"])
if channel not in {0, 1}:
raise ValueError("USBCANFD-200U channel must be 0 or 1")
if channel in seen:
raise ValueError("channel numbers must be unique")
seen.add(channel)
frame_format = str(config.get("frame_format", "can_fd"))
if frame_format not in {"classic_can", "can_fd"}:
raise ValueError("frame_format must be classic_can or can_fd")
raw_receive_ids = config.get("receive_ids")
if raw_receive_ids is not None:
if not isinstance(raw_receive_ids, list) or not 1 <= len(raw_receive_ids) <= 64:
raise ValueError("receive_ids must be an array of 1 to 64 identifiers")
if len(raw_receive_ids) != len(set(raw_receive_ids)) or any(
isinstance(frame_id, bool)
or not isinstance(frame_id, int)
or not 0 <= frame_id <= 0x7FF
for frame_id in raw_receive_ids
):
raise ValueError("receive_ids must contain unique 11-bit identifiers")
config["receive_ids"] = frozenset(raw_receive_ids)
config["channel"] = channel
config["frame_format"] = frame_format
configs.append(config)
return configs
def _verified_init(config: dict[str, Any]) -> ZcanInit:
arbitration_bitrate = int(config.get("arbitration_bitrate", 500_000))
data_bitrate = int(config.get("data_bitrate", 2_000_000))
if arbitration_bitrate != 500_000 or data_bitrate != 2_000_000:
raise ValueError(
"Linux vehicle CAN timing is locked to 500000/2000000 bit/s"
)
init = ZcanInit()
init.clk = 60_000_000
# Vendor ABI: bit0 normal/listen-only, bit1 ISO/BOSCH. Zero is normal ISO.
init.mode = 0 if bool(config.get("can_fd_iso", True)) else 2
init.aset = ZcanTiming(14, 3, 2, 0, 5)
init.dset = ZcanTiming(10, 2, 2, 0, 1)
return init
def _acceptance_filters(config: dict[str, Any]) -> ZcanFilterTable:
"""Build exact standard-ID filters when a passive consumer declares its IDs."""
filters = ZcanFilterTable()
receive_ids = config.get("receive_ids")
ranges = (
[(int(frame_id), int(frame_id)) for frame_id in sorted(receive_ids)]
if receive_ids is not None
else [(0x000, 0x7FF)]
)
# This SDK interprets size as valid bytes after the size field.
filters.size = len(ranges) * ctypes.sizeof(ZcanFilter)
for index, (start_id, end_id) in enumerate(ranges):
filters.table[index].type = 0
filters.table[index].sid = start_id
filters.table[index].eid = end_id
return filters
class Driver:
def __init__(self, args: argparse.Namespace):
self.device_type = int(args.device_type)
self.device_index = int(args.device_index)
self.monitor_only = bool(args.monitor_only or args.trace_all)
self.trace_all = bool(args.trace_all)
self.sdk = _load_sdk(args.dll)
_declare_functions(self.sdk)
self.configs = {
int(item["channel"]): item for item in _channel_configs(args.channels_json)
}
self.queues: dict[int, deque[dict[str, Any]]] = {
channel: deque() for channel in self.configs
}
self.started_channels: list[int] = []
self.opened = False
if self.sdk.VCI_OpenDevice(self.device_type, self.device_index, 0) == 0:
raise RuntimeError(
"VCI_OpenDevice failed; check USB 3068:0009 permissions and close other owners"
)
self.opened = True
try:
for channel, config in self.configs.items():
self._initialize_channel(channel, config)
except Exception:
self.close()
raise
def _initialize_channel(self, channel: int, config: dict[str, Any]) -> None:
init = _verified_init(config)
if self.sdk.VCI_InitCAN(
self.device_type, self.device_index, channel, ctypes.byref(init)
) == 0:
raise RuntimeError(f"VCI_InitCAN failed for channel {channel}")
filters = _acceptance_filters(config)
if self.sdk.VCI_SetReference(
self.device_type,
self.device_index,
channel,
CMD_CAN_FILTER,
ctypes.byref(filters),
) == 0:
log(f"[warning] channel {channel}: acceptance filter was not applied")
termination = ctypes.c_uint32(
1 if bool(config.get("termination_enabled", False)) else 0
)
if self.sdk.VCI_SetReference(
self.device_type,
self.device_index,
channel,
CMD_CAN_TRES,
ctypes.byref(termination),
) == 0:
raise RuntimeError(f"failed to configure termination for channel {channel}")
if self.sdk.VCI_StartCAN(self.device_type, self.device_index, channel) == 0:
raise RuntimeError(f"VCI_StartCAN failed for channel {channel}")
self.started_channels.append(channel)
self.sdk.VCI_ClearBuffer(self.device_type, self.device_index, channel)
def _validate_channel(self, channel: int) -> dict[str, Any]:
try:
return self.configs[channel]
except KeyError as exc:
raise ValueError(f"channel {channel} was not initialized") from exc
@staticmethod
def _frame(message: Any, *, is_fd: bool) -> dict[str, Any] | None:
info = int(message.hdr.inf)
if info & INFO_ERROR:
log(
f"[CAN error] ch={int(message.hdr.chn)} "
f"id=0x{int(message.hdr.id):X} fmt={'FD' if is_fd else 'CAN2.0'}"
)
return None
# CanFrame has no representation for extended or remote frames. Do not
# let them masquerade as verified standard data frames upstream.
if info & (INFO_EXTENDED | INFO_REMOTE):
return None
maximum = 64 if is_fd else 8
length = min(int(message.hdr.len), maximum)
return {
"arbitration_id": int(message.hdr.id),
"data_hex": bytes(message.dat[:length]).hex(),
"timestamp_us": int(message.hdr.ts),
"is_fd": is_fd,
"bitrate_switch": bool(info & INFO_BRS),
}
def _drain(self, channel: int, include_fd: bool) -> None:
receive_ids = self.configs[channel].get("receive_ids")
classic = (ZcanClassicMessage * MAX_BATCH)()
classic_count = int(
self.sdk.VCI_Receive(
self.device_type,
self.device_index,
channel,
classic,
MAX_BATCH,
0,
)
)
if classic_count > MAX_BATCH:
raise RuntimeError(f"VCI_Receive returned invalid count {classic_count}")
for index in range(classic_count):
frame = self._frame(classic[index], is_fd=False)
if frame is not None and (
receive_ids is None or frame["arbitration_id"] in receive_ids
):
self.queues[channel].append(frame)
self._trace(channel, frame)
if not include_fd:
return
fd = (ZcanFdMessage * MAX_BATCH)()
fd_count = int(
self.sdk.VCI_ReceiveFD(
self.device_type,
self.device_index,
channel,
fd,
MAX_BATCH,
0,
)
)
if fd_count > MAX_BATCH:
raise RuntimeError(f"VCI_ReceiveFD returned invalid count {fd_count}")
for index in range(fd_count):
frame = self._frame(fd[index], is_fd=True)
if frame is not None and (
receive_ids is None or frame["arbitration_id"] in receive_ids
):
self.queues[channel].append(frame)
self._trace(channel, frame)
def _trace(self, channel: int, frame: dict[str, Any]) -> None:
if not self.trace_all:
return
log(
"[RX] "
f"ID=0x{int(frame['arbitration_id']):03X} ch={channel} "
f"len={len(bytes.fromhex(str(frame['data_hex'])))} "
f"fmt={'CANFD' if frame['is_fd'] else 'CAN2.0'} "
f"brs={int(bool(frame['bitrate_switch']))} id=STD kind=DATA "
f"data={frame['data_hex']}"
)
def receive(self, channel: int, timeout_ms: int, include_fd: bool) -> dict[str, Any] | None:
config = self._validate_channel(channel)
if include_fd and config["frame_format"] != "can_fd":
raise ValueError(f"channel {channel} is configured for classic CAN")
deadline = time.monotonic() + max(0, timeout_ms) / 1000
while True:
if self.queues[channel]:
return self.queues[channel].popleft()
self._drain(channel, include_fd)
if self.queues[channel]:
return self.queues[channel].popleft()
if timeout_ms <= 0 or time.monotonic() >= deadline:
return None
time.sleep(min(0.001, max(0.0, deadline - time.monotonic())))
def send(
self,
channel: int,
arbitration_id: int,
data: bytes,
*,
is_fd: bool,
bitrate_switch: bool,
) -> dict[str, Any]:
config = self._validate_channel(channel)
if self.monitor_only:
raise PermissionError("Linux ZLG bridge is monitor-only; transmit rejected")
if not 0 <= arbitration_id <= 0x7FF:
raise ValueError("only 11-bit standard CAN identifiers are allowed")
if is_fd and config["frame_format"] != "can_fd":
raise ValueError(f"channel {channel} is configured for classic CAN")
if is_fd and len(data) not in CAN_FD_LENGTHS:
raise ValueError("invalid CAN FD payload length")
if not is_fd and len(data) > 8:
raise ValueError("classic CAN payload cannot exceed 8 bytes")
info = ZCAN_TX_NORM
if is_fd:
info |= 1 << INFO_FMT_SHIFT
if bitrate_switch:
if not is_fd:
raise ValueError("bitrate switch requires a CAN FD frame")
info |= INFO_BRS
message_type = ZcanFdMessage if is_fd else ZcanClassicMessage
message = message_type()
message.hdr.id = arbitration_id
message.hdr.inf = info
message.hdr.chn = channel
message.hdr.len = len(data)
for index, byte in enumerate(data):
message.dat[index] = byte
log(
"[TX] "
f"ID=0x{arbitration_id:03X} ch={channel} len={len(data)} "
f"fmt={'CANFD' if is_fd else 'CAN2.0'} brs={int(bitrate_switch)} "
f"id=STD kind=DATA err=0 data={data.hex()}"
)
transmit = self.sdk.VCI_TransmitFD if is_fd else self.sdk.VCI_Transmit
if transmit(
self.device_type,
self.device_index,
channel,
ctypes.byref(message),
1,
) != 1:
raise RuntimeError(f"{'VCI_TransmitFD' if is_fd else 'VCI_Transmit'} returned 0")
return {
"arbitration_id": arbitration_id,
"data_hex": data.hex(),
"is_fd": is_fd,
"bitrate_switch": bitrate_switch,
}
def close(self) -> None:
if not self.opened:
return
for channel in reversed(self.started_channels):
self.sdk.VCI_ResetCAN(self.device_type, self.device_index, channel)
self.sdk.VCI_CloseDevice(self.device_type, self.device_index)
self.opened = False
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run the Linux ZLG VCI JSON bridge")
parser.add_argument("--dll", required=True, help="Path to libusbcanfd.so")
parser.add_argument("--device-type", type=int, default=33)
parser.add_argument("--device-index", type=int, default=0)
parser.add_argument("--channels-json", default="[]")
# Accepted for protocol compatibility with the Windows bridge. Linux VCI
# transmit mode is encoded directly in each message.
parser.add_argument("--transmit-type", type=int, default=0)
parser.add_argument("--monitor-only", action="store_true")
parser.add_argument("--trace-all", action="store_true")
parser.add_argument("--self-test", action="store_true")
return parser
def _self_test(args: argparse.Namespace) -> None:
sdk = _load_sdk(args.dll)
_declare_functions(sdk)
emit(
{
"ok": True,
"event": "self_test",
"library": str(Path(args.dll).resolve()),
"device_opened": False,
"monitor_only": True,
"abi_sizes": abi_sizes(),
}
)
def main() -> None:
args = _parser().parse_args()
_separate_protocol_stdout()
if args.self_test:
_self_test(args)
return
driver: Driver | None = None
try:
driver = Driver(args)
emit(
{
"ok": True,
"event": "ready",
"backend": "linux_vci",
"device_type": args.device_type,
"device_index": args.device_index,
"channels": sorted(driver.configs),
"monitor_only": driver.monitor_only,
}
)
for raw in sys.stdin:
try:
request = json.loads(raw)
operation = str(request.get("operation", ""))
if operation == "close":
emit({"ok": True, "event": "closed"})
break
if operation == "receive":
frame = driver.receive(
int(request["channel"]),
max(0, int(request.get("timeout_ms", 0))),
bool(request.get("include_fd", False)),
)
emit({"ok": True, "frame": frame})
continue
if operation == "send":
response = driver.send(
int(request["channel"]),
int(request["arbitration_id"]),
bytes.fromhex(str(request.get("data_hex", ""))),
is_fd=bool(request.get("is_fd", False)),
bitrate_switch=bool(request.get("bitrate_switch", False)),
)
emit({"ok": True, **response})
continue
raise ValueError(f"unsupported operation: {operation!r}")
except Exception as exc: # noqa: BLE001 - JSON process boundary
emit({"ok": False, "message": str(exc)})
except Exception as exc:
emit({"ok": False, "message": str(exc)})
raise SystemExit(1) from exc
finally:
if driver is not None:
driver.close()
if __name__ == "__main__":
main()