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