feat: harden seat intent and expand HIL coverage
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
"""Seat thermal domain plugin."""
|
||||
|
||||
from .compiler import SeatCompiler
|
||||
from .executor import VirtualSeatExecutor
|
||||
from .executor import (
|
||||
SIMULATED_HARDWARE_FAULTS,
|
||||
SimulatedHardwareSeatExecutor,
|
||||
VirtualSeatExecutor,
|
||||
)
|
||||
from .generator import build_function_config, build_function_preset, build_intent_ir
|
||||
from .hardware_compiler import T2GSeatCompiler
|
||||
from .hardware_executor import HardwareExecutionError, HardwareSeatExecutor
|
||||
@@ -15,6 +19,8 @@ __all__ = [
|
||||
"T2GSeatCompiler",
|
||||
"HardwareExecutionError",
|
||||
"HardwareSeatExecutor",
|
||||
"SIMULATED_HARDWARE_FAULTS",
|
||||
"SimulatedHardwareSeatExecutor",
|
||||
"VirtualSeatExecutor",
|
||||
"build_function_config",
|
||||
"build_function_preset",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -83,7 +84,6 @@ class VirtualSeatExecutor:
|
||||
"results": results,
|
||||
},
|
||||
}
|
||||
|
||||
def _execute_thermal(
|
||||
self, command: dict[str, Any], operations: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -193,3 +193,109 @@ class VirtualSeatExecutor:
|
||||
"simulated": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
SIMULATED_HARDWARE_FAULTS = (
|
||||
"normal",
|
||||
"feedback_timeout",
|
||||
"adapter_disconnect",
|
||||
"motor_stall",
|
||||
"firmware_mismatch",
|
||||
"partial_feedback",
|
||||
)
|
||||
|
||||
|
||||
class SimulatedHardwareSeatExecutor:
|
||||
"""Development-only HIL facade that never opens a physical CAN adapter."""
|
||||
|
||||
_FAULT_DETAILS = {
|
||||
"feedback_timeout": ("FeedbackTimeout", "模拟台架未在限定时间内返回目标反馈。"),
|
||||
"adapter_disconnect": ("CanAdapterDisconnected", "模拟 CAN 适配器在执行前断开连接。"),
|
||||
"motor_stall": ("MotorStall", "模拟电机堵转,已触发停止保护。"),
|
||||
"firmware_mismatch": ("FirmwareMismatch", "模拟固件协议签名不匹配,控制帧未发送。"),
|
||||
"partial_feedback": ("PartialFeedback", "模拟台架只返回了部分执行器反馈。"),
|
||||
}
|
||||
|
||||
def __init__(self, matrix: SeatCanMatrix):
|
||||
self.virtual = VirtualSeatExecutor(matrix)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
command_plan: dict[str, Any],
|
||||
function_config: dict[str, Any],
|
||||
actuator_state: dict[str, Any] | None = None,
|
||||
*,
|
||||
fault_scenario: str = "normal",
|
||||
) -> dict[str, Any]:
|
||||
if fault_scenario not in SIMULATED_HARDWARE_FAULTS:
|
||||
raise ValueError(
|
||||
"simulation_fault must be one of "
|
||||
+ ", ".join(SIMULATED_HARDWARE_FAULTS)
|
||||
)
|
||||
|
||||
report = deepcopy(
|
||||
self.virtual.execute(command_plan, function_config, actuator_state)
|
||||
)
|
||||
report["metadata"]["name"] = "Seat simulated hardware execution report"
|
||||
report["metadata"]["labels"] = {
|
||||
"simulation": "true",
|
||||
"seat_feedback": "simulated_hardware",
|
||||
"fault_scenario": fault_scenario,
|
||||
}
|
||||
report["spec"]["mode"] = "hil"
|
||||
results = report["spec"]["results"]
|
||||
for item in results:
|
||||
item.setdefault("telemetry", {}).update(
|
||||
{
|
||||
"simulation": True,
|
||||
"simulation_kind": "simulated_hardware",
|
||||
"fault_scenario": fault_scenario,
|
||||
"physical_can_opened": False,
|
||||
}
|
||||
)
|
||||
if fault_scenario == "normal":
|
||||
for item in results:
|
||||
item["message"] = (
|
||||
item["message"]
|
||||
.replace("虚构", "模拟台架")
|
||||
.replace("虚拟", "模拟台架")
|
||||
)
|
||||
return report
|
||||
|
||||
error_type, message = self._FAULT_DETAILS[fault_scenario]
|
||||
affected = results
|
||||
if fault_scenario == "motor_stall":
|
||||
affected = [
|
||||
next(
|
||||
(item for item in results if item.get("telemetry", {}).get("motion_axis")),
|
||||
results[0],
|
||||
)
|
||||
]
|
||||
elif fault_scenario == "partial_feedback" and len(results) > 1:
|
||||
affected = results[1:]
|
||||
|
||||
for item in affected:
|
||||
telemetry = item["telemetry"]
|
||||
item["status"] = "failed"
|
||||
item["message"] = message
|
||||
telemetry.update(
|
||||
{
|
||||
"error_type": error_type,
|
||||
"failure_category": fault_scenario,
|
||||
"feedback_received": False,
|
||||
}
|
||||
)
|
||||
telemetry.pop("feedback_frame", None)
|
||||
if fault_scenario == "motor_stall":
|
||||
telemetry.update(
|
||||
{"motion_state": "stalled", "fault_mask": 1, "stop_all_attempted": True}
|
||||
)
|
||||
if fault_scenario == "firmware_mismatch":
|
||||
telemetry["command_sent"] = False
|
||||
if fault_scenario == "adapter_disconnect":
|
||||
telemetry["adapter_connected"] = False
|
||||
|
||||
succeeded = sum(item["status"] == "succeeded" for item in results)
|
||||
report["spec"]["status"] = "partially_succeeded" if succeeded else "failed"
|
||||
report["spec"]["finished_at"] = _now()
|
||||
return report
|
||||
|
||||
@@ -167,26 +167,49 @@ class T2GSeatCompiler:
|
||||
target = round(maximum * position_pct / 100)
|
||||
tolerance = max(1, round(maximum / 100))
|
||||
motion = operation.get("motion")
|
||||
relative_fallback: dict[str, Any] | None = None
|
||||
if motion is not None and motion.get("mode") == "timed":
|
||||
commands.append(
|
||||
self._compile_timed_motion(
|
||||
operation,
|
||||
physical_operations,
|
||||
axis,
|
||||
command_offset + index,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if motion is not None and motion.get("mode") == "relative":
|
||||
delta_pct = int(motion["delta_position_pct"])
|
||||
maximum_delta = int(axis["max_uncalibrated_relative_pct"])
|
||||
if delta_pct <= maximum_delta:
|
||||
commands.append(
|
||||
self._compile_uncalibrated_relative(
|
||||
operation,
|
||||
physical_operations,
|
||||
axis,
|
||||
command_offset + index,
|
||||
)
|
||||
relative_fallback = self._compile_uncalibrated_relative(
|
||||
operation,
|
||||
physical_operations,
|
||||
axis,
|
||||
command_offset + index,
|
||||
)
|
||||
continue
|
||||
# A large relative request cannot safely be approximated by a
|
||||
# long open-loop duty command or by several chained commands.
|
||||
# Compile its already-resolved target as an absolute move. The
|
||||
# executor will check the board's live Position_Valid bit before
|
||||
# putting Opcode 1 on the bus.
|
||||
commands.append(
|
||||
{
|
||||
# Prefer the resolved closed-loop target whenever the board has
|
||||
# a live calibrated position. The bounded timed-duty command
|
||||
# remains available only as an uncalibrated fallback.
|
||||
explicit_home = position_pct == 0
|
||||
command_opcode = int(
|
||||
self.protocol[
|
||||
"home_known_range_opcode"
|
||||
if explicit_home
|
||||
else "absolute_position_opcode"
|
||||
]
|
||||
)
|
||||
command_param_a = (
|
||||
int(self.protocol["home_known_range_key"])
|
||||
if explicit_home
|
||||
else target
|
||||
)
|
||||
command_param_b = (
|
||||
int(self.protocol["home_known_range_timeout_s"])
|
||||
if explicit_home
|
||||
else absolute_timeout_ms
|
||||
)
|
||||
command = {
|
||||
"id": f"t2g_move_{operation['id']}",
|
||||
"operation_ref": ",".join(
|
||||
item["id"] for item in physical_operations
|
||||
@@ -196,12 +219,14 @@ class T2GSeatCompiler:
|
||||
"signals": {
|
||||
"Direction": 0,
|
||||
"Motor_Selector": int(axis["motor_selector"]),
|
||||
"Opcode": int(self.protocol["absolute_position_opcode"]),
|
||||
"Param_A": target,
|
||||
# Opcode 1 Param_B is a timeout override, not duty.
|
||||
# The explicit calibrated value covers a full M0 stroke;
|
||||
# the legacy per-axis default can stop it at about 79%.
|
||||
"Param_B": absolute_timeout_ms,
|
||||
# An explicit 0% request is a physical zeroing request,
|
||||
# not permission to trust a potentially drifted Hall
|
||||
# counter. Opcode 0x0B proves the real near endpoint and
|
||||
# rebuilds position zero while preserving MaxHall.
|
||||
"Opcode": command_opcode,
|
||||
"Param_A": command_param_a,
|
||||
# Opcode 1 uses milliseconds; Opcode 0x0B uses seconds.
|
||||
"Param_B": command_param_b,
|
||||
"Protocol_Version": int(self.protocol["version"]),
|
||||
# Allocated immediately before hardware transmission. Zero keeps
|
||||
# the compiled plan deterministic and is never put on the bus.
|
||||
@@ -219,13 +244,76 @@ class T2GSeatCompiler:
|
||||
"tolerance": tolerance,
|
||||
"timeout_ms": max(
|
||||
int(operation.get("timeout_ms", 0)),
|
||||
absolute_timeout_ms + 3000,
|
||||
(
|
||||
command_param_b * 1000 + 5000
|
||||
if explicit_home
|
||||
else absolute_timeout_ms + 3000
|
||||
),
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
if relative_fallback is not None:
|
||||
command["relative_fallback"] = relative_fallback
|
||||
commands.append(command)
|
||||
return commands
|
||||
|
||||
def _compile_timed_motion(
|
||||
self,
|
||||
operation: dict[str, Any],
|
||||
physical_operations: list[dict[str, Any]],
|
||||
axis: dict[str, Any],
|
||||
command_index: int,
|
||||
) -> dict[str, Any]:
|
||||
motion = dict(operation["motion"])
|
||||
duration_ms = int(motion["applied_duration_ms"])
|
||||
minimum_duration = int(self.protocol["timed_duty_min_duration_ms"])
|
||||
protocol_maximum = int(self.protocol["timed_duty_max_duration_ms"])
|
||||
axis_maximum = int(
|
||||
axis.get("max_user_timed_duration_ms", protocol_maximum)
|
||||
)
|
||||
if not minimum_duration <= duration_ms <= min(axis_maximum, protocol_maximum):
|
||||
raise ValueError(
|
||||
"timed seat movement duration was not clamped to the configured "
|
||||
f"{minimum_duration}..{min(axis_maximum, protocol_maximum)} ms range"
|
||||
)
|
||||
if int(motion.get("max_duration_ms", axis_maximum)) != axis_maximum:
|
||||
raise ValueError(
|
||||
"timed seat movement semantic limit does not match hardware calibration"
|
||||
)
|
||||
direction_name = str(motion["direction"])
|
||||
direction = int(self.protocol[f"{direction_name}_direction"])
|
||||
timeout_ms = max(
|
||||
int(operation.get("timeout_ms", 8_000)), duration_ms + 5_000
|
||||
)
|
||||
return {
|
||||
"id": f"t2g_move_{operation['id']}",
|
||||
"operation_ref": ",".join(
|
||||
item["id"] for item in physical_operations
|
||||
),
|
||||
"bus": "seat_can",
|
||||
"message": "T2G_Seat_Move_Command_300",
|
||||
"signals": {
|
||||
"Direction": direction,
|
||||
"Motor_Selector": int(axis["motor_selector"]),
|
||||
"Opcode": int(self.protocol["timed_duty_opcode"]),
|
||||
"Param_A": duration_ms,
|
||||
"Param_B": int(self.protocol["default_duty_percent"])
|
||||
* int(self.protocol["timed_duty_level_scale"]),
|
||||
"Protocol_Version": int(self.protocol["version"]),
|
||||
"Sequence": 0,
|
||||
},
|
||||
"send_offset_ms": (
|
||||
command_index * int(self.protocol["inter_axis_settle_ms"])
|
||||
),
|
||||
"completion": {
|
||||
"message": "T2G_Seat_Move_Motor_Status_302",
|
||||
"signal": "Task_State",
|
||||
"operator": "eq",
|
||||
"value": 6,
|
||||
"timeout_ms": timeout_ms,
|
||||
},
|
||||
}
|
||||
|
||||
def _compile_uncalibrated_relative(
|
||||
self,
|
||||
operation: dict[str, Any],
|
||||
|
||||
@@ -60,6 +60,18 @@ PHYSICAL_AXIS_CAPABILITIES = {
|
||||
}
|
||||
MOTION_ACTIVE_TASK_STATES = {1, 2, 3, 4, 5}
|
||||
MOTION_FAULT_RESULTS = {5, 6, 7, 8, 9, 10, 11}
|
||||
MOTION_AXIS_NOTICE_LABELS = {
|
||||
"longitudinal": "水平",
|
||||
"cushion_tilt": "坐垫",
|
||||
"backrest_recline": "靠背",
|
||||
"legrest_height": "腿托",
|
||||
}
|
||||
MOTION_ENDPOINT_NOTICE_LABELS = {
|
||||
"longitudinal": {"near": "最前方", "far": "最后方"},
|
||||
"cushion_tilt": {"near": "最上方", "far": "最下方"},
|
||||
"backrest_recline": {"near": "最前方", "far": "最后方"},
|
||||
"legrest_height": {"near": "最下方", "far": "最上方"},
|
||||
}
|
||||
|
||||
|
||||
class HardwareExecutionError(RuntimeError):
|
||||
@@ -76,6 +88,10 @@ def _now() -> str:
|
||||
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _seconds_text(duration_ms: int) -> str:
|
||||
return f"{duration_ms / 1_000:g}"
|
||||
|
||||
|
||||
class HardwareSeatExecutor:
|
||||
"""Serialize access to one adapter and require real firmware feedback."""
|
||||
|
||||
@@ -95,6 +111,8 @@ class HardwareSeatExecutor:
|
||||
auto_home: bool = False,
|
||||
home_timeout_s: int = 120,
|
||||
firmware_guard: T2GFirmwareGuard | None = None,
|
||||
synchronize_position_before_first_motion: bool = False,
|
||||
position_sync_state: dict[str, bool] | None = None,
|
||||
):
|
||||
self.matrix = matrix
|
||||
self.bus_factory = bus_factory
|
||||
@@ -108,6 +126,14 @@ class HardwareSeatExecutor:
|
||||
self.auto_home = auto_home
|
||||
self.home_timeout_s = home_timeout_s
|
||||
self.firmware_guard = firmware_guard
|
||||
self.synchronize_position_before_first_motion = (
|
||||
synchronize_position_before_first_motion
|
||||
)
|
||||
self.position_sync_state = (
|
||||
position_sync_state
|
||||
if position_sync_state is not None
|
||||
else {"synchronized": False}
|
||||
)
|
||||
self._current_samples: dict[int, list[dict[str, Any]]] = {}
|
||||
|
||||
@classmethod
|
||||
@@ -133,6 +159,9 @@ class HardwareSeatExecutor:
|
||||
has_motion = any(item["message"] == "T2G_Seat_Move_Command_300" for item in commands)
|
||||
bus: CanBus | None = None
|
||||
motion_armed = False
|
||||
position_sync_required = False
|
||||
position_sync_deferred_to_commands = False
|
||||
position_sync_trigger = "process_first_motion"
|
||||
physical_state: dict[str, Any] | None = None
|
||||
self._current_samples = {}
|
||||
|
||||
@@ -140,6 +169,12 @@ class HardwareSeatExecutor:
|
||||
try:
|
||||
if self.firmware_guard is not None:
|
||||
bus, firmware_telemetry = self.firmware_guard.prepare_bus()
|
||||
if firmware_telemetry.get("programmed_this_request"):
|
||||
# Code flashed by another program can move the bench
|
||||
# without updating this repository firmware's WorkFlash
|
||||
# record. Never trust that record after replacement.
|
||||
self.position_sync_state["synchronized"] = False
|
||||
position_sync_trigger = "repository_firmware_reprogrammed"
|
||||
results.append(
|
||||
{
|
||||
"ref": "seat-controller-firmware",
|
||||
@@ -161,6 +196,33 @@ class HardwareSeatExecutor:
|
||||
if has_motion:
|
||||
self._stop_all(bus, require_ack=True)
|
||||
motion_armed = True
|
||||
position_sync_required = (
|
||||
self.synchronize_position_before_first_motion
|
||||
and not self.position_sync_state.get("synchronized", False)
|
||||
)
|
||||
position_sync_deferred_to_commands = (
|
||||
position_sync_required
|
||||
and self._commands_are_four_axis_known_range_home(commands)
|
||||
)
|
||||
if position_sync_required and not position_sync_deferred_to_commands:
|
||||
sync_axes = self._synchronize_position_memory(bus)
|
||||
self.position_sync_state["synchronized"] = True
|
||||
results.append(
|
||||
{
|
||||
"ref": "seat-position-memory-sync",
|
||||
"domain": "seat",
|
||||
"status": "succeeded",
|
||||
"message": (
|
||||
"four-axis physical endpoints were homed and "
|
||||
"the Hall position memory baseline was rebuilt"
|
||||
),
|
||||
"telemetry": {
|
||||
"trigger": position_sync_trigger,
|
||||
"axes": sync_axes,
|
||||
"simulated": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
previous_offset = 0
|
||||
for command in commands:
|
||||
offset = int(command.get("send_offset_ms", 0))
|
||||
@@ -192,6 +254,27 @@ class HardwareSeatExecutor:
|
||||
raise HardwareExecutionError(
|
||||
f"hardware executor refuses unknown message {command['message']}"
|
||||
)
|
||||
if position_sync_deferred_to_commands:
|
||||
self.position_sync_state["synchronized"] = True
|
||||
results.append(
|
||||
{
|
||||
"ref": "seat-position-memory-sync",
|
||||
"domain": "seat",
|
||||
"status": "succeeded",
|
||||
"message": (
|
||||
"the requested four-axis physical homing rebuilt "
|
||||
"the Hall position memory baseline"
|
||||
),
|
||||
"telemetry": {
|
||||
"trigger": position_sync_trigger,
|
||||
"axes": [
|
||||
{"motor_selector": motor, "mode": "home_known_range"}
|
||||
for motor in range(4)
|
||||
],
|
||||
"simulated": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - normalize transport and protocol failures
|
||||
if bus is not None and motion_armed:
|
||||
self._best_effort_stop(bus)
|
||||
@@ -642,15 +725,32 @@ class HardwareSeatExecutor:
|
||||
bus, command, operations, signals, motor
|
||||
)
|
||||
|
||||
preflight_values: dict[str, int] | None = None
|
||||
preflight_frame: CanFrame | None = None
|
||||
relative_fallback = command.get("relative_fallback")
|
||||
if isinstance(relative_fallback, dict):
|
||||
preflight_values, preflight_frame = self._motor_status_snapshot(bus, motor)
|
||||
if not preflight_values["Position_Valid"]:
|
||||
fallback_signals = dict(relative_fallback["signals"])
|
||||
return self._execute_timed_motion(
|
||||
bus,
|
||||
relative_fallback,
|
||||
operations,
|
||||
fallback_signals,
|
||||
motor,
|
||||
)
|
||||
|
||||
target = int(command["completion"]["value"])
|
||||
tolerance = int(command["completion"].get("tolerance", 0))
|
||||
axis_maximum = self.axis_maximum_x100.get(operation["capability"])
|
||||
preflight_values, preflight_frame = self._motor_status_snapshot(bus, motor)
|
||||
if preflight_values is None or preflight_frame is None:
|
||||
preflight_values, preflight_frame = self._motor_status_snapshot(bus, motor)
|
||||
baseline: dict[str, int] | None = None
|
||||
baseline_frame: CanFrame | None = None
|
||||
baseline_error: str | None = None
|
||||
position_recovery: dict[str, Any] | None = None
|
||||
if not preflight_values["Position_Valid"]:
|
||||
explicit_home = opcode == 0x0B
|
||||
if not preflight_values["Position_Valid"] and not explicit_home:
|
||||
if not self.auto_home:
|
||||
raise HardwareExecutionError(
|
||||
f"T2G motor {motor} absolute-position command blocked before send: "
|
||||
@@ -677,7 +777,7 @@ class HardwareSeatExecutor:
|
||||
|
||||
# Capture Hall telemetry after any required homing. It is required
|
||||
# for endpoint recovery at 0% and makes non-zero failures diagnosable.
|
||||
if opcode == 1:
|
||||
if opcode in {1, 0x0B}:
|
||||
try:
|
||||
baseline, baseline_frame = self._motor_diagnostic_snapshot(bus, motor)
|
||||
except HardwareExecutionError as exc:
|
||||
@@ -717,6 +817,33 @@ class HardwareSeatExecutor:
|
||||
)
|
||||
task_state = int(values["Task_State"])
|
||||
result = int(values["Result"])
|
||||
if explicit_home and task_state == 6 and result == 2:
|
||||
home_diagnostic, home_diagnostic_frame = self._motor_diagnostic_snapshot(
|
||||
bus, motor
|
||||
)
|
||||
home_hall = int(home_diagnostic["Hall_Count"])
|
||||
if home_hall > ENDPOINT_RECOVERY_MAX_STATIONARY_HALL_DELTA:
|
||||
raise HardwareExecutionError(
|
||||
f"T2G motor {motor} known-range home completed but Hall={home_hall}",
|
||||
telemetry={
|
||||
"motor_selector": motor,
|
||||
"motion_axis": MOTION_CAPABILITY_TO_AXIS[
|
||||
operation["capability"]
|
||||
],
|
||||
"position_valid": bool(values["Position_Valid"]),
|
||||
"home_diagnostic_frame": home_diagnostic_frame.public(),
|
||||
},
|
||||
)
|
||||
position_recovery = {
|
||||
"mode": "explicit_known_range_home",
|
||||
"hall_before": int(baseline["Hall_Count"]) if baseline else None,
|
||||
"hall_after": home_hall,
|
||||
"max_hall_count": int(home_diagnostic["Max_Hall_Count"]),
|
||||
"diagnostic_before_frame": (
|
||||
baseline_frame.public() if baseline_frame else None
|
||||
),
|
||||
"diagnostic_after_frame": home_diagnostic_frame.public(),
|
||||
}
|
||||
far_endpoint_tolerance_acceptance = bool(
|
||||
opcode == 1
|
||||
and axis_maximum is not None
|
||||
@@ -866,6 +993,11 @@ class HardwareSeatExecutor:
|
||||
actual_pct = round(values["Value_X100"] * 100 / maximum)
|
||||
common_telemetry = {
|
||||
"motion_axis": axis,
|
||||
"motion_mode": (
|
||||
"relative_absolute_target"
|
||||
if operation.get("motion", {}).get("mode") == "relative"
|
||||
else "absolute_target"
|
||||
),
|
||||
"motor_selector": motor,
|
||||
"physical_target": "t2g_primary_seat",
|
||||
"sequence": sequence,
|
||||
@@ -885,6 +1017,7 @@ class HardwareSeatExecutor:
|
||||
"ack_frame": ack_frame.public(),
|
||||
"feedback_frame": feedback.public(),
|
||||
"hardware_feedback": True,
|
||||
"current_diagnostic": self._current_diagnostic_summary(motor),
|
||||
"simulated": False,
|
||||
}
|
||||
return [
|
||||
@@ -1081,6 +1214,36 @@ class HardwareSeatExecutor:
|
||||
"system_ack": ack_values["Ack"],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _commands_are_four_axis_known_range_home(
|
||||
commands: list[dict[str, Any]],
|
||||
) -> bool:
|
||||
motion_commands = [
|
||||
item
|
||||
for item in commands
|
||||
if item.get("message") == "T2G_Seat_Move_Command_300"
|
||||
]
|
||||
return bool(motion_commands) and {
|
||||
int(item.get("signals", {}).get("Motor_Selector", -1))
|
||||
for item in motion_commands
|
||||
if int(item.get("signals", {}).get("Opcode", -1)) == 0x0B
|
||||
} == set(range(4)) and all(
|
||||
int(item.get("signals", {}).get("Opcode", -1)) == 0x0B
|
||||
for item in motion_commands
|
||||
)
|
||||
|
||||
def _synchronize_position_memory(self, bus: CanBus) -> list[dict[str, Any]]:
|
||||
"""Rebuild every axis reference from a verified physical endpoint."""
|
||||
|
||||
axes: list[dict[str, Any]] = []
|
||||
for motor in range(4):
|
||||
axes.append(self._home_known_range(bus, motor))
|
||||
if motor < 3:
|
||||
# WorkFlash commits after a motor settles. Match the compiler's
|
||||
# inter-axis spacing so a following home cannot race persistence.
|
||||
time.sleep(2)
|
||||
return axes
|
||||
|
||||
def _execute_timed_motion(
|
||||
self,
|
||||
bus: CanBus,
|
||||
@@ -1089,10 +1252,13 @@ class HardwareSeatExecutor:
|
||||
signals: dict[str, int],
|
||||
motor: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
motion = dict(operations[0].get("motion", {}))
|
||||
user_timed_motion = motion.get("mode") == "timed"
|
||||
baseline, baseline_frame = self._motor_diagnostic_snapshot(bus, motor)
|
||||
sequence = self._next_sequence()
|
||||
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)
|
||||
|
||||
@@ -1109,6 +1275,13 @@ class HardwareSeatExecutor:
|
||||
int(command["completion"]["timeout_ms"]),
|
||||
terminal,
|
||||
)
|
||||
executed_duration_ms = max(
|
||||
1,
|
||||
min(
|
||||
int(signals["Param_A"]),
|
||||
round((time.perf_counter() - motion_started) * 1_000),
|
||||
),
|
||||
)
|
||||
diagnostic: dict[str, int] | None = None
|
||||
diagnostic_frame: CanFrame | None = None
|
||||
diagnostic_error: str | None = None
|
||||
@@ -1142,8 +1315,45 @@ class HardwareSeatExecutor:
|
||||
if diagnostic is not None
|
||||
else int(baseline["Max_Hall_Count"]),
|
||||
)
|
||||
capability = operations[0]["capability"]
|
||||
axis = MOTION_CAPABILITY_TO_AXIS[capability]
|
||||
axis_maximum = max(1, int(self.axis_maximum_x100.get(capability, 10_000)))
|
||||
endpoint_position_tolerance = max(100, round(axis_maximum * 0.02))
|
||||
hall_endpoint_tolerance = max(2, round(max_hall * 0.02))
|
||||
near_endpoint_confirmed = bool(
|
||||
values.get("Near_Endpoint")
|
||||
or (
|
||||
values["Position_Valid"]
|
||||
and int(values["Value_X100"]) <= endpoint_position_tolerance
|
||||
)
|
||||
or hall_after <= hall_endpoint_tolerance
|
||||
)
|
||||
far_endpoint_confirmed = bool(
|
||||
values.get("Far_Endpoint")
|
||||
or (
|
||||
values["Position_Valid"]
|
||||
and int(values["Value_X100"])
|
||||
>= axis_maximum - endpoint_position_tolerance
|
||||
)
|
||||
or hall_after >= max_hall - hall_endpoint_tolerance
|
||||
)
|
||||
expected_endpoint = "far" if direction == 1 else "near"
|
||||
endpoint_reached = (
|
||||
expected_endpoint
|
||||
if (
|
||||
(expected_endpoint == "near" and near_endpoint_confirmed)
|
||||
or (expected_endpoint == "far" and far_endpoint_confirmed)
|
||||
)
|
||||
else None
|
||||
)
|
||||
endpoint_terminal_acceptance = bool(
|
||||
user_timed_motion
|
||||
and endpoint_reached is not None
|
||||
and task_state == 7
|
||||
and result in {4, 5, 6}
|
||||
)
|
||||
|
||||
if task_state != 6 or result != 2:
|
||||
if (task_state != 6 or result != 2) and not endpoint_terminal_acceptance:
|
||||
if result == 5 and hall_delta <= 2:
|
||||
failure_category = "stall_no_hall_activity"
|
||||
diagnostic_hint = (
|
||||
@@ -1177,10 +1387,12 @@ class HardwareSeatExecutor:
|
||||
f"{diagnostic_hint}",
|
||||
telemetry={
|
||||
"motor_selector": motor,
|
||||
"motion_axis": MOTION_CAPABILITY_TO_AXIS[
|
||||
operations[0]["capability"]
|
||||
],
|
||||
"motion_mode": "relative_timed_duty",
|
||||
"motion_axis": axis,
|
||||
"motion_mode": (
|
||||
"timed_user_request"
|
||||
if user_timed_motion
|
||||
else "relative_timed_duty"
|
||||
),
|
||||
"position_valid": bool(values["Position_Valid"]),
|
||||
"task_state": task_state,
|
||||
"motor_result": result,
|
||||
@@ -1210,7 +1422,10 @@ class HardwareSeatExecutor:
|
||||
},
|
||||
)
|
||||
|
||||
if diagnostic is None or diagnostic_frame is None:
|
||||
if (
|
||||
(diagnostic is None or diagnostic_frame is None)
|
||||
and not endpoint_terminal_acceptance
|
||||
):
|
||||
raise HardwareExecutionError(
|
||||
f"T2G timed motor {motor} completed but diagnostic Hall feedback "
|
||||
f"was unavailable: {diagnostic_error}",
|
||||
@@ -1239,7 +1454,7 @@ class HardwareSeatExecutor:
|
||||
# the public Hall position. COMPLETE/OK still proves raw Hall edges
|
||||
# because the firmware refuses timed success without one.
|
||||
direction_verified = None
|
||||
if direction_verified is False:
|
||||
if direction_verified is False and endpoint_reached is None:
|
||||
raise HardwareExecutionError(
|
||||
f"T2G timed motor {motor} completed but Hall direction did not match "
|
||||
f"the command: before={hall_before}, after={hall_after}, direction={direction}",
|
||||
@@ -1254,16 +1469,53 @@ class HardwareSeatExecutor:
|
||||
},
|
||||
)
|
||||
|
||||
axis = MOTION_CAPABILITY_TO_AXIS[operations[0]["capability"]]
|
||||
effective_position_pct = (
|
||||
round(int(values["Value_X100"]) * 100 / axis_maximum)
|
||||
if values["Position_Valid"]
|
||||
else round(hall_after * 100 / max_hall)
|
||||
)
|
||||
effective_position_pct = max(0, min(100, effective_position_pct))
|
||||
requested_duration_ms = int(
|
||||
motion.get("requested_duration_ms", signals["Param_A"])
|
||||
)
|
||||
applied_duration_ms = int(
|
||||
motion.get("applied_duration_ms", signals["Param_A"])
|
||||
)
|
||||
maximum_duration_ms = int(
|
||||
motion.get("max_duration_ms", applied_duration_ms)
|
||||
)
|
||||
user_notice: str | None = None
|
||||
if user_timed_motion:
|
||||
notice_parts: list[str] = []
|
||||
if requested_duration_ms > maximum_duration_ms:
|
||||
notice_parts.append(
|
||||
f"{MOTION_AXIS_NOTICE_LABELS[axis]}轴最大允许时长为"
|
||||
f"{_seconds_text(maximum_duration_ms)}秒"
|
||||
)
|
||||
execution_notice = (
|
||||
f"座椅{MOTION_AXIS_NOTICE_LABELS[axis]}已执行"
|
||||
f"{_seconds_text(executed_duration_ms)}秒"
|
||||
)
|
||||
if endpoint_reached is not None:
|
||||
execution_notice += (
|
||||
f",到达{MOTION_ENDPOINT_NOTICE_LABELS[axis][endpoint_reached]}"
|
||||
)
|
||||
notice_parts.append(execution_notice)
|
||||
user_notice = ";".join(notice_parts) + "。"
|
||||
common_telemetry = {
|
||||
"motion_axis": axis,
|
||||
"motion_mode": "relative_timed_duty",
|
||||
"motion_mode": (
|
||||
"timed_user_request"
|
||||
if user_timed_motion
|
||||
else "relative_timed_duty"
|
||||
),
|
||||
"motor_selector": motor,
|
||||
"physical_target": "t2g_primary_seat",
|
||||
"sequence": sequence,
|
||||
"requested_position_pct": int(
|
||||
operations[0]["parameters"]["position_pct"]
|
||||
),
|
||||
"effective_position_pct": effective_position_pct,
|
||||
"requested_delta_position_pct": int(
|
||||
operations[0].get("motion", {}).get("delta_position_pct", 0)
|
||||
),
|
||||
@@ -1271,6 +1523,14 @@ class HardwareSeatExecutor:
|
||||
.get("motion", {})
|
||||
.get("delta_angle_deg"),
|
||||
"duration_ms": int(signals["Param_A"]),
|
||||
"requested_duration_ms": requested_duration_ms,
|
||||
"applied_duration_ms": applied_duration_ms,
|
||||
"max_duration_ms": maximum_duration_ms,
|
||||
"duration_clamped": bool(motion.get("duration_clamped", False)),
|
||||
"executed_duration_ms": executed_duration_ms,
|
||||
"endpoint_reached": endpoint_reached,
|
||||
"endpoint_terminal_acceptance": endpoint_terminal_acceptance,
|
||||
"user_notice": user_notice,
|
||||
"duty_percent": int(signals["Param_B"]),
|
||||
"direction": direction,
|
||||
"task_state": task_state,
|
||||
@@ -1291,8 +1551,11 @@ class HardwareSeatExecutor:
|
||||
"ack_frame": ack_frame.public(),
|
||||
"feedback_frame": feedback.public(),
|
||||
"diagnostic_before_frame": baseline_frame.public(),
|
||||
"diagnostic_frame": diagnostic_frame.public(),
|
||||
"diagnostic_frame": (
|
||||
diagnostic_frame.public() if diagnostic_frame is not None else None
|
||||
),
|
||||
"hardware_feedback": True,
|
||||
"current_diagnostic": self._current_diagnostic_summary(motor),
|
||||
"simulated": False,
|
||||
}
|
||||
return [
|
||||
@@ -1301,9 +1564,13 @@ class HardwareSeatExecutor:
|
||||
"domain": "seat",
|
||||
"status": "succeeded",
|
||||
"message": (
|
||||
f"physical motor {motor} COMPLETE/OK confirmed relative {axis} "
|
||||
f"movement with Hall {hall_before}->{hall_after}; absolute position "
|
||||
"remains unavailable until endpoint calibration"
|
||||
user_notice
|
||||
if user_notice is not None
|
||||
else (
|
||||
f"physical motor {motor} COMPLETE/OK confirmed relative {axis} "
|
||||
f"movement with Hall {hall_before}->{hall_after}; absolute position "
|
||||
"remains unavailable until endpoint calibration"
|
||||
)
|
||||
),
|
||||
"telemetry": {
|
||||
**common_telemetry,
|
||||
|
||||
@@ -1,19 +1,46 @@
|
||||
"""Deterministic fallback parser for the supported seat thermal vocabulary."""
|
||||
"""Deterministic fallback parser for supported seat-control vocabulary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
import re
|
||||
import unicodedata
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
|
||||
TARGET_PATTERN = re.compile(r"主副驾|前排|主驾|驾驶位|司机位|副驾|乘客位")
|
||||
TARGET_PATTERN = re.compile(
|
||||
r"主副驾|前排右侧|右前座椅|右前座|副驾驶座椅|副驾驶座|副驾驶|副驾位|"
|
||||
r"乘客座椅|乘客座|乘客位|主驾驶座椅|主驾驶座|主驾驶|驾驶位|驾驶座|"
|
||||
r"司机位|司机座|主驾|前排|副驾"
|
||||
)
|
||||
FEATURE_PATTERN = re.compile(r"通风|加热")
|
||||
STATE_PATTERN = re.compile(r"关闭|关掉|停止|取消|打开|开启|启动|开")
|
||||
LEVEL_PATTERN = re.compile(r"([1-5一二三四五])\s*档")
|
||||
STATE_PATTERN = re.compile(
|
||||
r"设置为|调到|调至|设为|开到|关闭|关掉|停止|取消|打开|开启|启动|开"
|
||||
)
|
||||
LEVEL_PATTERN = re.compile(r"(最高|最低|[1-5一二三四五高中低])\s*档")
|
||||
POSITION_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?:位置)?(?:调到|移动到|移到|设为|设置为|调至|移动至|到|为)?"
|
||||
r"(?P<position>\d{{1,3}})(?:%|%)"
|
||||
r"(?P<position>\d{{1,3}})%"
|
||||
)
|
||||
ANGLE_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?:夹角|角度)?(?:调到|放倒到|抬到|移动到|移到|设为|设置为|"
|
||||
r"调至|到|为)?(?P<angle>\d{{2,3}})(?:度|°)"
|
||||
)
|
||||
RELATIVE_ANGLE_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?P<direction>向前|往前|朝前|前倾|向后|往后|朝后|后仰|"
|
||||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下)"
|
||||
r"(?:移动|调节|调整|调|倾斜|抬|升|降)?(?P<delta>\d{{1,3}})(?:度|°)"
|
||||
)
|
||||
RELATIVE_PERCENT_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
|
||||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下)"
|
||||
r"(?:移动|调节|调整|调|倾斜|抬|升|降)?(?P<delta>\d{{1,3}})%"
|
||||
)
|
||||
TIMED_MOTION_PATTERN_TEMPLATE = (
|
||||
r"{alias}(?:持续|保持)?(?P<direction>向前|往前|朝前|前移|前倾|向后|往后|朝后|后移|后仰|"
|
||||
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下)"
|
||||
r"(?:移动|调节|调整|调|倾斜|抬|升|降|运行|运动|持续|保持|时长)*"
|
||||
r"(?P<duration>\d+(?:\.\d+)?)(?P<unit>毫秒|ms|秒|s|分钟|分|min)"
|
||||
)
|
||||
ZERO_POSITION_PATTERN = re.compile(r"归零|回零|复位|零位")
|
||||
VENTILATION_FEEDBACK_PATTERN = re.compile(r"(?<!不)(?<!加)热|(?<!不)闷|出汗")
|
||||
@@ -24,13 +51,35 @@ AIRFLOW_CAUSE_PATTERN = re.compile(r"吹|风太大|风吹")
|
||||
FIRST_PERSON_PATTERN = re.compile(r"我|自己")
|
||||
VEHICLE_SCOPE_PATTERN = re.compile(r"车上|车内|车里|全车|整车|整个车")
|
||||
PASSENGER_ONLY_QUESTION = "当前所有座椅能力仅控制副驾,不支持主驾座椅目标。"
|
||||
UNSUPPORTED_SEAT_SCOPE_PATTERN = re.compile(
|
||||
r"后排|第二排|二排|第三排|三排|左前座椅|左前座|左前|"
|
||||
r"右后座椅|右后座|右后|左后座椅|左后座|左后|"
|
||||
r"所有座椅|全部座椅|全车座椅"
|
||||
)
|
||||
UNSUPPORTED_SEAT_SCOPE_QUESTION = (
|
||||
"当前台架仅支持副驾座椅,不能把后排、其他方位或全部座椅请求默认到副驾。"
|
||||
)
|
||||
|
||||
TARGETS = {
|
||||
"主驾": ["driver"],
|
||||
"驾驶位": ["driver"],
|
||||
"司机位": ["driver"],
|
||||
"主驾驶": ["driver"],
|
||||
"主驾驶座": ["driver"],
|
||||
"主驾驶座椅": ["driver"],
|
||||
"驾驶座": ["driver"],
|
||||
"司机座": ["driver"],
|
||||
"副驾": ["passenger"],
|
||||
"副驾驶": ["passenger"],
|
||||
"副驾驶座": ["passenger"],
|
||||
"副驾驶座椅": ["passenger"],
|
||||
"副驾位": ["passenger"],
|
||||
"乘客位": ["passenger"],
|
||||
"乘客座": ["passenger"],
|
||||
"乘客座椅": ["passenger"],
|
||||
"右前座": ["passenger"],
|
||||
"右前座椅": ["passenger"],
|
||||
"前排右侧": ["passenger"],
|
||||
"主副驾": ["driver", "passenger"],
|
||||
"前排": ["driver", "passenger"],
|
||||
}
|
||||
@@ -40,18 +89,384 @@ STATES = {
|
||||
"开启": "on",
|
||||
"启动": "on",
|
||||
"开": "on",
|
||||
"开到": "on",
|
||||
"调到": "on",
|
||||
"调至": "on",
|
||||
"设置为": "on",
|
||||
"设为": "on",
|
||||
"关闭": "off",
|
||||
"关掉": "off",
|
||||
"停止": "off",
|
||||
"取消": "off",
|
||||
}
|
||||
LEVELS = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5}
|
||||
MOTION_AXIS_ALIASES = {
|
||||
"longitudinal": ("水平", "前后", "纵向"),
|
||||
"cushion_tilt": ("坐盆", "坐垫"),
|
||||
"backrest_recline": ("靠背",),
|
||||
"legrest_height": ("腿托", "腿部支撑"),
|
||||
LEVELS = {
|
||||
"一": 1,
|
||||
"二": 2,
|
||||
"三": 3,
|
||||
"四": 4,
|
||||
"五": 5,
|
||||
"最低": 1,
|
||||
"低": 1,
|
||||
"中": 2,
|
||||
"最高": 3,
|
||||
"高": 3,
|
||||
}
|
||||
MOTION_AXIS_ALIASES = {
|
||||
"longitudinal": ("水平", "前后", "纵向", "滑轨"),
|
||||
"cushion_tilt": ("坐垫倾角", "坐盆", "坐垫"),
|
||||
"backrest_recline": ("靠背", "椅背"),
|
||||
"legrest_height": ("腿部支撑", "小腿托", "腿托", "脚托"),
|
||||
}
|
||||
MOTION_ANGLE_RANGES = {
|
||||
"backrest_recline": (95, 163),
|
||||
"legrest_height": (95, 167),
|
||||
}
|
||||
CHINESE_PERCENT_PATTERN = re.compile(r"百分之([零一二三四五六七八九十百两\d]+)")
|
||||
CHINESE_DEGREE_PATTERN = re.compile(r"([零一二三四五六七八九十百两]+)(度)")
|
||||
CHINESE_DURATION_PATTERN = re.compile(
|
||||
r"([零一二三四五六七八九十百两]+)(毫秒|秒|分钟|分)"
|
||||
)
|
||||
RELATIVE_DIRECTION_SIGNS = {
|
||||
"longitudinal": {
|
||||
"increase": {"向后", "往后", "朝后", "后移"},
|
||||
"decrease": {"向前", "往前", "朝前", "前移"},
|
||||
},
|
||||
"cushion_tilt": {
|
||||
"increase": {"向下", "往下", "降低", "降下"},
|
||||
"decrease": {"向上", "往上", "抬高", "抬起", "升高"},
|
||||
},
|
||||
"backrest_recline": {
|
||||
"increase": {"向后", "往后", "朝后", "后仰"},
|
||||
"decrease": {"向前", "往前", "朝前", "前倾"},
|
||||
},
|
||||
"legrest_height": {
|
||||
"increase": {"向上", "往上", "抬高", "抬起", "升高"},
|
||||
"decrease": {"向下", "往下", "降低", "降下"},
|
||||
},
|
||||
}
|
||||
TIMED_MOTION_MIN_DURATION_MS = 300
|
||||
TIMED_AXIS_MAX_DURATION_MS = {
|
||||
"longitudinal": 30_000,
|
||||
"cushion_tilt": 20_000,
|
||||
"backrest_recline": 30_000,
|
||||
"legrest_height": 25_000,
|
||||
}
|
||||
TIMED_AXIS_MS_PER_PCT = {
|
||||
"longitudinal": 120,
|
||||
"cushion_tilt": 80,
|
||||
"backrest_recline": 120,
|
||||
"legrest_height": 100,
|
||||
}
|
||||
MOTION_AXIS_LABELS = {
|
||||
"longitudinal": "水平轴",
|
||||
"cushion_tilt": "坐垫轴",
|
||||
"backrest_recline": "靠背轴",
|
||||
"legrest_height": "腿托轴",
|
||||
}
|
||||
MOTION_ENDPOINT_LABELS = {
|
||||
"longitudinal": {"increase": "最后方", "decrease": "最前方"},
|
||||
"cushion_tilt": {"increase": "最下方", "decrease": "最上方"},
|
||||
"backrest_recline": {"increase": "最后方", "decrease": "最前方"},
|
||||
"legrest_height": {"increase": "最上方", "decrease": "最下方"},
|
||||
}
|
||||
|
||||
|
||||
def _chinese_integer(token: str) -> int | None:
|
||||
if token.isdigit():
|
||||
return int(token)
|
||||
digits = {
|
||||
"零": 0,
|
||||
"一": 1,
|
||||
"二": 2,
|
||||
"两": 2,
|
||||
"三": 3,
|
||||
"四": 4,
|
||||
"五": 5,
|
||||
"六": 6,
|
||||
"七": 7,
|
||||
"八": 8,
|
||||
"九": 9,
|
||||
}
|
||||
total = 0
|
||||
current = 0
|
||||
for char in token:
|
||||
if char in digits:
|
||||
current = digits[char]
|
||||
elif char == "十":
|
||||
total += max(current, 1) * 10
|
||||
current = 0
|
||||
elif char == "百":
|
||||
total += max(current, 1) * 100
|
||||
current = 0
|
||||
else:
|
||||
return None
|
||||
return total + current
|
||||
|
||||
|
||||
def _normalize_text(value: str) -> str:
|
||||
text = unicodedata.normalize("NFKC", value.strip())
|
||||
text = text.replace("半分钟", "0.5分钟").replace("半秒", "0.5秒")
|
||||
text = re.sub(r"座椅(?:吹风|送风)", "座椅通风", text)
|
||||
text = re.sub(r"座椅(?:取暖|加温)", "座椅加热", text)
|
||||
text = text.replace("座椅暖一下", "座椅加热打开")
|
||||
|
||||
def replace_percent(match: re.Match[str]) -> str:
|
||||
number = _chinese_integer(match.group(1))
|
||||
return match.group(0) if number is None else f"{number}%"
|
||||
|
||||
text = CHINESE_PERCENT_PATTERN.sub(replace_percent, text)
|
||||
|
||||
def replace_degree(match: re.Match[str]) -> str:
|
||||
number = _chinese_integer(match.group(1))
|
||||
return match.group(0) if number is None else f"{number}{match.group(2)}"
|
||||
|
||||
text = CHINESE_DEGREE_PATTERN.sub(replace_degree, text)
|
||||
|
||||
def replace_duration(match: re.Match[str]) -> str:
|
||||
number = _chinese_integer(match.group(1))
|
||||
return match.group(0) if number is None else f"{number}{match.group(2)}"
|
||||
|
||||
text = CHINESE_DURATION_PATTERN.sub(replace_duration, text)
|
||||
return re.sub(r"\s+", "", text)
|
||||
|
||||
|
||||
def _relative_direction(axis: str, token: str) -> str | None:
|
||||
directions = RELATIVE_DIRECTION_SIGNS[axis]
|
||||
for operation, tokens in directions.items():
|
||||
if token in tokens:
|
||||
return operation
|
||||
return None
|
||||
|
||||
|
||||
def _context_motion_state(
|
||||
context: dict[str, Any] | None, axis: str
|
||||
) -> dict[str, Any] | None:
|
||||
if not context:
|
||||
return None
|
||||
state = (
|
||||
context.get("devices", {})
|
||||
.get("passenger", {})
|
||||
.get("position", {})
|
||||
.get(axis)
|
||||
)
|
||||
return state if isinstance(state, dict) else None
|
||||
|
||||
|
||||
def _motor_range(
|
||||
context: dict[str, Any] | None, axis: str
|
||||
) -> tuple[float, tuple[float, float] | None]:
|
||||
motor = (
|
||||
(context or {})
|
||||
.get("vehicle", {})
|
||||
.get("seat", {})
|
||||
.get("motors", {})
|
||||
.get(axis, {})
|
||||
)
|
||||
configured_range = motor.get("range") if isinstance(motor, dict) else None
|
||||
angle_range = MOTION_ANGLE_RANGES.get(axis)
|
||||
if angle_range is not None:
|
||||
display_angle = motor.get("display_angle") if isinstance(motor, dict) else None
|
||||
if isinstance(display_angle, dict):
|
||||
zero = display_angle.get("zero_deg")
|
||||
maximum = display_angle.get("max_deg")
|
||||
if isinstance(zero, (int, float)) and isinstance(maximum, (int, float)):
|
||||
angle_range = (float(zero), float(maximum))
|
||||
if isinstance(configured_range, (int, float)) and configured_range > 0:
|
||||
return float(configured_range), angle_range
|
||||
if angle_range is not None:
|
||||
return float(angle_range[1] - angle_range[0]), angle_range
|
||||
return 100.0, None
|
||||
|
||||
|
||||
def _clean_number(value: float) -> int | float:
|
||||
return int(value) if float(value).is_integer() else round(float(value), 1)
|
||||
|
||||
|
||||
def _duration_ms(value: str, unit: str) -> int:
|
||||
multiplier = {
|
||||
"毫秒": 1,
|
||||
"ms": 1,
|
||||
"秒": 1_000,
|
||||
"s": 1_000,
|
||||
"分钟": 60_000,
|
||||
"分": 60_000,
|
||||
"min": 60_000,
|
||||
}[unit]
|
||||
return round(float(value) * multiplier)
|
||||
|
||||
|
||||
def _format_seconds(duration_ms: int) -> str:
|
||||
seconds = duration_ms / 1_000
|
||||
return f"{seconds:g}"
|
||||
|
||||
|
||||
def _timed_motion_action(
|
||||
axis: str,
|
||||
direction_token: str,
|
||||
requested_duration_ms: int,
|
||||
context: dict[str, Any] | None,
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
operation = _relative_direction(axis, direction_token)
|
||||
if operation is None:
|
||||
return None, f"{direction_token}不适用于该座椅轴,请确认移动方向。"
|
||||
if requested_duration_ms <= 0:
|
||||
return None, "座椅按时长移动的时长必须大于0。"
|
||||
state = _context_motion_state(context, axis)
|
||||
if state is None:
|
||||
return None, "缺少当前座椅位置,无法安全计算按时长移动的方向和备用端点。"
|
||||
current_pct = state.get("position_pct")
|
||||
if (
|
||||
not isinstance(current_pct, (int, float))
|
||||
or isinstance(current_pct, bool)
|
||||
or not 0 <= float(current_pct) <= 100
|
||||
):
|
||||
return None, "当前座椅位置无效,无法安全执行按时长移动。"
|
||||
|
||||
maximum_duration_ms = TIMED_AXIS_MAX_DURATION_MS[axis]
|
||||
applied_duration_ms = min(
|
||||
maximum_duration_ms,
|
||||
max(TIMED_MOTION_MIN_DURATION_MS, requested_duration_ms),
|
||||
)
|
||||
duration_clamped = applied_duration_ms != requested_duration_ms
|
||||
estimated_delta_pct = applied_duration_ms / TIMED_AXIS_MS_PER_PCT[axis]
|
||||
signed_delta = estimated_delta_pct if operation == "increase" else -estimated_delta_pct
|
||||
raw_target = float(current_pct) + signed_delta
|
||||
target_pct = round(max(0.0, min(100.0, raw_target)))
|
||||
endpoint_expected = None
|
||||
if raw_target >= 100:
|
||||
endpoint_expected = "far"
|
||||
elif raw_target <= 0:
|
||||
endpoint_expected = "near"
|
||||
|
||||
motion_intent: dict[str, Any] = {
|
||||
"mode": "timed",
|
||||
"direction": operation,
|
||||
"start_position_pct": round(float(current_pct)),
|
||||
"delta_position_pct": abs(target_pct - round(float(current_pct))),
|
||||
"requested_duration_ms": requested_duration_ms,
|
||||
"applied_duration_ms": applied_duration_ms,
|
||||
"max_duration_ms": maximum_duration_ms,
|
||||
"min_duration_ms": TIMED_MOTION_MIN_DURATION_MS,
|
||||
"duration_clamped": duration_clamped,
|
||||
}
|
||||
if endpoint_expected is not None:
|
||||
motion_intent["endpoint_expected"] = endpoint_expected
|
||||
|
||||
action: dict[str, Any] = {
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": axis,
|
||||
"position_pct": target_pct,
|
||||
"motion_intent": motion_intent,
|
||||
}
|
||||
_, angle_range = _motor_range(context, axis)
|
||||
if angle_range is not None:
|
||||
target_angle = angle_range[0] + (
|
||||
angle_range[1] - angle_range[0]
|
||||
) * target_pct / 100
|
||||
action["angle_deg"] = _clean_number(target_angle)
|
||||
|
||||
axis_label = MOTION_AXIS_LABELS[axis]
|
||||
if requested_duration_ms > maximum_duration_ms:
|
||||
action["user_notice"] = (
|
||||
f"{axis_label}最大允许时长为{_format_seconds(maximum_duration_ms)}秒;"
|
||||
f"已将用户请求的{_format_seconds(requested_duration_ms)}秒钳制为"
|
||||
f"{_format_seconds(applied_duration_ms)}秒并继续执行。"
|
||||
)
|
||||
elif requested_duration_ms < TIMED_MOTION_MIN_DURATION_MS:
|
||||
action["user_notice"] = (
|
||||
f"{axis_label}最小允许时长为{_format_seconds(TIMED_MOTION_MIN_DURATION_MS)}秒;"
|
||||
f"已按{_format_seconds(applied_duration_ms)}秒继续执行。"
|
||||
)
|
||||
else:
|
||||
action["user_notice"] = (
|
||||
f"{axis_label}将按时长执行{_format_seconds(applied_duration_ms)}秒。"
|
||||
)
|
||||
if endpoint_expected is not None:
|
||||
endpoint_label = MOTION_ENDPOINT_LABELS[axis][operation]
|
||||
action["user_notice"] += f" 若提前触达机械端点,将在{endpoint_label}停止并按成功返回。"
|
||||
return action, None
|
||||
|
||||
|
||||
def _relative_motion_action(
|
||||
axis: str,
|
||||
direction_token: str,
|
||||
delta: int,
|
||||
unit: str,
|
||||
context: dict[str, Any] | None,
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
operation = _relative_direction(axis, direction_token)
|
||||
if operation is None:
|
||||
return None, f"{direction_token}不适用于该座椅轴,请确认移动方向。"
|
||||
if delta <= 0:
|
||||
return None, "座椅相对移动量必须大于0。"
|
||||
state = _context_motion_state(context, axis)
|
||||
if state is None:
|
||||
return None, "缺少当前座椅位置,无法安全计算相对移动目标。"
|
||||
current_pct = state.get("position_pct")
|
||||
if (
|
||||
not isinstance(current_pct, (int, float))
|
||||
or isinstance(current_pct, bool)
|
||||
or not 0 <= float(current_pct) <= 100
|
||||
):
|
||||
return None, "当前座椅位置无效,无法安全计算相对移动目标。"
|
||||
|
||||
physical_range, angle_range = _motor_range(context, axis)
|
||||
if unit == "degree" and angle_range is None:
|
||||
return None, "该座椅轴没有角度标定,不能按角度执行相对移动。"
|
||||
delta_pct = float(delta) if unit == "percent" else delta / physical_range * 100
|
||||
signed_delta = delta_pct if operation == "increase" else -delta_pct
|
||||
raw_target = float(current_pct) + signed_delta
|
||||
clamped = not 0 <= raw_target <= 100
|
||||
target_pct = round(max(0.0, min(100.0, raw_target)))
|
||||
if target_pct == round(float(current_pct)) and not clamped:
|
||||
return None, "相对移动量换算后小于该座椅轴的最小可执行步长。"
|
||||
|
||||
motion_intent: dict[str, Any] = {
|
||||
"mode": "relative",
|
||||
"direction": operation,
|
||||
"start_position_pct": round(float(current_pct)),
|
||||
"delta_position_pct": abs(target_pct - round(float(current_pct))),
|
||||
}
|
||||
action: dict[str, Any] = {
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": axis,
|
||||
"position_pct": target_pct,
|
||||
"motion_intent": motion_intent,
|
||||
}
|
||||
if unit == "degree" and angle_range is not None:
|
||||
current_angle = state.get("angle_deg")
|
||||
if not isinstance(current_angle, (int, float)) or isinstance(
|
||||
current_angle, bool
|
||||
):
|
||||
current_angle = angle_range[0] + (
|
||||
angle_range[1] - angle_range[0]
|
||||
) * float(current_pct) / 100
|
||||
signed_angle = float(delta) if operation == "increase" else -float(delta)
|
||||
raw_target_angle = float(current_angle) + signed_angle
|
||||
target_angle = max(angle_range[0], min(angle_range[1], raw_target_angle))
|
||||
clamped = clamped or target_angle != raw_target_angle
|
||||
action["angle_deg"] = _clean_number(target_angle)
|
||||
motion_intent["start_angle_deg"] = float(current_angle)
|
||||
motion_intent["delta_angle_deg"] = abs(target_angle - float(current_angle))
|
||||
if clamped:
|
||||
motion_intent["requested_delta_angle_deg"] = float(delta)
|
||||
if clamped:
|
||||
motion_intent["range_clamped"] = True
|
||||
motion_intent["requested_delta_position_pct"] = round(abs(delta_pct))
|
||||
endpoint_label = MOTION_ENDPOINT_LABELS[axis][operation]
|
||||
requested_value = f"{delta}度" if unit == "degree" else f"{delta}%"
|
||||
applied_value = (
|
||||
f"{_clean_number(motion_intent.get('delta_angle_deg', 0))}度"
|
||||
if unit == "degree"
|
||||
else f"{motion_intent['delta_position_pct']}%"
|
||||
)
|
||||
action["user_notice"] = (
|
||||
f"{MOTION_AXIS_LABELS[axis]}{direction_token}移动{requested_value}超过剩余行程;"
|
||||
f"已采用备用目标,实际移动最多{applied_value}并到达{endpoint_label}。"
|
||||
)
|
||||
return action, None
|
||||
|
||||
|
||||
def _nearest_preceding_target(clause: str, position: int) -> str | None:
|
||||
@@ -175,7 +590,7 @@ def resolve_contextual_intent(
|
||||
"""Resolve implicit comfort feedback against the current virtual seat state."""
|
||||
|
||||
result = deepcopy(parsed)
|
||||
text = re.sub(r"\s+", "", utterance.strip())
|
||||
text = _normalize_text(utterance)
|
||||
ventilation_cues = list(dict.fromkeys(VENTILATION_FEEDBACK_PATTERN.findall(text)))
|
||||
cold_cues = list(dict.fromkeys(HEATING_FEEDBACK_PATTERN.findall(text)))
|
||||
if not ventilation_cues and not cold_cues:
|
||||
@@ -369,12 +784,28 @@ def parse_local_intent(
|
||||
) -> dict[str, Any]:
|
||||
"""Parse common Chinese seat commands without making hardware assumptions."""
|
||||
|
||||
text = re.sub(r"\s+", "", utterance.strip())
|
||||
text = _normalize_text(utterance)
|
||||
clauses = [part for part in re.split(r"[,,。;;!!??]", text) if part]
|
||||
actions: list[dict[str, Any]] = []
|
||||
motion_actions: list[dict[str, Any]] = []
|
||||
ambiguities: list[dict[str, str]] = []
|
||||
|
||||
unsupported_scope = UNSUPPORTED_SEAT_SCOPE_PATTERN.search(text)
|
||||
if unsupported_scope is not None:
|
||||
return {
|
||||
"actions": [],
|
||||
"ambiguities": [
|
||||
{
|
||||
"field": "target",
|
||||
"question": UNSUPPORTED_SEAT_SCOPE_QUESTION,
|
||||
}
|
||||
],
|
||||
"decision_summary": [
|
||||
f"识别到未支持的座椅目标范围“{unsupported_scope.group(0)}”,未生成副驾动作。"
|
||||
],
|
||||
"confidence": 0.99,
|
||||
}
|
||||
|
||||
mentioned_axes = [
|
||||
axis
|
||||
for axis, aliases in MOTION_AXIS_ALIASES.items()
|
||||
@@ -399,7 +830,21 @@ def parse_local_intent(
|
||||
|
||||
for clause in clauses:
|
||||
level = _level_for_clause(clause)
|
||||
for feature_match in FEATURE_PATTERN.finditer(clause):
|
||||
clause_states = {
|
||||
STATES[match.group(0)] for match in STATE_PATTERN.finditer(clause)
|
||||
}
|
||||
feature_matches = list(FEATURE_PATTERN.finditer(clause))
|
||||
contradictory_feature_state = len(clause_states) > 1 and bool(feature_matches)
|
||||
if contradictory_feature_state:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": "passenger.thermal.state",
|
||||
"question": "同一条指令同时包含打开和关闭,请确认座椅功能的最终状态。",
|
||||
}
|
||||
)
|
||||
for feature_match in feature_matches:
|
||||
if contradictory_feature_state:
|
||||
continue
|
||||
feature_zh = feature_match.group(0)
|
||||
target_zh = _nearest_preceding_target(clause, feature_match.start())
|
||||
state = _state_for_feature(clause, feature_match.end())
|
||||
@@ -410,28 +855,245 @@ def parse_local_intent(
|
||||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||||
)
|
||||
continue
|
||||
if state is None:
|
||||
if level is not None and level > 3:
|
||||
ambiguities.append(
|
||||
{"field": "state", "question": f"请确认{target_zh}{feature_zh}是打开还是关闭。"}
|
||||
{
|
||||
"field": f"passenger.{FEATURES[feature_zh]}.level",
|
||||
"question": f"{feature_zh}档位必须在1到3档之间。",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if state is None and level is not None:
|
||||
state = "on"
|
||||
if state is None:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": "state",
|
||||
"question": f"请确认{target_zh}{feature_zh}是打开还是关闭。",
|
||||
}
|
||||
)
|
||||
continue
|
||||
requested_level = level
|
||||
if state == "on" and requested_level is None:
|
||||
requested_level = 1
|
||||
for target in TARGETS[target_zh]:
|
||||
actions.append(
|
||||
{
|
||||
"target": target,
|
||||
"function": FEATURES[feature_zh],
|
||||
"state": state,
|
||||
"requested_level": level,
|
||||
"requested_level": requested_level,
|
||||
}
|
||||
)
|
||||
|
||||
if ZERO_POSITION_PATTERN.search(text):
|
||||
continue
|
||||
for axis, aliases in MOTION_AXIS_ALIASES.items():
|
||||
|
||||
clause_axes = [
|
||||
axis
|
||||
for axis, aliases in MOTION_AXIS_ALIASES.items()
|
||||
if any(alias in clause for alias in aliases)
|
||||
]
|
||||
percentages = list(re.finditer(r"(?P<position>\d{1,3})%", clause))
|
||||
shared_position = None
|
||||
if len(clause_axes) > 1 and len(percentages) == 1 and re.search(
|
||||
r"都|全部|均", clause
|
||||
):
|
||||
shared_position = int(percentages[0].group("position"))
|
||||
target_zh = (
|
||||
_nearest_preceding_target(clause, percentages[0].start()) or "副驾"
|
||||
)
|
||||
if "driver" in TARGETS[target_zh]:
|
||||
ambiguities.append(
|
||||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||||
)
|
||||
else:
|
||||
requested_shared_position = shared_position
|
||||
shared_position = min(100, shared_position)
|
||||
motion_actions.extend(
|
||||
{
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": axis,
|
||||
"position_pct": shared_position,
|
||||
**(
|
||||
{
|
||||
"user_notice": (
|
||||
f"{MOTION_AXIS_LABELS[axis]}请求位置"
|
||||
f"{requested_shared_position}%超过最大行程;"
|
||||
"已采用备用目标并移动到对应机械末端。"
|
||||
)
|
||||
}
|
||||
if requested_shared_position > 100
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for axis in clause_axes
|
||||
)
|
||||
|
||||
if shared_position is not None:
|
||||
continue
|
||||
|
||||
timed_axes: set[str] = set()
|
||||
timed_aliases = {
|
||||
**MOTION_AXIS_ALIASES,
|
||||
"longitudinal": (*MOTION_AXIS_ALIASES["longitudinal"], "座椅", "座位"),
|
||||
}
|
||||
for axis, aliases in timed_aliases.items():
|
||||
for alias in aliases:
|
||||
match = re.search(
|
||||
TIMED_MOTION_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
|
||||
clause,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if match is None:
|
||||
continue
|
||||
timed_axes.add(axis)
|
||||
target_zh = _nearest_preceding_target(clause, match.start()) or "副驾"
|
||||
if "driver" in TARGETS[target_zh]:
|
||||
ambiguities.append(
|
||||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||||
)
|
||||
break
|
||||
action, question = _timed_motion_action(
|
||||
axis,
|
||||
match.group("direction"),
|
||||
_duration_ms(match.group("duration"), match.group("unit").lower()),
|
||||
context,
|
||||
)
|
||||
if action is None:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"passenger.position.{axis}",
|
||||
"question": question or "无法计算座椅按时长移动目标。",
|
||||
}
|
||||
)
|
||||
else:
|
||||
motion_actions.append(action)
|
||||
break
|
||||
|
||||
relative_axes: set[str] = set()
|
||||
for axis, aliases in MOTION_AXIS_ALIASES.items():
|
||||
if axis in timed_axes:
|
||||
continue
|
||||
for alias in aliases:
|
||||
angle_match = re.search(
|
||||
RELATIVE_ANGLE_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
|
||||
clause,
|
||||
)
|
||||
percent_match = re.search(
|
||||
RELATIVE_PERCENT_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
|
||||
clause,
|
||||
)
|
||||
match = angle_match or percent_match
|
||||
if match is None:
|
||||
continue
|
||||
relative_axes.add(axis)
|
||||
target_zh = _nearest_preceding_target(clause, match.start()) or "副驾"
|
||||
if "driver" in TARGETS[target_zh]:
|
||||
ambiguities.append(
|
||||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||||
)
|
||||
break
|
||||
unit = "degree" if angle_match is not None else "percent"
|
||||
action, question = _relative_motion_action(
|
||||
axis,
|
||||
match.group("direction"),
|
||||
int(match.group("delta")),
|
||||
unit,
|
||||
context,
|
||||
)
|
||||
if action is None:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"passenger.position.{axis}",
|
||||
"question": question or "无法计算座椅相对移动目标。",
|
||||
}
|
||||
)
|
||||
else:
|
||||
motion_actions.append(action)
|
||||
break
|
||||
|
||||
endpoint_actions: dict[str, int] = {}
|
||||
has_specific_non_longitudinal_axis = any(
|
||||
any(alias in clause for alias in MOTION_AXIS_ALIASES[axis])
|
||||
for axis in ("cushion_tilt", "backrest_recline", "legrest_height")
|
||||
)
|
||||
has_longitudinal_axis = any(
|
||||
alias in clause for alias in MOTION_AXIS_ALIASES["longitudinal"]
|
||||
) or (
|
||||
not has_specific_non_longitudinal_axis
|
||||
and any(alias in clause for alias in ("座椅", "座位"))
|
||||
)
|
||||
if has_longitudinal_axis:
|
||||
if re.search(r"最前面|最前|向前移到底|推到最前", clause):
|
||||
endpoint_actions["longitudinal"] = 0
|
||||
elif re.search(r"最后面|最后|向后移到底|移到最后", clause):
|
||||
endpoint_actions["longitudinal"] = 100
|
||||
cushion_alias = r"(?:坐垫倾角|坐盆|坐垫)"
|
||||
if re.search(
|
||||
cushion_alias + r"(?:抬到最上方|调到最上方|移到最上方|最高|抬到头|抬到底)",
|
||||
clause,
|
||||
):
|
||||
endpoint_actions["cushion_tilt"] = 0
|
||||
elif re.search(
|
||||
cushion_alias + r"(?:降到最下方|调到最下方|移到最下方|最低|降到头|降到底)",
|
||||
clause,
|
||||
):
|
||||
endpoint_actions["cushion_tilt"] = 100
|
||||
backrest_alias = r"(?:靠背|椅背)"
|
||||
if re.search(backrest_alias + r"(?:调直|立直|竖直)", clause):
|
||||
endpoint_actions["backrest_recline"] = 0
|
||||
elif re.search(
|
||||
backrest_alias
|
||||
+ r"(?:放倒到最后|放倒到底|完全放倒|后仰到底|向后移到底|最大角度)",
|
||||
clause,
|
||||
):
|
||||
endpoint_actions["backrest_recline"] = 100
|
||||
legrest_alias = r"(?:腿部支撑|小腿托|腿托|脚托)"
|
||||
if re.search(
|
||||
legrest_alias
|
||||
+ r"(?:收起|收回|降到最下方|调到最下方|降到最低|最低|降到头|降到底)",
|
||||
clause,
|
||||
):
|
||||
endpoint_actions["legrest_height"] = 0
|
||||
elif re.search(
|
||||
legrest_alias
|
||||
+ r"(?:升到最上方|抬到最上方|调到最上方|升到最高|抬到最高|最高|完全抬起|升到头|升到底|抬到底)",
|
||||
clause,
|
||||
):
|
||||
endpoint_actions["legrest_height"] = 100
|
||||
for axis, position_pct in endpoint_actions.items():
|
||||
target_zh = _nearest_preceding_target(clause, len(clause)) or "副驾"
|
||||
if "driver" in TARGETS[target_zh]:
|
||||
ambiguities.append(
|
||||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||||
)
|
||||
continue
|
||||
motion_actions.append(
|
||||
{
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": axis,
|
||||
"position_pct": position_pct,
|
||||
}
|
||||
)
|
||||
|
||||
for axis, aliases in MOTION_AXIS_ALIASES.items():
|
||||
if axis in endpoint_actions or axis in relative_axes or axis in timed_axes:
|
||||
continue
|
||||
for alias in aliases:
|
||||
position_match = re.search(
|
||||
POSITION_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
|
||||
clause,
|
||||
)
|
||||
angle_match = None
|
||||
if axis in MOTION_ANGLE_RANGES:
|
||||
angle_match = re.search(
|
||||
ANGLE_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
|
||||
clause,
|
||||
)
|
||||
match = position_match or angle_match
|
||||
if match is None:
|
||||
continue
|
||||
target_zh = _nearest_preceding_target(clause, match.start()) or "副驾"
|
||||
@@ -440,23 +1102,43 @@ def parse_local_intent(
|
||||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||||
)
|
||||
break
|
||||
position_pct = int(match.group("position"))
|
||||
if position_pct > 100:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"passenger.position.{axis}",
|
||||
"question": f"{alias}位置百分比必须在0到100之间。",
|
||||
}
|
||||
absolute_angle = None
|
||||
if position_match is not None:
|
||||
requested_position_pct = int(position_match.group("position"))
|
||||
position_pct = min(100, requested_position_pct)
|
||||
else:
|
||||
requested_angle = int(angle_match.group("angle"))
|
||||
minimum, maximum = MOTION_ANGLE_RANGES[axis]
|
||||
angle = max(minimum, min(maximum, requested_angle))
|
||||
position_pct = round(
|
||||
(angle - minimum) * 100 / (maximum - minimum)
|
||||
)
|
||||
break
|
||||
motion_actions.append(
|
||||
{
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": axis,
|
||||
"position_pct": position_pct,
|
||||
}
|
||||
)
|
||||
absolute_angle = angle
|
||||
motion_action = {
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": axis,
|
||||
"position_pct": position_pct,
|
||||
}
|
||||
if absolute_angle is not None:
|
||||
motion_action["angle_deg"] = absolute_angle
|
||||
if requested_angle != absolute_angle:
|
||||
endpoint_label = (
|
||||
MOTION_ENDPOINT_LABELS[axis]["increase"]
|
||||
if requested_angle > maximum
|
||||
else MOTION_ENDPOINT_LABELS[axis]["decrease"]
|
||||
)
|
||||
motion_action["user_notice"] = (
|
||||
f"{MOTION_AXIS_LABELS[axis]}请求角度{requested_angle}度超过"
|
||||
f"{minimum}到{maximum}度行程;已采用备用目标"
|
||||
f"{absolute_angle}度并移动到{endpoint_label}。"
|
||||
)
|
||||
elif requested_position_pct != position_pct:
|
||||
motion_action["user_notice"] = (
|
||||
f"{MOTION_AXIS_LABELS[axis]}请求位置{requested_position_pct}%超过"
|
||||
"0到100%行程;已采用100%的备用目标并移动到机械末端。"
|
||||
)
|
||||
motion_actions.append(motion_action)
|
||||
break
|
||||
|
||||
merged: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
@@ -498,6 +1180,15 @@ def parse_local_intent(
|
||||
actions = parsed["actions"]
|
||||
ambiguities = parsed["ambiguities"]
|
||||
|
||||
if (
|
||||
not actions
|
||||
and _contains_unsupported_driver_target(text)
|
||||
and not any(item.get("field") == "target" for item in ambiguities)
|
||||
):
|
||||
ambiguities.append(
|
||||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||||
)
|
||||
|
||||
if not actions and not ambiguities:
|
||||
ambiguities.append(
|
||||
{
|
||||
@@ -527,6 +1218,8 @@ def parse_local_intent(
|
||||
f"目标:副驾座椅{axis_labels[action['axis']]}位置"
|
||||
f"{action['position_pct']}%。"
|
||||
)
|
||||
if action.get("user_notice"):
|
||||
summary.append(str(action["user_notice"]))
|
||||
else:
|
||||
summary.append(
|
||||
f"目标:{labels[action['target']]}座椅"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
from cognitive_agent.infrastructure.can.hardware import HardwareSafetyError
|
||||
|
||||
|
||||
class SimulatedHardwareIntegrationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.service = SeatDemoService(REPOSITORY_ROOT)
|
||||
self.service.simulated_hardware_enabled = True
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary_directory.cleanup)
|
||||
self.service.artifact_root = Path(self.temporary_directory.name)
|
||||
|
||||
def test_normal_simulated_hardware_is_explicit_and_isolated(self) -> None:
|
||||
result = self.service.process(
|
||||
"副驾靠背调到50%",
|
||||
provider="local",
|
||||
execution_mode="simulated_hardware",
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
report = result["execution_report"]
|
||||
self.assertEqual(report["spec"]["mode"], "hil")
|
||||
self.assertEqual(report["metadata"]["labels"]["simulation"], "true")
|
||||
self.assertEqual(
|
||||
report["metadata"]["labels"]["seat_feedback"],
|
||||
"simulated_hardware",
|
||||
)
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertTrue(telemetry["simulation"])
|
||||
self.assertFalse(telemetry["physical_can_opened"])
|
||||
|
||||
def test_each_fault_scenario_produces_a_failed_simulated_report(self) -> None:
|
||||
expected_errors = {
|
||||
"feedback_timeout": "FeedbackTimeout",
|
||||
"adapter_disconnect": "CanAdapterDisconnected",
|
||||
"motor_stall": "MotorStall",
|
||||
"firmware_mismatch": "FirmwareMismatch",
|
||||
"partial_feedback": "PartialFeedback",
|
||||
}
|
||||
for scenario, error_type in expected_errors.items():
|
||||
with self.subTest(scenario=scenario):
|
||||
result = self.service.process(
|
||||
"副驾靠背调到50%",
|
||||
provider="local",
|
||||
execution_mode="simulated_hardware",
|
||||
simulation_fault=scenario,
|
||||
)
|
||||
self.assertEqual(result["status"], "execution_failed")
|
||||
report = result["execution_report"]
|
||||
self.assertEqual(
|
||||
report["metadata"]["labels"]["fault_scenario"], scenario
|
||||
)
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertEqual(telemetry["error_type"], error_type)
|
||||
self.assertFalse(telemetry["physical_can_opened"])
|
||||
|
||||
def test_simulated_hardware_requires_server_side_enablement(self) -> None:
|
||||
self.service.simulated_hardware_enabled = False
|
||||
with self.assertRaises(HardwareSafetyError):
|
||||
self.service.process(
|
||||
"副驾靠背调到50%",
|
||||
provider="local",
|
||||
execution_mode="simulated_hardware",
|
||||
)
|
||||
|
||||
def test_runtime_publishes_fault_catalog_without_enabling_real_hardware(self) -> None:
|
||||
runtime = self.service.runtime_info()
|
||||
self.assertTrue(runtime["simulated_hardware"]["enabled"])
|
||||
self.assertFalse(runtime["simulated_hardware"]["physical_can_opened"])
|
||||
self.assertIn("motor_stall", runtime["simulated_hardware"]["fault_scenarios"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -22,6 +22,17 @@ class LocalIntentParserTests(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def motion_context(cls) -> dict:
|
||||
context = cls.context()
|
||||
context["devices"]["passenger"]["position"] = {
|
||||
"longitudinal": {"position_pct": 75},
|
||||
"cushion_tilt": {"position_pct": 60},
|
||||
"backrest_recline": {"position_pct": 51, "angle_deg": 130},
|
||||
"legrest_height": {"position_pct": 69, "angle_deg": 145},
|
||||
}
|
||||
return context
|
||||
|
||||
def test_parses_passenger_ventilation_on(self) -> None:
|
||||
result = parse_local_intent("打开副驾座椅通风")
|
||||
|
||||
@@ -32,7 +43,7 @@ class LocalIntentParserTests(unittest.TestCase):
|
||||
"target": "passenger",
|
||||
"function": "ventilation",
|
||||
"state": "on",
|
||||
"requested_level": None,
|
||||
"requested_level": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -82,6 +93,229 @@ class LocalIntentParserTests(unittest.TestCase):
|
||||
[("longitudinal", 63), ("backrest_recline", 54)],
|
||||
)
|
||||
|
||||
def test_normalizes_target_level_and_fullwidth_percentage_aliases(self) -> None:
|
||||
thermal = parse_local_intent("右前座加热设置为中档")
|
||||
motion = parse_local_intent("副驾驶滑轨调到百分之五十")
|
||||
|
||||
self.assertEqual(thermal["actions"][0]["requested_level"], 2)
|
||||
self.assertEqual(motion["actions"][0]["axis"], "longitudinal")
|
||||
self.assertEqual(motion["actions"][0]["position_pct"], 50)
|
||||
|
||||
def test_converts_backrest_angle_to_calibrated_percentage(self) -> None:
|
||||
result = parse_local_intent("把副驾驶椅背设为130°")
|
||||
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
self.assertEqual(result["actions"][0]["axis"], "backrest_recline")
|
||||
self.assertEqual(result["actions"][0]["position_pct"], 51)
|
||||
|
||||
def test_parses_relative_backrest_angle_from_current_state(self) -> None:
|
||||
result = parse_local_intent("靠背向前移动10度", self.motion_context())
|
||||
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
action = result["actions"][0]
|
||||
self.assertEqual(action["axis"], "backrest_recline")
|
||||
self.assertEqual(action["position_pct"], 36)
|
||||
self.assertEqual(action["angle_deg"], 120)
|
||||
self.assertEqual(
|
||||
action["motion_intent"],
|
||||
{
|
||||
"mode": "relative",
|
||||
"direction": "decrease",
|
||||
"start_position_pct": 51,
|
||||
"delta_position_pct": 15,
|
||||
"start_angle_deg": 130.0,
|
||||
"delta_angle_deg": 10.0,
|
||||
},
|
||||
)
|
||||
|
||||
def test_parses_relative_direction_synonyms_and_chinese_angle(self) -> None:
|
||||
backward = parse_local_intent("椅背后仰十度", self.motion_context())
|
||||
legrest = parse_local_intent("腿托向下调10度", self.motion_context())
|
||||
|
||||
self.assertEqual(backward["actions"][0]["position_pct"], 66)
|
||||
self.assertEqual(backward["actions"][0]["angle_deg"], 140)
|
||||
self.assertEqual(legrest["actions"][0]["position_pct"], 55)
|
||||
self.assertEqual(legrest["actions"][0]["angle_deg"], 135)
|
||||
|
||||
def test_parses_relative_percentage_for_linear_axis(self) -> None:
|
||||
result = parse_local_intent("副驾滑轨向前移动10%", self.motion_context())
|
||||
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
self.assertEqual(result["actions"][0]["position_pct"], 65)
|
||||
self.assertEqual(result["actions"][0]["motion_intent"]["direction"], "decrease")
|
||||
|
||||
def test_rejects_relative_motion_without_state_and_clamps_endpoint_overflow(self) -> None:
|
||||
missing = parse_local_intent("靠背向前移动10度")
|
||||
context = self.motion_context()
|
||||
context["devices"]["passenger"]["position"]["backrest_recline"] = {
|
||||
"position_pct": 0,
|
||||
"angle_deg": 95,
|
||||
}
|
||||
overflow = parse_local_intent("靠背向前移动10度", context)
|
||||
|
||||
self.assertEqual(missing["actions"], [])
|
||||
self.assertIn("缺少当前座椅位置", missing["ambiguities"][0]["question"])
|
||||
self.assertEqual(overflow["ambiguities"], [])
|
||||
self.assertEqual(overflow["actions"][0]["position_pct"], 0)
|
||||
self.assertTrue(overflow["actions"][0]["motion_intent"]["range_clamped"])
|
||||
self.assertIn("最前方", overflow["actions"][0]["user_notice"])
|
||||
|
||||
def test_clamps_oversized_relative_angle_to_backrest_endpoint(self) -> None:
|
||||
context = self.motion_context()
|
||||
context["devices"]["passenger"]["position"]["backrest_recline"] = {
|
||||
"position_pct": 0,
|
||||
"angle_deg": 95,
|
||||
}
|
||||
|
||||
result = parse_local_intent("靠背向后移动70度", context)
|
||||
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
action = result["actions"][0]
|
||||
self.assertEqual(action["position_pct"], 100)
|
||||
self.assertEqual(action["angle_deg"], 163)
|
||||
self.assertEqual(action["motion_intent"]["requested_delta_angle_deg"], 70)
|
||||
self.assertEqual(action["motion_intent"]["delta_angle_deg"], 68)
|
||||
self.assertIn("备用目标", action["user_notice"])
|
||||
|
||||
def test_parses_and_clamps_user_timed_motion(self) -> None:
|
||||
result = parse_local_intent("靠背向后移动70秒", self.motion_context())
|
||||
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
action = result["actions"][0]
|
||||
self.assertEqual(action["position_pct"], 100)
|
||||
self.assertEqual(
|
||||
action["motion_intent"],
|
||||
{
|
||||
"mode": "timed",
|
||||
"direction": "increase",
|
||||
"start_position_pct": 51,
|
||||
"delta_position_pct": 49,
|
||||
"requested_duration_ms": 70_000,
|
||||
"applied_duration_ms": 30_000,
|
||||
"max_duration_ms": 30_000,
|
||||
"min_duration_ms": 300,
|
||||
"duration_clamped": True,
|
||||
"endpoint_expected": "far",
|
||||
},
|
||||
)
|
||||
self.assertIn("最大允许时长为30秒", action["user_notice"])
|
||||
|
||||
def test_supports_chinese_seconds_minutes_and_minimum_duration(self) -> None:
|
||||
chinese = parse_local_intent("椅背后仰两秒", self.motion_context())
|
||||
minute = parse_local_intent("座椅向前移动1分钟", self.motion_context())
|
||||
minimum = parse_local_intent("坐垫向下移动0.1秒", self.motion_context())
|
||||
|
||||
self.assertEqual(chinese["actions"][0]["motion_intent"]["applied_duration_ms"], 2_000)
|
||||
self.assertEqual(minute["actions"][0]["axis"], "longitudinal")
|
||||
self.assertEqual(minute["actions"][0]["position_pct"], 0)
|
||||
self.assertEqual(minute["actions"][0]["motion_intent"]["applied_duration_ms"], 30_000)
|
||||
self.assertEqual(minimum["actions"][0]["motion_intent"]["applied_duration_ms"], 300)
|
||||
|
||||
def test_clamps_absolute_position_and_angle_to_endpoint(self) -> None:
|
||||
percentage = parse_local_intent("副驾水平位置调到101%")
|
||||
angle = parse_local_intent("副驾靠背调到200度")
|
||||
|
||||
self.assertEqual(percentage["ambiguities"], [])
|
||||
self.assertEqual(percentage["actions"][0]["position_pct"], 100)
|
||||
self.assertIn("备用目标", percentage["actions"][0]["user_notice"])
|
||||
self.assertEqual(angle["ambiguities"], [])
|
||||
self.assertEqual(angle["actions"][0]["position_pct"], 100)
|
||||
self.assertEqual(angle["actions"][0]["angle_deg"], 163)
|
||||
|
||||
def test_applies_one_shared_percentage_to_all_named_axes(self) -> None:
|
||||
result = parse_local_intent("副驾驶水平、坐垫、椅背、脚托都调到40%")
|
||||
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
self.assertEqual(
|
||||
{(action["axis"], action["position_pct"]) for action in result["actions"]},
|
||||
{
|
||||
("longitudinal", 40),
|
||||
("cushion_tilt", 40),
|
||||
("backrest_recline", 40),
|
||||
("legrest_height", 40),
|
||||
},
|
||||
)
|
||||
|
||||
def test_normalizes_common_heating_and_ventilation_synonyms(self) -> None:
|
||||
warming = parse_local_intent("副驾座椅取暖开到2档")
|
||||
brief_warming = parse_local_intent("副驾座椅暖一下")
|
||||
airflow = parse_local_intent("副驾座椅吹风开到2档")
|
||||
|
||||
self.assertEqual(
|
||||
(warming["actions"][0]["function"], warming["actions"][0]["requested_level"]),
|
||||
("heating", 2),
|
||||
)
|
||||
self.assertEqual(
|
||||
(
|
||||
brief_warming["actions"][0]["function"],
|
||||
brief_warming["actions"][0]["requested_level"],
|
||||
),
|
||||
("heating", 1),
|
||||
)
|
||||
self.assertEqual(
|
||||
(airflow["actions"][0]["function"], airflow["actions"][0]["requested_level"]),
|
||||
("ventilation", 2),
|
||||
)
|
||||
|
||||
def test_rejects_unsupported_seat_scopes_instead_of_defaulting_to_passenger(self) -> None:
|
||||
for utterance in (
|
||||
"打开后排座椅加热",
|
||||
"打开左前座椅通风",
|
||||
"打开所有座椅加热",
|
||||
):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = parse_local_intent(utterance)
|
||||
self.assertEqual(result["actions"], [])
|
||||
self.assertEqual(result["ambiguities"][0]["field"], "target")
|
||||
self.assertIn("不能把", result["ambiguities"][0]["question"])
|
||||
|
||||
def test_axis_specific_endpoint_phrases_do_not_move_the_longitudinal_axis(self) -> None:
|
||||
cases = {
|
||||
"坐盆抬到最上方": ("cushion_tilt", 0),
|
||||
"坐盆降到最下方": ("cushion_tilt", 100),
|
||||
"靠背放倒到最后": ("backrest_recline", 100),
|
||||
"腿托升到最高": ("legrest_height", 100),
|
||||
"腿托降到最低": ("legrest_height", 0),
|
||||
}
|
||||
for utterance, expected in cases.items():
|
||||
with self.subTest(utterance=utterance):
|
||||
result = parse_local_intent(utterance)
|
||||
self.assertEqual(result["ambiguities"], [])
|
||||
self.assertEqual(
|
||||
[(item["axis"], item["position_pct"]) for item in result["actions"]],
|
||||
[expected],
|
||||
)
|
||||
|
||||
def test_parses_normal_half_second_and_minimum_clamped_timed_motion(self) -> None:
|
||||
normal = parse_local_intent("滑轨向后移动2秒", self.motion_context())
|
||||
half_second = parse_local_intent("靠背向后移动半秒", self.motion_context())
|
||||
minimum = parse_local_intent("腿托向上移动200毫秒", self.motion_context())
|
||||
|
||||
self.assertEqual(normal["actions"][0]["motion_intent"]["applied_duration_ms"], 2_000)
|
||||
self.assertFalse(normal["actions"][0]["motion_intent"]["duration_clamped"])
|
||||
self.assertEqual(
|
||||
half_second["actions"][0]["motion_intent"]["requested_duration_ms"],
|
||||
500,
|
||||
)
|
||||
self.assertEqual(minimum["actions"][0]["motion_intent"]["applied_duration_ms"], 300)
|
||||
self.assertTrue(minimum["actions"][0]["motion_intent"]["duration_clamped"])
|
||||
self.assertIn("最小允许时长为0.3秒", minimum["actions"][0]["user_notice"])
|
||||
|
||||
def test_rejects_unsupported_level_and_conflicting_state(self) -> None:
|
||||
unsupported = parse_local_intent("副驾加热开到4档")
|
||||
conflicting = parse_local_intent("打开副驾加热然后关闭副驾加热")
|
||||
|
||||
self.assertEqual(unsupported["actions"], [])
|
||||
self.assertEqual(unsupported["ambiguities"][0]["field"], "passenger.heating.level")
|
||||
self.assertEqual(conflicting["actions"], [])
|
||||
self.assertEqual(conflicting["ambiguities"][0]["field"], "passenger.thermal.state")
|
||||
|
||||
def test_rejects_driver_target_even_when_only_a_mode_is_named(self) -> None:
|
||||
result = parse_local_intent("打开主驾零重力模式")
|
||||
|
||||
self.assertEqual(result["actions"], [])
|
||||
self.assertEqual(result["ambiguities"][0]["field"], "target")
|
||||
|
||||
def test_rejects_driver_four_axis_zero(self) -> None:
|
||||
result = parse_local_intent("把主驾座椅四轴归零")
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ class ReactiveFakeBus:
|
||||
absolute_failure_targets: dict[int, int] | None = None,
|
||||
timed_failure_result: int | None = None,
|
||||
timed_failure_event: int = 4,
|
||||
timed_endpoint: str | None = None,
|
||||
):
|
||||
self.matrix = matrix
|
||||
self.complete_motion = complete_motion
|
||||
@@ -86,6 +87,7 @@ class ReactiveFakeBus:
|
||||
self.absolute_failure_targets = dict(absolute_failure_targets or {})
|
||||
self.timed_failure_result = timed_failure_result
|
||||
self.timed_failure_event = timed_failure_event
|
||||
self.timed_endpoint = timed_endpoint
|
||||
self.hall = [0, 0, 0, 0]
|
||||
self.position = [0, 0, 0, 0]
|
||||
self.max_hall = [480, 374, 917, 647]
|
||||
@@ -268,12 +270,70 @@ class ReactiveFakeBus:
|
||||
},
|
||||
)
|
||||
)
|
||||
# The real controller publishes cyclic status. Keep one more
|
||||
# sample queued for the executor's post-home preflight read.
|
||||
# The real controller publishes all four cyclic motor status
|
||||
# samples after a home completes. Keep a complete cycle queued
|
||||
# for the next axis preflight in a four-axis zero request.
|
||||
for cyclic_motor in range(4):
|
||||
self.received.append(
|
||||
self._frame(
|
||||
"T2G_Seat_Move_Motor_Status_302",
|
||||
{
|
||||
"Motor_Sub_ID": cyclic_motor,
|
||||
"Task_State": 0,
|
||||
"Result": 0,
|
||||
"Position_Valid": 1,
|
||||
"Motor_Sequence": sequence,
|
||||
"Value_X100": self.position[cyclic_motor],
|
||||
"Duty_Percent": 0,
|
||||
},
|
||||
)
|
||||
)
|
||||
for cyclic_motor in range(4):
|
||||
self.received.append(
|
||||
self._frame(
|
||||
"T2G_Seat_Move_Diagnostic_303",
|
||||
{
|
||||
"Motor_Sub_ID": cyclic_motor,
|
||||
"Hall_Count": self.hall[cyclic_motor],
|
||||
"Max_Hall_Count": self.max_hall[cyclic_motor],
|
||||
"Hall_Speed_Half_Pps": 180,
|
||||
},
|
||||
)
|
||||
)
|
||||
if opcode == 3 and self.timed_endpoint is not None:
|
||||
motor = command["Motor_Selector"]
|
||||
at_far_endpoint = self.timed_endpoint == "far"
|
||||
self.hall[motor] = self.max_hall[motor] if at_far_endpoint else 0
|
||||
self.position[motor] = 10_000 if at_far_endpoint else 0
|
||||
self.received.append(
|
||||
self._frame("T2G_Seat_Move_Motor_Status_302", home_status)
|
||||
self._frame(
|
||||
"T2G_Seat_Move_Motor_Status_302",
|
||||
{
|
||||
"Motor_Sub_ID": motor,
|
||||
"Task_State": 7,
|
||||
"Result": 4,
|
||||
"Direction": command["Direction"],
|
||||
"Position_Valid": 1,
|
||||
"Near_Endpoint": int(not at_far_endpoint),
|
||||
"Far_Endpoint": int(at_far_endpoint),
|
||||
"Motor_Sequence": sequence,
|
||||
"Value_X100": self.position[motor],
|
||||
"Duty_Percent": 0,
|
||||
},
|
||||
)
|
||||
)
|
||||
if opcode == 3 and self.timed_failure_result is not None:
|
||||
self.received.append(
|
||||
self._frame(
|
||||
"T2G_Seat_Move_Diagnostic_303",
|
||||
{
|
||||
"Motor_Sub_ID": motor,
|
||||
"Last_Motor_Event": 3 if at_far_endpoint else 2,
|
||||
"Hall_Count": self.hall[motor],
|
||||
"Max_Hall_Count": self.max_hall[motor],
|
||||
},
|
||||
)
|
||||
)
|
||||
elif opcode == 3 and self.timed_failure_result is not None:
|
||||
motor = command["Motor_Selector"]
|
||||
self.received.append(
|
||||
self._frame(
|
||||
@@ -387,7 +447,7 @@ class ProtocolSignatureFakeBus:
|
||||
self.received: deque[CanFrame] = deque()
|
||||
self.closed = False
|
||||
if signature:
|
||||
self.received.append(CanFrame(0x301, bytes.fromhex("10 00 00 00 00 00 F0 30")))
|
||||
self.received.append(CanFrame(0x301, bytes.fromhex("10 00 00 00 00 00 F0 60")))
|
||||
for motor in range(4):
|
||||
self.received.append(
|
||||
CanFrame(0x302, bytes([motor, 0, 0x08, 0, 0, 0, 0, 0]))
|
||||
@@ -480,7 +540,7 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
self.assertEqual(command["signals"]["Param_B"], 30_000)
|
||||
self.assertEqual(command["completion"]["timeout_ms"], 33_000)
|
||||
|
||||
def test_compiler_maps_small_relative_backrest_request_to_timed_duty(self) -> None:
|
||||
def test_compiler_prefers_absolute_relative_target_with_timed_fallback(self) -> None:
|
||||
config = function_config(
|
||||
operation(
|
||||
"seat.set_backrest_recline",
|
||||
@@ -498,11 +558,16 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
|
||||
command = self.compiler.compile(config, {})["spec"]["commands"][0]
|
||||
|
||||
self.assertEqual(command["signals"]["Opcode"], 3)
|
||||
self.assertEqual(command["signals"]["Direction"], 1)
|
||||
self.assertEqual(command["signals"]["Param_A"], 1320)
|
||||
self.assertEqual(command["signals"]["Param_B"], 900)
|
||||
self.assertEqual(command["completion"]["signal"], "Task_State")
|
||||
self.assertEqual(command["signals"]["Opcode"], 1)
|
||||
self.assertEqual(command["signals"]["Direction"], 0)
|
||||
self.assertEqual(command["signals"]["Param_A"], 3100)
|
||||
self.assertEqual(command["completion"]["signal"], "Value_X100")
|
||||
fallback = command["relative_fallback"]
|
||||
self.assertEqual(fallback["signals"]["Opcode"], 3)
|
||||
self.assertEqual(fallback["signals"]["Direction"], 1)
|
||||
self.assertEqual(fallback["signals"]["Param_A"], 1320)
|
||||
self.assertEqual(fallback["signals"]["Param_B"], 900)
|
||||
self.assertEqual(fallback["completion"]["signal"], "Task_State")
|
||||
|
||||
def test_compiler_maps_large_relative_backrest_request_to_absolute_target(self) -> None:
|
||||
config = function_config(
|
||||
@@ -527,6 +592,34 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
self.assertEqual(command["signals"]["Param_A"], 6400)
|
||||
self.assertEqual(command["completion"]["signal"], "Value_X100")
|
||||
|
||||
def test_compiler_emits_bounded_user_timed_motion(self) -> None:
|
||||
config = function_config(
|
||||
operation(
|
||||
"seat.set_backrest_recline",
|
||||
{"position_pct": 100, "angle_deg": 163},
|
||||
motion={
|
||||
"mode": "timed",
|
||||
"direction": "increase",
|
||||
"start_position_pct": 0,
|
||||
"delta_position_pct": 100,
|
||||
"requested_duration_ms": 70_000,
|
||||
"applied_duration_ms": 30_000,
|
||||
"max_duration_ms": 30_000,
|
||||
"min_duration_ms": 300,
|
||||
"duration_clamped": True,
|
||||
"endpoint_expected": "far",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
command = self.compiler.compile(config, {})["spec"]["commands"][0]
|
||||
|
||||
self.assertEqual(command["signals"]["Opcode"], 3)
|
||||
self.assertEqual(command["signals"]["Direction"], 1)
|
||||
self.assertEqual(command["signals"]["Param_A"], 30_000)
|
||||
self.assertEqual(command["signals"]["Param_B"], 900)
|
||||
self.assertEqual(command["completion"]["timeout_ms"], 35_000)
|
||||
|
||||
def test_driver_target_is_rejected_by_hardware_compiler(self) -> None:
|
||||
driver = function_config(
|
||||
operation(
|
||||
@@ -603,6 +696,87 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
self.assertEqual([item.data[0] for item in bus.sent], [9, 1])
|
||||
self.assertTrue(bus.closed)
|
||||
|
||||
def test_firmware_replacement_forces_four_axis_position_memory_sync(self) -> None:
|
||||
config = function_config(
|
||||
operation("seat.set_backrest_recline", {"position_pct": 50})
|
||||
)
|
||||
plan = self.compiler.compile(config, {})
|
||||
bus = ReactiveFakeBus(self.matrix)
|
||||
bus.hall = [220, 180, 600, 310]
|
||||
bus.position = [4500, 4800, 6500, 4700]
|
||||
position_sync_state = {"synchronized": True}
|
||||
|
||||
class ReprogrammedGuard:
|
||||
def prepare_bus(self) -> tuple[ReactiveFakeBus, dict[str, object]]:
|
||||
return bus, {
|
||||
"firmware_verified": True,
|
||||
"programmed_this_request": True,
|
||||
}
|
||||
|
||||
with patch("seat_domain.hardware_executor.time.sleep"):
|
||||
report = HardwareSeatExecutor(
|
||||
self.matrix,
|
||||
lambda: bus,
|
||||
ack_timeout_ms=20,
|
||||
motor_max_hall={0: 480, 1: 374, 2: 917, 3: 647},
|
||||
firmware_guard=ReprogrammedGuard(), # type: ignore[arg-type]
|
||||
synchronize_position_before_first_motion=True,
|
||||
position_sync_state=position_sync_state,
|
||||
).execute(plan, config)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
sent_commands = [
|
||||
self.matrix.decode("T2G_Seat_Move_Command_300", frame.data)
|
||||
for frame in bus.sent
|
||||
if frame.arbitration_id == 0x300
|
||||
]
|
||||
self.assertEqual(
|
||||
[(item["Opcode"], item["Motor_Selector"]) for item in sent_commands],
|
||||
[(9, 0), (0x0B, 0), (0x0B, 1), (0x0B, 2), (0x0B, 3), (1, 2)],
|
||||
)
|
||||
sync = next(
|
||||
item
|
||||
for item in report["spec"]["results"]
|
||||
if item["ref"] == "seat-position-memory-sync"
|
||||
)
|
||||
self.assertEqual(
|
||||
sync["telemetry"]["trigger"], "repository_firmware_reprogrammed"
|
||||
)
|
||||
self.assertEqual(len(sync["telemetry"]["axes"]), 4)
|
||||
self.assertTrue(position_sync_state["synchronized"])
|
||||
|
||||
def test_first_process_motion_syncs_once_and_reuses_the_reference(self) -> None:
|
||||
config = function_config(
|
||||
operation("seat.set_longitudinal_position", {"position_pct": 40})
|
||||
)
|
||||
plan = self.compiler.compile(config, {})
|
||||
state = {"synchronized": False}
|
||||
first_bus = ReactiveFakeBus(self.matrix)
|
||||
with patch("seat_domain.hardware_executor.time.sleep"):
|
||||
first = HardwareSeatExecutor(
|
||||
self.matrix,
|
||||
lambda: first_bus,
|
||||
ack_timeout_ms=20,
|
||||
motor_max_hall={0: 480, 1: 374, 2: 917, 3: 647},
|
||||
synchronize_position_before_first_motion=True,
|
||||
position_sync_state=state,
|
||||
).execute(plan, config)
|
||||
|
||||
second_bus = ReactiveFakeBus(self.matrix)
|
||||
second = HardwareSeatExecutor(
|
||||
self.matrix,
|
||||
lambda: second_bus,
|
||||
ack_timeout_ms=20,
|
||||
motor_max_hall={0: 480, 1: 374, 2: 917, 3: 647},
|
||||
synchronize_position_before_first_motion=True,
|
||||
position_sync_state=state,
|
||||
).execute(plan, config)
|
||||
|
||||
self.assertEqual(first["spec"]["status"], "succeeded")
|
||||
self.assertEqual(second["spec"]["status"], "succeeded")
|
||||
self.assertEqual([item.data[0] for item in first_bus.sent], [9, 0x0B, 0x0B, 0x0B, 0x0B, 1])
|
||||
self.assertEqual([item.data[0] for item in second_bus.sent], [9, 1])
|
||||
|
||||
def test_passive_snapshot_reads_all_passenger_axes_and_thermal_state(self) -> None:
|
||||
bus = ReactiveFakeBus(self.matrix)
|
||||
for motor, value_x100 in enumerate((1200, 3400, 5600, 7800)):
|
||||
@@ -693,12 +867,15 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
command["signals"]["Param_A"] == 0
|
||||
command["signals"]["Opcode"] == 0x0B
|
||||
and command["signals"]["Param_A"] == 0x4B52
|
||||
and command["signals"]["Param_B"] == 120
|
||||
for command in plan["spec"]["commands"]
|
||||
)
|
||||
)
|
||||
|
||||
bus = ReactiveFakeBus(self.matrix)
|
||||
position_sync_state = {"synchronized": False}
|
||||
report = HardwareSeatExecutor(
|
||||
self.matrix,
|
||||
lambda: bus,
|
||||
@@ -706,6 +883,8 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
stop_all_motor_selector=int(
|
||||
self.compiler.protocol["stop_all_motor_selector"]
|
||||
),
|
||||
synchronize_position_before_first_motion=True,
|
||||
position_sync_state=position_sync_state,
|
||||
).execute(plan, config)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
@@ -716,7 +895,15 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
]
|
||||
self.assertEqual(
|
||||
[(item["Opcode"], item["Motor_Selector"]) for item in sent_commands],
|
||||
[(9, 0), (1, 0), (1, 1), (1, 2), (1, 3)],
|
||||
[(9, 0), (0x0B, 0), (0x0B, 1), (0x0B, 2), (0x0B, 3)],
|
||||
)
|
||||
self.assertTrue(position_sync_state["synchronized"])
|
||||
self.assertEqual(
|
||||
sum(
|
||||
item["ref"] == "seat-position-memory-sync"
|
||||
for item in report["spec"]["results"]
|
||||
),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_zero_recovers_verified_stale_near_endpoint_offset(self) -> None:
|
||||
@@ -741,15 +928,12 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
for frame in bus.sent
|
||||
if frame.arbitration_id == 0x300
|
||||
]
|
||||
self.assertEqual(
|
||||
[item["Opcode"] for item in sent_commands],
|
||||
[9, 1, 3, 0x0B],
|
||||
)
|
||||
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 0x0B])
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
recovery = telemetry["position_recovery"]
|
||||
self.assertEqual(recovery["mode"], "verified_endpoint_drift_rehome")
|
||||
self.assertGreater(recovery["away_probe"]["hall_delta"], 2)
|
||||
self.assertEqual(recovery["home"]["hall_after"], 0)
|
||||
self.assertEqual(recovery["mode"], "explicit_known_range_home")
|
||||
self.assertEqual(recovery["hall_before"], 6)
|
||||
self.assertEqual(recovery["hall_after"], 0)
|
||||
self.assertEqual(telemetry["effective_position_pct"], 0)
|
||||
|
||||
def test_zero_recovers_after_full_travel_reaches_near_endpoint_window(self) -> None:
|
||||
@@ -778,14 +962,10 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
for frame in bus.sent
|
||||
if frame.arbitration_id == 0x300
|
||||
]
|
||||
self.assertEqual(
|
||||
[item["Opcode"] for item in sent_commands],
|
||||
[9, 1, 3, 0x0B],
|
||||
)
|
||||
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 0x0B])
|
||||
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
|
||||
self.assertEqual(recovery["failed_absolute_hall_before"], 374)
|
||||
self.assertEqual(recovery["failed_absolute_hall_after"], 4)
|
||||
self.assertEqual(recovery["home"]["hall_after"], 0)
|
||||
self.assertEqual(recovery["hall_before"], 374)
|
||||
self.assertEqual(recovery["hall_after"], 0)
|
||||
|
||||
def test_zero_recovers_real_m0_stall_at_calibrated_near_endpoint(self) -> None:
|
||||
config = function_config(
|
||||
@@ -813,22 +993,16 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
for frame in bus.sent
|
||||
if frame.arbitration_id == 0x300
|
||||
]
|
||||
self.assertEqual(
|
||||
[item["Opcode"] for item in sent_commands],
|
||||
[9, 1, 3, 0x0B],
|
||||
)
|
||||
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 0x0B])
|
||||
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
|
||||
self.assertEqual(recovery["failed_absolute_hall_before"], 480)
|
||||
self.assertEqual(recovery["failed_absolute_hall_after"], 52)
|
||||
self.assertEqual(recovery["endpoint_recovery_max_near_hall"], 72)
|
||||
self.assertGreater(recovery["away_probe"]["hall_delta"], 2)
|
||||
self.assertEqual(recovery["home"]["hall_after"], 0)
|
||||
self.assertEqual(recovery["hall_before"], 480)
|
||||
self.assertEqual(recovery["hall_after"], 0)
|
||||
self.assertEqual(
|
||||
report["spec"]["results"][0]["telemetry"]["effective_position_pct"],
|
||||
0,
|
||||
)
|
||||
|
||||
def test_zero_does_not_rehome_a_midrange_stall_as_an_endpoint(self) -> None:
|
||||
def test_explicit_zero_homes_even_when_the_stored_position_is_midrange(self) -> None:
|
||||
config = function_config(
|
||||
operation("seat.set_longitudinal_position", {"position_pct": 0})
|
||||
)
|
||||
@@ -844,20 +1018,16 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
motor_max_hall={0: 480, 1: 374, 2: 917, 3: 647},
|
||||
).execute(plan, config)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "failed")
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
sent_commands = [
|
||||
self.matrix.decode("T2G_Seat_Move_Command_300", frame.data)
|
||||
for frame in bus.sent
|
||||
if frame.arbitration_id == 0x300
|
||||
]
|
||||
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 1, 9])
|
||||
self.assertIn("STALL", report["spec"]["results"][-1]["message"])
|
||||
current = report["spec"]["results"][-1]["telemetry"][
|
||||
"current_diagnostic"
|
||||
]
|
||||
self.assertEqual(current["sample_count"], 1)
|
||||
self.assertEqual(current["max_current_delta_raw"], 700)
|
||||
self.assertTrue(current["stall_overlap_seen"])
|
||||
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 0x0B])
|
||||
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
|
||||
self.assertEqual(recovery["hall_before"], 116)
|
||||
self.assertEqual(recovery["hall_after"], 0)
|
||||
|
||||
def test_zero_recovers_near_endpoint_timeout_after_full_travel(self) -> None:
|
||||
config = function_config(
|
||||
@@ -881,9 +1051,8 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
|
||||
self.assertEqual(recovery["failed_absolute_hall_before"], 605)
|
||||
self.assertEqual(recovery["failed_absolute_hall_after"], 3)
|
||||
self.assertEqual(recovery["home"]["hall_after"], 0)
|
||||
self.assertEqual(recovery["hall_before"], 605)
|
||||
self.assertEqual(recovery["hall_after"], 0)
|
||||
|
||||
def test_far_endpoint_timeout_within_position_tolerance_is_accepted(self) -> None:
|
||||
config = function_config(
|
||||
@@ -1036,6 +1205,114 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
self.assertGreater(telemetry["hall_delta"], 0)
|
||||
self.assertTrue(telemetry["hall_direction_verified"])
|
||||
|
||||
def test_executor_accepts_user_timed_motion_that_reaches_endpoint_early(self) -> None:
|
||||
config = function_config(
|
||||
operation(
|
||||
"seat.set_backrest_recline",
|
||||
{"position_pct": 100, "angle_deg": 163},
|
||||
motion={
|
||||
"mode": "timed",
|
||||
"direction": "increase",
|
||||
"start_position_pct": 0,
|
||||
"delta_position_pct": 100,
|
||||
"requested_duration_ms": 70_000,
|
||||
"applied_duration_ms": 30_000,
|
||||
"max_duration_ms": 30_000,
|
||||
"min_duration_ms": 300,
|
||||
"duration_clamped": True,
|
||||
"endpoint_expected": "far",
|
||||
},
|
||||
)
|
||||
)
|
||||
plan = self.compiler.compile(config, {})
|
||||
bus = ReactiveFakeBus(self.matrix, timed_endpoint="far")
|
||||
|
||||
report = HardwareSeatExecutor(
|
||||
self.matrix,
|
||||
lambda: bus,
|
||||
ack_timeout_ms=20,
|
||||
axis_maximum_x100={"seat.set_backrest_recline": 10_000},
|
||||
).execute(plan, config)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual([item.data[0] for item in bus.sent], [9, 3])
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertEqual(telemetry["motion_mode"], "timed_user_request")
|
||||
self.assertEqual(telemetry["endpoint_reached"], "far")
|
||||
self.assertTrue(telemetry["endpoint_terminal_acceptance"])
|
||||
self.assertEqual(telemetry["effective_position_pct"], 100)
|
||||
self.assertIn("最大允许时长为30秒", telemetry["user_notice"])
|
||||
self.assertIn("到达最后方", telemetry["user_notice"])
|
||||
|
||||
def test_executor_does_not_hide_non_endpoint_user_timed_stall(self) -> None:
|
||||
config = function_config(
|
||||
operation(
|
||||
"seat.set_backrest_recline",
|
||||
{"position_pct": 100, "angle_deg": 163},
|
||||
motion={
|
||||
"mode": "timed",
|
||||
"direction": "increase",
|
||||
"start_position_pct": 0,
|
||||
"delta_position_pct": 100,
|
||||
"requested_duration_ms": 2_000,
|
||||
"applied_duration_ms": 2_000,
|
||||
"max_duration_ms": 30_000,
|
||||
"min_duration_ms": 300,
|
||||
"duration_clamped": False,
|
||||
},
|
||||
)
|
||||
)
|
||||
plan = self.compiler.compile(config, {})
|
||||
bus = ReactiveFakeBus(
|
||||
self.matrix,
|
||||
timed_failure_result=5,
|
||||
timed_failure_event=4,
|
||||
)
|
||||
|
||||
report = HardwareSeatExecutor(
|
||||
self.matrix,
|
||||
lambda: bus,
|
||||
ack_timeout_ms=20,
|
||||
axis_maximum_x100={"seat.set_backrest_recline": 10_000},
|
||||
).execute(plan, config)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "failed")
|
||||
self.assertEqual(
|
||||
report["spec"]["results"][-1]["telemetry"]["failure_category"],
|
||||
"stall_no_hall_activity",
|
||||
)
|
||||
|
||||
def test_executor_uses_closed_loop_target_for_calibrated_relative_motion(self) -> None:
|
||||
config = function_config(
|
||||
operation(
|
||||
"seat.set_backrest_recline",
|
||||
{"position_pct": 31, "angle_deg": 109},
|
||||
motion={
|
||||
"mode": "relative",
|
||||
"direction": "increase",
|
||||
"start_position_pct": 20,
|
||||
"delta_position_pct": 11,
|
||||
"start_angle_deg": 104,
|
||||
"delta_angle_deg": 5,
|
||||
},
|
||||
)
|
||||
)
|
||||
plan = self.compiler.compile(config, {})
|
||||
bus = ReactiveFakeBus(self.matrix, position_valid=True)
|
||||
|
||||
report = HardwareSeatExecutor(
|
||||
self.matrix,
|
||||
lambda: bus,
|
||||
ack_timeout_ms=20,
|
||||
axis_maximum_x100={"seat.set_backrest_recline": 10_000},
|
||||
).execute(plan, config)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual([item.data[0] for item in bus.sent], [9, 1])
|
||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||
self.assertEqual(telemetry["motion_mode"], "relative_absolute_target")
|
||||
self.assertEqual(telemetry["effective_position_pct"], 31)
|
||||
|
||||
def test_executor_reports_stall_with_post_failure_hall_diagnostics(self) -> None:
|
||||
config = function_config(
|
||||
operation(
|
||||
@@ -1209,6 +1486,7 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
automatic = SeatHardwareSettings.from_env(ROOT)
|
||||
self.assertTrue(automatic.enabled)
|
||||
self.assertTrue(automatic.auto_flash_enabled)
|
||||
self.assertTrue(automatic.position_sync_enabled)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
@@ -1225,6 +1503,7 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
dry_run = SeatHardwareSettings.from_env(ROOT)
|
||||
self.assertFalse(dry_run.enabled)
|
||||
self.assertFalse(dry_run.auto_flash_enabled)
|
||||
self.assertFalse(dry_run.position_sync_enabled)
|
||||
|
||||
def test_settings_allow_explicit_local_debug_mode_without_token(self) -> None:
|
||||
environment = {
|
||||
@@ -1280,7 +1559,7 @@ class T2GHardwareTests(unittest.TestCase):
|
||||
|
||||
self.assertIs(prepared, bus)
|
||||
self.assertFalse(telemetry["programmed_this_request"])
|
||||
self.assertEqual(telemetry["firmware_revision"], 3)
|
||||
self.assertEqual(telemetry["firmware_revision"], 6)
|
||||
self.assertEqual(bus.sent, [])
|
||||
self.assertEqual(telemetry["max_hall_counts"], [480, 374, 917, 647])
|
||||
self.assertFalse(bus.closed)
|
||||
|
||||
Reference in New Issue
Block a user