优化无线/有线CAN,以及控制指令解析

This commit is contained in:
minghanchen
2026-08-26 20:11:09 +08:00
parent 9ac8fdface
commit 4a0570706f
49 changed files with 8409 additions and 1289 deletions
@@ -1,393 +1,52 @@
"""Wireless passenger-seat executor for the seat-ota runtime protocol."""
"""Wireless passenger-seat executor for the firmware-native CAN0 protocol.
The module name is retained for import compatibility. Control is deliberately
not translated to the older seat-ota ``0x6A0`` runtime: the head unit must
relay the same classic-CAN ``0x300/0x29E/0x3CE`` frames used by the trusted
passenger-seat firmware.
"""
from __future__ import annotations
import threading
import time
from datetime import UTC, datetime
from copy import deepcopy
from typing import Any, Callable
from cognitive_agent.infrastructure.can.gateway import CanBus, CanFrame
from seat_domain import HardwareExecutionError, HardwareSeatExecutor
from cognitive_agent.infrastructure.can.gateway import CanBus
from seat_domain import 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
# The seat domain's semantic mode IDs predate the seat-ota firmware enum.
# Keep the public meaning stable while sending the firmware selector exactly.
OTA_DYNAMIC_SELECTOR_BY_SEMANTIC_MODE = {1: 2, 2: 1}
OTA_STATUS_NAMES = {
0x00: "OK",
0x01: "BAD_MAGIC",
0x02: "BAD_OPCODE",
0x03: "BAD_STATE",
0x04: "BAD_ARGUMENT",
0x0C: "RX_OVERFLOW",
0x0D: "BUS_OFF",
}
class MqttNativeSeatExecutor:
"""Run the native passenger-seat executor over the MQTT CAN0 bus.
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.
``HardwareSeatExecutor`` remains the single implementation of frame encoding
and sequence handling. This adapter selects its publish-only evidence mode
and changes report provenance from a local bus to the wireless MQTT relay;
physical completion is synchronized later from the state topic.
"""
_sequence_lock = threading.Lock()
_sequence = 0
def __init__(
self,
status_executor: HardwareSeatExecutor,
native_executor: HardwareSeatExecutor,
bus_factory: Callable[[], CanBus],
) -> None:
self.status_executor = status_executor
self.native_executor = native_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()
# Keep the executor on the explicitly supplied remote CAN0 factory.
# In particular, never fall back to a local ZLG adapter or firmware
# programmer when wireless control is selected.
self.native_executor.bus_factory = bus_factory
self.native_executor.feedback_required = False
@staticmethod
def _operation_debug_command(operation: dict[str, Any]) -> str:
capability = str(operation["capability"])
parameters = operation.get("parameters", {})
axes = {
"seat.set_longitudinal_position": "horizontal",
"seat.set_cushion_tilt": "basin",
"seat.set_backrest_recline": "backrest",
"seat.set_legrest_height": "legrest",
}
if capability in axes:
motion = operation.get("motion", {})
relative_directions = {
"horizontal": {"increase": "backward", "decrease": "forward"},
"basin": {"increase": "down", "decrease": "up"},
"backrest": {"increase": "backward", "decrease": "forward"},
"legrest": {"increase": "up", "decrease": "down"},
}
direction = relative_directions[axes[capability]].get(
str(motion.get("direction", ""))
)
delta = motion.get("delta_position_pct")
if motion.get("mode") == "relative" and direction and delta is not None:
return (
f"seat passenger {axes[capability]} relative "
f"{direction} {int(delta)}"
)
return (
f"seat passenger {axes[capability]} position "
f"{parameters['position_pct']}"
)
if capability in {"seat.set_ventilation", "seat.set_heating"}:
feature = "vent" if capability.endswith("ventilation") else "heat"
if parameters.get("state") == "off":
return f"seat passenger {feature} off"
level = {1: "low", 2: "medium", 3: "high"}.get(
int(parameters.get("level", 1)), "low"
)
return f"seat passenger {feature} {level}"
if capability == "seat.start_dynamic_mode":
mode = "rocking-horse" if int(parameters["mode_id"]) == 1 else "cradle"
return f"seat passenger dynamic {mode}"
if capability == "seat.stop_dynamic_mode":
return "seat passenger dynamic stop"
return capability
"""Expose the shared readable command formatter to existing callers."""
@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,
}
return HardwareSeatExecutor._operation_debug_command(operation) # noqa: SLF001
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 OTA_DYNAMIC_SELECTOR_BY_SEMANTIC_MODE[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,
"ota_dynamic_selector": None if stopping else selector,
"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 read_physical_state(self) -> dict[str, Any]:
state = deepcopy(self.native_executor.read_physical_state())
state["source"] = "wireless_mqtt_ch0"
return state
def execute(
self,
@@ -395,147 +54,30 @@ class MqttOtaSeatExecutor:
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
thermal_publication_start: int | None = None
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(",")
]
publication_start = (
thermal_publication_start
if thermal_publication_start is not None
else len(getattr(bus, "published_messages", ()))
)
set_debug_command = getattr(bus, "set_debug_command", None)
if callable(set_debug_command):
set_debug_command(
"; ".join(
self._operation_debug_command(item)
for item in referenced
)
)
if command["message"] == "T2G_Seat_Thermal_Mode_3CE":
encoded = self.status_executor.matrix.encode(
command["message"], command["signals"]
)
bus.send(
int(encoded["frame_id"], 16),
bytes.fromhex(encoded["data_hex"]),
)
thermal_publication_start = publication_start
continue
if command["message"] == "T2G_Seat_Thermal_Control_29E":
command_results = self.status_executor._execute_thermal( # noqa: SLF001
bus, command, referenced
)
thermal_publication_start = None
elif command["message"] == "T2G_Seat_Move_Command_300":
opcode = int(command["signals"]["Opcode"])
if opcode in {0x0E, 0x0F}:
motion_armed = opcode == 0x0E
command_results = self._execute_dynamic(
bus, command, referenced
)
motion_armed = False
else:
motion_armed = True
command_results = self._execute_move(
bus, command, referenced
)
motion_armed = False
else:
raise HardwareExecutionError(
"wireless CAN0 command is not in the passenger-seat runtime "
f"whitelist: {command['message']}"
)
mqtt_messages = list(
getattr(bus, "published_messages", ())[
publication_start:
]
)
if mqtt_messages:
for item in command_results:
item.setdefault("telemetry", {})[
"mqtt_messages"
] = mqtt_messages
results.extend(command_results)
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
report = self.native_executor.execute(
command_plan,
function_config,
actuator_state,
)
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",
"seat_feedback": "wireless_mqtt_ch0",
},
},
"spec": spec,
}
metadata = report.setdefault("metadata", {})
metadata["name"] = "native CAN0 MQTT hardware execution report"
labels = metadata.setdefault("labels", {})
labels["feedback"] = "wireless_mqtt_ch0"
labels["seat_feedback"] = "wireless_mqtt_ch0"
spec = report.setdefault("spec", {})
physical_state = spec.get("physical_state")
if isinstance(physical_state, dict):
physical_state["source"] = "wireless_mqtt_ch0"
for result in spec.get("results", []):
if not isinstance(result, dict):
continue
telemetry = result.setdefault("telemetry", {})
if isinstance(telemetry, dict):
telemetry["control_transport"] = "wireless_mqtt"
telemetry["seat_feedback"] = "wireless_mqtt_ch0"
return report
# Compatibility alias for code and saved tests that still import the old
# class name. The implementation above never publishes a 0x6A0 control frame.
MqttOtaSeatExecutor = MqttNativeSeatExecutor
@@ -227,6 +227,57 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]:
}
return decoded
if frame.arbitration_id == 0x4BB and len(data) >= 49:
# The vehicle differential capture maps B41..B44 to the four outlet
# vertical position signatures. 0x26 was observed only after CLOSE
# and 0x80 after OPEN, independently for every outlet. B45..B48 are
# retained as correlated horizontal evidence rather than interpreted
# as a switch enum.
names = (
"driver_left",
"driver_right",
"passenger_left",
"passenger_right",
)
switches = {}
for index, name in enumerate(names):
vertical_raw = int(data[41 + index])
state = {0x80: "open", 0x26: "close"}.get(
vertical_raw, "unknown"
)
switches[name] = {
"state": state,
"vertical_raw": vertical_raw,
"horizontal_raw": int(data[45 + index]),
"evidence": "0x4BB_position_signature",
}
decoded["domain_state"] = {"outlet": {"switches": switches}}
return decoded
if frame.arbitration_id == 0x37B and len(data) >= 15 and data[0] == 0x50:
# Matrix Sheet1 rows 8822/8823/8830/8831 expose direct individual
# switch enums on platforms that route the 0x37B/0x50 variant.
starts = {
"driver_left": 64,
"driver_right": 66,
"passenger_left": 116,
"passenger_right": 118,
}
decoded["domain_state"] = {
"outlet": {
"switches": {
name: {
"state": {1: "open", 2: "close"}.get(
_bits(data, start, 2), "unknown"
),
"evidence": "0x37B_0x50_switch_enum",
}
for name, start in starts.items()
}
}
}
return decoded
if frame.arbitration_id == 0x4FA and len(data) >= 8:
thermal = {
1: ("off", 0),
@@ -462,6 +513,16 @@ def feedback_matches(expected: str, frame: CanFrame | None) -> bool | None:
)
return position is not None and abs(position - int(parts[2])) <= 3
if family == "outlet_switch":
state = (
decoded.get("domain_state", {})
.get("outlet", {})
.get("switches", {})
.get(parts[1], {})
.get("state")
)
return None if state is None else state == parts[2]
if family == "indoor_light":
power = decoded.get("domain_state", {}).get("interior_light", {}).get("power")
return power == parts[1]
@@ -72,6 +72,12 @@ DRIVER_SEAT_THERMAL_MIDDLE_PATTERN = re.compile(
r"(?:中等(?:档|级)?|中档|中级|中间档|适中)"
r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)"
)
DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN = re.compile(
r"^(?:(?:然后|随后|接着|同时|并且|再|并|以及)?(?:把|将)?(?:按摩)?)"
r"(?:模式|强度|力度|档位|等级|"
r"(?:切换|改成|改为|调整|调成|调为|设置|设为)"
r".{0,8}(?:波浪|脉冲|伸展|拉伸|松弛|舒展|放松)(?:模式)?)"
)
_FEATURE_BOUNDARY = re.compile(
r"并且|同时|然后|随后|接着|以及|并|再|和|与|、|,"
)
@@ -199,16 +205,15 @@ def _scoped_text(
}
selected_indexes = set(subject_indexes)
if continuation_pattern:
selected_indexes.update(
index + 1
for index in subject_indexes
if index + 1 < len(clauses)
and re.search(
for index in subject_indexes:
continuation_index = index + 1
while continuation_index < len(clauses) and re.search(
continuation_pattern,
clauses[index + 1],
clauses[continuation_index],
re.IGNORECASE,
)
)
):
selected_indexes.add(continuation_index)
continuation_index += 1
return ",".join(
clause
for index, clause in enumerate(clauses)
@@ -360,6 +365,12 @@ def _parse_exact_command(text: str) -> list[dict[str, Any]]:
if match:
return [_action("climate", "climate.set_front_defrost", "vehicle", {"state": match[1]}, "用户输入了受信任的空调前除霜命令。")]
match = re.fullmatch(
r"outlet (driver-left|driver-right|passenger-left|passenger-right) (open|close)",
command,
)
if match:
return [_action("outlet", "outlet.set_switch_state", match[1].replace("-", "_"), {"state": match[2]}, "用户输入了受信任的单风口开关命令。")]
match = re.fullmatch(
r"outlet (driver-left|driver-right|passenger-left|passenger-right) percent (\d+) (\d+)",
command,
@@ -529,7 +540,26 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
inherited_driver_scope = False
combined_actions: list[dict[str, Any]] = []
seen: set[tuple[str, str, tuple[tuple[str, str], ...]]] = set()
for clause in seat_clauses:
index = 0
while index < len(seat_clauses):
clause = seat_clauses[index]
if "按摩" in clause:
continuation_index = index + 1
while (
continuation_index < len(seat_clauses)
and DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN.search(
seat_clauses[continuation_index]
)
):
continuation = re.sub(
r"^(?:然后|随后|接着|同时|并且|再|并|以及)?"
r"(?:把|将)?(?:按摩)?",
"",
seat_clauses[continuation_index],
)
clause = f"{clause}{continuation}"
continuation_index += 1
index = continuation_index - 1
if re.search(r"主驾|驾驶位|司机位|左前座椅", clause):
inherited_driver_scope = True
elif re.search(r"副驾|副驾驶|乘客|右前", clause):
@@ -550,6 +580,7 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
if signature not in seen:
seen.add(signature)
combined_actions.append(action)
index += 1
return combined_actions
if re.search(
@@ -626,7 +657,7 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
))
if "按摩" in compact:
massage_text = _feature_context(compact, "按摩")
massage_text = _feature_context(compact, "按摩", radius=48)
massage_actions: list[dict[str, Any]] = []
if re.search(r"关闭|关掉|停止|取消|不要|别开", massage_text):
massage_actions.append({"action": "off"})
@@ -922,6 +953,7 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
compact,
r"座椅|座位|靠背|椅背|坐盆|坐垫|腿托|脚托|按摩|记忆|预设|"
r"通风|加热|前移|后移|向前移动|向后移动",
DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN.pattern,
)
actions: list[dict[str, Any]] = _parse_chinese_driver_seat_commands(
seat_text
@@ -1009,9 +1041,18 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
elif re.search(r"外循环", climate_text):
actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "external"}, "用户指定空调外循环。"))
has_face = _positive_outlet_feature(climate_text, r"吹面|吹脸")
has_feet = _positive_outlet_feature(climate_text, r"吹脚")
has_defrost = _positive_outlet_feature(climate_text, r"除霜")
# A phrase such as “打开副驾左吹面出风口” controls one motorized
# outlet; “吹面” inside that device name must not also select the
# vehicle-wide HVAC face mode. Remove only explicit four-outlet
# device names, leaving an independent “空调设为吹面” clause intact.
climate_mode_text = re.sub(
r"(?:主驾左|主驾右|副驾左|副驾右)(?:吹面)?(?:出)?风口",
"",
climate_text,
)
has_face = _positive_outlet_feature(climate_mode_text, r"吹面|吹脸")
has_feet = _positive_outlet_feature(climate_mode_text, r"吹脚")
has_defrost = _positive_outlet_feature(climate_mode_text, r"除霜")
outlet_mode = (
"face_feet_defrost"
if has_face and has_feet and has_defrost
@@ -1162,7 +1203,19 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
actions.append(_action("ambient_light", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": brightness_level}, "用户指定了已验证的氛围灯亮度档位。"))
outlet_text = _scoped_text(compact, r"风口")
outlet_target = next((value for label, value in OUTLET_TARGETS.items() if f"{label}风口" in outlet_text), None)
outlet_target = next(
(
value
for label, value in OUTLET_TARGETS.items()
if re.search(rf"{label}(?:吹面)?(?:出)?风口", outlet_text)
),
None,
)
outlet_switch_state = None
if re.search(r"(?:打开|开启)(?:一下)?", outlet_text):
outlet_switch_state = "open"
elif re.search(r"(?:关闭|关掉)(?:一下)?", outlet_text):
outlet_switch_state = "close"
calibrated_position = re.search(
r"(?:标定坐标|标定位置).*?(?:左右|水平)"
r"(-?\d{1,3})(?![\d.]).*?(?:上下|垂直)(-?\d{1,3})(?![\d.])",
@@ -1173,7 +1226,17 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
r"(?:上下|垂直)(\d{1,3})%?(?![\d.])",
outlet_text,
)
if (
if outlet_target and outlet_switch_state:
actions.append(
_action(
"outlet",
"outlet.set_switch_state",
outlet_target,
{"state": outlet_switch_state},
"用户指定了一个前排电动风口及其开关状态。",
)
)
elif (
outlet_target
and calibrated_position
and all(-128 <= int(value) <= 126 for value in calibrated_position.groups())
@@ -17,6 +17,12 @@ from cognitive_agent.application.native_can_feedback import (
decode_feedback,
feedback_matches,
)
from cognitive_agent.generated.can_semantic_protocol import (
CAN1_SUPPORTED_CAPABILITIES,
PROTOCOL_VERSION,
frame as shared_protocol_frame,
payload_variant as shared_payload_variant,
)
from cognitive_agent.infrastructure.can.gateway import CanBus, CanFrame
@@ -86,7 +92,7 @@ class NativeCanRuntime:
"""Compile named capabilities into byte-exact CAN_DEV frames."""
compiler_version = "1.0.0"
mapping_version = "can-dev-verified-body-can-v5"
mapping_version = f"shared-can-semantic-protocol-{PROTOCOL_VERSION}"
def __init__(self, repository_root: Path):
self.root = repository_root
@@ -111,30 +117,7 @@ class NativeCanRuntime:
@property
def supported_capabilities(self) -> frozenset[str]:
return frozenset(
{
"window.set_motion",
"window.set_position",
"climate.set_power",
"climate.set_ac",
"climate.set_fan_level",
"climate.set_outlet_mode",
"climate.set_temperature",
"climate.set_control_mode",
"climate.set_cycle_mode",
"door.set_rear_lock",
"outlet.set_position_percent",
"outlet.set_calibrated_position",
*DRIVER_SEAT_CAPABILITIES,
"interior_light.set_indoor_power",
"interior_light.set_reading_power",
"ambient_light.set_power",
"ambient_light.set_color_index",
"ambient_light.set_brightness_level",
"roof.set_shade_motion",
"roof.set_shade_position",
}
)
return CAN1_SUPPORTED_CAPABILITIES
def supports(self, action: dict[str, Any]) -> bool:
capability = str(action.get("capability"))
@@ -386,6 +369,8 @@ class NativeCanRuntime:
return f"ac cycle {parameters['mode']}"
if capability == "door.set_rear_lock":
return f"door rear {parameters['state']}"
if capability == "outlet.set_switch_state":
return f"outlet {target} {parameters['state']}"
if capability == "outlet.set_position_percent":
return (
f"outlet {target} percent {parameters['horizontal_pct']} "
@@ -453,7 +438,18 @@ class NativeCanRuntime:
started_at: str,
mode: str,
) -> dict[str, Any]:
succeeded = all(item["status"] == "succeeded" for item in results)
verification_statuses = [
str(item.get("telemetry", {}).get("verification_status") or "")
for item in results
]
verification_status = (
"failed_or_blocked"
if any(value == "failed_or_blocked" for value in verification_statuses)
else "submitted_unverified"
if any(value == "submitted_unverified" for value in verification_statuses)
else "verified_success"
)
succeeded = verification_status != "failed_or_blocked"
request_id = function_config["metadata"]["request_id"]
domain = function_config["spec"]["domain"]
return {
@@ -472,6 +468,7 @@ class NativeCanRuntime:
"spec": {
"mode": mode,
"status": "succeeded" if succeeded else "failed",
"verification_status": verification_status,
"started_at": started_at,
"finished_at": _now(),
"results": results,
@@ -494,13 +491,10 @@ class NativeCanRuntime:
payload[0] = 0x0A
_set_bits(payload, 14, 2, state)
return [
self._classic(
0x32B,
self._shared_frame(
"can1.door.rear_lock",
payload,
1,
0,
(0x407,),
f"rear_lock:{parameters['state']}",
expected=f"rear_lock:{parameters['state']}",
)
]
@@ -508,12 +502,18 @@ class NativeCanRuntime:
state = self._enum(parameters, "state", {"on": 1, "off": 2})
payload = bytearray(8)
_set_bits(payload, 28, 2, state)
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_power:{parameters['state']}")]
return [self._shared_frame(
"can1.climate.power",
payload,
expected=f"climate_power:{parameters['state']}",
)]
if capability == "climate.set_fan_level":
level = self._integer(parameters, "level", 1, 7)
payload = bytearray(8)
_set_bits(payload, 4, 4, level)
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_fan:{level}")]
return [self._shared_frame(
"can1.climate.fan", payload, expected=f"climate_fan:{level}"
)]
if capability == "climate.set_outlet_mode":
mode = self._enum(
parameters,
@@ -530,25 +530,28 @@ class NativeCanRuntime:
)
payload = bytearray(8)
_set_bits(payload, 0, 4, mode)
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_mode:{mode}")]
return [self._shared_frame(
"can1.climate.mode", payload, expected=f"climate_mode:{mode}"
)]
if capability == "climate.set_control_mode":
mode = self._enum(parameters, "mode", {"manual": 1, "auto": 2})
payload = bytearray(8)
_set_bits(payload, 16, 2, mode)
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_control:{mode}")]
return [self._shared_frame(
"can1.climate.control",
payload,
expected=f"climate_control:{mode}",
)]
if capability == "climate.set_cycle_mode":
self._reject_unexpected_parameters(parameters, {"mode"})
mode = self._enum(parameters, "mode", {"internal": 1, "external": 2})
payload = bytearray(8)
_set_bits(payload, 19, 2, mode)
return [
self._classic(
0x1DE,
self._shared_frame(
"can1.climate.cycle",
payload,
3,
100,
(0x404,),
f"climate_cycle:{parameters['mode']}",
expected=f"climate_cycle:{parameters['mode']}",
)
]
if capability == "climate.set_temperature":
@@ -573,13 +576,11 @@ class NativeCanRuntime:
_set_bits(payload, start, 8, temperature)
label = "driver" if start == 32 else "passenger" if start == 40 else "rear"
specs.append(
self._classic(
0x1DE,
self._shared_frame(
"can1.climate.temperature",
payload,
1 if start == 48 else 3,
100,
(0x404,),
f"climate_temperature:{label}:{temperature}",
key="rear" if start == 48 else "front",
expected=f"climate_temperature:{label}:{temperature}",
)
)
return specs
@@ -587,19 +588,20 @@ class NativeCanRuntime:
if capability == "window.set_motion":
motion = self._enum(parameters, "motion", {"open": 1, "close": 2, "stop": 3})
payload = self._window_payload(target, motion)
return [self._fd(0x431, payload, 3, 100, (0x388,), f"window_motion:{target}:{parameters['motion']}")]
return [self._shared_frame(
"can1.window.motion",
payload,
expected=f"window_motion:{target}:{parameters['motion']}",
)]
if capability == "window.set_position":
position = self._integer(parameters, "position_pct", 0, 100)
if target not in {"front_left", "rear_left", "front_right", "rear_right"}:
raise NativeCanCompileError("absolute window position requires one window")
return [
NativeFrameSpec(
frame_id=0x431,
payload=self._window_payload(target, 3),
is_fd=True,
bitrate_switch=True,
feedback_ids=(0x388,),
expected_feedback=f"window_position:{target}:{position}",
self._shared_frame(
"can1.window.position",
self._window_payload(target, 3),
expected=f"window_position:{target}:{position}",
special_handler="window_position",
special_parameters={"target": target, "position_pct": position},
)
@@ -624,36 +626,56 @@ class NativeCanRuntime:
self._require_driver_seat_target(target)
return [self._driver_seat_memory_frame(parameters)]
if capability == "outlet.set_switch_state":
return [self._outlet_switch_frame(target, parameters)]
if capability in {"outlet.set_position_percent", "outlet.set_calibrated_position"}:
return [self._outlet_frame(capability, target, parameters)]
if capability == "interior_light.set_indoor_power":
return [self._all_reading_light_frame(parameters, "indoor_light")]
return [
self._all_reading_light_frame(
parameters, "indoor_light", "can1.light.indoor"
)
]
if capability == "interior_light.set_reading_power":
if target != "all":
raise NativeCanCompileError(
"only the all-reading-lights target is verified on CH1"
)
return [self._all_reading_light_frame(parameters, "indoor_light")]
return [
self._all_reading_light_frame(
parameters, "indoor_light", "can1.light.reading_all"
)
]
if capability == "ambient_light.set_power":
self._reject_unexpected_parameters(parameters, {"state"})
state = str(parameters.get("state"))
self._enum(parameters, "state", {"off": 0, "on": 1})
payload = bytes(
(0x00, 0x36 if state == "on" else 0x31, 0x00, 0x75, 0x00, 0x80, 0x00, 0x00)
)
return [self._fd(0x3FC, payload, 3, 100, (0x42E,), f"ambient_power:{state}")]
payload = shared_payload_variant("can1.light.ambient_power", state)
return [self._shared_frame(
"can1.light.ambient_power",
payload,
expected=f"ambient_power:{state}",
)]
if capability == "ambient_light.set_color_index":
self._reject_unexpected_parameters(parameters, {"color_index"})
color = self._integer(parameters, "color_index", 1, 127)
payload = bytes((color, 0x30, 0x00, 0x75, 0x00, 0x00, 0x00, 0x00))
return [self._fd(0x3FC, payload, 3, 100, (0x42E,), f"ambient_color:{color}")]
return [self._shared_frame(
"can1.light.ambient_color",
payload,
expected=f"ambient_color:{color}",
)]
if capability == "ambient_light.set_brightness_level":
self._reject_unexpected_parameters(parameters, {"brightness_level"})
level = self._integer(parameters, "brightness_level", 1, 5)
payload = bytes((0x00, 0x31 + level, 0x00, 0x75, 0x00, 0x00, 0x00, 0x00))
return [self._fd(0x3FC, payload, 3, 100, (0x42E,), f"ambient_brightness:{level}")]
return [self._shared_frame(
"can1.light.ambient_brightness",
payload,
expected=f"ambient_brightness:{level}",
)]
if capability == "roof.set_shade_motion":
motion = self._enum(parameters, "motion", {"open": 1, "close": 2, "half": 3, "stop": 4})
@@ -668,41 +690,45 @@ class NativeCanRuntime:
raise NativeCanCompileError(f"CAN_DEV does not implement {capability}")
@staticmethod
def _classic(
frame_id: int,
def _shared_frame(
rule_id: str,
payload: bytes | bytearray,
repeat: int = 1,
period_ms: int = 0,
feedback_ids: tuple[int, ...] = (),
*,
key: str = "command",
expected: str = "",
special_handler: str = "",
special_parameters: dict[str, Any] | None = None,
repeat_count: int | None = None,
) -> NativeFrameSpec:
return NativeFrameSpec(
frame_id,
bytes(payload),
repeat_count=repeat,
period_ms=period_ms,
feedback_ids=feedback_ids,
expected_feedback=expected,
)
"""Build a frame using transport metadata generated from the shared manifest."""
@staticmethod
def _fd(
frame_id: int,
payload: bytes | bytearray,
repeat: int = 1,
period_ms: int = 0,
feedback_ids: tuple[int, ...] = (),
expected: str = "",
) -> NativeFrameSpec:
metadata = shared_protocol_frame(rule_id, key)
encoded = bytes(payload)
dlc = int(metadata["dlc"])
if len(encoded) != dlc:
raise NativeCanCompileError(
f"{rule_id}/{key} payload must contain {dlc} bytes"
)
repeat = metadata["repeat"]
if repeat_count is None:
repeat_count = (
int(repeat["count"])
if repeat["kind"] == "fixed"
else 1
)
return NativeFrameSpec(
frame_id,
bytes(payload),
is_fd=True,
bitrate_switch=True,
repeat_count=repeat,
period_ms=period_ms,
feedback_ids=feedback_ids,
frame_id=int(str(metadata["frame_id"]), 16),
payload=encoded,
is_fd=metadata["format"] == "can_fd",
bitrate_switch=bool(metadata["brs"]),
repeat_count=repeat_count,
period_ms=int(repeat["period_ms"]),
feedback_ids=tuple(
int(str(value), 16) for value in metadata["feedback_ids"]
),
expected_feedback=expected,
special_handler=special_handler,
special_parameters=special_parameters,
)
@staticmethod
@@ -791,13 +817,53 @@ class NativeCanRuntime:
payload[7] = 0xFF
_set_bits(payload, horizontal_start, 8, horizontal + 128)
_set_bits(payload, vertical_start, 8, vertical + 128)
return self._fd(
0x4EF,
return self._shared_frame(
(
"can1.outlet.percent"
if capability == "outlet.set_position_percent"
else "can1.outlet.position"
),
payload,
3,
100,
(0x2F5, 0x37B),
f"outlet:{target}:{horizontal}:{vertical}",
expected=f"outlet:{target}:{horizontal}:{vertical}",
)
def _outlet_switch_frame(
self, target: str, parameters: dict[str, Any]
) -> NativeFrameSpec:
"""Compile the four independently observed front-outlet switch fields."""
self._reject_unexpected_parameters(parameters, {"state"})
state = str(parameters.get("state"))
raw = self._enum(parameters, "state", {"open": 1, "close": 2})
layouts = {
# Keep every untouched byte exactly as emitted by the head unit.
# In particular, passenger B6/B7 are 00 00 rather than FF FF.
"driver_left": (0x02, 0, 0xFF, 0xFF),
"driver_right": (0x02, 2, 0xFF, 0xFF),
"passenger_left": (0x03, 0, 0x00, 0x00),
"passenger_right": (0x03, 2, 0x00, 0x00),
}
try:
sub_id, shift, byte6, byte7 = layouts[target]
except KeyError as exc:
raise NativeCanCompileError(
f"unsupported outlet switch target {target}"
) from exc
payload = shared_payload_variant(
"can1.outlet.switch", f"{target.replace('_', '-')}:{state}"
)
# Keep the table above as a decoder/layout guard, but take the exact
# observed request bytes from the generated shared protocol.
if payload != bytes(
(sub_id, 0xFF, 0xFF, 0xFF, 0xFF, raw << shift, byte6, byte7)
):
raise NativeCanCompileError(
f"generated outlet switch payload disagrees with layout for {target}"
)
return self._shared_frame(
"can1.outlet.switch",
payload,
expected=f"outlet_switch:{target}:{state}",
)
@staticmethod
@@ -845,13 +911,10 @@ class NativeCanRuntime:
payload[1] = (
control_value << 4 if feature == "heating" else control_value
)
return self._fd(
0x431,
return self._shared_frame(
"can1.seat.driver_thermal",
payload,
3,
100,
(0x4FA,),
f"driver_seat_{feature}:{feedback_value}",
expected=f"driver_seat_{feature}:{feedback_value}",
)
def _driver_seat_massage_frame(
@@ -886,7 +949,9 @@ class NativeCanRuntime:
expected = f"driver_seat_massage_mode:{mode}"
else:
raise NativeCanCompileError("action must be off, strength, or mode")
return self._fd(0x4C1, payload, 3, 100, (0x4FA,), expected)
return self._shared_frame(
"can1.seat.driver_massage", payload, expected=expected
)
def _driver_seat_axis_frame(
self, parameters: dict[str, Any]
@@ -929,29 +994,35 @@ class NativeCanRuntime:
_set_bits(payload, starts[axis], 2, allowed_directions[direction])
expected = f"driver_seat_axis:{axis}:{direction}"
if direction == "stop":
return self._fd(
0x4C1, stop_payload, 3, 100, (0x494,), expected
return self._shared_frame(
"can1.seat.driver_axis",
stop_payload,
key="stop",
expected=expected,
)
repeat_count = (duration_ms + 99) // 100
return NativeFrameSpec(
frame_id=0x4C1,
payload=bytes(payload),
is_fd=True,
bitrate_switch=True,
period_ms = int(
shared_protocol_frame("can1.seat.driver_axis", "motion")["repeat"][
"period_ms"
]
)
repeat_count = (duration_ms + period_ms - 1) // period_ms
stop_metadata = shared_protocol_frame("can1.seat.driver_axis", "stop")
return self._shared_frame(
"can1.seat.driver_axis",
payload,
key="motion",
expected=expected,
repeat_count=repeat_count,
period_ms=100,
feedback_ids=(0x494,),
expected_feedback=expected,
special_handler="driver_seat_axis",
special_parameters={
"duration_ms": duration_ms,
"final_action_hold_ms": 100,
"final_action_hold_ms": period_ms,
"stop_data_hex": " ".join(
f"{value:02X}" for value in stop_payload
),
"stop_repeat_count": 3,
"stop_period_ms": 100,
"stop_repeat_count": int(stop_metadata["repeat"]["count"]),
"stop_period_ms": int(stop_metadata["repeat"]["period_ms"]),
},
)
@@ -1005,42 +1076,48 @@ class NativeCanRuntime:
payload_15 = learned_payload("payload_15", 0x15)
payload_16 = learned_payload("payload_16", 0x16)
sequence_metadata = shared_protocol_frame(
"can1.seat.driver_memory", "sequence"
)
sequence_count = int(sequence_metadata["repeat"]["count"])
if sequence_count % 2:
raise NativeCanCompileError(
"driver memory sequence count must contain complete 0x16/0x15 pairs"
)
sequence = [
value.hex(" ").upper()
for _ in range(10)
for _ in range(sequence_count // 2)
for value in (payload_16, payload_15)
]
return NativeFrameSpec(
frame_id=0x4C1,
payload=payload_16,
is_fd=True,
bitrate_switch=True,
feedback_ids=(0x494,),
expected_feedback=f"driver_seat_memory_status:{slot}",
return self._shared_frame(
"can1.seat.driver_memory",
payload_16,
key="sequence",
expected=f"driver_seat_memory_status:{slot}",
special_handler="driver_seat_memory_recall",
special_parameters={
"slot": slot,
"fresh_status_timeout_ms": 2_500,
"post_status_timeout_ms": 1_200,
"period_ms": 50,
"period_ms": int(sequence_metadata["repeat"]["period_ms"]),
"frame_sequence": sequence,
},
)
def _all_reading_light_frame(
self, parameters: dict[str, Any], feedback_family: str
self,
parameters: dict[str, Any],
feedback_family: str,
rule_id: str,
) -> NativeFrameSpec:
self._reject_unexpected_parameters(parameters, {"state"})
state = self._enum(parameters, "state", {"off": 1, "on": 2})
payload = bytearray((0, 0, 3, 0xFF, 0xFF, 0, 0, 0))
_set_bits(payload, 50, 2, state)
return self._fd(
0x4F5,
return self._shared_frame(
rule_id,
payload,
3,
100,
(0x42E,),
f"{feedback_family}:{parameters['state']}",
expected=f"{feedback_family}:{parameters['state']}",
)
@staticmethod
@@ -1052,11 +1129,16 @@ class NativeCanRuntime:
def _shade_preset_frame(self, motion: int) -> NativeFrameSpec:
target = {1: 100, 2: 0, 3: 50}.get(motion)
expected = "shade_stopped" if motion == 4 else f"shade_position:{target}"
return NativeFrameSpec(
frame_id=0x431,
payload=self._shade_payload(motion),
feedback_ids=(0x41A,),
expected_feedback=expected,
selector = {1: "open", 2: "close", 3: "half", 4: "stop"}[motion]
payload = shared_payload_variant("can1.shade.motion", selector)
if payload != self._shade_payload(motion):
raise NativeCanCompileError(
f"generated shade payload disagrees with motion {motion}"
)
return self._shared_frame(
"can1.shade.motion",
payload,
expected=expected,
special_handler="shade_preset",
special_parameters={
"motion": motion,
@@ -1067,20 +1149,25 @@ class NativeCanRuntime:
)
def _shade_position_frame(self, position: int) -> NativeFrameSpec:
return NativeFrameSpec(
frame_id=0x431,
payload=self._shade_payload(4),
feedback_ids=(0x41A,),
expected_feedback=f"shade_position:{position}",
return self._shared_frame(
"can1.shade.position",
self._shade_payload(4),
expected=f"shade_position:{position}",
special_handler="shade_position",
special_parameters={
"position_pct": position,
"fresh_feedback_timeout_ms": 2_500,
"motion_timeout_ms": 15_000,
"tolerance_pct": 2,
"open_data_hex": "10 00 00 00 00 00 00 00",
"close_data_hex": "20 00 00 00 00 00 00 00",
"stop_data_hex": "40 00 00 00 00 00 00 00",
"open_data_hex": shared_payload_variant(
"can1.shade.motion", "open"
).hex(" ").upper(),
"close_data_hex": shared_payload_variant(
"can1.shade.motion", "close"
).hex(" ").upper(),
"stop_data_hex": shared_payload_variant(
"can1.shade.motion", "stop"
).hex(" ").upper(),
},
)
@@ -1173,7 +1260,19 @@ class NativeCanExecutor:
feedback_match = self._feedback_matches(command, feedback)
verified = feedback is not None and feedback_match is True
delivered = bool(sent_frames)
succeeded = verified or (delivered and not feedback_required)
# The operation-level status describes whether the approved
# command was submitted. Physical completion remains a
# separate evidence level in ``verification_status`` and
# ``physical_effect_confirmed``. A feedback timeout must
# not be rewritten as a transport/orchestration failure.
succeeded = delivered
verification_status = (
"verified_success"
if verified
else "submitted_unverified"
if delivered
else "failed_or_blocked"
)
physical_channel = (
feedback.channel
if feedback is not None
@@ -1226,6 +1325,7 @@ class NativeCanExecutor:
"feedback_state": self._feedback_state(feedback),
"feedback_required": feedback_required,
"delivery_status": delivery_status,
"verification_status": verification_status,
"physical_effect_confirmed": verified,
"control_gate": (
"compiled_action_and_frame_whitelist"
@@ -1254,6 +1354,7 @@ class NativeCanExecutor:
"requested_parameters": operation["parameters"],
"command_frame": runtime.command_frame_public(command),
"channel": 1,
"verification_status": "failed_or_blocked",
"simulated": False,
},
}
+354 -49
View File
@@ -54,9 +54,12 @@ from cognitive_agent.application.model_plan_adapter import ( # noqa: E402
SEAT_MOTION_CAPABILITIES,
)
from cognitive_agent.application.mqtt_ota_seat_executor import ( # noqa: E402
MqttOtaSeatExecutor,
MqttNativeSeatExecutor,
)
from cognitive_agent.application.native_can_intent import ( # noqa: E402
DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN,
merge_native_can_intent,
)
from cognitive_agent.application.native_can_intent import merge_native_can_intent # noqa: E402
from cognitive_agent.application.direct_can_control import ( # noqa: E402
build_direct_can_intent,
)
@@ -103,7 +106,10 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
VehicleCanSettings,
VehicleMqttSettings,
)
from cognitive_agent.infrastructure.can.gateway import ZlgChannelConfig # noqa: E402
from cognitive_agent.infrastructure.can.gateway import ( # noqa: E402
ZlgCanBridgeProcess,
ZlgChannelConfig,
)
from cognitive_agent.infrastructure.can.mqtt_transport import ( # noqa: E402
MqttCanTransport,
)
@@ -153,6 +159,88 @@ def _wireless_mqtt_publish_preflight() -> dict[str, Any]:
}
EXECUTION_VERIFICATION_STATUSES = frozenset(
{"verified_success", "submitted_unverified", "failed_or_blocked"}
)
def _operation_verification_status(item: dict[str, Any]) -> str:
"""Classify one operation without conflating Tx and physical feedback."""
telemetry = item.get("telemetry", {})
explicit = str(telemetry.get("verification_status") or "")
if explicit in EXECUTION_VERIFICATION_STATUSES:
return explicit
if item.get("status") != "succeeded":
return "failed_or_blocked"
if (
telemetry.get("physical_effect_confirmed") is True
or telemetry.get("feedback_frame") is not None
or telemetry.get("hardware_feedback") is True
or telemetry.get("firmware_verified") is True
):
return "verified_success"
if (
telemetry.get("command_frame") is not None
or telemetry.get("delivery_status") in {"submitted", "queued", "published"}
):
return "submitted_unverified"
# Dry-run and control-plane operations are complete at their own evidence
# level and do not claim that a physical actuator moved.
return "verified_success"
def _annotate_execution_report(report: dict[str, Any]) -> tuple[str, bool]:
"""Add task-level evidence and normalize submission success.
Returns ``(verification_status, partially_succeeded)``. Support steps such
as firmware identity checks do not turn a failed requested operation into a
partially successful user task.
"""
spec = report.setdefault("spec", {})
results = [item for item in spec.get("results", []) if isinstance(item, dict)]
operation_results = [
item
for item in results
if item.get("ref") not in {"seat-controller-firmware"}
] or results
statuses_by_ref: dict[str, list[str]] = {}
for item in operation_results:
statuses_by_ref.setdefault(str(item.get("ref") or "operation"), []).append(
_operation_verification_status(item)
)
statuses = [
(
"failed_or_blocked"
if "failed_or_blocked" in values
else "submitted_unverified"
if "submitted_unverified" in values
else "verified_success"
)
for values in statuses_by_ref.values()
]
failed = sum(status == "failed_or_blocked" for status in statuses)
accepted = len(statuses) - failed
partially_succeeded = bool(failed and accepted)
verification_status = (
"failed_or_blocked"
if failed
else "submitted_unverified"
if any(status == "submitted_unverified" for status in statuses)
else "verified_success"
)
spec["verification_status"] = verification_status
spec["status"] = (
"partially_succeeded"
if partially_succeeded
else "failed"
if failed
else "succeeded"
)
return verification_status, partially_succeeded
def _has_mixed_driver_passenger_seat_scope(utterance: str) -> bool:
"""Detect one request that crosses the CH1-driver and CH0-passenger boundary."""
@@ -358,6 +446,17 @@ class SeatDemoService:
# 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
elif self.vehicle_can_settings.state_configured:
vehicle_can_info["state_sync"] = {
"mode": "on_demand_shared_adapter",
"state": "ready",
"read_only": True,
"poll_interval_ms": 5_000,
"snapshot_freshness_ms": 5_000,
"capture_sequence": None,
"observed_frame_ids": [],
"last_error": None,
}
vehicle_mqtt_info = self._vehicle_mqtt_transport.public_info()
return {
"service": "cabin-cognitive-orchestrator",
@@ -383,6 +482,16 @@ class SeatDemoService:
"id": WIRED_CAN_TRANSPORT,
"label": "有线控制",
**vehicle_can_info,
"target_policy": (
"dual_channel_wired_can0_passenger_can1_vehicle"
if self.hardware_settings.configured
and self.vehicle_can_settings.configured
else vehicle_can_info.get("target_policy")
),
"channels": {
"seat": 0,
"vehicle": self.vehicle_can_settings.channel,
},
},
{
"id": WIRELESS_MQTT_TRANSPORT,
@@ -411,7 +520,7 @@ class SeatDemoService:
and self.vehicle_can_settings.enabled
),
"active_policy": (
"dual_channel_passenger_ch0_driver_ch1"
"dual_channel_wired_can0_passenger_can1_vehicle"
if self.hardware_settings.configured
and self.vehicle_can_settings.configured
else "ch1_body_can_driver_only"
@@ -759,6 +868,7 @@ class SeatDemoService:
)
control_transport = self._validate_control_transport(control_transport)
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
wired_state_bridge = None
if mqtt_selected:
if not self.vehicle_mqtt_settings.state_configured:
raise HardwareSafetyError("the MQTT CAN0 seat relay is not configured")
@@ -768,13 +878,40 @@ class SeatDemoService:
)
else:
if not self.hardware_settings.configured:
raise HardwareSafetyError("the T2G seat hardware runtime is not configured")
bus_factory = None
raise HardwareSafetyError(
"the passenger-seat CAN0 hardware runtime is not configured"
)
if (
self.hardware_settings.native_passenger_can0
and self.vehicle_can_settings.adapter_configured
):
wired_state_bridge = self.vehicle_can_settings.bridge(
[
ZlgChannelConfig(
channel=0,
frame_format="classic_can",
arbitration_bitrate=self.hardware_settings.bitrate,
data_bitrate=self.vehicle_can_settings.data_bitrate,
can_fd_iso=True,
termination_enabled=(
self.hardware_settings.termination_enabled
),
)
],
monitor_only=True,
)
bus_factory = lambda: wired_state_bridge.channel_bus(0)
else:
bus_factory = None
vehicle_profile = self.vehicle_profiles.get(vehicle_id)
physical_state = self._new_hardware_executor(
bus_factory,
remote_transport=mqtt_selected,
).read_physical_state()
try:
physical_state = self._new_hardware_executor(
bus_factory,
remote_transport=mqtt_selected,
).read_physical_state()
finally:
if wired_state_bridge is not None:
wired_state_bridge.close()
if mqtt_selected:
physical_state["source"] = "wireless_mqtt_ch0"
context = self.conversations.synchronize_physical_passenger(
@@ -1006,12 +1143,16 @@ class SeatDemoService:
bus_factory: Any | None = None,
*,
remote_transport: bool = False,
) -> HardwareSeatExecutor | MqttOtaSeatExecutor | ZcanBridgeSeatExecutor:
) -> HardwareSeatExecutor | MqttNativeSeatExecutor | ZcanBridgeSeatExecutor:
shared_bridge_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 bus_factory is None and shared_bridge_enabled:
if (
bus_factory is None
and shared_bridge_enabled
and self.hardware_settings.backend != "zlgcan-linux"
):
# Import lazily so the ordinary dry-run service does not initialize
# any self-repair runtime state. Both modules then share the one
# process that is allowed to own the Windows ZLG adapter.
@@ -1054,22 +1195,72 @@ class SeatDemoService:
stop_all_motor_selector=int(
self.hardware_compiler.protocol["stop_all_motor_selector"]
),
auto_home=self.hardware_settings.auto_home_enabled,
auto_home=(
self.hardware_settings.auto_home_enabled
if not remote_transport
else False
),
home_timeout_s=self.hardware_settings.home_timeout_s,
firmware_guard=self.hardware_settings.firmware_guard(
resolved_bus_factory,
allow_programming=not remote_transport,
firmware_guard=(
None
if remote_transport
or self.hardware_settings.native_passenger_can0
else self.hardware_settings.firmware_guard(
resolved_bus_factory,
allow_programming=not remote_transport,
)
),
synchronize_position_before_first_motion=(
self.hardware_settings.position_sync_enabled
if not remote_transport
else False
),
position_sync_state=self._seat_position_memory_state,
require_thermal_feedback=not remote_transport,
feedback_required=not remote_transport,
)
if remote_transport:
return MqttOtaSeatExecutor(executor, resolved_bus_factory)
return MqttNativeSeatExecutor(executor, resolved_bus_factory)
return executor
def _new_linux_wired_dual_channel_bridge(self) -> ZlgCanBridgeProcess:
"""Open CAN0 passenger classic CAN and CAN1 body CAN FD under one owner."""
if not self.hardware_settings.native_passenger_can0:
raise HardwareSafetyError(
"the shared Linux dual-channel bridge requires native passenger CAN0"
)
if not self.vehicle_can_settings.configured:
raise HardwareSafetyError(
"the shared Linux dual-channel bridge requires configured vehicle CAN1"
)
if (
self.hardware_settings.device_type
!= self.vehicle_can_settings.device_type
or self.hardware_settings.device_index
!= self.vehicle_can_settings.device_index
or self.hardware_settings.zlg_dll.resolve()
!= self.vehicle_can_settings.zlg_dll.resolve()
):
raise HardwareSafetyError(
"CAN0 and CAN1 must use the same Linux ZLG device and SDK library"
)
return self.vehicle_can_settings.bridge(
[
ZlgChannelConfig(
channel=0,
frame_format="classic_can",
arbitration_bitrate=self.hardware_settings.bitrate,
data_bitrate=self.vehicle_can_settings.data_bitrate,
can_fd_iso=True,
termination_enabled=(
self.hardware_settings.termination_enabled
),
),
self.vehicle_can_settings.channel_config(),
]
)
def preview_native_can(
self,
utterance: str,
@@ -1867,10 +2058,11 @@ class SeatDemoService:
if parsed.get("actions") and parsed.get("native_can_actions"):
if (
control_transport == WIRED_CAN_TRANSPORT
and self.hardware_settings.backend != "zlgcan"
and self.hardware_settings.backend
not in {"zlgcan", "zlgcan-linux"}
):
raise HardwareSafetyError(
"linked seat/vehicle execution requires the shared zlgcan backend"
"linked seat/vehicle execution requires a shared ZLG backend"
)
if control_transport == WIRED_CAN_TRANSPORT:
try:
@@ -2282,7 +2474,7 @@ class SeatDemoService:
"safety_check",
"succeeded",
(
"无线 MQTT CAN0 座椅语义白名单与 seat-ota 报文白名单已通过;"
"无线 MQTT CAN0 座椅语义白名单与固件原生 0x300 报文白名单已通过;"
"本地 T2G 固件 profile、USB-CAN 与物理台架连接不参与校验。"
if hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
@@ -2307,7 +2499,7 @@ class SeatDemoService:
simulation_fault if simulated_hardware_request else None
),
"position_calibration_gate": (
"remote_seat_ota_feedback"
"remote_native_can0_feedback"
if hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
else "runtime_can_preflight"
@@ -2315,7 +2507,7 @@ class SeatDemoService:
else "not_applicable"
),
"mapping_status": (
"mqtt_can0_seat_ota_whitelist"
"mqtt_can0_native_firmware_whitelist"
if hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
else "protocol_hil_verified_combined_firmware_hil_pending"
@@ -2335,17 +2527,47 @@ class SeatDemoService:
if hardware_request:
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
vehicle_safety_preflight = None
if mqtt_selected:
vehicle_safety_preflight = _wireless_mqtt_publish_preflight()
hardware_executor = self._new_hardware_executor(
lambda: self._vehicle_mqtt_transport.bus(channel=0),
remote_transport=True,
wired_bridge = None
try:
if mqtt_selected:
vehicle_safety_preflight = _wireless_mqtt_publish_preflight()
hardware_executor = self._new_hardware_executor(
lambda: self._vehicle_mqtt_transport.bus(channel=0),
remote_transport=True,
)
elif (
self.hardware_settings.native_passenger_can0
and self.vehicle_can_settings.configured
):
wired_bridge = self._new_linux_wired_dual_channel_bridge()
vehicle_safety_preflight = observe_vehicle_safety(
wired_bridge.channel_bus(1),
timeout_ms=min(
5_000,
max(
1_000,
self.vehicle_can_settings.feedback_timeout_ms,
),
),
required_channel=1,
)
require_stationary_vehicle(vehicle_safety_preflight)
hardware_executor = self._new_hardware_executor(
lambda: wired_bridge.channel_bus(0)
)
else:
hardware_executor = self._new_hardware_executor()
execution_report = hardware_executor.execute(
command_plan, function_config, context["devices"]
)
else:
hardware_executor = self._new_hardware_executor()
execution_report = hardware_executor.execute(
command_plan, function_config, context["devices"]
)
except VehicleSafetyPreflightError as exc:
raise HardwareSafetyError(
"CH1 vehicle safety preflight refused passenger CAN0 execution: "
f"{exc}"
) from exc
finally:
if wired_bridge is not None:
wired_bridge.close()
if vehicle_safety_preflight is not None:
for item in execution_report["spec"].get("results", []):
item.setdefault("telemetry", {})[
@@ -2362,14 +2584,21 @@ class SeatDemoService:
execution_report = self.executor.execute(
command_plan, function_config, context["devices"]
)
execution_outcome, execution_partially_succeeded = (
_annotate_execution_report(execution_report)
)
report_issues = self.contracts.validate(execution_report)
validation.extend(report_issues)
result["execution_report"] = execution_report
result["execution_outcome"] = execution_outcome
execution_succeeded = (
not report_issues and execution_report["spec"]["status"] == "succeeded"
not report_issues
and execution_report["spec"]["status"] == "succeeded"
)
if report_issues:
result["status"] = "execution_report_invalid"
elif execution_partially_succeeded:
result["status"] = "partially_succeeded"
elif not execution_succeeded:
result["status"] = "execution_failed"
elif parsed.get("deferred_requests"):
@@ -2406,7 +2635,11 @@ class SeatDemoService:
"execute",
"succeeded" if execution_succeeded else "failed",
(
"真实 CAN 命令已发送,并收到 IAR 固件的实际状态回读。"
"真实 CAN 命令已提交;当前反馈窗口未确认车辆效果,状态以实时 CAN 回读为准。"
if hardware_request
and execution_succeeded
and execution_outcome == "submitted_unverified"
else "真实 CAN 命令已发送,并收到 IAR 固件的实际状态回读。"
if hardware_request and execution_succeeded
else (
(
@@ -2608,12 +2841,18 @@ class SeatDemoService:
feedback_by_ref = {
item["ref"]: item.get("telemetry", {})
for item in execution_report["spec"]["results"]
if item.get("status") == "succeeded"
if _operation_verification_status(item) == "verified_success"
}
updated = deepcopy(actions)
updated: list[dict[str, Any]] = []
operations = function_config["spec"]["operations"]
for action, operation in zip(updated, operations, strict=True):
telemetry = feedback_by_ref.get(operation["id"], {})
for source_action, operation in zip(actions, operations, strict=True):
telemetry = feedback_by_ref.get(operation["id"])
if telemetry is None:
# Submission alone is not an authoritative vehicle-state
# update. The asynchronous CAN/MQTT state monitor will apply
# the change if and when the vehicle reports it.
continue
action = deepcopy(source_action)
if action.get("action_type") == "position":
if telemetry.get("effective_position_pct") is not None:
action["position_pct"] = int(
@@ -2633,12 +2872,14 @@ class SeatDemoService:
if telemetry.get("motion_mode") == "timed_user_request"
else "relative_hall_confirmed_open_loop"
)
updated.append(action)
continue
raw = telemetry.get("feedback_value")
if raw is not None:
raw_value = int(raw)
action["state"] = "off" if raw_value <= 1 else "on"
action["requested_level"] = max(0, raw_value - 1)
updated.append(action)
physical_state = execution_report.get("spec", {}).get("physical_state")
if isinstance(physical_state, dict):
@@ -4259,6 +4500,8 @@ class SeatDemoService:
simulation_fault=simulation_fault,
control_transport=control_transport,
)
for domain_report in reports.values():
_annotate_execution_report(domain_report)
report_results = [
item
for domain_report in reports.values()
@@ -4276,7 +4519,7 @@ class SeatDemoService:
domain
for domain in operation_domains
if all(
item.get("status") == "succeeded"
_operation_verification_status(item) != "failed_or_blocked"
for item in operation_results
if str(item.get("domain")) == domain
)
@@ -4284,13 +4527,29 @@ class SeatDemoService:
failed_domains = {
str(item.get("domain"))
for item in operation_results
if item.get("status") == "failed"
if _operation_verification_status(item) == "failed_or_blocked"
}
execution_succeeded = all(
domain_report["spec"]["status"] == "succeeded"
for domain_report in reports.values()
) and all(item.get("status") == "succeeded" for item in report_results)
execution_succeeded = not failed_domains
execution_partially_succeeded = bool(successful_domains and failed_domains)
execution_outcome = (
"failed_or_blocked"
if failed_domains
else "submitted_unverified"
if any(
_operation_verification_status(item) == "submitted_unverified"
for item in operation_results
)
else "verified_success"
)
verified_domains = {
domain
for domain in operation_domains
if all(
_operation_verification_status(item) == "verified_success"
for item in operation_results
if str(item.get("domain")) == domain
)
}
physical_state = (
reports.get("seat", {}).get("spec", {}).get("physical_state")
if hardware_request
@@ -4299,7 +4558,14 @@ class SeatDemoService:
simulated_seat = simulated_hardware_request and "seat" in reports
report_spec: dict[str, Any] = {
"mode": "hil" if hardware_request or simulated_seat else "dry_run",
"status": "succeeded" if execution_succeeded else "failed",
"status": (
"partially_succeeded"
if execution_partially_succeeded
else "succeeded"
if execution_succeeded
else "failed"
),
"verification_status": execution_outcome,
"started_at": execution_started_at,
"finished_at": _now(),
"results": report_results,
@@ -4352,6 +4618,7 @@ class SeatDemoService:
report_issues = self.contracts.validate(execution_report)
validation.extend(report_issues)
result["execution_report"] = execution_report
result["execution_outcome"] = execution_outcome
result["validation"] = validation
if report_issues:
result["status"] = "execution_report_invalid"
@@ -4376,6 +4643,11 @@ class SeatDemoService:
"无线 MQTT CAN0/CAN1 白名单控制帧已提交;"
"车辆实际状态以状态话题回读为准。"
if wireless_hardware_request and execution_succeeded
else "有线 CAN0/CAN1 白名单控制帧已提交;"
"当前反馈窗口未确认车辆效果,状态以实时 CAN 回读为准。"
if hardware_request
and execution_succeeded
and execution_outcome == "submitted_unverified"
else "CH1 车身 CAN 动作已按目标字段物理回读闭环执行。"
if hardware_request and native_config_keys and execution_succeeded
else "副驾座椅已按独立 T2G 物理 CAN 回读闭环执行。"
@@ -4418,6 +4690,7 @@ class SeatDemoService:
"successful_domains": sorted(successful_domains),
"failed_domains": sorted(failed_domains),
"partially_succeeded": execution_partially_succeeded,
"execution_outcome": execution_outcome,
},
domain="orchestration",
)
@@ -4439,8 +4712,17 @@ class SeatDemoService:
*[
action
for action in parsed.get("domain_actions", [])
if execution_succeeded
or str(action.get("domain")) in successful_domains
if (
not hardware_request
and (
execution_succeeded
or str(action.get("domain")) in successful_domains
)
)
or (
hardware_request
and str(action.get("domain")) in verified_domains
)
],
*[
{
@@ -4550,8 +4832,16 @@ class SeatDemoService:
vehicle_safety_preflight: dict[str, Any] | None = None
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
wired_native_passenger = bool(
not mqtt_selected
and "seat" in configs
and self.hardware_settings.native_passenger_can0
and self.vehicle_can_settings.configured
)
if hardware_request and (
native_config_keys or (mqtt_selected and "seat" in configs)
native_config_keys
or (mqtt_selected and "seat" in configs)
or wired_native_passenger
):
shared_runtime_enabled = (
not mqtt_selected
@@ -4584,6 +4874,10 @@ class SeatDemoService:
# on CH0; passing a raw bus factory here would bypass its one-
# owner queue and allow self-repair to race orchestration.
seat_bus_factory = None
elif wired_native_passenger:
bridge = self._new_linux_wired_dual_channel_bridge()
native_bus_factory = lambda: bridge.channel_bus(1)
seat_bus_factory = lambda: bridge.channel_bus(0)
else:
channel_configs = [self.vehicle_can_settings.channel_config()]
if "seat" in configs:
@@ -4779,11 +5073,22 @@ class SeatDemoService:
]
clauses: list[str] = []
for fragment in fragments:
# A massage mode/strength phrase may omit both "主驾" and "按摩"
# after the first clause. Keep the whole request atomic so the ON
# fallback does not emit relax before an explicit later mode.
massage_continuation = bool(
clauses
and "按摩" in clauses[-1]
and DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN.search(fragment)
)
# An exclusion is a qualifier of the control immediately before it,
# not an independent command. Keeping both pieces together lets
# the deterministic window parser remove the excluded targets
# before the command list is finalized.
if clauses and re.search(r"除外|除去|不包括|不含", fragment):
exclusion_qualifier = bool(
clauses and re.search(r"除外|除去|不包括|不含", fragment)
)
if massage_continuation or exclusion_qualifier:
clauses[-1] = f"{clauses[-1]},{fragment}"
else:
clauses.append(fragment)
@@ -0,0 +1 @@
"""Generated protocol modules. Regenerate them from their checked-in sources."""
@@ -0,0 +1,864 @@
# Generated by tools/generate_can_semantic_protocol.py; do not edit.
"""Generated Python view of the shared CAN semantic protocol."""
from __future__ import annotations
from typing import Any, Iterable
PROTOCOL_SOURCE_SHA256 = "cfb7a7eb28d14a82da70d44efece7f045a7a87a4cd549d7537dc00438c297e6e"
PROTOCOL_MANIFEST: dict[str, Any] = {'schema': 'cognitive-agent-can-semantic-protocol/v1',
'version': '1.0.0',
'description': 'Single source of truth for CAN0/CAN1 semantic commands and transport metadata. '
'Frame encoders and feedback algorithms remain named special handlers when they '
'require stateful closed-loop behavior.',
'channels': {'0': {'name': 'passenger_seat_can', 'frame_format': 'classic_can', 'bitrate': 500000},
'1': {'name': 'vehicle_body_can',
'frame_format': 'mixed',
'arbitration_bitrate': 500000,
'data_bitrate': 2000000}},
'rules': [{'id': 'can0.seat.position',
'channel': 0,
'domain': 'seat',
'capabilities': ['seat.set_longitudinal_position',
'seat.set_cushion_tilt',
'seat.set_backrest_recline',
'seat.set_legrest_height'],
'help': 'seat passenger <horizontal|basin|backrest|legrest> position <0-100>',
'patterns': [[{'literal': 'seat'},
{'literal': 'passenger'},
{'enum': 'axis',
'values': ['horizontal',
'longitudinal',
'basin',
'height',
'backrest',
'legrest']},
{'literal': 'position'},
{'integer': 'position_pct', 'minimum': 0, 'maximum': 100}]],
'frames': [{'key': 'move',
'frame_id': '0x300',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'can0_motion_fields',
'base_payload_hex': '00 10 00 00 00 00 00 00',
'fields': {'opcode': {'start_bit': 0,
'bit_length': 8,
'absolute': 1,
'home_zero': 11},
'motor': {'start_bit': 8,
'bit_length': 2,
'values': {'horizontal': 0,
'longitudinal': 0,
'basin': 1,
'height': 1,
'backrest': 2,
'legrest': 3}},
'protocol_version': {'start_bit': 12,
'bit_length': 2,
'constant': 1},
'sequence': {'start_bit': 16,
'bit_length': 16,
'runtime_allocated': True},
'param_a': {'start_bit': 32,
'bit_length': 16,
'scale': 100,
'home_key': 19282},
'param_b': {'start_bit': 48,
'bit_length': 16,
'absolute_timeout_ms': 30000,
'home_timeout_s': 120}},
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 0},
'feedback_ids': ['0x301', '0x302'],
'feedback_policy': 'ack_then_motor_terminal'}],
'special_handler': 'can0_absolute_position',
'evidence': 'passenger_firmware_hil_verified'},
{'id': 'can0.seat.relative',
'channel': 0,
'domain': 'seat',
'capabilities': ['seat.set_longitudinal_position',
'seat.set_cushion_tilt',
'seat.set_backrest_recline',
'seat.set_legrest_height'],
'help': 'seat passenger <axis> relative <direction> <1-100>',
'patterns': [[{'literal': 'seat'},
{'literal': 'passenger'},
{'enum': 'axis', 'values': ['horizontal', 'longitudinal']},
{'literal': 'relative'},
{'enum': 'direction', 'values': ['forward', 'backward']},
{'integer': 'delta_pct', 'minimum': 1, 'maximum': 100}],
[{'literal': 'seat'},
{'literal': 'passenger'},
{'enum': 'axis', 'values': ['basin', 'height']},
{'literal': 'relative'},
{'enum': 'direction', 'values': ['up', 'down']},
{'integer': 'delta_pct', 'minimum': 1, 'maximum': 100}],
[{'literal': 'seat'},
{'literal': 'passenger'},
{'literal': 'backrest'},
{'literal': 'relative'},
{'enum': 'direction', 'values': ['forward', 'backward']},
{'integer': 'delta_pct', 'minimum': 1, 'maximum': 100}],
[{'literal': 'seat'},
{'literal': 'passenger'},
{'literal': 'legrest'},
{'literal': 'relative'},
{'enum': 'direction', 'values': ['up', 'down']},
{'integer': 'delta_pct', 'minimum': 1, 'maximum': 100}]],
'frames': [{'key': 'move',
'frame_id': '0x300',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'can0_relative_closed_loop_or_timed_fallback',
'base_payload_hex': '03 10 00 00 00 00 00 00',
'fields': {'closed_loop_opcode': 1,
'timed_fallback_opcode': 3,
'timed_fallback_max_delta_pct': 15,
'direction_values': {'increase': 1, 'decrease': 2}},
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 0},
'feedback_ids': ['0x301', '0x302'],
'feedback_policy': 'fresh_position_then_ack_then_motor_terminal'}],
'special_handler': 'can0_relative_position',
'evidence': 'passenger_firmware_hil_verified'},
{'id': 'can0.seat.thermal',
'channel': 0,
'domain': 'seat',
'capabilities': ['seat.set_ventilation', 'seat.set_heating'],
'help': 'seat passenger <vent|heat> <off|low|medium|high>',
'patterns': [[{'literal': 'seat'},
{'literal': 'passenger'},
{'enum': 'feature', 'values': ['vent', 'heat']},
{'enum': 'level', 'values': ['off', 'low', 'medium', 'high']}]],
'frames': [{'key': 'thermal_mode',
'frame_id': '0x3CE',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'can0_heating_mode_optional',
'base_payload_hex': '10 00 00 00 00 00 00 00',
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 0},
'feedback_ids': ['0x4FA'],
'optional_when': 'heating_on'},
{'key': 'thermal_control',
'frame_id': '0x29E',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'can0_thermal_bit_fields',
'base_payload_hex': '00 00 00 00 00 00 00 00',
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 0},
'feedback_ids': ['0x4FA'],
'feedback_policy': 'thermal_state_match'}],
'special_handler': 'can0_thermal_preserve_peer_feature',
'evidence': 'passenger_firmware_hil_verified'},
{'id': 'can0.seat.dynamic',
'channel': 0,
'domain': 'seat',
'capabilities': ['seat.start_dynamic_mode', 'seat.stop_dynamic_mode'],
'help': 'seat passenger dynamic <rocking-horse|cradle|stop>',
'patterns': [[{'literal': 'seat'},
{'literal': 'passenger'},
{'literal': 'dynamic'},
{'enum': 'mode', 'values': ['rocking-horse', 'cradle', 'stop']}],
[{'literal': 'seat'},
{'literal': 'passenger'},
{'literal': 'dynamic'},
{'literal': 'start'},
{'enum': 'mode', 'values': ['rocking-horse', 'cradle']}]],
'frames': [{'key': 'dynamic',
'frame_id': '0x300',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'can0_dynamic_fields',
'base_payload_hex': '0E 10 00 00 00 00 00 00',
'fields': {'start_opcode': 14,
'stop_opcode': 15,
'mode_values': {'rocking-horse': 1, 'cradle': 2, 'stop': 0}},
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 0},
'feedback_ids': ['0x301', '0x306'],
'feedback_policy': 'ack_then_dynamic_state'}],
'special_handler': 'can0_dynamic_mode',
'evidence': 'passenger_firmware_hil_verified'},
{'id': 'can1.door.rear_lock',
'channel': 1,
'domain': 'door',
'capabilities': ['door.set_rear_lock'],
'help': 'door rear <lock|unlock>',
'patterns': [[{'literal': 'door'},
{'literal': 'rear'},
{'enum': 'state', 'values': ['lock', 'unlock']}]],
'frames': [{'key': 'command',
'frame_id': '0x32B',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'bit_fields',
'base_payload_hex': '0A 00 00 00 00 00 00 00',
'fields': {'state': {'start_bit': 14,
'bit_length': 2,
'values': {'lock': 1, 'unlock': 2}}},
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 0},
'feedback_ids': ['0x407'],
'feedback_policy': 'rear_lock_state_match'}],
'evidence': 'matrix_candidate_with_feedback'},
{'id': 'can1.window.motion',
'channel': 1,
'domain': 'window',
'capabilities': ['window.set_motion'],
'help': 'window <all|front|rear|front-left|front-right|rear-left|rear-right> '
'<open|close|stop>',
'patterns': [[{'literal': 'window'},
{'enum': 'target',
'values': ['all',
'front',
'rear',
'front-left',
'front-right',
'rear-left',
'rear-right']},
{'enum': 'motion', 'values': ['open', 'close', 'stop']}]],
'frames': [{'key': 'command',
'frame_id': '0x431',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'window_motion_bit_fields',
'base_payload_hex': '00 00 00 00 00 00 00 00',
'fields': {'starts': [48, 56, 51, 59],
'motion_values': {'open': 1, 'close': 2, 'stop': 3}},
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x388'],
'feedback_policy': 'window_state_match'}],
'evidence': 'vehicle_trace_verified'},
{'id': 'can1.window.position',
'channel': 1,
'domain': 'window',
'capabilities': ['window.set_position'],
'help': 'window <front-left|front-right|rear-left|rear-right> position <0-100>',
'patterns': [[{'literal': 'window'},
{'enum': 'target',
'values': ['front-left', 'front-right', 'rear-left', 'rear-right']},
{'literal': 'position'},
{'integer': 'position_pct', 'minimum': 0, 'maximum': 100}]],
'frames': [{'key': 'command',
'frame_id': '0x431',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'window_feedback_closed_loop',
'base_payload_hex': '00 00 00 00 00 00 00 00',
'repeat': {'kind': 'special', 'count': 1, 'period_ms': 0},
'feedback_ids': ['0x388'],
'feedback_policy': 'fresh_position_then_motion_then_stop'}],
'special_handler': 'window_position'},
{'id': 'can1.climate.power',
'channel': 1,
'domain': 'climate',
'capabilities': ['climate.set_power', 'climate.set_ac'],
'help': 'ac <on|off>',
'patterns': [[{'literal': 'ac'}, {'enum': 'state', 'values': ['on', 'off']}]],
'frames': [{'key': 'command',
'frame_id': '0x1DE',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'bit_fields',
'base_payload_hex': '00 00 00 00 00 00 00 00',
'fields': {'state': {'start_bit': 28,
'bit_length': 2,
'values': {'on': 1, 'off': 2}}},
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x404'],
'feedback_policy': 'climate_power_match'}],
'evidence': 'matrix_and_vehicle_trace_verified'},
{'id': 'can1.climate.fan',
'channel': 1,
'domain': 'climate',
'capabilities': ['climate.set_fan_level'],
'help': 'ac fan <1-7>',
'patterns': [[{'literal': 'ac'},
{'literal': 'fan'},
{'integer': 'level', 'minimum': 1, 'maximum': 7}]],
'frames': [{'key': 'command',
'frame_id': '0x1DE',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'bit_fields',
'base_payload_hex': '00 00 00 00 00 00 00 00',
'fields': {'level': {'start_bit': 4, 'bit_length': 4}},
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x404'],
'feedback_policy': 'climate_fan_match'}],
'evidence': 'matrix_and_vehicle_trace_verified'},
{'id': 'can1.climate.mode',
'channel': 1,
'domain': 'climate',
'capabilities': ['climate.set_outlet_mode'],
'help': 'ac mode '
'<face|face-feet|feet|feet-defrost|defrost|face-feet-defrost|face-defrost>',
'patterns': [[{'literal': 'ac'},
{'literal': 'mode'},
{'enum': 'mode',
'values': ['face',
'face-feet',
'feet',
'feet-defrost',
'defrost',
'face-feet-defrost',
'face-defrost']}]],
'frames': [{'key': 'command',
'frame_id': '0x1DE',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'bit_fields',
'base_payload_hex': '00 00 00 00 00 00 00 00',
'fields': {'mode': {'start_bit': 0,
'bit_length': 4,
'values': {'face': 1,
'face-feet': 2,
'feet': 3,
'feet-defrost': 4,
'defrost': 5,
'face-feet-defrost': 6,
'face-defrost': 7}}},
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x404'],
'feedback_policy': 'climate_mode_match'}],
'evidence': 'matrix_and_vehicle_trace_verified'},
{'id': 'can1.climate.temperature',
'channel': 1,
'domain': 'climate',
'capabilities': ['climate.set_temperature'],
'help': 'ac temp <driver|passenger|rear> <18-32>',
'patterns': [[{'literal': 'ac'},
{'literal': 'temp'},
{'enum': 'target', 'values': ['driver', 'passenger', 'rear']},
{'integer': 'temperature_c', 'minimum': 18, 'maximum': 32}]],
'frames': [{'key': 'front',
'frame_id': '0x1DE',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'climate_temperature',
'base_payload_hex': '00 00 00 00 00 00 00 00',
'fields': {'starts': {'driver': 32, 'passenger': 40}},
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x404'],
'feedback_policy': 'climate_temperature_match'},
{'key': 'rear',
'frame_id': '0x1DE',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'climate_temperature',
'base_payload_hex': '00 00 00 00 00 00 00 00',
'fields': {'starts': {'rear': 48}},
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 100},
'feedback_ids': ['0x404'],
'feedback_policy': 'climate_temperature_match'}],
'evidence': 'matrix_and_vehicle_trace_verified'},
{'id': 'can1.climate.control',
'channel': 1,
'domain': 'climate',
'capabilities': ['climate.set_control_mode'],
'help': 'ac control <manual|auto>',
'patterns': [[{'literal': 'ac'},
{'literal': 'control'},
{'enum': 'mode', 'values': ['manual', 'auto']}]],
'frames': [{'key': 'command',
'frame_id': '0x1DE',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'bit_fields',
'base_payload_hex': '00 00 00 00 00 00 00 00',
'fields': {'mode': {'start_bit': 16,
'bit_length': 2,
'values': {'manual': 1, 'auto': 2}}},
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x404'],
'feedback_policy': 'climate_control_match'}],
'evidence': 'matrix_and_vehicle_trace_verified'},
{'id': 'can1.climate.cycle',
'channel': 1,
'domain': 'climate',
'capabilities': ['climate.set_cycle_mode'],
'help': 'ac cycle <internal|external>',
'patterns': [[{'literal': 'ac'},
{'literal': 'cycle'},
{'enum': 'mode', 'values': ['internal', 'external']}]],
'frames': [{'key': 'command',
'frame_id': '0x1DE',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'bit_fields',
'base_payload_hex': '00 00 00 00 00 00 00 00',
'fields': {'mode': {'start_bit': 19,
'bit_length': 2,
'values': {'internal': 1, 'external': 2}}},
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x404'],
'feedback_policy': 'climate_cycle_match'}],
'evidence': 'matrix_and_vehicle_trace_verified'},
{'id': 'can1.outlet.switch',
'channel': 1,
'domain': 'outlet',
'capabilities': ['outlet.set_switch_state'],
'help': 'outlet <driver-left|driver-right|passenger-left|passenger-right> <open|close>',
'patterns': [[{'literal': 'outlet'},
{'enum': 'target',
'values': ['driver-left',
'driver-right',
'passenger-left',
'passenger-right']},
{'enum': 'state', 'values': ['open', 'close']}]],
'frames': [{'key': 'command',
'frame_id': '0x4EF',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'static_variant',
'base_payload_hex': '00 00 00 00 00 00 00 00',
'payload_variants': {'driver-left:open': '02 FF FF FF FF 01 FF FF',
'driver-left:close': '02 FF FF FF FF 02 FF FF',
'driver-right:open': '02 FF FF FF FF 04 FF FF',
'driver-right:close': '02 FF FF FF FF 08 FF FF',
'passenger-left:open': '03 FF FF FF FF 01 00 00',
'passenger-left:close': '03 FF FF FF FF 02 00 00',
'passenger-right:open': '03 FF FF FF FF 04 00 00',
'passenger-right:close': '03 FF FF FF FF 08 00 00'},
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x4BB', '0x37B'],
'feedback_policy': 'outlet_switch_state_match'}],
'evidence': 'vehicle_differential_trace_verified'},
{'id': 'can1.outlet.percent',
'channel': 1,
'domain': 'outlet',
'capabilities': ['outlet.set_position_percent'],
'help': 'outlet <target> percent <0-100> <0-100>',
'patterns': [[{'literal': 'outlet'},
{'enum': 'target',
'values': ['driver-left',
'driver-right',
'passenger-left',
'passenger-right']},
{'literal': 'percent'},
{'integer': 'horizontal_pct', 'minimum': 0, 'maximum': 100},
{'integer': 'vertical_pct', 'minimum': 0, 'maximum': 100}]],
'frames': [{'key': 'command',
'frame_id': '0x4EF',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'outlet_calibrated_coordinates',
'base_payload_hex': '00 FF FF FF FF 00 00 00',
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x2F5', '0x37B'],
'feedback_policy': 'outlet_position_match'}],
'special_handler': 'outlet_percent_to_calibrated_position'},
{'id': 'can1.outlet.position',
'channel': 1,
'domain': 'outlet',
'capabilities': ['outlet.set_calibrated_position'],
'help': 'outlet <target> position <-128..126> <-128..126>',
'patterns': [[{'literal': 'outlet'},
{'enum': 'target',
'values': ['driver-left',
'driver-right',
'passenger-left',
'passenger-right']},
{'literal': 'position'},
{'integer': 'horizontal_value', 'minimum': -128, 'maximum': 126},
{'integer': 'vertical_value', 'minimum': -128, 'maximum': 126}]],
'frames': [{'key': 'command',
'frame_id': '0x4EF',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'outlet_calibrated_coordinates',
'base_payload_hex': '00 FF FF FF FF 00 00 00',
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x2F5', '0x37B'],
'feedback_policy': 'outlet_position_match'}],
'special_handler': 'outlet_calibrated_position'},
{'id': 'can1.seat.driver_thermal',
'channel': 1,
'domain': 'seat',
'capabilities': ['seat.set_driver_ventilation_native',
'seat.set_driver_heating_native'],
'help': 'seat driver <vent|heat> <off|low|high>',
'patterns': [[{'literal': 'seat'},
{'literal': 'driver'},
{'enum': 'feature', 'values': ['vent', 'heat']},
{'enum': 'level', 'values': ['off', 'low', 'high']}]],
'frames': [{'key': 'command',
'frame_id': '0x431',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'driver_seat_thermal',
'base_payload_hex': '00 00 00 00 00 00 00 00',
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x4FA'],
'feedback_policy': 'driver_seat_thermal_match'}],
'evidence': 'vehicle_trace_verified'},
{'id': 'can1.seat.driver_massage',
'channel': 1,
'domain': 'seat',
'capabilities': ['seat.set_driver_massage_native'],
'help': 'seat driver massage <off|strength weak|medium|strong|mode '
'wave|pulse|stretch|slack|relax>',
'patterns': [[{'literal': 'seat'},
{'literal': 'driver'},
{'literal': 'massage'},
{'literal': 'off'}],
[{'literal': 'seat'},
{'literal': 'driver'},
{'literal': 'massage'},
{'literal': 'strength'},
{'enum': 'strength', 'values': ['weak', 'medium', 'strong']}],
[{'literal': 'seat'},
{'literal': 'driver'},
{'literal': 'massage'},
{'literal': 'mode'},
{'enum': 'mode',
'values': ['wave', 'pulse', 'stretch', 'slack', 'relax']}]],
'frames': [{'key': 'command',
'frame_id': '0x4C1',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'driver_seat_massage',
'base_payload_hex': '12 00 00 00 00 00 00 00',
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x4FA'],
'feedback_policy': 'driver_seat_massage_match'}],
'evidence': 'vehicle_trace_verified'},
{'id': 'can1.seat.driver_axis',
'channel': 1,
'domain': 'seat',
'capabilities': ['seat.set_driver_axis_native'],
'help': 'seat driver <horizontal|height|backrest|basin> <direction|stop> [100-2000]',
'patterns': [[{'literal': 'seat'},
{'literal': 'driver'},
{'enum': 'axis', 'values': ['horizontal', 'backrest']},
{'enum': 'direction', 'values': ['forward', 'backward']}],
[{'literal': 'seat'},
{'literal': 'driver'},
{'enum': 'axis', 'values': ['horizontal', 'backrest']},
{'enum': 'direction', 'values': ['forward', 'backward']},
{'integer': 'duration_ms', 'minimum': 100, 'maximum': 2000}],
[{'literal': 'seat'},
{'literal': 'driver'},
{'enum': 'axis', 'values': ['height', 'basin']},
{'enum': 'direction', 'values': ['up', 'down']}],
[{'literal': 'seat'},
{'literal': 'driver'},
{'enum': 'axis', 'values': ['height', 'basin']},
{'enum': 'direction', 'values': ['up', 'down']},
{'integer': 'duration_ms', 'minimum': 100, 'maximum': 2000}],
[{'literal': 'seat'},
{'literal': 'driver'},
{'enum': 'axis', 'values': ['horizontal', 'height', 'backrest', 'basin']},
{'literal': 'stop'}]],
'frames': [{'key': 'motion',
'frame_id': '0x4C1',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'driver_seat_axis',
'base_payload_hex': '05 00 00 00 00 00 00 00',
'repeat': {'kind': 'duration_ceiling',
'count': 0,
'period_ms': 100,
'default_duration_ms': 500},
'feedback_ids': ['0x494'],
'feedback_policy': 'activity_then_forced_stop'},
{'key': 'stop',
'frame_id': '0x4C1',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'static',
'base_payload_hex': '05 00 00 00 00 00 00 00',
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x494'],
'feedback_policy': 'submitted_stop'}],
'special_handler': 'driver_seat_axis'},
{'id': 'can1.seat.driver_memory',
'channel': 1,
'domain': 'seat',
'capabilities': ['seat.set_driver_position_native', 'seat.recall_driver_memory_native'],
'help': 'seat driver position <1-3> | seat driver memory recall <1-3>',
'patterns': [[{'literal': 'seat'},
{'literal': 'driver'},
{'literal': 'position'},
{'integer': 'slot', 'minimum': 1, 'maximum': 3}],
[{'literal': 'seat'},
{'literal': 'driver'},
{'literal': 'memory'},
{'literal': 'recall'},
{'integer': 'slot', 'minimum': 1, 'maximum': 3}]],
'frames': [{'key': 'sequence',
'frame_id': '0x4C1',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'learned_driver_memory_pair',
'base_payload_hex': '16 00 00 00 00 00 00 00',
'repeat': {'kind': 'sequence', 'count': 20, 'period_ms': 50},
'feedback_ids': ['0x494'],
'feedback_policy': 'fresh_initialized_status_then_post_status'}],
'special_handler': 'driver_seat_memory_recall'},
{'id': 'can1.light.indoor',
'channel': 1,
'domain': 'light',
'capabilities': ['interior_light.set_indoor_power'],
'help': 'light indoor <on|off>',
'patterns': [[{'literal': 'light'},
{'literal': 'indoor'},
{'enum': 'state', 'values': ['on', 'off']}]],
'frames': [{'key': 'command',
'frame_id': '0x4F5',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'reading_light_all',
'base_payload_hex': '00 00 03 FF FF 00 00 00',
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x42E'],
'feedback_policy': 'indoor_light_match'}],
'evidence': 'vehicle_trace_verified'},
{'id': 'can1.light.reading_all',
'channel': 1,
'domain': 'light',
'capabilities': ['interior_light.set_reading_power'],
'help': 'light reading all <on|off>',
'patterns': [[{'literal': 'light'},
{'literal': 'reading'},
{'literal': 'all'},
{'enum': 'state', 'values': ['on', 'off']}]],
'frames': [{'key': 'command',
'frame_id': '0x4F5',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'reading_light_all',
'base_payload_hex': '00 00 03 FF FF 00 00 00',
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x42E'],
'feedback_policy': 'indoor_light_match'}],
'evidence': 'vehicle_trace_verified'},
{'id': 'can1.light.ambient_power',
'channel': 1,
'domain': 'light',
'capabilities': ['ambient_light.set_power'],
'help': 'light ambient <on|off>',
'patterns': [[{'literal': 'light'},
{'literal': 'ambient'},
{'enum': 'state', 'values': ['on', 'off']}]],
'frames': [{'key': 'command',
'frame_id': '0x3FC',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'static_variant',
'base_payload_hex': '00 00 00 00 00 00 00 00',
'payload_variants': {'on': '00 36 00 75 00 80 00 00',
'off': '00 31 00 75 00 80 00 00'},
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x42E'],
'feedback_policy': 'ambient_power_match'}],
'evidence': 'vehicle_trace_verified'},
{'id': 'can1.light.ambient_color',
'channel': 1,
'domain': 'light',
'capabilities': ['ambient_light.set_color_index'],
'help': 'light ambient color <1-127>',
'patterns': [[{'literal': 'light'},
{'literal': 'ambient'},
{'literal': 'color'},
{'integer': 'color_index', 'minimum': 1, 'maximum': 127}]],
'frames': [{'key': 'command',
'frame_id': '0x3FC',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'ambient_color',
'base_payload_hex': '00 30 00 75 00 00 00 00',
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x42E'],
'feedback_policy': 'ambient_color_match'}],
'evidence': 'vehicle_trace_verified'},
{'id': 'can1.light.ambient_brightness',
'channel': 1,
'domain': 'light',
'capabilities': ['ambient_light.set_brightness_level'],
'help': 'light ambient brightness <1-5>',
'patterns': [[{'literal': 'light'},
{'literal': 'ambient'},
{'literal': 'brightness'},
{'integer': 'brightness_level', 'minimum': 1, 'maximum': 5}]],
'frames': [{'key': 'command',
'frame_id': '0x3FC',
'format': 'can_fd',
'brs': True,
'dlc': 8,
'template_kind': 'ambient_brightness',
'base_payload_hex': '00 30 00 75 00 00 00 00',
'repeat': {'kind': 'fixed', 'count': 3, 'period_ms': 100},
'feedback_ids': ['0x42E'],
'feedback_policy': 'ambient_brightness_match'}],
'evidence': 'vehicle_trace_verified'},
{'id': 'can1.shade.motion',
'channel': 1,
'domain': 'roof',
'capabilities': ['roof.set_shade_motion'],
'help': 'shade <open|close|half|stop>',
'patterns': [[{'literal': 'shade'},
{'enum': 'motion', 'values': ['open', 'close', 'half', 'stop']}]],
'frames': [{'key': 'command',
'frame_id': '0x431',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'static_variant',
'base_payload_hex': '00 00 00 00 00 00 00 00',
'payload_variants': {'open': '10 00 00 00 00 00 00 00',
'close': '20 00 00 00 00 00 00 00',
'half': '30 00 00 00 00 00 00 00',
'stop': '40 00 00 00 00 00 00 00'},
'repeat': {'kind': 'fixed', 'count': 1, 'period_ms': 0},
'feedback_ids': ['0x41A'],
'feedback_policy': 'shade_position_or_stop_submission'}],
'special_handler': 'shade_preset'},
{'id': 'can1.shade.position',
'channel': 1,
'domain': 'roof',
'capabilities': ['roof.set_shade_position'],
'help': 'shade position <0-100>',
'patterns': [[{'literal': 'shade'},
{'literal': 'position'},
{'integer': 'position_pct', 'minimum': 0, 'maximum': 100}]],
'frames': [{'key': 'command',
'frame_id': '0x431',
'format': 'classic_can',
'brs': False,
'dlc': 8,
'template_kind': 'shade_feedback_closed_loop',
'base_payload_hex': '40 00 00 00 00 00 00 00',
'repeat': {'kind': 'special', 'count': 1, 'period_ms': 0},
'feedback_ids': ['0x41A'],
'feedback_policy': 'fresh_position_then_motion_then_stop'}],
'special_handler': 'shade_position'}]}
PROTOCOL_VERSION = str(PROTOCOL_MANIFEST["version"])
RULES: tuple[dict[str, Any], ...] = tuple(PROTOCOL_MANIFEST["rules"])
RULE_BY_ID = {str(rule["id"]): rule for rule in RULES}
CAN0_SUPPORTED_CAPABILITIES = frozenset(
capability
for rule in RULES
if int(rule["channel"]) == 0
for capability in rule["capabilities"]
)
CAN1_SUPPORTED_CAPABILITIES = frozenset(
capability
for rule in RULES
if int(rule["channel"]) == 1
for capability in rule["capabilities"]
)
def rule(rule_id: str) -> dict[str, Any]:
try:
return RULE_BY_ID[rule_id]
except KeyError as exc:
raise KeyError(f"unknown generated CAN protocol rule {rule_id}") from exc
def frame(rule_id: str, key: str = "command") -> dict[str, Any]:
for item in rule(rule_id)["frames"]:
if item["key"] == key:
return item
raise KeyError(f"rule {rule_id} has no frame named {key}")
def command_contract(rule_id: str, key: str = "command") -> dict[str, Any]:
metadata = frame(rule_id, key)
return {
"source_version": PROTOCOL_VERSION,
"rule_id": rule_id,
"frame_key": key,
"frame_id": metadata["frame_id"],
"format": metadata["format"],
"brs": bool(metadata["brs"]),
"dlc": int(metadata["dlc"]),
"repeat": dict(metadata["repeat"]),
"feedback_ids": list(metadata["feedback_ids"]),
"feedback_policy": str(metadata.get("feedback_policy", "")),
}
def payload_variant(rule_id: str, selector: str, key: str = "command") -> bytes:
item = frame(rule_id, key)
try:
value = item["payload_variants"][selector]
except KeyError as exc:
raise KeyError(f"rule {rule_id} frame {key} has no payload {selector}") from exc
return bytes.fromhex(str(value))
def rules_for_capability(capability: str, channel: int | None = None) -> tuple[dict[str, Any], ...]:
return tuple(
item
for item in RULES
if capability in item["capabilities"]
and (channel is None or int(item["channel"]) == channel)
)
def _matches_token(word: str, token: dict[str, Any]) -> bool:
if "literal" in token:
return word == token["literal"]
if "enum" in token:
return word in token["values"]
try:
value = int(word, 10)
except ValueError:
return False
return str(value) == word or (word.startswith("-") and str(value) == word)
def _matches_integer(word: str, token: dict[str, Any]) -> bool:
try:
value = int(word, 10)
except ValueError:
return False
return token["minimum"] <= value <= token["maximum"] and str(value) == word
def match_semantic_command(words: str | Iterable[str], channel: int | None = None) -> dict[str, Any] | None:
normalized = tuple(
part.lower()
for part in (words.split() if isinstance(words, str) else words)
)
for item in RULES:
if channel is not None and int(item["channel"]) != channel:
continue
for pattern in item["patterns"]:
if len(pattern) != len(normalized):
continue
if all(
_matches_integer(word, token) if "integer" in token else _matches_token(word, token)
for word, token in zip(normalized, pattern)
):
return item
return None
def whitelist_help(channel: int) -> tuple[str, ...]:
return tuple(str(item["help"]) for item in RULES if int(item["channel"]) == channel)
@@ -1,4 +1,4 @@
"""Configuration and fail-closed gates for the T2G seat hardware bench."""
"""Configuration and fail-closed gates for the passenger-seat CAN0 bench."""
from __future__ import annotations
@@ -174,16 +174,39 @@ class SeatHardwareSettings:
@classmethod
def from_env(cls, repository_root: Path) -> "SeatHardwareSettings":
hardware_enabled = _env_bool("COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED")
legacy_hardware_enabled = _env_bool(
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED"
)
vehicle_backend = os.getenv(
"COGNITIVE_AGENT_VEHICLE_CAN_BACKEND", "zlgcan"
).strip().lower()
vehicle_enabled = _env_bool("COGNITIVE_AGENT_VEHICLE_CAN_ENABLED")
# On the Linux N7 harness the same USBCANFD-200U owns both channels:
# CAN0 is the latest passenger-seat firmware and CAN1 is body CAN FD.
# Keep the old Windows/IAR bench opt-in intact, while making the paired
# Linux CAN0 role follow an explicitly enabled wired vehicle adapter.
wired_passenger_enabled = _env_bool(
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED",
default=vehicle_enabled and vehicle_backend == "zlgcan-linux",
)
hardware_enabled = legacy_hardware_enabled or wired_passenger_enabled
auto_flash_enabled = _env_bool(
"COGNITIVE_AGENT_SEAT_AUTO_FLASH_ENABLED",
default=hardware_enabled,
default=legacy_hardware_enabled and not wired_passenger_enabled,
)
backend = os.getenv("COGNITIVE_AGENT_SEAT_CAN_BACKEND", "zlgcan").strip().lower()
if backend not in {"zlgcan", "python-can"}:
backend = os.getenv(
"COGNITIVE_AGENT_SEAT_CAN_BACKEND",
"zlgcan-linux" if wired_passenger_enabled else "zlgcan",
).strip().lower()
if backend not in {"zlgcan", "zlgcan-linux", "python-can"}:
raise HardwareSafetyError(
"COGNITIVE_AGENT_SEAT_CAN_BACKEND must be zlgcan or python-can"
"COGNITIVE_AGENT_SEAT_CAN_BACKEND must be zlgcan, "
"zlgcan-linux or python-can"
)
linux_vci = backend == "zlgcan-linux"
default_linux_library = (
repository_root.parent / "project-code" / "canfd" / "libusbcanfd.so"
)
firmware_root = (
repository_root / "firmware" / "t2g" / "Seat_Controller_demo"
)
@@ -216,22 +239,31 @@ class SeatHardwareSettings:
backend=backend,
bitrate=_env_int("COGNITIVE_AGENT_SEAT_CAN_BITRATE", 500_000),
termination_enabled=_env_bool(
"COGNITIVE_AGENT_SEAT_CAN_TERMINATION_ENABLED", default=True
"COGNITIVE_AGENT_SEAT_CAN_TERMINATION_ENABLED",
default=False if linux_vci else True,
),
channel=os.getenv("COGNITIVE_AGENT_SEAT_CAN_CHANNEL", "0").strip(),
interface=os.getenv("COGNITIVE_AGENT_SEAT_CAN_INTERFACE", "socketcan").strip(),
device_type=_env_int("COGNITIVE_AGENT_ZLGCAN_DEVICE_TYPE", 41),
device_type=_env_int(
"COGNITIVE_AGENT_ZLGCAN_DEVICE_TYPE", 33 if linux_vci else 41
),
device_index=_env_int("COGNITIVE_AGENT_ZLGCAN_DEVICE_INDEX", 0),
zlg_python=Path(
os.getenv(
"COGNITIVE_AGENT_ZLGCAN_PYTHON",
r"C:\Program Files (x86)\ZCANPRO\python38\python.exe",
sys.executable
if linux_vci
else r"C:\Program Files (x86)\ZCANPRO\python38\python.exe",
)
),
zlg_dll=Path(
os.getenv(
"COGNITIVE_AGENT_ZLGCAN_DLL",
r"C:\Program Files (x86)\ZCANPRO\zlgcan.dll",
"COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY"
if linux_vci
else "COGNITIVE_AGENT_ZLGCAN_DLL",
str(default_linux_library)
if linux_vci
else r"C:\Program Files (x86)\ZCANPRO\zlgcan.dll",
)
),
bridge_script=repository_root
@@ -239,7 +271,7 @@ class SeatHardwareSettings:
/ "cognitive_agent"
/ "infrastructure"
/ "can"
/ "zlgcan_bridge.py",
/ ("zlgcan_linux_bridge.py" if linux_vci else "zlgcan_bridge.py"),
cspybat=Path(
os.getenv(
"COGNITIVE_AGENT_IAR_CSPYBAT",
@@ -250,7 +282,7 @@ class SeatHardwareSettings:
auto_home_enabled=_env_bool("COGNITIVE_AGENT_SEAT_AUTO_HOME_ENABLED"),
position_sync_enabled=_env_bool(
"COGNITIVE_AGENT_SEAT_POSITION_SYNC_ENABLED",
default=hardware_enabled,
default=legacy_hardware_enabled and not wired_passenger_enabled,
),
home_timeout_s=_env_int(
"COGNITIVE_AGENT_SEAT_HOME_TIMEOUT_S", 120
@@ -277,7 +309,7 @@ class SeatHardwareSettings:
def adapter_configured(self) -> bool:
if self.bitrate != 500_000:
return False
if self.backend == "zlgcan":
if self.backend in {"zlgcan", "zlgcan-linux"}:
return (
self.zlg_python.is_file()
and self.zlg_dll.is_file()
@@ -285,8 +317,28 @@ class SeatHardwareSettings:
)
return bool(self.interface and self.channel)
@property
def native_passenger_can0(self) -> bool:
"""Whether CAN0 uses the latest Linux passenger-seat firmware protocol."""
if self.backend != "zlgcan-linux":
return False
try:
return int(self.channel, 0) == 0
except ValueError:
return False
@property
def configured(self) -> bool:
if self.native_passenger_can0:
return bool(
self.enabled
and (not self.approval_required or self.approval_token)
and self.adapter_configured
and self.device_type == 33
)
if self.backend == "zlgcan-linux":
return False
return bool(
self.enabled
and (not self.approval_required or self.approval_token)
@@ -308,7 +360,8 @@ class SeatHardwareSettings:
def authorize(self, supplied_token: str | None) -> None:
if not self.enabled:
raise HardwareSafetyError(
"hardware execution is disabled; set COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED=true"
"passenger-seat hardware execution is disabled; set "
"COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED=true"
)
if self.approval_required:
if not self.approval_token:
@@ -319,6 +372,24 @@ class SeatHardwareSettings:
supplied_token, self.approval_token
):
raise HardwareSafetyError("hardware approval token is invalid")
if self.native_passenger_can0:
if self.device_type != 33:
raise HardwareSafetyError(
"the Linux USBCANFD-200U passenger path requires device type 33"
)
if self.bitrate != 500_000:
raise HardwareSafetyError(
"the passenger CAN0 protocol requires 500000 bit/s"
)
if not self.adapter_configured:
raise HardwareSafetyError(
"the configured Linux ZLG CAN0 runtime is not ready"
)
return
if self.backend == "zlgcan-linux":
raise HardwareSafetyError(
"the Linux passenger-seat path is fixed to CAN0"
)
if not self.profiles:
raise HardwareSafetyError(
"the combined IAR seat-controller firmware profile is not selected"
@@ -357,15 +428,15 @@ class SeatHardwareSettings:
target_key = (str(target.get("zone", "")), str(target.get("instance", "")))
if target_key != SUPPORTED_PHYSICAL_TARGET:
raise HardwareSafetyError(
"the T2G bench accepts only passenger/front_right seat operations"
"passenger CAN0 accepts only passenger/front_right seat operations"
)
try:
profile = CAPABILITY_PROFILE[capability]
except KeyError as exc:
raise HardwareSafetyError(
f"the T2G IAR firmware does not implement {capability}"
f"the passenger-seat CAN0 whitelist does not implement {capability}"
) from exc
if profile not in self.profiles:
if not self.native_passenger_can0 and profile not in self.profiles:
raise HardwareSafetyError(
f"{capability} requires the '{profile}' firmware profile"
)
@@ -373,7 +444,7 @@ class SeatHardwareSettings:
physical_requests[capability] = parameters
def bus_factory(self) -> Callable[[], CanBus]:
if self.backend == "zlgcan":
if self.backend in {"zlgcan", "zlgcan-linux"}:
try:
numeric_channel = int(self.channel, 0)
except ValueError as exc:
@@ -447,7 +518,11 @@ class SeatHardwareSettings:
"configured": self.configured,
"approval_required": self.approval_required,
"approval_configured": bool(self.approval_token),
"platform": "t2g_seat_bench",
"platform": (
"n7_passenger_seat_can0"
if self.native_passenger_can0
else "t2g_seat_bench"
),
"backend": self.backend,
"adapter_configured": self.adapter_configured,
"channel": self.channel,
@@ -456,15 +531,48 @@ class SeatHardwareSettings:
"frame_format": "classic_can_11_bit",
"firmware_profiles": sorted(self.profiles),
"firmware_images": images,
"firmware_status": "passive_protocol_verified_before_each_hardware_request",
"firmware_protocol_signature": FIRMWARE_PROTOCOL_SIGNATURE,
"firmware_source": "repository",
"protocol_profile": (
"passenger-seat-native-can0-v1"
if self.native_passenger_can0
else "legacy-t2g-combined"
),
"firmware_status": (
"external_passenger_firmware_feedback_verified"
if self.native_passenger_can0
else "passive_protocol_verified_before_each_hardware_request"
),
"firmware_protocol_signature": (
None
if self.native_passenger_can0
else FIRMWARE_PROTOCOL_SIGNATURE
),
"firmware_source": (
"external_passenger_firmware"
if self.native_passenger_can0
else "repository"
),
"firmware_sources": (
[
"/home/orin/project511/passenger_seat_firmware/firmware",
"/home/orin/project511/passenger_seat_firmware/Passenger_Seat_Controller_demo",
]
if self.native_passenger_can0
else []
),
"auto_flash_enabled": self.auto_flash_enabled,
"programmer_configured": self.programmer_configured,
"programmer_backend": "iar_cspy_cmsisdap",
"programmer_backend": (
"not_applicable_external_firmware"
if self.native_passenger_can0
else "iar_cspy_cmsisdap"
),
"supported_targets": ["front_right"],
"supported_seat_instances": ["passenger"],
"physical_target": "t2g_primary_seat",
"physical_target": (
"n7_front_right_passenger_seat"
if self.native_passenger_can0
else "t2g_primary_seat"
),
"target_aliasing": "disabled_front_passenger_only",
"auto_home_enabled": self.auto_home_enabled,
"position_sync_enabled": self.position_sync_enabled,
@@ -474,14 +582,19 @@ class SeatHardwareSettings:
else "disabled"
),
"home_timeout_s": self.home_timeout_s,
"limitations": [
"limitations": ([
"CAN0 is fixed to classic CAN 500 kbit/s with standard 11-bit identifiers",
"control uses 0x300/0x29E/0x3CE and feedback uses 0x301-0x306/0x4FA",
"CAN0 and CAN1 are opened by one Linux VCI device owner during linked execution",
"all passenger-seat capabilities remain restricted to passenger/front_right",
] if self.native_passenger_can0 else [
"hardware execution requires the unified Seat_Controller_demo image on one development board",
"all seat capabilities are restricted to passenger/front_right",
"M0/M1/M2/M3 map to horizontal, seat-pan, backrest and legrest on one physical seat",
"on-demand recovery of an unexpectedly invalid target axis remains separately disabled by default",
"when position synchronization is enabled, the first process motion and every repository firmware replacement rebuild all four physical Hall references",
"current diagnostics use conservative bench thresholds and do not replace external power limiting",
],
]),
}
@@ -877,16 +990,28 @@ class VehicleMqttSettings:
"frame_contract": "n7.can.frame.v1",
"state_contract": "n7-can-signals/v1",
"control_contract": "n7-headunit-can-command/v1",
"can0_seat_protocol": "seat-ota-runtime",
"can0_state_contract": "0x6A0-SEAT_INFO-plus-0x6A2-to-0x302",
"can0_control_id": "0x6A0",
"can0_control_opcodes": ["0x63", "0x64", "0x65", "0x66"],
"can0_seat_protocol": "passenger-seat-native-can0-v1",
"can0_state_contract": (
"0x301-0x306-and-0x4FA; read-only legacy 0x6A2 may map to 0x302"
),
"can0_control_id": "0x300",
"can0_control_ids": ["0x300", "0x29E", "0x3CE"],
"can0_control_opcodes": [
"0x01",
"0x02",
"0x03",
"0x08",
"0x09",
"0x0B",
"0x0E",
"0x0F",
],
"can0_forbidden_workflows": [
"flash",
"bootloader_entry",
"test",
"calibration",
"homing",
"full_range_calibration",
"clear_fault",
],
"approval_required": self.approval_required,
"approval_configured": bool(self.approval_token),
@@ -4,9 +4,9 @@ The MQTT topics carry CAN frames, never decoded vehicle-state patches. Every
incoming frame is therefore subjected to the same allow-list and byte decoder
as a frame received from the wired ZLG adapter.
CAN0 is the classic-CAN passenger-seat protocol defined by
``/home/orin/project511/seat-code-file/seat-ota`` and remains raw for the seat
domain decoder. CAN1 is the rest-of-vehicle protocol defined by
CAN0 is the classic-CAN passenger-seat protocol defined by the trusted sources
under ``/home/orin/project511/passenger_seat_firmware`` and remains raw for the
seat domain decoder. CAN1 is the rest-of-vehicle protocol defined by
``/home/orin/project511/project-code/canfd/CAN矩阵表_filled.xlsx`` and is
decoded by :mod:`cognitive_agent.application.native_can_feedback`.
"""
@@ -42,7 +42,9 @@ MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset(
{*range(9), 12, 16, 20, 24, 32, 48, 64}
)
MQTT_SAFETY_FEEDBACK_IDS = frozenset({0x342, 0x4EB})
MQTT_VEHICLE_FEEDBACK_IDS = OBSERVABLE_IDS | frozenset({0x2F5, 0x37B})
MQTT_VEHICLE_FEEDBACK_IDS = OBSERVABLE_IDS | frozenset(
{0x2F5, 0x37B, 0x4BB}
)
# CAN0 accepts both state formats currently emitted by the passenger-seat
# stack. The production firmware publishes its authoritative classic-CAN
# status directly on 0x301..0x306/0x4FA. Older head-unit builds expose the
@@ -60,10 +62,12 @@ MQTT_ACCEPTED_FEEDBACK_IDS = {
1: MQTT_VEHICLE_FEEDBACK_IDS,
}
MQTT_APPROVED_CONTROL_IDS = {
0: frozenset({0x29E, 0x3CE, 0x6A0}),
0: frozenset({0x29E, 0x300, 0x3CE}),
1: frozenset({0x1DE, 0x32B, 0x3FC, 0x431, 0x4C1, 0x4EF, 0x4F5}),
}
MQTT_OTA_SEAT_RUNTIME_OPCODES = frozenset({0x63, 0x64, 0x65, 0x66})
MQTT_NATIVE_SEAT_RUNTIME_OPCODES = frozenset(
{0x01, 0x02, 0x03, 0x08, 0x09, 0x0B, 0x0E, 0x0F}
)
_ID_KEYS = ("arbitration_id", "can_id", "canId", "frame_id", "frameId", "id")
_DATA_KEYS = ("data_hex", "dataHex", "data", "payload", "bytes")
@@ -192,16 +196,58 @@ def _parse_one_frame(value: Any, *, default_channel: int) -> CanFrame:
)
def _ota_seat_status_frames(frames: list[CanFrame]) -> list[CanFrame]:
def _explicit_ota_seat_selector(value: Any, frame: CanFrame) -> int | None:
"""Read trusted head-unit metadata that identifies a 0x6A2 response.
Recent head-unit builds publish only received 0x6A2 frames and attach the
polled SEAT_INFO selector to each JSON signal. The selector is transport
metadata rather than a CAN payload byte, so keep it only after validating
both its bounded type and the complete read-only response signature.
"""
if not isinstance(value, dict) or "selector" not in value:
return None
selector = value["selector"]
if isinstance(selector, bool) or not isinstance(selector, int):
raise ValueError( # noqa: TRY004
"CAN0 seat selector must be an integer from 0 to 14"
)
if not 0 <= selector <= 14:
raise ValueError("CAN0 seat selector must be an integer from 0 to 14")
if not (
frame.channel == 0
and frame.arbitration_id == 0x6A2
and len(frame.data) == 8
and frame.data[0] == 0x7C
and frame.data[2] == 0x60
and frame.data[3] == 0x00
):
raise ValueError(
"CAN0 seat selector is accepted only on a valid 0x6A2 SEAT_INFO response"
)
return selector
def _ota_seat_status_frames(
frames: list[CanFrame],
explicit_selectors: list[int | None] | None = None,
) -> list[CanFrame]:
"""Translate read-only 0x6A0/0x6A2 seat telemetry into 0x302 states.
The seat-ota runtime returns a selector value in a generic response frame:
0x6A0 is ``C7 sequence 60 selector ...`` and 0x6A2 is
``7C sequence 60 status uint32-le``. Only the non-mutating SEAT_INFO
opcode is paired here. The original 0x6A2 response remains available to
opcode is paired here. Newer head-unit envelopes may instead carry a
validated ``selector`` beside each received 0x6A2 frame. If both sources
exist they must agree. The original 0x6A2 response remains available to
command-response waiters; the generated 0x302 frame is internal only.
"""
if explicit_selectors is None:
explicit_selectors = [None] * len(frames)
if len(explicit_selectors) != len(frames):
raise ValueError("CAN0 seat selector metadata does not match the frame batch")
selectors_by_request: dict[tuple[int, int], int] = {}
for frame in frames:
if (
@@ -215,7 +261,9 @@ def _ota_seat_status_frames(frames: list[CanFrame]) -> list[CanFrame]:
selectors_by_request[(frame.data[1], frame.data[2])] = frame.data[3]
values: dict[int, tuple[int, CanFrame]] = {}
for frame in frames:
for frame, explicit_selector in zip(
frames, explicit_selectors, strict=True
):
if (
frame.channel != 0
or frame.arbitration_id != 0x6A2
@@ -225,7 +273,22 @@ def _ota_seat_status_frames(frames: list[CanFrame]) -> list[CanFrame]:
or frame.data[3] != 0x00
):
continue
selector = selectors_by_request.get((frame.data[1], frame.data[2]))
request_selector = selectors_by_request.get(
(frame.data[1], frame.data[2])
)
if (
explicit_selector is not None
and request_selector is not None
and explicit_selector != request_selector
):
# Conflicting metadata must not be allowed to relabel one axis as
# another. Keep the raw response but reject this conversion.
continue
selector = (
explicit_selector
if explicit_selector is not None
else request_selector
)
if selector is None:
continue
values[selector] = (int.from_bytes(frame.data[4:8], "little"), frame)
@@ -348,14 +411,16 @@ def parse_mqtt_can_payload(payload: bytes | str) -> list[CanFrame]:
item_count = sum(len(items) for items, _ in batches)
if not 1 <= item_count <= 256:
raise ValueError("MQTT CAN frame batch must contain between 1 and 256 frames")
parsed_frames = [
_parse_one_frame(item, default_channel=default_channel)
for items, default_channel in batches
for item in items
]
frames = [
frame
for frame in parsed_frames
parsed_entries: list[tuple[CanFrame, int | None]] = []
for items, default_channel in batches:
for item in items:
frame = _parse_one_frame(item, default_channel=default_channel)
parsed_entries.append(
(frame, _explicit_ota_seat_selector(item, frame))
)
accepted_entries = [
(frame, selector)
for frame, selector in parsed_entries
if frame.arbitration_id
in (
MQTT_SEAT_WIRE_FEEDBACK_IDS
@@ -363,7 +428,12 @@ def parse_mqtt_can_payload(payload: bytes | str) -> list[CanFrame]:
else MQTT_VEHICLE_FEEDBACK_IDS
)
]
return [*frames, *_ota_seat_status_frames(frames)]
frames = [frame for frame, _selector in accepted_entries]
explicit_selectors = [selector for _frame, selector in accepted_entries]
return [
*frames,
*_ota_seat_status_frames(frames, explicit_selectors),
]
def _validate_headunit_command_frame(frame: CanFrame) -> None:
@@ -428,31 +498,60 @@ def mqtt_control_payload(
)
def _validate_ota_seat_runtime_control(data: bytes) -> None:
"""Reject every CAN0 OTA opcode except normal bounded seat runtime control."""
def _validate_native_seat_motion_control(data: bytes) -> None:
"""Validate the firmware-native 0x300 semantic runtime subset.
if len(data) != 8 or data[0] != 0xC7:
raise CanGatewayError("CAN0 seat-ota control requires C7 and DLC 8")
opcode = data[2]
selector = data[3]
argument = int.from_bytes(data[4:8], "little")
if opcode not in MQTT_OTA_SEAT_RUNTIME_OPCODES:
The byte layout and limits match ``seat_demo_compat.inc`` and
``Seat_Controller_demo/App/seat_move_application.c``. Maintenance-only
calibration, generic query and clear-fault opcodes are intentionally not
exposed through natural-language/MQTT control.
"""
if len(data) != 8:
raise CanGatewayError("CAN0 native 0x300 control requires DLC 8")
opcode = data[0]
control = data[1]
motor = control & 0x03
direction = (control >> 2) & 0x03
version = (control >> 4) & 0x03
sequence = int.from_bytes(data[2:4], "little")
param_a = int.from_bytes(data[4:6], "little")
param_b = int.from_bytes(data[6:8], "little")
if version != 1 or control & 0xC0:
raise CanGatewayError("CAN0 native 0x300 requires protocol version 1")
if sequence == 0:
raise CanGatewayError("CAN0 native 0x300 requires a runtime sequence")
if opcode not in MQTT_NATIVE_SEAT_RUNTIME_OPCODES:
raise CanGatewayError(
"CAN0 seat-ota control allows only STOP, MOVE, HEARTBEAT, or DYNAMIC"
"CAN0 native 0x300 opcode is outside the semantic control whitelist"
)
if opcode == 0x63 and (selector not in {*range(4), 0xFF} or argument != 0):
raise CanGatewayError("CAN0 seat STOP selector or argument is invalid")
if opcode == 0x64 and (selector not in range(4) or argument > 1_000):
raise CanGatewayError("CAN0 seat MOVE requires axis 0..3 and target 0..1000")
if opcode == 0x65 and (selector not in {*range(4), 0xFF} or argument != 0):
raise CanGatewayError("CAN0 seat HEARTBEAT selector or argument is invalid")
if opcode == 0x66 and (
selector not in {1, 2}
or (argument != 0 and not 5 <= argument <= 300)
if opcode in {0x09, 0x0E, 0x0F} and motor != 0:
raise CanGatewayError("CAN0 native global seat command requires motor 0")
if opcode == 0x03:
if direction not in {1, 2}:
raise CanGatewayError("CAN0 native timed move requires direction 1 or 2")
elif direction != 0:
raise CanGatewayError("CAN0 native non-timed command requires direction 0")
bounded_timeout = param_b == 0 or 100 <= param_b <= 60_000
if opcode == 0x01 and (param_a > 10_000 or not bounded_timeout):
raise CanGatewayError("CAN0 native absolute move payload is invalid")
if opcode == 0x02:
relative = int.from_bytes(data[4:6], "little", signed=True)
if not -10_000 <= relative <= 10_000 or not bounded_timeout:
raise CanGatewayError("CAN0 native relative move payload is invalid")
if opcode == 0x03 and not (
100 <= param_a <= 60_000 and 300 <= param_b <= 1_000
):
raise CanGatewayError(
"CAN0 seat DYNAMIC requires mode 1/2 and duration 5..300 seconds"
)
raise CanGatewayError("CAN0 native timed move payload is invalid")
if opcode in {0x08, 0x09, 0x0F} and (param_a != 0 or param_b != 0):
raise CanGatewayError("CAN0 native stop payload is invalid")
if opcode == 0x0B and not (
param_a == 0x4B52 and 10 <= param_b <= 600
):
raise CanGatewayError("CAN0 native known-range home payload is invalid")
if opcode == 0x0E and (param_a not in {1, 2} or param_b != 0):
raise CanGatewayError("CAN0 native dynamic-mode payload is invalid")
def _validate_native_seat_thermal_control(frame: CanFrame) -> None:
@@ -819,8 +918,8 @@ class MqttCanTransport:
if frame.channel == 0 and (frame.is_fd or frame.bitrate_switch):
raise CanGatewayError("CAN0 passenger-seat control requires classic CAN")
_validate_headunit_command_frame(frame)
if frame.channel == 0 and frame.arbitration_id == 0x6A0:
_validate_ota_seat_runtime_control(frame.data)
if frame.channel == 0 and frame.arbitration_id == 0x300:
_validate_native_seat_motion_control(frame.data)
elif frame.channel == 0:
_validate_native_seat_thermal_control(frame)
with self._condition:
+86 -51
View File
@@ -44,6 +44,7 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
HardwareBusyError,
HardwareSafetyError,
)
from cognitive_agent.infrastructure.can.gateway import CanGatewayError # noqa: E402
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
HardwareAccessRequiredError,
)
@@ -387,7 +388,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
status="failed",
utterance=utterance,
source=source,
error={"code": code, "message": str(error)},
error={
"code": code,
"message": str(error),
"outcome": "failed_or_blocked",
},
)
try:
@@ -536,6 +541,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
{
"error": "hardware_access_required",
"message": str(exc),
"outcome": "failed_or_blocked",
"access_state": exc.state,
},
HTTPStatus.FORBIDDEN,
@@ -546,6 +552,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
{
"error": "hardware_busy",
"message": str(exc),
"outcome": "failed_or_blocked",
"retry_after_seconds": exc.retry_after_seconds,
},
HTTPStatus.CONFLICT,
@@ -554,12 +561,33 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
except HardwareSafetyError as exc:
fail_display_sync("hardware_safety_gate", exc)
self._json(
{"error": "hardware_safety_gate", "message": str(exc)},
{
"error": "hardware_safety_gate",
"message": str(exc),
"outcome": "failed_or_blocked",
},
HTTPStatus.FORBIDDEN,
)
except CanGatewayError as exc:
fail_display_sync("can_transport_failed", exc)
self._json(
{
"error": "can_transport_failed",
"message": str(exc),
"outcome": "failed_or_blocked",
},
HTTPStatus.SERVICE_UNAVAILABLE,
)
except (ValueError, json.JSONDecodeError) as exc:
fail_display_sync("invalid_request", exc)
self._json({"error": "invalid_request", "message": str(exc)}, HTTPStatus.BAD_REQUEST)
self._json(
{
"error": "invalid_request",
"message": str(exc),
"outcome": "failed_or_blocked",
},
HTTPStatus.BAD_REQUEST,
)
except OpenAIIntentError as exc:
fail_display_sync("openai_request_failed", exc)
self.log_error("OpenAI intent request failed: %s", exc)
@@ -567,6 +595,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
{
"error": "openai_request_failed",
"message": "GPT 意图解析失败,请检查 API Key、模型权限或网络状态。",
"outcome": "failed_or_blocked",
},
HTTPStatus.BAD_GATEWAY,
)
@@ -577,6 +606,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
{
"error": "internal_error",
"message": "请求处理失败,请查看服务端日志。",
"outcome": "failed_or_blocked",
"detail": str(exc),
},
HTTPStatus.INTERNAL_SERVER_ERROR,
)
@@ -618,6 +649,36 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
def on_commands_validated(result: dict[str, object]) -> None:
emit({"type": "validated_commands", "payload": result})
def emit_terminal_error(
code: str,
message: str,
*,
detail: str | None = None,
extra: dict[str, object] | None = None,
) -> None:
"""Publish one terminal failure using can_read_trans outcome terms."""
error = {
"code": code,
"message": message,
"outcome": "failed_or_blocked",
**({"detail": detail} if detail else {}),
}
self.vehicle_display_sync.update_task(
status="failed",
utterance=utterance,
source=source,
error=error,
)
emit({
"type": "error",
"error": code,
"message": message,
"outcome": "failed_or_blocked",
**({"detail": detail} if detail else {}),
**(extra or {}),
})
utterance = str(common_arguments.get("utterance") or "")
try:
if path == "/api/requests/preview":
@@ -663,63 +724,37 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)
emit({"type": "result", "payload": result})
except HardwareAccessRequiredError as exc:
self.vehicle_display_sync.update_task(
status="failed",
utterance=utterance,
source=source,
error={"code": "hardware_access_required", "message": str(exc)},
emit_terminal_error(
"hardware_access_required",
str(exc),
extra={"access_state": exc.state},
)
emit({
"type": "error",
"error": "hardware_access_required",
"message": str(exc),
"access_state": exc.state,
})
except HardwareBusyError as exc:
self.vehicle_display_sync.update_task(
status="failed",
utterance=utterance,
source=source,
error={"code": "hardware_busy", "message": str(exc)},
emit_terminal_error(
"hardware_busy",
str(exc),
extra={"retry_after_seconds": exc.retry_after_seconds},
)
emit({
"type": "error",
"error": "hardware_busy",
"message": str(exc),
"retry_after_seconds": exc.retry_after_seconds,
})
except HardwareSafetyError as exc:
self.vehicle_display_sync.update_task(
status="failed",
utterance=utterance,
source=source,
error={"code": "hardware_safety_gate", "message": str(exc)},
)
emit({
"type": "error",
"error": "hardware_safety_gate",
"message": str(exc),
})
emit_terminal_error("hardware_safety_gate", str(exc))
except CanGatewayError as exc:
emit_terminal_error("can_transport_failed", str(exc))
except (ValueError, json.JSONDecodeError) as exc:
emit({
"type": "error",
"error": "invalid_request",
"message": str(exc),
})
emit_terminal_error("invalid_request", str(exc))
except OpenAIIntentError as exc:
self.log_error("OpenAI intent request failed: %s", exc)
emit({
"type": "error",
"error": "openai_request_failed",
"message": "GPT 意图解析失败,请检查 API Key、模型权限或网络状态。",
})
emit_terminal_error(
"openai_request_failed",
"GPT 意图解析失败,请检查 API Key、模型权限或网络状态。",
detail=str(exc),
)
except Exception as exc: # noqa: BLE001 - stream a terminal event
self.log_error("streaming request failed: %s", exc)
emit({
"type": "error",
"error": "internal_error",
"message": "请求处理失败,请查看服务端日志。",
})
emit_terminal_error(
"internal_error",
"请求处理失败,请查看服务端日志。",
detail=str(exc),
)
def do_DELETE(self) -> None: # noqa: N802 - stdlib handler API
parsed_url = urlparse(self.path)