merge: integrate wired and wireless CAN into N7Web

This commit is contained in:
minghanchen
2026-08-19 10:21:27 +08:00
34 changed files with 4740 additions and 246 deletions
@@ -0,0 +1,443 @@
"""Wireless passenger-seat executor for the seat-ota runtime protocol."""
from __future__ import annotations
import threading
import time
from datetime import UTC, datetime
from typing import Any, Callable
from cognitive_agent.infrastructure.can.gateway import CanBus, CanFrame
from seat_domain import HardwareExecutionError, HardwareSeatExecutor
OTA_CONTROL_ID = 0x6A0
OTA_RESPONSE_ID = 0x6A2
OTA_CONTROL_MAGIC = 0xC7
OTA_RESPONSE_MAGIC = 0x7C
OTA_OPCODE_SEAT_STOP = 0x63
OTA_OPCODE_SEAT_MOVE = 0x64
OTA_OPCODE_SEAT_HEARTBEAT = 0x65
OTA_OPCODE_SEAT_DYNAMIC_START = 0x66
OTA_SEAT_AXIS_ALL = 0xFF
OTA_RESPONSE_TIMEOUT_MS = 1_500
OTA_HEARTBEAT_INTERVAL_MS = 500
OTA_DYNAMIC_DURATION_SECONDS = 300
OTA_STATUS_NAMES = {
0x00: "OK",
0x01: "BAD_MAGIC",
0x02: "BAD_OPCODE",
0x03: "BAD_STATE",
0x04: "BAD_ARGUMENT",
0x0C: "RX_OVERFLOW",
0x0D: "BUS_OFF",
}
def _now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
class MqttOtaSeatExecutor:
"""Map verified seat plans to the board's bounded OTA runtime opcodes.
Flash, bootloader-entry, calibration, homing and test opcodes are not
represented here. The MQTT bus performs a second payload-level check
before it will publish any 0x6A0 frame.
"""
_sequence_lock = threading.Lock()
_sequence = 0
def __init__(
self,
status_executor: HardwareSeatExecutor,
bus_factory: Callable[[], CanBus],
) -> None:
self.status_executor = status_executor
self.bus_factory = bus_factory
@classmethod
def _next_sequence(cls) -> int:
with cls._sequence_lock:
cls._sequence = (cls._sequence + 1) & 0xFF
if cls._sequence == 0:
cls._sequence = 1
return cls._sequence
def read_physical_state(self) -> dict[str, Any]:
return self.status_executor.read_physical_state()
@staticmethod
def _axis_state(frame: CanFrame) -> dict[str, Any] | None:
if frame.arbitration_id != 0x302 or len(frame.data) != 8:
return None
flags = frame.data[2]
return {
"axis": frame.data[0],
"moving": bool(flags & (1 << 2)),
"position_valid": bool(flags & (1 << 3)),
"fault": bool(flags & (1 << 7)) or (frame.data[1] >> 4) == 7,
"value_x100": int.from_bytes(frame.data[5:7], "little"),
"frame": frame,
}
def _receive_axis(
self,
bus: CanBus,
axis: int,
timeout_ms: int,
) -> dict[str, Any]:
deadline = time.monotonic() + max(1, timeout_ms) / 1_000
while True:
remaining_ms = round((deadline - time.monotonic()) * 1_000)
if remaining_ms <= 0:
raise HardwareExecutionError(
f"timed out after {timeout_ms} ms waiting for wireless seat axis {axis}"
)
frame = bus.receive(remaining_ms)
if frame is None:
raise HardwareExecutionError(
f"timed out after {timeout_ms} ms waiting for wireless seat axis {axis}"
)
state = self._axis_state(frame)
if state is not None and state["axis"] == axis:
return state
def _wait_response(
self,
bus: CanBus,
sequence: int,
opcode: int,
timeout_ms: int = OTA_RESPONSE_TIMEOUT_MS,
) -> tuple[CanFrame, int]:
deadline = time.monotonic() + max(1, timeout_ms) / 1_000
while True:
remaining_ms = round((deadline - time.monotonic()) * 1_000)
if remaining_ms <= 0:
raise HardwareExecutionError(
f"timed out after {timeout_ms} ms waiting for CAN 0x6A2 "
f"sequence={sequence} opcode=0x{opcode:02X}"
)
frame = bus.receive(remaining_ms)
if frame is None:
raise HardwareExecutionError(
f"timed out after {timeout_ms} ms waiting for CAN 0x6A2 "
f"sequence={sequence} opcode=0x{opcode:02X}"
)
if (
frame.arbitration_id != OTA_RESPONSE_ID
or len(frame.data) != 8
or frame.data[0] != OTA_RESPONSE_MAGIC
or frame.data[1] != sequence
or frame.data[2] != opcode
):
continue
status = frame.data[3]
value = int.from_bytes(frame.data[4:8], "little")
if status != 0:
raise HardwareExecutionError(
"seat-ota runtime rejected CAN0 command: "
f"status={OTA_STATUS_NAMES.get(status, f'0x{status:02X}')}, "
f"opcode=0x{opcode:02X}, value=0x{value:08X}",
telemetry={
"response_frame": frame.public(),
"ota_status": status,
"ota_value": value,
},
)
return frame, value
def _send_control(
self,
bus: CanBus,
opcode: int,
selector: int,
argument: int,
) -> tuple[CanFrame, CanFrame, int]:
sequence = self._next_sequence()
payload = bytes(
(OTA_CONTROL_MAGIC, sequence, opcode, selector)
) + int(argument).to_bytes(4, "little")
command_frame = bus.send(OTA_CONTROL_ID, payload)
response_frame, value = self._wait_response(bus, sequence, opcode)
return command_frame, response_frame, value
def _best_effort_stop(self, bus: CanBus) -> None:
try:
self._send_control(
bus,
OTA_OPCODE_SEAT_STOP,
OTA_SEAT_AXIS_ALL,
0,
)
except Exception: # noqa: BLE001 - preserve the primary failure
pass
def _execute_move(
self,
bus: CanBus,
command: dict[str, Any],
operations: list[dict[str, Any]],
) -> list[dict[str, Any]]:
signals = command["signals"]
legacy_opcode = int(signals["Opcode"])
if legacy_opcode not in {0x01, 0x0B}:
raise HardwareExecutionError(
"wireless seat-ota supports absolute four-axis targets only; "
f"legacy opcode 0x{legacy_opcode:02X} was not sent"
)
axis = int(signals["Motor_Selector"])
if axis not in range(4):
raise HardwareExecutionError("wireless seat axis must be between 0 and 3")
target_x100 = int(command["completion"]["value"])
tolerance_x100 = int(command["completion"].get("tolerance", 100))
target_permille = max(0, min(1_000, round(target_x100 / 10)))
preflight = self._receive_axis(bus, axis, OTA_RESPONSE_TIMEOUT_MS)
if preflight["fault"] or not preflight["position_valid"]:
raise HardwareExecutionError(
f"wireless seat axis {axis} is faulted or position-invalid before send",
telemetry={"preflight_frame": preflight["frame"].public()},
)
command_frame, response_frame, job_state = self._send_control(
bus,
OTA_OPCODE_SEAT_MOVE,
axis,
target_permille,
)
deadline = time.monotonic() + int(command["completion"]["timeout_ms"]) / 1_000
next_heartbeat = time.monotonic() + OTA_HEARTBEAT_INTERVAL_MS / 1_000
feedback = preflight
while True:
now = time.monotonic()
if now >= deadline:
raise HardwareExecutionError(
f"wireless seat axis {axis} did not reach target "
f"{target_x100}±{tolerance_x100}",
telemetry={"last_feedback_frame": feedback["frame"].public()},
)
wait_until = min(deadline, next_heartbeat)
frame = bus.receive(max(1, round((wait_until - now) * 1_000)))
state = self._axis_state(frame) if frame is not None else None
if state is not None and state["axis"] == axis:
feedback = state
if state["fault"]:
raise HardwareExecutionError(
f"wireless seat axis {axis} reported a driver fault",
telemetry={"feedback_frame": state["frame"].public()},
)
if (
state["position_valid"]
and not state["moving"]
and abs(state["value_x100"] - target_x100) <= tolerance_x100
):
break
if time.monotonic() >= next_heartbeat:
self._send_control(
bus,
OTA_OPCODE_SEAT_HEARTBEAT,
axis,
0,
)
next_heartbeat = (
time.monotonic() + OTA_HEARTBEAT_INTERVAL_MS / 1_000
)
output = []
for operation in operations:
output.append(
{
"ref": operation["id"],
"domain": "seat",
"status": "succeeded",
"message": (
"seat-ota 0x6A2 accepted the MOVE lease and fresh CAN0 "
"position telemetry confirmed the target"
),
"telemetry": {
"motion_axis": operation["capability"].removeprefix(
"seat.set_"
),
"effective_position_pct": round(
feedback["value_x100"] / 100
),
"position_valid": True,
"ota_target_permille": target_permille,
"ota_job_state": job_state,
"command_frame": command_frame.public(),
"response_frame": response_frame.public(),
"feedback_frame": feedback["frame"].public(),
"hardware_feedback": True,
"simulated": False,
},
}
)
return output
def _execute_dynamic(
self,
bus: CanBus,
command: dict[str, Any],
operations: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if len(operations) != 1:
raise HardwareExecutionError(
"one wireless seat dynamic command must reference one operation"
)
operation = operations[0]
stopping = operation["capability"] == "seat.stop_dynamic_mode"
mode_id = 0 if stopping else int(operation["parameters"]["mode_id"])
opcode = OTA_OPCODE_SEAT_STOP if stopping else OTA_OPCODE_SEAT_DYNAMIC_START
selector = OTA_SEAT_AXIS_ALL if stopping else mode_id
duration = 0 if stopping else OTA_DYNAMIC_DURATION_SECONDS
command_frame, response_frame, job_state = self._send_control(
bus,
opcode,
selector,
duration,
)
active = bool(job_state & 1)
job_type = (job_state >> 4) & 0x0F
if (stopping and active) or (not stopping and (not active or job_type != 4)):
raise HardwareExecutionError(
"seat-ota response did not confirm the requested dynamic state",
telemetry={
"response_frame": response_frame.public(),
"ota_job_state": job_state,
},
)
return [
{
"ref": operation["id"],
"domain": "seat",
"status": "succeeded",
"message": (
"seat-ota 0x6A2 confirmed dynamic mode stopped"
if stopping
else "seat-ota 0x6A2 confirmed the bounded dynamic mode is active"
),
"telemetry": {
"dynamic_mode_id": mode_id,
"dynamic_mode_name": (
"" if stopping else "木马模式" if mode_id == 1 else "动态摇篮模式"
),
"dynamic_state": 0 if stopping else 1,
"repeat": "none" if stopping else "bounded_runtime",
"max_runtime_ms": 0 if stopping else duration * 1_000,
"ota_job_state": job_state,
"command_frame": command_frame.public(),
"response_frame": response_frame.public(),
"hardware_feedback": True,
"simulated": False,
},
}
]
def execute(
self,
command_plan: dict[str, Any],
function_config: dict[str, Any],
actuator_state: dict[str, Any] | None = None,
) -> dict[str, Any]:
del actuator_state
started_at = _now()
operations = {
item["id"]: item for item in function_config["spec"]["operations"]
}
results: list[dict[str, Any]] = []
bus: CanBus | None = None
motion_armed = False
physical_state: dict[str, Any] | None = None
with HardwareSeatExecutor._adapter_lock: # noqa: SLF001 - shared CAN0 owner
try:
bus = self.bus_factory()
previous_offset = 0
for command in command_plan["spec"]["commands"]:
offset = int(command.get("send_offset_ms", 0))
if offset > previous_offset:
time.sleep((offset - previous_offset) / 1_000)
previous_offset = offset
referenced = [
operations[item]
for item in command["operation_ref"].split(",")
]
if command["message"] != "T2G_Seat_Move_Command_300":
raise HardwareExecutionError(
"seat-ota firmware does not provide wireless thermal control; "
f"{command['message']} was not sent"
)
opcode = int(command["signals"]["Opcode"])
if opcode in {0x0E, 0x0F}:
motion_armed = opcode == 0x0E
results.extend(
self._execute_dynamic(bus, command, referenced)
)
motion_armed = False
else:
motion_armed = True
results.extend(self._execute_move(bus, command, referenced))
motion_armed = False
except Exception as exc: # noqa: BLE001 - normalize protocol failures
if bus is not None and motion_armed:
self._best_effort_stop(bus)
reported = {item["ref"] for item in results}
operation_ref = next(
(item for item in operations if item not in reported),
next(iter(operations)),
)
results.append(
{
"ref": operation_ref,
"domain": "seat",
"status": "failed",
"message": str(exc),
"telemetry": {
**getattr(exc, "telemetry", {}),
"error_type": type(exc).__name__,
"stop_all_attempted": bool(bus is not None and motion_armed),
"simulated": False,
},
}
)
finally:
if bus is not None:
try:
physical_state = self.status_executor._capture_physical_state( # noqa: SLF001
bus,
results,
)
physical_state["source"] = "wireless_mqtt_ch0"
except Exception: # noqa: BLE001 - keep command outcome primary
physical_state = None
bus.close()
succeeded = bool(results) and all(
item["status"] == "succeeded" for item in results
)
request_id = function_config["metadata"]["request_id"]
spec: dict[str, Any] = {
"mode": "hil",
"status": "succeeded" if succeeded else "failed",
"started_at": started_at,
"finished_at": _now(),
"results": results,
}
if physical_state is not None:
spec["physical_state"] = physical_state
return {
"apiVersion": "cognitive-agent/v1alpha1",
"kind": "ExecutionReport",
"metadata": {
"id": f"report-{request_id}",
"name": "seat-ota MQTT hardware execution report",
"version": "1.0.0",
"request_id": request_id,
"labels": {
"simulation": "false",
"feedback": "wireless_mqtt_ch0",
},
},
"spec": spec,
}
@@ -2,7 +2,9 @@
The ZLG SDK accepting a transmit request is not evidence that an ECU acted.
This module contains the small, byte-exact subset of CAN_DEV feedback that the
web console is allowed to present as physical state.
web console is allowed to present as physical state. CH1 signal definitions
come from ``project-code/canfd/CAN矩阵表_filled.xlsx`` Sheet1; decoded
summaries embedded in an MQTT envelope are never used here.
"""
from __future__ import annotations
@@ -294,8 +296,43 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]:
"rear_brightness_level": (
rear_brightness_raw - 1 if 1 <= rear_brightness_raw <= 6 else None
),
"front_color_index": front_color,
"rear_color_index": rear_color,
# Sheet1 rows 1896-1897 define 0x01-0x1F as colors and
# 0x20-0x7F as reserved. Do not expose a reserved raw value
# as though it were a real cabin color.
"front_color_index": (
front_color if 1 <= front_color <= 0x1F else None
),
"rear_color_index": (
rear_color if 1 <= rear_color <= 0x1F else None
),
},
}
return decoded
if frame.arbitration_id == 0x3FF and len(data) >= 8:
# CAN矩阵表_filled.xlsx Sheet1 rows 169, 172 and 173. The
# illuminance signal reserves 0x3FE and uses 0x3FF as its initial
# value, so only the explicitly valid 0x000-0x3FD range is physical.
illuminance_raw = _bits(data, 46, 10)
decoded["domain_state"] = {
"interior_light": {
"power": _state(_bits(data, 28, 2)),
"footwell_power": _state(_bits(data, 35, 2)),
"ambient_illuminance_lux": (
illuminance_raw * 30 if illuminance_raw <= 0x3FD else None
),
}
}
return decoded
if frame.arbitration_id == 0x3F3 and len(data) >= 8:
# CAN矩阵表_filled.xlsx Sheet1 rows 2336 and 2338.
decoded["domain_state"] = {
"interior_light": {
"wall_light_power": _state(_bits(data, 58, 2)),
},
"ambient_light": {
"power": _state(_bits(data, 62, 2), on=2, off=1),
},
}
return decoded
+397 -110
View File
@@ -53,6 +53,9 @@ from cognitive_agent.application.model_plan_adapter import ( # noqa: E402
SEAT_CAPABILITIES,
SEAT_MOTION_CAPABILITIES,
)
from cognitive_agent.application.mqtt_ota_seat_executor import ( # noqa: E402
MqttOtaSeatExecutor,
)
from cognitive_agent.application.native_can_intent import merge_native_can_intent # noqa: E402
from cognitive_agent.application.native_can_relative import ( # noqa: E402
NativeCanRelativeResolver,
@@ -76,6 +79,12 @@ from cognitive_agent.application.vehicle_profile import ( # noqa: E402
VehicleProfile,
VehicleProfileRegistry,
)
from cognitive_agent.application.vehicle_state_monitor import ( # noqa: E402
OBSERVABLE_IDS,
REQUIRED_BODY_IDS,
REQUIRED_SEAT_IDS,
VehicleStateMonitor,
)
from cognitive_agent.application.zcan_seat_executor import ( # noqa: E402
ZcanBridgeSeatExecutor,
)
@@ -89,8 +98,12 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
PHYSICAL_BENCH_COORDINATOR,
SeatHardwareSettings,
VehicleCanSettings,
VehicleMqttSettings,
)
from cognitive_agent.infrastructure.can.gateway import ZlgChannelConfig # noqa: E402
from cognitive_agent.infrastructure.can.mqtt_transport import ( # noqa: E402
MqttCanTransport,
)
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
PHYSICAL_BENCH_ACCESS,
)
@@ -117,6 +130,11 @@ PASSENGER_SEAT_SCOPE_PATTERN = re.compile(
r"\bpassenger\b|\bfront[-_ ]?right\b|\bright[-_ ]?front\b|副驾|乘客|右前",
re.IGNORECASE,
)
WIRED_CAN_TRANSPORT = "wired_can"
WIRELESS_MQTT_TRANSPORT = "wireless_mqtt"
VEHICLE_CONTROL_TRANSPORTS = frozenset(
{WIRED_CAN_TRANSPORT, WIRELESS_MQTT_TRANSPORT}
)
def _has_mixed_driver_passenger_seat_scope(utterance: str) -> bool:
@@ -209,6 +227,17 @@ class SeatDemoService:
)
self.hardware_settings = SeatHardwareSettings.from_env(repository_root)
self.vehicle_can_settings = VehicleCanSettings.from_env(repository_root)
self.vehicle_mqtt_settings = VehicleMqttSettings.from_env()
self._vehicle_state_monitor = (
VehicleStateMonitor(self.vehicle_can_settings)
if self.vehicle_can_settings.backend == "zlgcan-linux"
and self.vehicle_can_settings.state_configured
and not self.vehicle_can_settings.configured
else None
)
self._vehicle_mqtt_transport = MqttCanTransport(
self.vehicle_mqtt_settings
)
self.native_can_runtime = NativeCanRuntime(repository_root)
self.native_can_relative_resolver = NativeCanRelativeResolver(repository_root)
self.hardware_execution = PHYSICAL_BENCH_COORDINATOR
@@ -272,6 +301,22 @@ class SeatDemoService:
and self.vehicle_can_settings.channel == 1
)
def _driver_only_vehicle_execution_active(self, transport: str) -> bool:
if self.hardware_settings.enabled:
return False
if transport == WIRELESS_MQTT_TRANSPORT:
return self.vehicle_mqtt_settings.configured
return self.driver_only_ch1_execution_active
@staticmethod
def _validate_control_transport(transport: str) -> str:
normalized = str(transport).strip().lower()
if normalized not in VEHICLE_CONTROL_TRANSPORTS:
raise ValueError(
"control_transport must be wired_can or wireless_mqtt"
)
return normalized
def runtime_info(self) -> dict[str, Any]:
catalog = yaml.safe_load(
(self.domain_root / "config" / "capabilities.yaml").read_text(encoding="utf-8")
@@ -283,6 +328,15 @@ class SeatDemoService:
hardware_info = self.hardware_settings.public_info()
hardware_info.update(self.hardware_execution.public_info())
hardware_info.update(self.hardware_access.public_info())
vehicle_can_info = self.vehicle_can_settings.public_info()
if self._vehicle_state_monitor is not None:
state_sync_info = self._vehicle_state_monitor.public_info()
if self.hardware_settings.configured:
# Keep the slower legacy cadence when the same page also polls
# the physical passenger-seat path through the shared adapter.
state_sync_info["poll_interval_ms"] = 5_000
vehicle_can_info["state_sync"] = state_sync_info
vehicle_mqtt_info = self._vehicle_mqtt_transport.public_info()
return {
"service": "cabin-cognitive-orchestrator",
"domain": "cabin",
@@ -294,9 +348,27 @@ class SeatDemoService:
"hardware_execution_allowed": (
self.hardware_settings.configured
or self.vehicle_can_settings.configured
or self.vehicle_mqtt_settings.configured
),
"hardware": hardware_info,
"vehicle_can": self.vehicle_can_settings.public_info(),
"vehicle_can": vehicle_can_info,
"vehicle_mqtt": vehicle_mqtt_info,
"vehicle_transports": {
"default": WIRED_CAN_TRANSPORT,
"selection_scope": "browser_request",
"options": [
{
"id": WIRED_CAN_TRANSPORT,
"label": "有线控制",
**vehicle_can_info,
},
{
"id": WIRELESS_MQTT_TRANSPORT,
"label": "无线控制",
**vehicle_mqtt_info,
},
],
},
"vehicle_safety_policy": {
"required_channel": 1,
"required_gear": "P",
@@ -322,6 +394,8 @@ class SeatDemoService:
and self.vehicle_can_settings.configured
else "ch1_body_can_driver_only"
if self.vehicle_can_settings.configured
else "ch1_body_can_read_only"
if self.vehicle_can_settings.state_configured
else "ch0_passenger_only"
if self.hardware_settings.configured
else "virtual_only"
@@ -407,23 +481,54 @@ class SeatDemoService:
conversation_id: str,
vehicle_id: str | None = None,
*,
control_transport: str = WIRED_CAN_TRANSPORT,
hardware_client_id: str | None = None,
hardware_access_bypass: bool = True,
) -> dict[str, Any]:
"""Read the bench only when no physical execution currently owns it."""
control_transport = self._validate_control_transport(control_transport)
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
if hardware_access_bypass:
# Wireless CAN0 is a thread-safe view over the persistent MQTT
# subscription. HardwareSeatExecutor still serializes this seat
# read with seat actuation through its adapter lock, while an
# absent CAN0 stream must not block the independent cached CAN1
# dashboard or make control requests fail with hardware_busy.
if mqtt_selected:
return self._synchronize_hardware_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
with self.hardware_execution.reserve():
return self._synchronize_hardware_state_once(conversation_id, vehicle_id)
return self._synchronize_hardware_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
if mqtt_selected:
with self.hardware_access.use(authorized_client_id):
return self._synchronize_hardware_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
with self.hardware_execution.reserve():
with self.hardware_access.use(authorized_client_id):
return self._synchronize_hardware_state_once(conversation_id, vehicle_id)
return self._synchronize_hardware_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
def _synchronize_hardware_state_once(
self,
conversation_id: str,
vehicle_id: str | None = None,
*,
control_transport: str = WIRED_CAN_TRANSPORT,
) -> dict[str, Any]:
"""Read the bench and project it into the current passenger conversation."""
@@ -431,10 +536,26 @@ class SeatDemoService:
raise ValueError(
"conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens"
)
if not self.hardware_settings.configured:
raise HardwareSafetyError("the T2G seat hardware runtime is not configured")
control_transport = self._validate_control_transport(control_transport)
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
if mqtt_selected:
if not self.vehicle_mqtt_settings.state_configured:
raise HardwareSafetyError("the MQTT CAN0 seat relay is not configured")
bus_factory = lambda: self._vehicle_mqtt_transport.bus(
channel=0,
replay_latest=True,
)
else:
if not self.hardware_settings.configured:
raise HardwareSafetyError("the T2G seat hardware runtime is not configured")
bus_factory = None
vehicle_profile = self.vehicle_profiles.get(vehicle_id)
physical_state = self._new_hardware_executor().read_physical_state()
physical_state = self._new_hardware_executor(
bus_factory,
remote_transport=mqtt_selected,
).read_physical_state()
if mqtt_selected:
physical_state["source"] = "wireless_mqtt_ch0"
context = self.conversations.synchronize_physical_passenger(
conversation_id,
physical_state,
@@ -444,7 +565,8 @@ class SeatDemoService:
"status": "succeeded",
"conversation_id": conversation_id,
"conversation_turn": context["turn_count"],
"source": "physical_can",
"source": "wireless_mqtt_ch0" if mqtt_selected else "physical_can",
"control_transport": control_transport,
"captured_at": physical_state.get("captured_at"),
"complete": bool(physical_state.get("complete")),
"missing": list(physical_state.get("missing", [])),
@@ -457,38 +579,103 @@ class SeatDemoService:
conversation_id: str,
vehicle_id: str | None = None,
*,
control_transport: str = WIRED_CAN_TRANSPORT,
hardware_client_id: str | None = None,
hardware_access_bypass: bool = True,
) -> dict[str, Any]:
"""Passively receive CH1 driver-seat feedback without transmitting CAN."""
control_transport = self._validate_control_transport(control_transport)
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
if hardware_access_bypass:
# The MQTT snapshot is already decoded and protected by the
# transport condition lock. It is independent from physical
# execution ownership and should remain readable during CAN0
# waits or an in-flight command.
if mqtt_selected:
return self._synchronize_vehicle_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
with self.hardware_execution.reserve():
return self._synchronize_vehicle_state_once(conversation_id, vehicle_id)
return self._synchronize_vehicle_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
if mqtt_selected:
with self.hardware_access.use(authorized_client_id):
return self._synchronize_vehicle_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
with self.hardware_execution.reserve():
with self.hardware_access.use(authorized_client_id):
return self._synchronize_vehicle_state_once(conversation_id, vehicle_id)
return self._synchronize_vehicle_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
def start(self) -> None:
if self._vehicle_state_monitor is not None:
self._vehicle_state_monitor.start()
self._vehicle_mqtt_transport.start()
def close(self) -> None:
if self._vehicle_state_monitor is not None:
self._vehicle_state_monitor.close()
self._vehicle_mqtt_transport.close()
def _synchronize_vehicle_state_once(
self,
conversation_id: str,
vehicle_id: str | None = None,
*,
control_transport: str = WIRED_CAN_TRANSPORT,
) -> dict[str, Any]:
if not CONVERSATION_ID_PATTERN.fullmatch(conversation_id):
raise ValueError(
"conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens"
)
if not self.vehicle_can_settings.configured:
control_transport = self._validate_control_transport(control_transport)
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
if mqtt_selected:
if not self.vehicle_mqtt_settings.state_configured:
raise HardwareSafetyError("the MQTT vehicle CAN relay is not configured")
elif not self.vehicle_can_settings.state_configured:
raise HardwareSafetyError("the CH1 vehicle CAN runtime is not configured")
vehicle_profile = self.vehicle_profiles.get(vehicle_id)
shared_runtime_enabled = os.getenv(
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED",
os.getenv("COGNITIVE_AGENT_SEAT_SHARED_ZCANPRO_BRIDGE_ENABLED", "true"),
).strip().lower() in {"1", "true", "yes", "on"}
shared_runtime_enabled = (
self.vehicle_can_settings.backend != "zlgcan-linux"
and self.vehicle_can_settings.configured
and os.getenv(
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED",
os.getenv(
"COGNITIVE_AGENT_SEAT_SHARED_ZCANPRO_BRIDGE_ENABLED", "true"
),
).strip().lower() in {"1", "true", "yes", "on"}
)
bridge = None
if shared_runtime_enabled:
capture_sequence: int | None = None
if mqtt_selected:
snapshot = self._vehicle_mqtt_transport.snapshot()
feedback_states = list(snapshot.feedback_states)
observed_ids = set(snapshot.observed_ids)
captured_at = snapshot.captured_at
capture_sequence = snapshot.capture_sequence
elif self._vehicle_state_monitor is not None:
snapshot = self._vehicle_state_monitor.snapshot()
feedback_states = list(snapshot.feedback_states)
observed_ids = set(snapshot.observed_ids)
captured_at = snapshot.captured_at
capture_sequence = snapshot.capture_sequence
elif shared_runtime_enabled:
from cognitive_agent.application.self_repair.service import BRIDGE_MANAGER
if not hasattr(BRIDGE_MANAGER, "channel_bus_factory"):
@@ -500,68 +687,65 @@ class SeatDemoService:
)()
else:
bridge = self.vehicle_can_settings.bridge(
[self.vehicle_can_settings.channel_config()]
[self.vehicle_can_settings.channel_config()],
monitor_only=True,
)
bus = bridge.channel_bus(self.vehicle_can_settings.channel)
feedback_states: list[dict[str, Any]] = []
observed_ids: set[int] = set()
# Keep driver-seat completeness compatible with the existing endpoint,
# while passively collecting every recognized CH1 body-state frame for
# the whole-vehicle dashboard during the same bounded receive window.
required_seat_ids = {0x4FA, 0x494}
required_body_ids = {0x342, 0x4EB, 0x407}
observable_ids = {
*required_seat_ids,
*required_body_ids,
0x388,
0x404,
0x41A,
0x42E,
}
deadline = time.monotonic() + self.vehicle_can_settings.feedback_timeout_ms / 1000
try:
while time.monotonic() < deadline and observed_ids != observable_ids:
remaining_ms = max(1, int((deadline - time.monotonic()) * 1000))
frame = bus.receive(remaining_ms, include_fd=True)
if frame is None:
break
if frame.channel != self.vehicle_can_settings.channel:
continue
if frame.arbitration_id not in observable_ids or len(frame.data) < 8:
continue
decoded = decode_feedback(frame)
if not any(
key in decoded
for key in ("actuator_state", "domain_state", "vehicle_safety")
):
# 0x4EB is multiplexed. Only subtype 0x07 carries the
# verified speed signal, so unrelated subframes must not
# make the body-state snapshot look complete.
continue
frame_id = str(decoded.get("frame_id", ""))
decoded.update(
{
"valid": True,
"feedback_frame_id": frame_id,
"captured_at": _now(),
"verification": {
"authoritative": True,
"channel": 1,
"simulated": False,
"read_only": True,
"frame_id": frame_id,
"payload_size": len(frame.data),
},
}
)
feedback_states.append(decoded)
observed_ids.add(frame.arbitration_id)
finally:
if bridge is not None:
bridge.close()
else:
bus.close()
if not mqtt_selected and self._vehicle_state_monitor is None:
feedback_states = []
observed_ids = set()
captured_at = None
# Keep driver-seat completeness compatible with the existing endpoint,
# while passively collecting every recognized CH1 body-state frame for
# the whole-vehicle dashboard during the same bounded receive window.
deadline = (
time.monotonic()
+ self.vehicle_can_settings.feedback_timeout_ms / 1000
)
try:
while time.monotonic() < deadline and observed_ids != OBSERVABLE_IDS:
remaining_ms = max(1, int((deadline - time.monotonic()) * 1000))
frame = bus.receive(remaining_ms, include_fd=True)
if frame is None:
break
if frame.channel != self.vehicle_can_settings.channel:
continue
if frame.arbitration_id not in OBSERVABLE_IDS or len(frame.data) < 8:
continue
decoded = decode_feedback(frame)
if not any(
key in decoded
for key in ("actuator_state", "domain_state", "vehicle_safety")
):
# 0x4EB is multiplexed. Only subtype 0x07 carries the
# verified speed signal, so unrelated subframes must not
# make the body-state snapshot look complete.
continue
frame_id = str(decoded.get("frame_id", ""))
decoded.update(
{
"valid": True,
"feedback_frame_id": frame_id,
"captured_at": _now(),
"verification": {
"authoritative": True,
"channel": 1,
"simulated": False,
"read_only": True,
"frame_id": frame_id,
"payload_size": len(frame.data),
},
}
)
feedback_states.append(decoded)
observed_ids.add(frame.arbitration_id)
captured_at = str(decoded["captured_at"])
finally:
if bridge is not None:
bridge.close()
else:
bus.close()
context = self.conversations.synchronize_physical_ch1_feedback(
conversation_id,
@@ -570,19 +754,23 @@ class SeatDemoService:
context = self._context_for_vehicle(context, vehicle_profile)
missing = [
f"0x{frame_id:03X}"
for frame_id in sorted(required_seat_ids - observed_ids)
for frame_id in sorted(REQUIRED_SEAT_IDS - observed_ids)
]
body_state_missing = [
f"0x{frame_id:03X}"
for frame_id in sorted(required_body_ids - observed_ids)
for frame_id in sorted(REQUIRED_BODY_IDS - observed_ids)
]
return {
"status": "succeeded",
"conversation_id": conversation_id,
"conversation_turn": context["turn_count"],
"source": "physical_can_ch1",
"source": (
"wireless_mqtt_ch1" if mqtt_selected else "physical_can_ch1"
),
"control_transport": control_transport,
"read_only": True,
"captured_at": feedback_states[-1].get("captured_at") if feedback_states else _now(),
"captured_at": captured_at or _now(),
"capture_sequence": capture_sequence,
"complete": not missing,
"missing": missing,
"body_state_complete": not body_state_missing,
@@ -593,8 +781,11 @@ class SeatDemoService:
}
def _new_hardware_executor(
self, bus_factory: Any | None = None
) -> HardwareSeatExecutor:
self,
bus_factory: Any | None = None,
*,
remote_transport: bool = False,
) -> HardwareSeatExecutor | MqttOtaSeatExecutor | ZcanBridgeSeatExecutor:
shared_bridge_enabled = os.getenv(
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED",
os.getenv("COGNITIVE_AGENT_SEAT_SHARED_ZCANPRO_BRIDGE_ENABLED", "true"),
@@ -624,7 +815,7 @@ class SeatDemoService:
auto_home=self.hardware_settings.auto_home_enabled,
)
resolved_bus_factory = bus_factory or self.hardware_settings.bus_factory()
return HardwareSeatExecutor(
executor = HardwareSeatExecutor(
self.hardware_matrix,
resolved_bus_factory,
thermal_timeout_ms=self.hardware_settings.thermal_timeout_ms,
@@ -644,12 +835,19 @@ class SeatDemoService:
),
auto_home=self.hardware_settings.auto_home_enabled,
home_timeout_s=self.hardware_settings.home_timeout_s,
firmware_guard=self.hardware_settings.firmware_guard(resolved_bus_factory),
firmware_guard=self.hardware_settings.firmware_guard(
resolved_bus_factory,
allow_programming=not remote_transport,
),
synchronize_position_before_first_motion=(
self.hardware_settings.position_sync_enabled
),
position_sync_state=self._seat_position_memory_state,
require_thermal_feedback=not remote_transport,
)
if remote_transport:
return MqttOtaSeatExecutor(executor, resolved_bus_factory)
return executor
def process(
self,
@@ -664,7 +862,9 @@ class SeatDemoService:
hardware_client_id: str | None = None,
hardware_access_bypass: bool = True,
reasoning_effort: str | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
# Creating a reusable scene is a configuration workflow. Synthesize
# and validate it in dry-run even when the console currently targets
# the real bench, otherwise virtual domains such as climate or ambient
@@ -681,8 +881,11 @@ class SeatDemoService:
vehicle_id,
simulation_fault,
reasoning_effort,
control_transport,
)
if hardware_access_bypass or self.driver_only_ch1_execution_active:
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
control_transport
):
with self.hardware_execution.reserve():
return self._process_once(
utterance,
@@ -693,6 +896,7 @@ class SeatDemoService:
vehicle_id,
simulation_fault,
reasoning_effort,
control_transport,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
@@ -706,6 +910,7 @@ class SeatDemoService:
vehicle_id,
simulation_fault,
reasoning_effort,
control_transport,
)
def _process_once(
@@ -718,7 +923,9 @@ class SeatDemoService:
vehicle_id: str | None = None,
simulation_fault: str = "normal",
reasoning_effort: str | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
utterance = utterance.strip()
if not utterance:
raise ValueError("utterance must not be empty")
@@ -776,6 +983,7 @@ class SeatDemoService:
vehicle_profile,
simulation_fault,
mode_create_name,
control_transport,
)
request_id = f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
@@ -798,6 +1006,7 @@ class SeatDemoService:
"reasoning_effort_requested": effective_reasoning_effort,
"reasoning_effort_used": None,
"execution_mode": execution_mode,
"control_transport": control_transport,
"hardware_execution_allowed": (
hardware_request and not control_plane_request
),
@@ -899,7 +1108,14 @@ class SeatDemoService:
parsed = apply_language_safety_guards(parsed, utterance)
parsed = self._partition_native_actions(
parsed,
promote_supported=(hardware_request and self.vehicle_can_settings.enabled),
promote_supported=(
hardware_request
and (
self.vehicle_mqtt_settings.enabled
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.vehicle_can_settings.enabled
)
),
)
if hardware_request and not control_plane_request:
if parsed.get("domain_actions"):
@@ -914,28 +1130,40 @@ class SeatDemoService:
+ ", ".join(unsupported)
)
if parsed.get("actions"):
self.hardware_settings.authorize(approval_token)
(
self.vehicle_mqtt_settings
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.hardware_settings
).authorize(approval_token)
if parsed.get("native_can_actions"):
self.vehicle_can_settings.authorize(approval_token)
(
self.vehicle_mqtt_settings
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.vehicle_can_settings
).authorize(approval_token)
if not parsed.get("actions") and not parsed.get("native_can_actions"):
raise HardwareSafetyError(
"hardware mode requires at least one verified seat or vehicle CAN operation"
)
if parsed.get("actions") and parsed.get("native_can_actions"):
if self.hardware_settings.backend != "zlgcan":
if (
control_transport == WIRED_CAN_TRANSPORT
and self.hardware_settings.backend != "zlgcan"
):
raise HardwareSafetyError(
"linked seat/vehicle execution requires the shared zlgcan backend"
)
try:
seat_channel = int(self.hardware_settings.channel, 0)
except ValueError as exc:
raise HardwareSafetyError(
"ZLG seat CAN channel must be an integer"
) from exc
if seat_channel == self.vehicle_can_settings.channel:
raise HardwareSafetyError(
"seat and vehicle CAN must use different adapter channels"
)
if control_transport == WIRED_CAN_TRANSPORT:
try:
seat_channel = int(self.hardware_settings.channel, 0)
except ValueError as exc:
raise HardwareSafetyError(
"ZLG seat CAN channel must be an integer"
) from exc
if seat_channel == self.vehicle_can_settings.channel:
raise HardwareSafetyError(
"seat and vehicle CAN must use different adapter channels"
)
self._trace(
traces,
request_id,
@@ -1052,6 +1280,7 @@ class SeatDemoService:
simulated_hardware_request=simulated_hardware_request,
simulation_fault=simulation_fault,
vehicle_profile=vehicle_profile,
control_transport=control_transport,
)
intent_ir = build_intent_ir(
@@ -1366,10 +1595,41 @@ class SeatDemoService:
execute_started = time.perf_counter()
if hardware_request:
hardware_executor = self._new_hardware_executor()
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
vehicle_safety_preflight = None
if mqtt_selected:
try:
vehicle_safety_preflight = observe_vehicle_safety(
self._vehicle_mqtt_transport.bus(channel=1),
timeout_ms=min(
5_000,
max(1_000, self.vehicle_mqtt_settings.feedback_timeout_ms),
),
required_channel=1,
)
require_stationary_vehicle(vehicle_safety_preflight)
except VehicleSafetyPreflightError as exc:
raise HardwareSafetyError(
"CAN1 vehicle safety preflight refused wireless passenger-seat execution: "
f"{exc}"
) from exc
hardware_executor = self._new_hardware_executor(
lambda: self._vehicle_mqtt_transport.bus(channel=0),
remote_transport=True,
)
else:
hardware_executor = self._new_hardware_executor()
execution_report = hardware_executor.execute(
command_plan, function_config, context["devices"]
)
if vehicle_safety_preflight is not None:
execution_report["spec"]["vehicle_safety_preflight"] = (
vehicle_safety_preflight
)
for item in execution_report["spec"].get("results", []):
item.setdefault("telemetry", {})[
"vehicle_safety_preflight"
] = vehicle_safety_preflight
elif simulated_hardware_request:
execution_report = self.simulated_hardware_executor.execute(
command_plan,
@@ -2481,6 +2741,7 @@ class SeatDemoService:
vehicle_profile: VehicleProfile,
simulation_fault: str,
mode_name: str,
control_transport: str,
) -> dict[str, Any]:
"""Build, verify, and persist a new named mode in one user turn.
@@ -2502,6 +2763,7 @@ class SeatDemoService:
approval_token,
vehicle_profile.id,
simulation_fault,
control_transport=control_transport,
)
if source_result.get("status") not in {"succeeded", "partially_succeeded"}:
source_result["decision_summary"] = [
@@ -2519,6 +2781,7 @@ class SeatDemoService:
None,
vehicle_profile.id,
"normal",
control_transport=control_transport,
)
if saved_result.get("status") != "succeeded" or not saved_result.get(
"mode_profile"
@@ -2887,6 +3150,7 @@ class SeatDemoService:
simulated_hardware_request: bool = False,
simulation_fault: str = "normal",
vehicle_profile: VehicleProfile | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
) -> dict[str, Any]:
"""Run isolated adapters under the selected physical-bus policy."""
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
@@ -3194,6 +3458,7 @@ class SeatDemoService:
hardware_request=hardware_request,
simulated_hardware_request=simulated_hardware_request,
simulation_fault=simulation_fault,
control_transport=control_transport,
)
report_results = [
item
@@ -3470,21 +3735,33 @@ class SeatDemoService:
hardware_request: bool,
simulated_hardware_request: bool,
simulation_fault: str,
control_transport: str = WIRED_CAN_TRANSPORT,
) -> dict[str, dict[str, Any]]:
reports: dict[str, dict[str, Any]] = {}
bridge = None
seat_bus_factory = None
native_bus_factory = None
vehicle_safety_preflight: dict[str, Any] | None = None
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
if hardware_request and native_config_keys:
shared_runtime_enabled = os.getenv(
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED",
os.getenv(
"COGNITIVE_AGENT_SEAT_SHARED_ZCANPRO_BRIDGE_ENABLED", "true"
),
).strip().lower() in {"1", "true", "yes", "on"}
if shared_runtime_enabled:
if hardware_request and (
native_config_keys or (mqtt_selected and "seat" in configs)
):
shared_runtime_enabled = (
not mqtt_selected
and self.vehicle_can_settings.backend != "zlgcan-linux"
and os.getenv(
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED",
os.getenv(
"COGNITIVE_AGENT_SEAT_SHARED_ZCANPRO_BRIDGE_ENABLED", "true"
),
).strip().lower() in {"1", "true", "yes", "on"}
)
if mqtt_selected:
native_bus_factory = lambda: self._vehicle_mqtt_transport.bus(channel=1)
if "seat" in configs:
seat_bus_factory = lambda: self._vehicle_mqtt_transport.bus(channel=0)
elif shared_runtime_enabled:
from cognitive_agent.application.self_repair.service import (
BRIDGE_MANAGER,
)
@@ -3531,9 +3808,14 @@ class SeatDemoService:
native_bus_factory(),
timeout_ms=min(
5_000,
max(1_000, self.vehicle_can_settings.feedback_timeout_ms),
max(
1_000,
self.vehicle_mqtt_settings.feedback_timeout_ms
if mqtt_selected
else self.vehicle_can_settings.feedback_timeout_ms,
),
),
required_channel=self.vehicle_can_settings.channel,
required_channel=1,
)
require_stationary_vehicle(vehicle_safety_preflight)
except VehicleSafetyPreflightError as exc:
@@ -3560,7 +3842,9 @@ class SeatDemoService:
executor = NativeCanExecutor(
native_bus_factory,
feedback_timeout_ms=(
self.vehicle_can_settings.feedback_timeout_ms
self.vehicle_mqtt_settings.feedback_timeout_ms
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.vehicle_can_settings.feedback_timeout_ms
),
)
native_reports[key] = executor.execute(
@@ -3581,7 +3865,10 @@ class SeatDemoService:
config = configs["seat"]
if hardware_request:
seat_executor = (
self._new_hardware_executor(seat_bus_factory)
self._new_hardware_executor(
seat_bus_factory,
remote_transport=mqtt_selected,
)
if seat_bus_factory is not None
else self._new_hardware_executor()
)
@@ -176,7 +176,7 @@ def _build_bridge_manager():
termination_enabled=seat.termination_enabled,
)
]
if vehicle.enabled:
if vehicle.configured and vehicle.backend == "zlgcan":
if vehicle.channel == seat_channel:
raise ValueError("seat CH0 and vehicle CH1 must use different channels")
if (
@@ -0,0 +1,244 @@
"""Persistent read-only CH1 vehicle-state receiver for the Linux ZLG backend."""
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from copy import deepcopy
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from typing import Any
from cognitive_agent.application.native_can_feedback import decode_feedback
from cognitive_agent.infrastructure.can.gateway import CanFrame, CanGatewayError
from cognitive_agent.infrastructure.can.hardware import VehicleCanSettings
REQUIRED_SEAT_IDS = frozenset({0x4FA, 0x494})
REQUIRED_BODY_IDS = frozenset({0x342, 0x4EB, 0x407})
REQUIRED_UI_IDS = REQUIRED_SEAT_IDS | REQUIRED_BODY_IDS
OBSERVABLE_IDS = REQUIRED_UI_IDS | frozenset(
{0x388, 0x3F3, 0x3FF, 0x404, 0x41A, 0x42E}
)
PERSISTENT_MONITOR_POLL_INTERVAL_MS = 500
def _now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
@dataclass(frozen=True)
class VehicleStateSnapshot:
feedback_states: tuple[dict[str, Any], ...]
observed_ids: frozenset[int]
captured_at: str | None
capture_sequence: int
class VehicleStateMonitor:
"""Keep one monitor-only adapter owner online and cache fresh decoded state."""
def __init__(
self,
settings: VehicleCanSettings,
*,
decoder: Callable[[CanFrame], dict[str, Any]] = decode_feedback,
reconnect_delay_s: float = 1.0,
) -> None:
self.settings = settings
self.decoder = decoder
self.reconnect_delay_s = max(0.01, float(reconnect_delay_s))
self.initial_snapshot_timeout_s = max(0, settings.feedback_timeout_ms) / 1000
self.freshness_s = max(3.0, self.initial_snapshot_timeout_s * 2)
self._condition = threading.Condition(threading.RLock())
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._bridge: Any | None = None
self._latest: dict[int, tuple[float, dict[str, Any]]] = {}
self._baseline_ready = False
self._capture_sequence = 0
self._state = "idle"
self._last_error: str | None = None
def start(self) -> None:
with self._condition:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._state = "connecting"
self._thread = threading.Thread(
target=self._run,
name="zlg-linux-vehicle-state-monitor",
daemon=True,
)
self._thread.start()
def close(self) -> None:
self._stop_event.set()
with self._condition:
self._condition.notify_all()
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=2.0)
with self._condition:
bridge = self._bridge
if thread is not None and thread.is_alive() and bridge is not None:
bridge.close()
thread.join(timeout=1.0)
with self._condition:
if self._thread is None or not self._thread.is_alive():
self._state = "stopped"
self._thread = None
self._condition.notify_all()
def snapshot(self) -> VehicleStateSnapshot:
"""Return cached fresh state, waiting only for the first required baseline."""
self.start()
deadline = time.monotonic() + self.initial_snapshot_timeout_s
with self._condition:
while not self._baseline_ready:
fresh_ids = self._fresh_ids(time.monotonic())
if REQUIRED_UI_IDS <= fresh_ids:
self._baseline_ready = True
break
if self._state == "error" and not fresh_ids:
raise CanGatewayError(
self._last_error or "Linux vehicle-state monitor is unavailable"
)
remaining_s = deadline - time.monotonic()
if remaining_s <= 0 or self._stop_event.is_set():
break
self._condition.wait(timeout=remaining_s)
now = time.monotonic()
observed_ids = self._fresh_ids(now)
if self._state == "error" and not observed_ids:
raise CanGatewayError(
self._last_error or "Linux vehicle-state monitor is unavailable"
)
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 public_info(self) -> dict[str, Any]:
with self._condition:
return {
"mode": "persistent_monitor",
"state": self._state,
"read_only": True,
"poll_interval_ms": PERSISTENT_MONITOR_POLL_INTERVAL_MS,
"snapshot_freshness_ms": round(self.freshness_s * 1000),
"capture_sequence": self._capture_sequence,
"observed_frame_ids": [
f"0x{frame_id:03X}"
for frame_id in sorted(self._fresh_ids(time.monotonic()))
],
"last_error": self._last_error,
}
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 _record(self, frame: CanFrame) -> None:
if (
frame.channel != self.settings.channel
or frame.arbitration_id not in OBSERVABLE_IDS
or len(frame.data) < 8
):
return
decoded = self.decoder(frame)
if not any(
key in decoded
for key in ("actuator_state", "domain_state", "vehicle_safety")
):
# 0x4EB is multiplexed; only subtype 0x07 contains verified speed.
return
frame_id = str(decoded.get("frame_id", ""))
decoded.update(
{
"valid": True,
"feedback_frame_id": frame_id,
"captured_at": _now(),
"verification": {
"authoritative": True,
"channel": 1,
"simulated": False,
"read_only": True,
"frame_id": frame_id,
"payload_size": len(frame.data),
},
}
)
with self._condition:
self._capture_sequence += 1
self._latest[frame.arbitration_id] = (time.monotonic(), decoded)
if REQUIRED_UI_IDS <= self._fresh_ids(time.monotonic()):
self._baseline_ready = True
self._condition.notify_all()
def _run(self) -> None:
while not self._stop_event.is_set():
bridge = None
try:
with self._condition:
self._state = "connecting"
self._condition.notify_all()
channel_config = replace(
self.settings.channel_config(),
receive_ids=tuple(sorted(OBSERVABLE_IDS)),
)
bridge = self.settings.bridge(
[channel_config],
monitor_only=True,
)
bus = bridge.channel_bus(self.settings.channel)
with self._condition:
self._bridge = bridge
self._latest.clear()
self._baseline_ready = False
self._last_error = None
self._state = "online"
self._condition.notify_all()
while not self._stop_event.is_set():
frame = bus.receive(100, include_fd=True)
if frame is not None:
self._record(frame)
except Exception as exc: # noqa: BLE001 - normalize vendor/runtime failures
if not self._stop_event.is_set():
with self._condition:
self._state = "error"
self._last_error = str(exc)
self._condition.notify_all()
finally:
with self._condition:
if self._bridge is bridge:
self._bridge = None
if bridge is not None:
bridge.close()
if not self._stop_event.wait(self.reconnect_delay_s):
continue
with self._condition:
self._state = "stopped"
self._condition.notify_all()
@@ -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()
+9
View File
@@ -237,6 +237,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
query = parse_qs(parsed_url.query)
conversation_id = str(query.get("conversation_id", [""])[0])
vehicle_id = str(query.get("vehicle_id", [""])[0]) or None
control_transport = str(
query.get("control_transport", ["wired_can"])[0]
)
hardware_client_id = str(query.get("hardware_client_id", [""])[0]) or None
try:
self._json(
@@ -247,6 +250,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)(
conversation_id,
vehicle_id,
control_transport=control_transport,
hardware_client_id=hardware_client_id,
hardware_access_bypass=self._is_unproxied_loopback_request(),
)
@@ -420,6 +424,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if payload.get("reasoning_effort") is not None
else None
),
control_transport=str(
payload.get("control_transport", "wired_can")
),
)
self.vehicle_display_sync.update_task(
status=str(result.get("status", "succeeded")),
@@ -1209,6 +1216,7 @@ def main() -> None:
parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000")))
args = parser.parse_args()
server = ThreadingHTTPServer((args.host, args.port), SeatDemoHandler)
SeatDemoHandler.service.start()
SeatDemoHandler.extension.start()
SeatDemoHandler.calibration.start()
SeatDemoHandler.repair.start()
@@ -1240,6 +1248,7 @@ def main() -> None:
pass
finally:
server.server_close()
SeatDemoHandler.service.close()
SeatDemoHandler.extension.close()
SeatDemoHandler.calibration.close()
SeatDemoHandler.repair.close()