feat: harden seat intent and expand HIL coverage

This commit is contained in:
vee
2026-08-02 05:20:38 +08:00
parent 4aded5c21d
commit 55d0d400cb
62 changed files with 53112 additions and 1748 deletions
@@ -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()
+235 -1
View File
@@ -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("把主驾座椅四轴归零")
+329 -50
View File
@@ -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)