添加无线模块
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user