优化无线/有线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
@@ -9,6 +9,11 @@ from typing import Any
import yaml
from cognitive_agent.generated.can_semantic_protocol import (
CAN0_SUPPORTED_CAPABILITIES,
command_contract as shared_protocol_contract,
)
STATE_VALUES = {"on": 1, "off": 2}
@@ -16,7 +21,7 @@ STATE_VALUES = {"on": 1, "off": 2}
class T2GSeatCompiler:
"""Deterministic compiler for the HIL-verified T2G seat bench protocol."""
version = "2.1.0"
version = "2.2.0"
def __init__(self, mapping_path: Path, calibration_path: Path):
self.mapping = yaml.safe_load(mapping_path.read_text(encoding="utf-8"))
@@ -29,6 +34,10 @@ class T2GSeatCompiler:
for item in self.mapping["spec"]["mappings"]
}
@staticmethod
def _protocol_contract(rule_id: str, key: str) -> dict[str, Any]:
return shared_protocol_contract(rule_id, key)
def compile(
self,
function_config: dict[str, Any],
@@ -128,6 +137,9 @@ class T2GSeatCompiler:
"operation_ref": operation_ref,
"bus": "seat_can",
"message": "T2G_Seat_Thermal_Mode_3CE",
"protocol": self._protocol_contract(
"can0.seat.thermal", "thermal_mode"
),
"signals": {"Driver_Seat_Thermal_Mode": 1},
"send_offset_ms": 0,
}
@@ -138,6 +150,9 @@ class T2GSeatCompiler:
"operation_ref": operation_ref,
"bus": "seat_can",
"message": "T2G_Seat_Thermal_Control_29E",
"protocol": self._protocol_contract(
"can0.seat.thermal", "thermal_control"
),
"signals": {
"Driver_Seat_Heating_Gear": int(desired["heating"]["level"]),
"Driver_Seat_Heating_On": STATE_VALUES[desired["heating"]["state"]],
@@ -232,6 +247,15 @@ class T2GSeatCompiler:
),
"bus": "seat_can",
"message": "T2G_Seat_Move_Command_300",
"protocol": self._protocol_contract(
(
"can0.seat.relative"
if (operation.get("motion") or {}).get("mode")
in {"relative", "timed"}
else "can0.seat.position"
),
"move",
),
"signals": {
"Direction": 0,
"Motor_Selector": int(axis["motor_selector"]),
@@ -292,6 +316,9 @@ class T2GSeatCompiler:
"operation_ref": operation["id"],
"bus": "seat_can",
"message": "T2G_Seat_Move_Command_300",
"protocol": self._protocol_contract(
"can0.seat.dynamic", "dynamic"
),
"signals": {
"Direction": 0,
"Motor_Selector": 0,
@@ -357,6 +384,7 @@ class T2GSeatCompiler:
),
"bus": "seat_can",
"message": "T2G_Seat_Move_Command_300",
"protocol": self._protocol_contract("can0.seat.relative", "move"),
"signals": {
"Direction": direction,
"Motor_Selector": int(axis["motor_selector"]),
@@ -426,6 +454,7 @@ class T2GSeatCompiler:
),
"bus": "seat_can",
"message": "T2G_Seat_Move_Command_300",
"protocol": self._protocol_contract("can0.seat.relative", "move"),
"signals": {
"Direction": direction,
"Motor_Selector": int(axis["motor_selector"]),
@@ -469,6 +498,10 @@ class T2GSeatCompiler:
def _require_supported(self, operation: dict[str, Any]) -> None:
key = (operation["capability"], operation["target"]["zone"])
if key[0] not in CAN0_SUPPORTED_CAPABILITIES:
raise ValueError(
f"shared CAN0 protocol does not support capability={key[0]}"
)
if key not in self.supported:
raise ValueError(
"T2G hardware mapping does not support "
+486 -29
View File
@@ -9,6 +9,9 @@ from typing import Any, Callable
from cognitive_agent.infrastructure.can.gateway import CanBus, CanFrame
from cognitive_agent.infrastructure.firmware import T2GFirmwareGuard
from cognitive_agent.generated.can_semantic_protocol import (
command_contract as shared_protocol_contract,
)
from .executor import MOTION_CAPABILITY_TO_AXIS
from .matrix import SeatCanMatrix
@@ -114,6 +117,7 @@ class HardwareSeatExecutor:
synchronize_position_before_first_motion: bool = False,
position_sync_state: dict[str, bool] | None = None,
require_thermal_feedback: bool = True,
feedback_required: bool = True,
):
self.matrix = matrix
self.bus_factory = bus_factory
@@ -136,7 +140,19 @@ class HardwareSeatExecutor:
else {"synchronized": False}
)
self.require_thermal_feedback = bool(require_thermal_feedback)
# A locally attached CAN adapter can synchronously enforce the complete
# 0x301/0x302/0x306/0x4FA feedback contract. The wireless head-unit
# relay currently publishes CAN0 state asynchronously, so its HTTP
# execution path must stop after the approved frame has entered MQTT.
# Keep this opt-out explicit and default-on so wired HIL retains its
# strict physical verification behavior.
self.feedback_required = bool(feedback_required)
self._current_samples: dict[int, list[dict[str, Any]]] = {}
# Evidence for the currently executing *requested* command. Safety
# prerequisites and best-effort STOP frames are deliberately excluded,
# so a feedback timeout can be reported as submitted-but-unverified
# without mistaking a prerequisite frame for the requested operation.
self._active_command_frame: CanFrame | None = None
@classmethod
def _next_sequence(cls) -> int:
@@ -145,6 +161,49 @@ class HardwareSeatExecutor:
cls._sequence = 1
return cls._sequence
@staticmethod
def _operation_debug_command(operation: dict[str, Any]) -> str:
"""Return the CAN0 semantic command attached to MQTT debug metadata."""
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:
axis = axes[capability]
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[axis].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 {axis} relative {direction} {int(delta)}"
return f"seat passenger {axis} position {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
def execute(
self,
command_plan: dict[str, Any],
@@ -169,6 +228,8 @@ class HardwareSeatExecutor:
position_sync_deferred_to_commands = False
position_sync_trigger = "process_first_motion"
physical_state: dict[str, Any] | None = None
motion_publication_start: int | None = None
thermal_publication_start: int | None = None
self._current_samples = {}
with self._adapter_lock:
@@ -200,7 +261,22 @@ class HardwareSeatExecutor:
else:
bus = self.bus_factory()
if has_motion:
self._stop_all(bus, require_ack=True)
motion_publication_start = 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(operation)
for operation in operations.values()
)
)
# STOP_ALL is the firmware-defined sequence barrier. Keep
# publishing it for a remote relay, but do not block the
# requested command on a synchronous 0x301 that is delivered
# later through the MQTT state stream.
self._stop_all(bus, require_ack=self.feedback_required)
motion_armed = True
position_sync_required = (
self.synchronize_position_before_first_motion
@@ -231,14 +307,37 @@ class HardwareSeatExecutor:
)
previous_offset = 0
for command in commands:
self._active_command_frame = None
offset = int(command.get("send_offset_ms", 0))
if offset > previous_offset:
time.sleep((offset - previous_offset) / 1000)
previous_offset = offset
operation_ids = command["operation_ref"].split(",")
referenced = [operations[item] for item in operation_ids]
publication_start = (
thermal_publication_start
if thermal_publication_start is not None
else motion_publication_start
if motion_publication_start is not None
and command["message"] == "T2G_Seat_Move_Command_300"
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":
frame = self._send_encoded(bus, command["message"], command["signals"])
frame = self._send_encoded(
bus,
command["message"],
command["signals"],
self._resolved_protocol(command),
)
self._active_command_frame = frame
results.append(
{
"ref": command["id"],
@@ -252,19 +351,56 @@ class HardwareSeatExecutor:
},
}
)
thermal_publication_start = publication_start
continue
elif not self.feedback_required:
command_results = self._submit_without_feedback(
bus, command, referenced
)
elif command["message"] == "T2G_Seat_Thermal_Control_29E":
results.extend(self._execute_thermal(bus, command, referenced))
command_results = self._execute_thermal(
bus, command, referenced
)
thermal_publication_start = None
elif command["message"] == "T2G_Seat_Move_Command_300":
if int(command["signals"]["Opcode"]) in {0x0E, 0x0F}:
results.extend(
self._execute_dynamic_mode(bus, command, referenced)
command_results = self._execute_dynamic_mode(
bus, command, referenced
)
else:
results.extend(self._execute_motion(bus, command, referenced))
command_results = self._execute_motion(
bus, command, referenced
)
motion_publication_start = None
else:
raise HardwareExecutionError(
f"hardware executor refuses unknown message {command['message']}"
)
mqtt_messages = list(
getattr(bus, "published_messages", ())[publication_start:]
)
if mqtt_messages:
for item in command_results:
telemetry = item.setdefault("telemetry", {})
telemetry["mqtt_messages"] = mqtt_messages
if not self.feedback_required:
delivery_status = (
"published"
if all(
message.get("delivery_status") == "published"
for message in mqtt_messages
)
else "queued"
)
telemetry["delivery_status"] = delivery_status
item["message"] = (
"MQTT CAN0 白名单控制帧已由 Broker 确认;"
"副驾座椅效果以异步状态回读为准。"
if delivery_status == "published"
else "MQTT CAN0 白名单控制帧已进入客户端发布队列;"
"副驾座椅效果以异步状态回读为准。"
)
results.extend(command_results)
if position_sync_deferred_to_commands:
self.position_sync_state["synchronized"] = True
results.append(
@@ -287,36 +423,129 @@ class HardwareSeatExecutor:
}
)
except Exception as exc: # noqa: BLE001 - normalize transport and protocol failures
if bus is not None and motion_armed:
submitted_frame = self._active_command_frame
feedback_timed_out = (
submitted_frame is not None
and isinstance(exc, HardwareExecutionError)
and str(exc).startswith("timed out after ")
)
if bus is not None and motion_armed and self.feedback_required:
self._best_effort_stop(bus)
operation_ref = self._first_unreported_operation(operations, results)
mqtt_messages = list(getattr(bus, "published_messages", ())) if bus else []
delivery_status = "submitted"
if mqtt_messages:
delivery_status = (
"published"
if all(
item.get("delivery_status") == "published"
for item in mqtt_messages
)
else "queued"
)
results.append(
{
"ref": operation_ref,
"domain": "seat",
"status": "failed",
"message": str(exc),
"status": "succeeded" if feedback_timed_out else "failed",
"message": (
"CAN0 控制帧已提交,但反馈窗口内未完成车辆状态确认;"
"编排提交成功,实体效果仍未验证。"
if feedback_timed_out
else str(exc)
),
"telemetry": {
**getattr(exc, "telemetry", {}),
"error_type": type(exc).__name__,
"stop_all_attempted": bool(bus is not None and motion_armed),
**(
{
"command_frame": submitted_frame.public(),
"delivery_status": delivery_status,
"verification_status": "submitted_unverified",
"physical_effect_confirmed": False,
"feedback_error": str(exc),
**(
{"mqtt_messages": mqtt_messages}
if mqtt_messages
else {}
),
}
if feedback_timed_out
else {
**(
{"command_frame": submitted_frame.public()}
if submitted_frame is not None
else {}
),
"verification_status": "failed_or_blocked",
"physical_effect_confirmed": False,
}
),
"simulated": False,
},
}
)
finally:
if bus is not None:
try:
physical_state = self._capture_physical_state(bus, results)
except Exception: # noqa: BLE001 - do not mask the command outcome
if self.feedback_required:
try:
physical_state = self._capture_physical_state(bus, results)
except Exception: # noqa: BLE001 - do not mask the command outcome
physical_state = self._physical_state_from_results(results)
else:
# MQTT state synchronization runs independently. Do not
# add another bounded receive wait to the control request.
physical_state = self._physical_state_from_results(results)
bus.close()
succeeded = all(item["status"] == "succeeded" for item in results)
for item in results:
telemetry = item.setdefault("telemetry", {})
telemetry.setdefault(
"verification_status",
(
"verified_success"
if item.get("status") == "succeeded"
and (
telemetry.get("feedback_frame") is not None
or telemetry.get("hardware_feedback") is True
or telemetry.get("firmware_verified") is True
)
else "submitted_unverified"
if item.get("status") == "succeeded"
and telemetry.get("command_frame") is not None
else "verified_success"
if item.get("status") == "succeeded"
else "failed_or_blocked"
),
)
telemetry.setdefault(
"physical_effect_confirmed",
telemetry["verification_status"] == "verified_success",
)
if telemetry.get("command_frame") is not None:
telemetry.setdefault("delivery_status", "submitted")
verification_status = (
"failed_or_blocked"
if any(
item.get("telemetry", {}).get("verification_status")
== "failed_or_blocked"
for item in results
)
else "submitted_unverified"
if any(
item.get("telemetry", {}).get("verification_status")
== "submitted_unverified"
for item in results
)
else "verified_success"
)
succeeded = verification_status != "failed_or_blocked"
request_id = function_config["metadata"]["request_id"]
spec: dict[str, Any] = {
"mode": "hil",
"status": "succeeded" if succeeded else "failed",
"verification_status": verification_status,
"started_at": started_at,
"finished_at": _now(),
"results": results,
@@ -336,6 +565,59 @@ class HardwareSeatExecutor:
"spec": spec,
}
def _submit_without_feedback(
self,
bus: CanBus,
command: dict[str, Any],
operations: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Publish one approved CAN0 command without a synchronous state gate.
This mode is reserved for the wireless head-unit relay. It still uses
the same matrix encoder and generated protocol contract as wired HIL,
including a fresh firmware sequence for every 0x300 command. Only the
evidence level changes: submission succeeds, while physical completion
remains unverified until the independent MQTT state stream reports it.
"""
signals = dict(command["signals"])
if command["message"] == "T2G_Seat_Move_Command_300":
signals["Sequence"] = self._next_sequence()
elif command["message"] != "T2G_Seat_Thermal_Control_29E":
raise HardwareExecutionError(
f"wireless CAN0 executor refuses unknown message {command['message']}"
)
command_frame = self._send_encoded(
bus,
command["message"],
signals,
self._resolved_protocol(command),
)
self._active_command_frame = command_frame
return [
{
"ref": operation["id"],
"domain": "seat",
"status": "succeeded",
"message": (
"CAN0 控制帧已提交到无线 MQTT;副驾座椅效果等待异步状态回读。"
),
"telemetry": {
"requested_target": operation["target"]["instance"],
"requested_parameters": operation.get("parameters", {}),
"command_frame": command_frame.public(),
"feedback_required": False,
"feedback_observed": False,
"delivery_status": "submitted",
"verification_status": "submitted_unverified",
"physical_effect_confirmed": False,
"control_gate": "compiled_action_and_frame_whitelist",
"simulated": False,
},
}
for operation in operations
]
def read_physical_state(self) -> dict[str, Any]:
"""Read the passenger seat status without transmitting a CAN command."""
@@ -359,6 +641,7 @@ class HardwareSeatExecutor:
results: list[dict[str, Any]],
) -> dict[str, Any]:
state = self._physical_state_from_results(results)
observed_ids: set[int] = set()
deadline = time.monotonic() + max(
self.thermal_timeout_ms,
self.ack_timeout_ms,
@@ -371,10 +654,89 @@ class HardwareSeatExecutor:
frame = bus.receive(remaining_ms)
if frame is None:
break
if len(frame.data) >= 8 and frame.arbitration_id in {
0x301,
0x302,
0x303,
0x304,
0x305,
0x306,
0x4FA,
}:
observed_ids.add(frame.arbitration_id)
self._observe_auxiliary_frame(frame)
if frame.arbitration_id == 0x302 and len(frame.data) >= 8:
if frame.arbitration_id == 0x301 and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x301, frame.data)
state["system"] = {
"ack": int(values["Ack"]),
"state": int(values["System_State"]),
"reject_reason": int(values["Reject_Reason"]),
"last_sequence": int(values["Last_Sequence"]),
"last_opcode": int(values["Last_Opcode"]),
"active_mask": int(values["Active_Mask"]),
"safety_inhibit": bool(values["Safety_Inhibit"]),
"calibration_busy": bool(values["Calibration_Busy"]),
"fault": bool(values["Fault_Summary"]),
"faulted_mask": int(values["Faulted_Mask"]),
"position_valid_mask": int(values["Position_Valid_Mask"]),
"firmware_revision": int(values["Firmware_Revision"]),
"feedback_frame": frame.public(),
}
elif frame.arbitration_id == 0x302 and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x302, frame.data)
self._record_motion_state(state, values, frame)
elif frame.arbitration_id == 0x303 and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x303, frame.data)
motor = int(values["Motor_Sub_ID"])
if motor in PHYSICAL_MOTOR_AXES:
state.setdefault("diagnostics", {}).setdefault("motors", {})[
PHYSICAL_MOTOR_AXES[motor]
] = {
"hall_count": int(values["Hall_Count"]),
"max_hall_count": int(values["Max_Hall_Count"]),
"hall_speed_half_pps": int(values["Hall_Speed_Half_Pps"]),
"last_event": int(values["Last_Motor_Event"]),
"fault_flags": int(values["Fault_Flags"]),
"retry_count": int(values["Retry_Count"]),
"feedback_frame": frame.public(),
}
elif frame.arbitration_id == 0x304 and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x304, frame.data)
motor = int(values["Motor_Sub_ID"])
if motor in PHYSICAL_MOTOR_AXES:
motor_state = state.setdefault("diagnostics", {}).setdefault(
"motors", {}
).setdefault(PHYSICAL_MOTOR_AXES[motor], {})
motor_state["current"] = {
"sample_valid": bool(values["Current_Sample_Valid"]),
"baseline_valid": bool(values["Current_Baseline_Valid"]),
"high_current": bool(values["High_Current"]),
"hall_activity_low": bool(values["Hall_Activity_Low"]),
"calibrating": bool(values["Current_Calibrating"]),
"raw": int(values["Current_Raw"]),
"baseline_raw": int(values["Current_Baseline_Raw"]),
"stall_delta_threshold_raw": int(
values["Stall_Current_Delta_Threshold_Raw"]
),
"feedback_frame": frame.public(),
}
elif frame.arbitration_id == 0x305 and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x305, frame.data)
state.setdefault("diagnostics", {})["thermal"] = {
"fan_sample_valid": bool(values["Fan_Current_Sample_Valid"]),
"heater_sample_valid": bool(
values["Heater_Current_Sample_Valid"]
),
"fan_running": bool(values["Fan_Running"]),
"heater_level": int(values["Heater_Level"]),
"fan_current_raw": int(values["Fan_Current_Raw"]),
"heater_current_raw": int(values["Heater_Current_Raw"]),
"fan_adc_error_count": int(values["Fan_Adc_Error_Count"]),
"heater_adc_error_count": int(
values["Heater_Adc_Error_Count"]
),
"feedback_frame": frame.public(),
}
elif frame.arbitration_id == 0x4FA and len(frame.data) >= 8:
values = self.matrix.decode_by_frame_id(0x4FA, frame.data)
self._record_thermal_state(state, values, frame)
@@ -383,6 +745,11 @@ class HardwareSeatExecutor:
self._record_dynamic_state(state, values, frame)
state["captured_at"] = _now()
state["source"] = "physical_can"
state["channel"] = 0
state["protocol_profile"] = "passenger-seat-native-can0-v1"
state["observed_frame_ids"] = [
f"0x{frame_id:03X}" for frame_id in sorted(observed_ids)
]
state["target"] = {
"device": "seat",
"zone": "front_right",
@@ -532,10 +899,66 @@ class HardwareSeatExecutor:
return next((item for item in operations if item not in reported), next(iter(operations)))
def _send_encoded(
self, bus: CanBus, message: str, signals: dict[str, int]
self,
bus: CanBus,
message: str,
signals: dict[str, int],
protocol: dict[str, Any] | None = None,
) -> CanFrame:
encoded = self.matrix.encode(message, signals)
return bus.send(int(encoded["frame_id"], 16), bytes.fromhex(encoded["data_hex"]))
frame_id = int(encoded["frame_id"], 16)
payload = bytes.fromhex(encoded["data_hex"])
if protocol is not None:
expected_id = int(str(protocol["frame_id"]), 16)
if frame_id != expected_id:
raise HardwareExecutionError(
f"shared protocol expects 0x{expected_id:03X}, matrix encoded 0x{frame_id:03X}"
)
if protocol["format"] != "classic_can" or protocol["brs"]:
raise HardwareExecutionError(
"CAN0 shared protocol requires classic CAN without BRS"
)
if len(payload) != int(protocol["dlc"]):
raise HardwareExecutionError(
"CAN0 encoded payload length disagrees with shared protocol"
)
return bus.send(frame_id, payload)
@staticmethod
def _resolved_protocol(command: dict[str, Any]) -> dict[str, Any]:
"""Return the generated contract, including for legacy in-memory plans."""
protocol = command.get("protocol")
if isinstance(protocol, dict):
return protocol
message = str(command.get("message", ""))
if message == "T2G_Seat_Thermal_Mode_3CE":
rule_id, key = "can0.seat.thermal", "thermal_mode"
elif message == "T2G_Seat_Thermal_Control_29E":
rule_id, key = "can0.seat.thermal", "thermal_control"
elif message == "T2G_Seat_Move_Command_300":
opcode = int(command.get("signals", {}).get("Opcode", -1))
if opcode in {0x0E, 0x0F}:
rule_id, key = "can0.seat.dynamic", "dynamic"
elif opcode == 0x03:
rule_id, key = "can0.seat.relative", "move"
else:
rule_id, key = "can0.seat.position", "move"
else:
raise HardwareExecutionError(
f"cannot resolve generated CAN0 protocol for message {message}"
)
return shared_protocol_contract(rule_id, key)
@classmethod
def _protocol_feedback_ids(cls, command: dict[str, Any]) -> tuple[int, ...]:
protocol = cls._resolved_protocol(command)
values = protocol.get("feedback_ids")
if not isinstance(values, list) or not values:
raise HardwareExecutionError(
"compiled CAN0 command has no generated feedback IDs"
)
return tuple(int(str(value), 16) for value in values)
def _receive_until(
self,
@@ -635,7 +1058,13 @@ class HardwareSeatExecutor:
}
def _wait_ack(
self, bus: CanBus, sequence: int, opcode: int, *, expected_ack: int = 1
self,
bus: CanBus,
sequence: int,
opcode: int,
*,
expected_ack: int = 1,
arbitration_id: int = 0x301,
) -> tuple[dict[str, int], CanFrame]:
def matches(values: dict[str, int], _: CanFrame) -> bool:
if values["Last_Sequence"] != sequence or values["Last_Opcode"] != opcode:
@@ -655,7 +1084,9 @@ class HardwareSeatExecutor:
)
return True
return self._receive_until(bus, 0x301, self.ack_timeout_ms, matches)
return self._receive_until(
bus, arbitration_id, self.ack_timeout_ms, matches
)
def _stop_all(self, bus: CanBus, *, require_ack: bool) -> tuple[CanFrame, CanFrame | None]:
sequence = self._next_sequence()
@@ -686,7 +1117,14 @@ class HardwareSeatExecutor:
command: dict[str, Any],
operations: list[dict[str, Any]],
) -> list[dict[str, Any]]:
command_frame = self._send_encoded(bus, command["message"], command["signals"])
command_frame = self._send_encoded(
bus,
command["message"],
command["signals"],
self._resolved_protocol(command),
)
self._active_command_frame = command_frame
(thermal_feedback_id,) = self._protocol_feedback_ids(command)
expected: dict[str, int] = {}
for operation in operations:
feature = operation["capability"].removeprefix("seat.set_")
@@ -703,7 +1141,7 @@ class HardwareSeatExecutor:
return all(values[name] == value for name, value in expected.items())
values, feedback = self._receive_until(
bus, 0x4FA, self.thermal_timeout_ms, matches
bus, thermal_feedback_id, self.thermal_timeout_ms, matches
)
output = []
for operation in operations:
@@ -758,9 +1196,16 @@ class HardwareSeatExecutor:
signals = dict(command["signals"])
sequence = self._next_sequence()
signals["Sequence"] = sequence
command_frame = self._send_encoded(bus, command["message"], signals)
ack_feedback_id, dynamic_feedback_id = self._protocol_feedback_ids(command)
command_frame = self._send_encoded(
bus, command["message"], signals, self._resolved_protocol(command)
)
self._active_command_frame = command_frame
ack_values, ack_frame = self._wait_ack(
bus, sequence, 0x0F if stopping else 0x0E
bus,
sequence,
0x0F if stopping else 0x0E,
arbitration_id=ack_feedback_id,
)
completion = command["completion"]
@@ -780,7 +1225,7 @@ class HardwareSeatExecutor:
status_values, status_frame = self._receive_until(
bus,
0x306,
dynamic_feedback_id,
int(completion["timeout_ms"]),
matches,
)
@@ -916,8 +1361,14 @@ class HardwareSeatExecutor:
sequence = self._next_sequence()
signals["Sequence"] = sequence
self._reset_current_samples(motor)
command_frame = self._send_encoded(bus, command["message"], signals)
ack_values, ack_frame = self._wait_ack(bus, sequence, opcode)
ack_feedback_id, motor_feedback_id = self._protocol_feedback_ids(command)
command_frame = self._send_encoded(
bus, command["message"], signals, self._resolved_protocol(command)
)
self._active_command_frame = command_frame
ack_values, ack_frame = self._wait_ack(
bus, sequence, opcode, arbitration_id=ack_feedback_id
)
def complete(values: dict[str, int], _: CanFrame) -> bool:
if values["Motor_Sub_ID"] != motor or values["Motor_Sequence"] != sequence:
@@ -941,7 +1392,7 @@ class HardwareSeatExecutor:
values, feedback = self._receive_until(
bus,
0x302,
motor_feedback_id,
int(command["completion"]["timeout_ms"]),
complete,
)
@@ -1389,8 +1840,14 @@ class HardwareSeatExecutor:
signals["Sequence"] = sequence
self._reset_current_samples(motor)
motion_started = time.perf_counter()
command_frame = self._send_encoded(bus, command["message"], signals)
ack_values, ack_frame = self._wait_ack(bus, sequence, 3)
ack_feedback_id, motor_feedback_id = self._protocol_feedback_ids(command)
command_frame = self._send_encoded(
bus, command["message"], signals, self._resolved_protocol(command)
)
self._active_command_frame = command_frame
ack_values, ack_frame = self._wait_ack(
bus, sequence, 3, arbitration_id=ack_feedback_id
)
def terminal(values: dict[str, int], _: CanFrame) -> bool:
if values["Motor_Sub_ID"] != motor or values["Motor_Sequence"] != sequence:
@@ -1401,7 +1858,7 @@ class HardwareSeatExecutor:
values, feedback = self._receive_until(
bus,
0x302,
motor_feedback_id,
int(command["completion"]["timeout_ms"]),
terminal,
)
+1 -1
View File
@@ -152,7 +152,7 @@ LEVELS = {
MOTION_AXIS_ALIASES = {
"longitudinal": ("水平", "前后", "纵向", "滑轨"),
# Internal capability name is retained for compatibility. On the
# seat-ota CAN0 firmware this board axis is the physical seat-height axis.
# Native CAN0 firmware axis 1 is the physical seat-height axis.
"cushion_tilt": (
"座椅高度",
"高度",
+81 -4
View File
@@ -930,6 +930,52 @@ class T2GHardwareTests(unittest.TestCase):
def test_passive_snapshot_reads_all_passenger_axes_and_thermal_state(self) -> None:
bus = ReactiveFakeBus(self.matrix)
bus.received.extend(
[
bus._frame(
"T2G_Seat_Move_System_Status_301",
{
"Ack": 1,
"System_State": 2,
"Last_Sequence": 9,
"Last_Opcode": 1,
"Position_Valid_Mask": 0x0F,
"Firmware_Revision": 1,
},
),
bus._frame(
"T2G_Seat_Move_Diagnostic_303",
{
"Motor_Sub_ID": 0,
"Hall_Count": 120,
"Max_Hall_Count": 480,
"Hall_Speed_Half_Pps": 4,
},
),
bus._frame(
"T2G_Seat_Move_Current_Diagnostic_304",
{
"Motor_Sub_ID": 0,
"Current_Sample_Valid": 1,
"Current_Baseline_Valid": 1,
"Current_Raw": 240,
"Current_Baseline_Raw": 100,
"Stall_Current_Delta_Threshold_Raw": 500,
},
),
bus._frame(
"T2G_Seat_Thermal_Current_Diagnostic_305",
{
"Fan_Current_Sample_Valid": 1,
"Heater_Current_Sample_Valid": 1,
"Fan_Running": 1,
"Heater_Level": 2,
"Fan_Current_Raw": 300,
"Heater_Current_Raw": 450,
},
),
]
)
for motor, value_x100 in enumerate((1200, 3400, 5600, 7800)):
bus.received.append(
bus._frame(
@@ -982,6 +1028,26 @@ class T2GHardwareTests(unittest.TestCase):
self.assertEqual(physical["ventilation"]["state"], "off")
self.assertEqual(physical["heating"]["state"], "on")
self.assertEqual(physical["heating"]["level"], 2)
self.assertEqual(physical["channel"], 0)
self.assertEqual(
physical["protocol_profile"], "passenger-seat-native-can0-v1"
)
self.assertEqual(physical["system"]["position_valid_mask"], 0x0F)
self.assertEqual(
physical["diagnostics"]["motors"]["longitudinal"]["hall_count"],
120,
)
self.assertEqual(
physical["diagnostics"]["motors"]["longitudinal"]["current"]["raw"],
240,
)
self.assertEqual(
physical["diagnostics"]["thermal"]["heater_current_raw"], 450
)
self.assertEqual(
physical["observed_frame_ids"],
["0x301", "0x302", "0x303", "0x304", "0x305", "0x4FA"],
)
self.assertTrue(bus.closed)
def test_executor_sends_firmware_valid_stop_all_before_four_axis_zero(self) -> None:
@@ -1516,11 +1582,18 @@ class T2GHardwareTests(unittest.TestCase):
self.matrix, lambda: bus, ack_timeout_ms=20
).execute(plan, config)
self.assertEqual(report["spec"]["status"], "failed")
self.assertEqual([item.data[0] for item in bus.sent], [9, 1, 9])
self.assertTrue(
report["spec"]["results"][-1]["telemetry"]["stop_all_attempted"]
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual(
report["spec"]["verification_status"],
"submitted_unverified",
)
self.assertEqual([item.data[0] for item in bus.sent], [9, 1, 9])
telemetry = report["spec"]["results"][-1]["telemetry"]
self.assertTrue(telemetry["stop_all_attempted"])
self.assertEqual(telemetry["delivery_status"], "submitted")
self.assertEqual(telemetry["command_frame"]["frame_id"], "0x300")
self.assertEqual(telemetry["verification_status"], "submitted_unverified")
self.assertFalse(telemetry["physical_effect_confirmed"])
def test_executor_waits_for_real_thermal_status(self) -> None:
config = function_config(
@@ -1536,6 +1609,7 @@ class T2GHardwareTests(unittest.TestCase):
telemetry = report["spec"]["results"][0]["telemetry"]
self.assertEqual(telemetry["feedback_frame"]["frame_id"], "0x4FA")
self.assertEqual(telemetry["feedback_value"], 3)
self.assertEqual(telemetry["verification_status"], "verified_success")
def test_executor_preserves_can_adapter_open_failure(self) -> None:
config = function_config(
@@ -1553,6 +1627,9 @@ class T2GHardwareTests(unittest.TestCase):
self.assertEqual(report["spec"]["status"], "failed")
failure = report["spec"]["results"][-1]
self.assertEqual(failure["telemetry"]["error_type"], "CanGatewayError")
self.assertEqual(
failure["telemetry"]["verification_status"], "failed_or_blocked"
)
self.assertIn("ZCAN_OpenDevice failed", failure["message"])
self.assertFalse(failure["telemetry"]["stop_all_attempted"])