添加无线模块

This commit is contained in:
minghanchen
2026-08-19 09:59:36 +08:00
parent 344f662970
commit 52c4dd08f9
33 changed files with 4740 additions and 246 deletions
@@ -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()