Files
Cognitive_Agent/src/cognitive_agent/infrastructure/can/mqtt_transport.py
T
2026-08-25 11:39:41 +08:00

1000 lines
39 KiB
Python

"""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,
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
WIRELESS_MQTT_CH1_SOURCE = "wireless_mqtt_ch1"
MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset(
{*range(9), 12, 16, 20, 24, 32, 48, 64}
)
MQTT_SAFETY_FEEDBACK_IDS = frozenset({0x342, 0x4EB})
MQTT_VEHICLE_FEEDBACK_IDS = OBSERVABLE_IDS | frozenset({0x2F5, 0x37B})
# CAN0 accepts both state formats currently emitted by the passenger-seat
# stack. The production firmware publishes its authoritative classic-CAN
# status directly on 0x301..0x306/0x4FA. Older head-unit builds expose the
# read-only seat-ota request/response exchange on 0x6A0/0x6A2; that exchange is
# retained and translated to an internal 0x302 position frame below.
MQTT_SEAT_NATIVE_FEEDBACK_IDS = frozenset(
{0x301, 0x302, 0x303, 0x304, 0x305, 0x306, 0x4FA}
)
MQTT_SEAT_WIRE_FEEDBACK_IDS = MQTT_SEAT_NATIVE_FEEDBACK_IDS | frozenset(
{0x6A0, 0x6A2}
)
MQTT_SEAT_FEEDBACK_IDS = MQTT_SEAT_NATIVE_FEEDBACK_IDS | frozenset({0x6A2})
MQTT_ACCEPTED_FEEDBACK_IDS = {
0: MQTT_SEAT_FEEDBACK_IDS,
1: MQTT_VEHICLE_FEEDBACK_IDS,
}
MQTT_APPROVED_CONTROL_IDS = {
0: frozenset({0x29E, 0x3CE, 0x6A0}),
1: frozenset({0x1DE, 0x32B, 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. The original 0x6A2 response remains available to
command-response waiters; the generated 0x302 frame is internal only.
"""
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:
continue
packed_position, position_frame = position_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))
if flags_entry is None:
# The production Android poller intentionally requests selectors
# 1..4 only. A valid position/range pair is sufficient to expose
# position, but it does not prove motion or a driver fault.
flags = 0
flags_frame = position_frame
moving = False
position_valid = True
driver_fault = False
direction = 0
else:
flags, flags_frame = flags_entry
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")
parsed_frames = [
_parse_one_frame(item, default_channel=default_channel)
for items, default_channel in batches
for item in items
]
frames = [
frame
for frame in parsed_frames
if frame.arbitration_id
in (
MQTT_SEAT_WIRE_FEEDBACK_IDS
if frame.channel == 0
else MQTT_VEHICLE_FEEDBACK_IDS
)
]
return [*frames, *_ota_seat_status_frames(frames)]
def _validate_headunit_command_frame(frame: CanFrame) -> None:
"""Mirror the Android head unit's strict ``CanCommand`` frame checks."""
if frame.channel not in {0, 1}:
raise CanGatewayError("MQTT CAN command channel must be CAN0 or CAN1")
if frame.is_fd:
if not frame.bitrate_switch:
raise CanGatewayError("MQTT CAN FD command requires BRS")
if len(frame.data) not in MQTT_VALID_FD_PAYLOAD_LENGTHS:
raise CanGatewayError(
"MQTT CAN FD command length must be 0..8/12/16/20/24/32/48/64"
)
elif frame.bitrate_switch:
raise CanGatewayError("MQTT classic CAN command cannot enable BRS")
elif len(frame.data) > 8:
raise CanGatewayError("MQTT classic CAN command cannot exceed 8 bytes")
def mqtt_control_payload(
frame: CanFrame,
*,
response_timeout_ms: int = 1_500,
request_id: str | None = None,
command: str | None = None,
) -> str:
"""Serialize a frame as the Android head unit's strict ``CanCommand`` JSON."""
_validate_headunit_command_frame(frame)
command_request_id = request_id or f"cognitive-agent-{uuid.uuid4().hex}"
if not command_request_id.strip() or len(command_request_id) > 128:
raise CanGatewayError("MQTT CAN command requestId must contain 1..128 characters")
debug_command = "" if command is None else str(command).strip()
if len(debug_command) > 256:
raise CanGatewayError("MQTT CAN debug command must contain at most 256 characters")
bounded_timeout_ms = max(100, min(5_000, int(response_timeout_ms)))
return json.dumps(
{
"requestId": command_request_id,
# Debug-only semantic metadata. The head unit continues to execute
# only the validated channel/ID/frame/data fields below.
"command": debug_command,
"channel": f"can{frame.channel}",
"canId": f"0x{frame.arbitration_id:03X}",
"frameType": "fd" if frame.is_fd else "classic",
"brs": frame.bitrate_switch,
"extended": False,
"remote": False,
"data": frame.data.hex(" ").upper(),
"repeat": 1,
"intervalMs": 0,
# Vehicle state is consumed asynchronously from can/state by the
# Cognitive Agent. Do not serialize Android's command worker on a
# per-command response wait as well.
"expectResponse": False,
"responseTimeoutMs": bounded_timeout_ms,
},
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"
)
def _validate_native_seat_thermal_control(frame: CanFrame) -> None:
"""Validate the two runtime thermal frames implemented by the CAN0 firmware."""
if len(frame.data) != 8:
raise CanGatewayError("CAN0 passenger-seat thermal control requires DLC 8")
if frame.arbitration_id == 0x3CE:
mode = (frame.data[0] >> 4) & 0x03
if mode not in {1, 2} or frame.data[0] & 0xCF or any(frame.data[1:]):
raise CanGatewayError("CAN0 0x3CE thermal mode payload is invalid")
return
if frame.arbitration_id != 0x29E:
raise CanGatewayError("CAN0 native thermal frame ID is invalid")
fan_on = frame.data[0] & 0x03
fan_mode = (frame.data[0] >> 2) & 0x03
heating_on = (frame.data[0] >> 4) & 0x03
fan_level = frame.data[2] & 0x0F
heating_level = (frame.data[2] >> 4) & 0x0F
if (
fan_on not in {1, 2}
or fan_mode != 1
or heating_on not in {1, 2}
or fan_level not in range(4)
or heating_level not in range(4)
or (fan_on == 1 and fan_level == 0)
or (fan_on == 2 and fan_level != 0)
or (heating_on == 1 and heating_level == 0)
or (heating_on == 2 and heating_level != 0)
or frame.data[1] != 0
or any(frame.data[3:])
):
raise CanGatewayError("CAN0 0x29E thermal control payload is invalid")
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 in {0x302, 0x303, 0x304}
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
# The matrix decoder is shared with the wired CH1 monitor and therefore
# emits its wired source marker by default. Preserve the common byte
# decoder while making the actual transport explicit before this state
# reaches the conversation store or browser.
decoded["source"] = WIRELESS_MQTT_CH1_SOURCE
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(),
key=lambda item: (
0 if item[0][1] == 0x306 else
1 if item[0][1] == 0x4FA else
2,
item[0],
),
)
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,
*,
command: str | None = None,
) -> dict[str, Any]:
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")
_validate_headunit_command_frame(frame)
if frame.channel == 0 and frame.arbitration_id == 0x6A0:
_validate_ota_seat_runtime_control(frame.data)
elif frame.channel == 0:
_validate_native_seat_thermal_control(frame)
with self._condition:
client = self._client
state = self._state
if client is None or state != "online":
raise CanGatewayError("MQTT CAN relay is not connected")
raw_payload = mqtt_control_payload(
frame,
response_timeout_ms=self.settings.feedback_timeout_ms,
command=command,
)
result = client.publish(
self.settings.control_topic,
raw_payload,
qos=self.settings.qos,
retain=False,
)
if int(getattr(result, "rc", 0)) != 0:
raise CanGatewayError(f"MQTT CAN publish failed with code {result.rc}")
# Paho already accepted the payload into its network-loop queue when
# publish() returns rc == MQTT_ERR_SUCCESS. Waiting here serializes
# every repeated CAN frame and previously added up to two seconds per
# frame. Observe an already-available acknowledgement without ever
# blocking the control pipeline.
is_published = getattr(result, "is_published", None)
broker_confirmed = bool(is_published()) if callable(is_published) else False
return {
"topic": self.settings.control_topic,
"qos": self.settings.qos,
"retain": False,
"delivery_status": "published" if broker_confirmed else "queued",
"message_id": getattr(result, "mid", None),
"payload": json.loads(raw_payload),
}
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._debug_command = ""
self._published_messages: list[dict[str, Any]] = []
self._closed = False
@property
def published_messages(self) -> tuple[dict[str, Any], ...]:
"""Return this bus view's accepted MQTT publications in send order."""
return tuple(deepcopy(self._published_messages))
def set_debug_command(self, command: str | None) -> None:
"""Attach semantic debug metadata to subsequent MQTT publications."""
value = "" if command is None else str(command).strip()
if len(value) > 256:
raise CanGatewayError("MQTT CAN debug command must contain at most 256 characters")
self._debug_command = value
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,
)
publication = self._transport.publish(frame, command=self._debug_command)
self._published_messages.append(publication)
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