优化无线/有线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)