优化了副驾座椅控制
This commit is contained in:
@@ -329,6 +329,188 @@ class Can1SemanticWhitelistTests(unittest.TestCase):
|
||||
24,
|
||||
)
|
||||
|
||||
def test_disabling_one_outlet_feature_never_enables_that_mode(self) -> None:
|
||||
for index, utterance in enumerate(
|
||||
("关闭吹面出风口", "把吹面出风口关闭")
|
||||
):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-one-close-outlet-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "needs_confirmation")
|
||||
self.assertEqual(self._debug_commands(result), [])
|
||||
ambiguities = result["intent_ir"]["spec"]["ambiguities"]
|
||||
self.assertEqual(
|
||||
ambiguities[0]["reason"],
|
||||
"outlet_feature_disable_requires_absolute_mode",
|
||||
)
|
||||
|
||||
preview = self.service.preview_native_can(
|
||||
"关闭吹面出风口",
|
||||
provider="openai",
|
||||
conversation_id="conv-can-one-close-outlet-preview",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertFalse(preview["supplement_required"])
|
||||
self.assertEqual(preview["unresolved_clauses"], [])
|
||||
self.assertEqual(self._debug_commands(preview), [])
|
||||
|
||||
explicit_replacement = self.service.process(
|
||||
"关闭吹面出风口,切换到吹脚模式",
|
||||
provider="local",
|
||||
conversation_id="conv-can-one-replace-outlet",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(explicit_replacement["status"], "succeeded")
|
||||
self.assertEqual(self._debug_commands(explicit_replacement), ["ac mode feet"])
|
||||
|
||||
def test_driver_seat_natural_language_levels_and_motion_aliases(self) -> None:
|
||||
cases = (
|
||||
("打开主驾座椅按摩", ["seat driver massage mode relax"]),
|
||||
(
|
||||
"主驾座椅按摩强度设置为最大",
|
||||
["seat driver massage strength strong"],
|
||||
),
|
||||
(
|
||||
"主驾座椅按摩强度最小",
|
||||
["seat driver massage strength weak"],
|
||||
),
|
||||
(
|
||||
"主驾座椅按摩强度调中",
|
||||
["seat driver massage strength medium"],
|
||||
),
|
||||
("主驾座椅朝前挪一秒", ["seat driver horizontal forward 1000"]),
|
||||
("主驾座椅后退两秒", ["seat driver horizontal backward 2000"]),
|
||||
("主驾座椅椅背放倒", ["seat driver backrest backward 500"]),
|
||||
("主驾座椅座垫放低", ["seat driver basin down 500"]),
|
||||
("主驾座椅往上移动", ["seat driver height up 500"]),
|
||||
("主驾座椅通风最低", ["seat driver vent low"]),
|
||||
("主驾座椅通风最小", ["seat driver vent low"]),
|
||||
("主驾座椅通风最高", ["seat driver vent high"]),
|
||||
("主驾座椅通风最大", ["seat driver vent high"]),
|
||||
("空调风量设置为最低", ["ac fan 1"]),
|
||||
("空调风量设置为中等", ["ac fan 4"]),
|
||||
("空调风量设置为最大", ["ac fan 7"]),
|
||||
("氛围灯亮度最低", ["light ambient brightness 1"]),
|
||||
("氛围灯亮度中等", ["light ambient brightness 3"]),
|
||||
("氛围灯亮度最高", ["light ambient brightness 5"]),
|
||||
(
|
||||
"打开主驾座椅通风并关闭按摩",
|
||||
["seat driver vent low", "seat driver massage off"],
|
||||
),
|
||||
(
|
||||
"关闭主驾座椅通风并打开按摩",
|
||||
["seat driver vent off", "seat driver massage mode relax"],
|
||||
),
|
||||
)
|
||||
for index, (utterance, expected) in enumerate(cases):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-one-seat-alias-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(self._debug_commands(result), expected)
|
||||
|
||||
stop_all = self.service.process(
|
||||
"停止主驾座椅移动",
|
||||
provider="local",
|
||||
conversation_id="conv-can-one-seat-stop-all",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(stop_all["status"], "succeeded")
|
||||
self.assertEqual(
|
||||
self._debug_commands(stop_all),
|
||||
[
|
||||
"seat driver horizontal stop",
|
||||
"seat driver height stop",
|
||||
"seat driver backrest stop",
|
||||
"seat driver basin stop",
|
||||
],
|
||||
)
|
||||
|
||||
def test_unsupported_driver_seat_middle_thermal_level_is_explicit(self) -> None:
|
||||
for index, feature in enumerate(("通风", "加热")):
|
||||
utterance = f"主驾座椅{feature}调到中档"
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-one-seat-middle-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "needs_confirmation")
|
||||
self.assertEqual(self._debug_commands(result), [])
|
||||
self.assertIn(
|
||||
"driver_seat_thermal_middle_level_unsupported",
|
||||
{
|
||||
item.get("reason")
|
||||
for item in result["intent_ir"]["spec"]["ambiguities"]
|
||||
},
|
||||
)
|
||||
|
||||
preview = self.service.preview_native_can(
|
||||
"主驾座椅通风设置为中等",
|
||||
provider="openai",
|
||||
conversation_id="conv-can-one-seat-middle-preview",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertFalse(preview["supplement_required"])
|
||||
self.assertEqual(preview["unresolved_clauses"], [])
|
||||
self.assertEqual(self._debug_commands(preview), [])
|
||||
|
||||
def test_vehicle_position_aliases_stay_scoped_to_the_device_domain(self) -> None:
|
||||
cases = (
|
||||
("打开左前车窗", ["window front-left open"]),
|
||||
("打开右前车窗", ["window front-right open"]),
|
||||
("打开左后车窗", ["window rear-left open"]),
|
||||
("打开右后车窗", ["window rear-right open"]),
|
||||
("打开主驾车窗", ["window front-left open"]),
|
||||
("打开副驾车窗", ["window front-right open"]),
|
||||
("打开左前座椅通风", ["seat driver vent low"]),
|
||||
("打开主驾座椅通风", ["seat driver vent low"]),
|
||||
("主驾空调温度设置为24度", ["ac temp driver 24"]),
|
||||
("副驾空调温度设置为24度", ["ac temp passenger 24"]),
|
||||
)
|
||||
for index, (utterance, expected) in enumerate(cases):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-position-alias-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(self._debug_commands(result), expected)
|
||||
|
||||
for index, utterance in enumerate(
|
||||
("打开右前座椅通风", "打开副驾座椅通风")
|
||||
):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can-passenger-alias-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
operations = [
|
||||
operation
|
||||
for config in result.get("function_configs", {}).values()
|
||||
for operation in config.get("spec", {}).get("operations", [])
|
||||
]
|
||||
self.assertNotIn(
|
||||
"seat.set_driver_ventilation_native",
|
||||
{item["capability"] for item in operations},
|
||||
)
|
||||
self.assertEqual(operations[0]["capability"], "seat.set_ventilation")
|
||||
self.assertEqual(operations[0]["target"]["zone"], "front_right")
|
||||
|
||||
def test_fractional_values_are_not_silently_truncated(self) -> None:
|
||||
utterances = (
|
||||
"空调温度设为24.5度",
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cognitive_agent.application.direct_can_control import build_direct_can_intent
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
|
||||
def test_driver_device_batch_keeps_each_exact_control_independent() -> None:
|
||||
commands = [
|
||||
"seat driver vent low",
|
||||
"seat driver heat off",
|
||||
"seat driver massage mode stretch",
|
||||
"seat driver massage strength strong",
|
||||
]
|
||||
|
||||
normalized, parsed = build_direct_can_intent(commands)
|
||||
|
||||
assert normalized == commands
|
||||
assert [item["capability"] for item in parsed["native_can_actions"]] == [
|
||||
"seat.set_driver_ventilation_native",
|
||||
"seat.set_driver_heating_native",
|
||||
"seat.set_driver_massage_native",
|
||||
"seat.set_driver_massage_native",
|
||||
]
|
||||
assert [item["parameters"] for item in parsed["native_can_actions"]] == [
|
||||
{"state": "on", "level": 2},
|
||||
{"state": "off"},
|
||||
{"action": "mode", "mode": "stretch"},
|
||||
{"action": "strength", "strength": 3},
|
||||
]
|
||||
assert {
|
||||
item["parse_source"] for item in parsed["native_can_actions"]
|
||||
} == {"direct_control"}
|
||||
|
||||
|
||||
def test_passenger_device_batch_builds_can0_position_action() -> None:
|
||||
normalized, parsed = build_direct_can_intent(
|
||||
["seat passenger backrest position 36"]
|
||||
)
|
||||
|
||||
assert normalized == ["seat passenger backrest position 36"]
|
||||
assert parsed["native_can_actions"] == []
|
||||
assert parsed["actions"] == [
|
||||
{
|
||||
"target": "passenger",
|
||||
"action_type": "position",
|
||||
"axis": "backrest_recline",
|
||||
"position_pct": 36,
|
||||
"parse_source": "direct_control",
|
||||
"source_span": "seat passenger backrest position 36",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_passenger_can0_canonical_absolute_relative_thermal_and_dynamic() -> None:
|
||||
context = {
|
||||
"devices": {
|
||||
"passenger": {
|
||||
"position": {
|
||||
"longitudinal": {"position_pct": 40},
|
||||
"cushion_tilt": {"position_pct": 50},
|
||||
"backrest_recline": {"position_pct": 60},
|
||||
"legrest_height": {"position_pct": 70},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
commands = [
|
||||
"seat passenger horizontal position 35",
|
||||
"seat passenger basin relative up 10",
|
||||
"seat passenger vent medium",
|
||||
"seat passenger dynamic rocking-horse",
|
||||
"seat passenger dynamic stop",
|
||||
]
|
||||
|
||||
normalized, parsed = build_direct_can_intent(commands, context)
|
||||
|
||||
assert normalized == commands
|
||||
assert parsed["detected_domains"] == ["seat"]
|
||||
assert parsed["actions"][0]["axis"] == "longitudinal"
|
||||
assert parsed["actions"][0]["position_pct"] == 35
|
||||
assert parsed["actions"][1]["axis"] == "cushion_tilt"
|
||||
assert parsed["actions"][1]["position_pct"] == 40
|
||||
assert parsed["actions"][1]["motion_intent"] == {
|
||||
"mode": "relative",
|
||||
"direction": "decrease",
|
||||
"start_position_pct": 50,
|
||||
"delta_position_pct": 10,
|
||||
}
|
||||
assert parsed["actions"][2]["function"] == "ventilation"
|
||||
assert parsed["actions"][2]["requested_level"] == 2
|
||||
assert parsed["actions"][3]["action_type"] == "dynamic_mode"
|
||||
assert parsed["actions"][3]["mode_id"] == 1
|
||||
assert parsed["actions"][4]["action_type"] == "dynamic_mode_stop"
|
||||
|
||||
|
||||
def test_passenger_can0_relative_command_requires_current_position() -> None:
|
||||
with pytest.raises(ValueError, match="requires a valid current"):
|
||||
build_direct_can_intent(
|
||||
["seat passenger horizontal relative forward 10"],
|
||||
{"devices": {"passenger": {"position": {}}}},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"commands",
|
||||
(
|
||||
[],
|
||||
["seat driver vent medium"],
|
||||
["seat passenger height position 101"],
|
||||
["headlight low-beam on"],
|
||||
),
|
||||
)
|
||||
def test_direct_batch_rejects_non_whitelisted_commands(commands: list[str]) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
build_direct_can_intent(commands)
|
||||
|
||||
|
||||
def test_service_direct_batch_bypasses_natural_language_interpreter() -> None:
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = Path(directory) / "artifacts"
|
||||
with patch.object(
|
||||
service,
|
||||
"_interpret",
|
||||
side_effect=AssertionError("natural-language parsing must not run"),
|
||||
):
|
||||
result = service.process_can_commands(
|
||||
[
|
||||
"ac on",
|
||||
"ac temp driver 24",
|
||||
"ac fan 2",
|
||||
"ac cycle internal",
|
||||
],
|
||||
execution_mode="dry_run",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["provider_used"] == "direct_can_control"
|
||||
assert result["control_transport"] == "wireless_mqtt"
|
||||
assert result["can_commands"] == [
|
||||
"ac on",
|
||||
"ac temp driver 24",
|
||||
"ac fan 2",
|
||||
"ac cycle internal",
|
||||
]
|
||||
operations = [
|
||||
operation
|
||||
for configuration in result["function_configs"].values()
|
||||
for operation in configuration["spec"]["operations"]
|
||||
]
|
||||
assert [item["capability"] for item in operations] == [
|
||||
"climate.set_power",
|
||||
"climate.set_temperature",
|
||||
"climate.set_fan_level",
|
||||
"climate.set_cycle_mode",
|
||||
]
|
||||
assert {item["parse_source"] for item in operations} == {"direct_control"}
|
||||
|
||||
|
||||
def test_service_direct_batch_combines_can0_and_can1_semantics() -> None:
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.artifact_root = Path(directory) / "artifacts"
|
||||
result = service.process_can_commands(
|
||||
[
|
||||
"seat passenger basin relative up 10",
|
||||
"seat passenger vent medium",
|
||||
"seat passenger dynamic rocking-horse",
|
||||
"ac fan 2",
|
||||
"light ambient on",
|
||||
],
|
||||
conversation_id="conv-direct-can0-can1",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["can_commands"] == [
|
||||
"seat passenger basin relative up 10",
|
||||
"seat passenger vent medium",
|
||||
"seat passenger dynamic rocking-horse",
|
||||
"ac fan 2",
|
||||
"light ambient on",
|
||||
]
|
||||
operations = [
|
||||
operation
|
||||
for configuration in result["function_configs"].values()
|
||||
for operation in configuration["spec"]["operations"]
|
||||
]
|
||||
by_capability = {item["capability"]: item for item in operations}
|
||||
assert by_capability["seat.set_cushion_tilt"]["parameters"] == {
|
||||
"position_pct": 40
|
||||
}
|
||||
assert by_capability["seat.set_cushion_tilt"]["motion"] == {
|
||||
"mode": "relative",
|
||||
"direction": "decrease",
|
||||
"start_position_pct": 50,
|
||||
"delta_position_pct": 10,
|
||||
}
|
||||
assert by_capability["seat.start_dynamic_mode"]["parameters"] == {
|
||||
"mode_id": 1
|
||||
}
|
||||
assert by_capability["seat.set_ventilation"]["parameters"] == {
|
||||
"state": "on",
|
||||
"level": 2,
|
||||
}
|
||||
assert by_capability["climate.set_fan_level"]["parameters"] == {"level": 2}
|
||||
assert by_capability["ambient_light.set_power"]["parameters"] == {
|
||||
"state": "on"
|
||||
}
|
||||
assert {item["parse_source"] for item in operations} == {"direct_control"}
|
||||
@@ -190,6 +190,10 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "mode", "mode": "wave"},
|
||||
),
|
||||
"打开主驾座椅按摩": (
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "mode", "mode": "relax"},
|
||||
),
|
||||
"主驾座椅按摩强度调高": (
|
||||
"seat.set_driver_massage_native",
|
||||
{"action": "strength", "strength": 3},
|
||||
@@ -239,7 +243,6 @@ class DriverSeatNativeCanTests(unittest.TestCase):
|
||||
"打开右前座椅加热",
|
||||
"打开主驾和副驾座椅通风",
|
||||
"seat driver vent low, seat passenger vent low",
|
||||
"打开主驾座椅按摩",
|
||||
"主驾座椅向前移动50毫秒",
|
||||
"seat driver height forward 500",
|
||||
)
|
||||
|
||||
@@ -306,7 +306,7 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
|
||||
self.assertIn("const driverOnlyCh1 = isDriverOnlyCh1Runtime()", submit_source)
|
||||
self.assertIn("&& !driverOnlyCh1", submit_source)
|
||||
self.assertIn(
|
||||
'state.executionMode === "hardware" && !driverOnlyCh1',
|
||||
'&& state.controlTransport !== "wireless_mqtt"',
|
||||
submit_source,
|
||||
)
|
||||
startup_source = self.app_source[
|
||||
|
||||
@@ -203,7 +203,7 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
self.assertEqual(frame.arbitration_id, 0x42E)
|
||||
self.assertTrue(frame.is_fd)
|
||||
|
||||
def test_ignores_legacy_can0_state_outside_seat_ota_wire_protocol(self) -> None:
|
||||
def test_accepts_native_passenger_seat_firmware_status(self) -> None:
|
||||
frames = parse_mqtt_can_payload(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -220,7 +220,9 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertEqual(frames, [])
|
||||
self.assertEqual(len(frames), 1)
|
||||
self.assertEqual(frames[0].channel, 0)
|
||||
self.assertEqual(frames[0].arbitration_id, 0x302)
|
||||
|
||||
def test_translates_seat_ota_info_responses_into_four_position_states(self) -> None:
|
||||
values = {
|
||||
@@ -317,7 +319,7 @@ class MqttCanPayloadTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
[(frame.channel, frame.arbitration_id) for frame in frames],
|
||||
[(1, 0x342)],
|
||||
[(0, 0x302), (1, 0x342)],
|
||||
)
|
||||
|
||||
def test_rejects_decoded_state_invalid_channels_and_channel_conflicts(self) -> None:
|
||||
@@ -435,6 +437,76 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
self.assertLess(time.perf_counter() - started, 0.05)
|
||||
self.assertEqual(snapshot.observed_ids, frozenset({0x388}))
|
||||
|
||||
def test_native_can0_status_maps_to_passenger_frontend_state(self) -> None:
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
{
|
||||
"schema": "n7-can-signals/v1",
|
||||
"signals": [
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x302",
|
||||
"frameType": "classic",
|
||||
"bytes": [axis, 0x26, 0x08, 0, 0, value & 0xFF,
|
||||
(value >> 8) & 0xFF, 0],
|
||||
}
|
||||
for axis, value in enumerate((1000, 2500, 5000, 9000))
|
||||
] + [
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x4FA",
|
||||
"frameType": "classic",
|
||||
"bytes": [0x22, 0, 0, 0, 0, 0, 0, 0],
|
||||
},
|
||||
{
|
||||
"channel": "can0",
|
||||
"canId": "0x306",
|
||||
"frameType": "classic",
|
||||
"bytes": [1, 2, 3, 0x0F, 2, 0, 100, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service._vehicle_mqtt_transport = self.transport
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(state_configured=True)
|
||||
|
||||
payload = service._synchronize_hardware_state_once(
|
||||
"conv-native-can0-state",
|
||||
control_transport="wireless_mqtt",
|
||||
)
|
||||
|
||||
passenger = payload["actuator_state"]["passenger"]
|
||||
self.assertEqual(
|
||||
{
|
||||
axis: value["position_pct"]
|
||||
for axis, value in passenger["position"].items()
|
||||
if axis in {
|
||||
"longitudinal",
|
||||
"cushion_tilt",
|
||||
"backrest_recline",
|
||||
"legrest_height",
|
||||
}
|
||||
},
|
||||
{
|
||||
"longitudinal": 10,
|
||||
"cushion_tilt": 25,
|
||||
"backrest_recline": 50,
|
||||
"legrest_height": 90,
|
||||
},
|
||||
)
|
||||
self.assertEqual(passenger["ventilation"]["level"], 1)
|
||||
self.assertEqual(passenger["heating"]["level"], 3)
|
||||
self.assertEqual(passenger["dynamic_mode"]["mode_id"], 1)
|
||||
self.assertEqual(passenger["dynamic_mode"]["state"], "running")
|
||||
self.assertTrue(
|
||||
all(
|
||||
item["feedback_source"] == "wireless_mqtt_ch0"
|
||||
for item in passenger["position"].values()
|
||||
if item.get("position_valid") is not None
|
||||
)
|
||||
)
|
||||
|
||||
def test_vehicle_envelope_four_frames_decode_from_raw_can1_bytes(self) -> None:
|
||||
self.client.emit(
|
||||
"n7/n7-headunit/can/state",
|
||||
@@ -758,6 +830,158 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_wireless_hardware_can1_service_skips_local_hardware_connection(self) -> None:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service._vehicle_mqtt_transport = self.transport
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(
|
||||
enabled=True,
|
||||
configured=True,
|
||||
feedback_timeout_ms=1,
|
||||
authorize=lambda _token: None,
|
||||
)
|
||||
validated_events: list[dict[str, object]] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
service.artifact_root = Path(temporary_directory)
|
||||
with (
|
||||
patch.object(
|
||||
service.hardware_execution,
|
||||
"reserve",
|
||||
side_effect=AssertionError(
|
||||
"wireless CAN1 must not reserve the local physical bench"
|
||||
),
|
||||
),
|
||||
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
|
||||
):
|
||||
result = service.process(
|
||||
"空调温度设置为24度",
|
||||
provider="local",
|
||||
conversation_id="conv-wireless-can1-no-local-hardware",
|
||||
execution_mode="hardware",
|
||||
control_transport="wireless_mqtt",
|
||||
_on_commands_validated=validated_events.append,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(list(result["command_plans"]), ["native_climate"])
|
||||
self.assertEqual(len(validated_events), 1)
|
||||
self.assertEqual(
|
||||
list(validated_events[0]["command_plans"]),
|
||||
["native_climate"],
|
||||
)
|
||||
payloads = [json.loads(item[1]) for item in self.client.published]
|
||||
self.assertEqual(len(payloads), 3)
|
||||
self.assertTrue(all(item["channel"] == "can1" for item in payloads))
|
||||
self.assertTrue(all(item["command"] == "ac temp driver 24" for item in payloads))
|
||||
|
||||
def test_wireless_hardware_combines_can0_and_can1_without_local_profile(self) -> None:
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service._vehicle_mqtt_transport = self.transport
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(
|
||||
enabled=True,
|
||||
configured=True,
|
||||
feedback_timeout_ms=1,
|
||||
authorize=lambda _token: None,
|
||||
)
|
||||
validated_events: list[dict[str, object]] = []
|
||||
seat_execution: dict[str, object] = {}
|
||||
|
||||
def execute_seat(
|
||||
plan: dict[str, object],
|
||||
config: dict[str, object],
|
||||
_state: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
seat_execution["plan"] = plan
|
||||
seat_execution["config"] = config
|
||||
operation = config["spec"]["operations"][0] # type: ignore[index]
|
||||
request_id = config["metadata"]["request_id"] # type: ignore[index]
|
||||
return {
|
||||
"apiVersion": "cognitive-agent/v1alpha1",
|
||||
"kind": "ExecutionReport",
|
||||
"metadata": {
|
||||
"id": f"report-{request_id}",
|
||||
"name": "wireless CAN0 test report",
|
||||
"version": "1.0.0",
|
||||
"request_id": request_id,
|
||||
"labels": {
|
||||
"simulation": "false",
|
||||
"seat_feedback": "wireless_mqtt_ch0",
|
||||
},
|
||||
},
|
||||
"spec": {
|
||||
"mode": "hil",
|
||||
"status": "succeeded",
|
||||
"started_at": "2026-08-25T00:00:00Z",
|
||||
"finished_at": "2026-08-25T00:00:00Z",
|
||||
"results": [
|
||||
{
|
||||
"ref": operation["id"],
|
||||
"domain": "seat",
|
||||
"status": "succeeded",
|
||||
"message": "wireless CAN0 command published",
|
||||
"telemetry": {
|
||||
"effective_position_pct": 35,
|
||||
"position_valid": True,
|
||||
"simulated": False,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
service.artifact_root = Path(temporary_directory)
|
||||
with (
|
||||
patch.object(
|
||||
type(service.hardware_settings),
|
||||
"validate_function_config",
|
||||
side_effect=AssertionError(
|
||||
"wireless mixed CAN0/CAN1 must not check combined profile"
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
service.hardware_execution,
|
||||
"reserve",
|
||||
side_effect=AssertionError(
|
||||
"wireless mixed CAN0/CAN1 must not reserve local hardware"
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
service,
|
||||
"_new_hardware_executor",
|
||||
return_value=SimpleNamespace(execute=execute_seat),
|
||||
) as executor_factory,
|
||||
patch("cognitive_agent.application.native_can_runtime.time.sleep"),
|
||||
):
|
||||
result = service.process(
|
||||
"把副驾座椅水平位置调到35%,空调温度设置为24度",
|
||||
provider="local",
|
||||
conversation_id="conv-wireless-can0-can1-no-local-hardware",
|
||||
execution_mode="hardware",
|
||||
control_transport="wireless_mqtt",
|
||||
_on_commands_validated=validated_events.append,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(
|
||||
set(result["command_plans"]),
|
||||
{"seat", "native_climate"},
|
||||
)
|
||||
self.assertEqual(len(validated_events), 1)
|
||||
self.assertEqual(
|
||||
set(validated_events[0]["command_plans"]),
|
||||
{"seat", "native_climate"},
|
||||
)
|
||||
self.assertEqual(
|
||||
seat_execution["plan"]["spec"]["commands"][0]["message"], # type: ignore[index]
|
||||
"T2G_Seat_Move_Command_300",
|
||||
)
|
||||
self.assertTrue(executor_factory.call_args.kwargs["remote_transport"])
|
||||
payloads = [json.loads(item[1]) for item in self.client.published]
|
||||
self.assertEqual(len(payloads), 3)
|
||||
self.assertTrue(all(item["channel"] == "can1" for item in payloads))
|
||||
self.assertTrue(all(item["command"] == "ac temp driver 24" for item in payloads))
|
||||
|
||||
def test_wireless_fixed_controls_and_shade_preset_never_wait_for_state(
|
||||
self,
|
||||
) -> None:
|
||||
@@ -935,6 +1159,21 @@ class MqttCanTransportTests(unittest.TestCase):
|
||||
self.assertEqual(payload["command"], "")
|
||||
self.assertEqual(payload["frameType"], "classic")
|
||||
self.assertFalse(payload["brs"])
|
||||
bus.set_debug_command("seat passenger heat low")
|
||||
bus.send(0x3CE, bytes.fromhex("10 00 00 00 00 00 00 00"))
|
||||
bus.send(0x29E, bytes.fromhex("16 00 10 00 00 00 00 00"))
|
||||
thermal_payloads = [
|
||||
json.loads(item[1]) for item in self.client.published[-2:]
|
||||
]
|
||||
self.assertEqual(
|
||||
[item["canId"] for item in thermal_payloads],
|
||||
["0x3CE", "0x29E"],
|
||||
)
|
||||
self.assertTrue(
|
||||
all(item["command"] == "seat passenger heat low" for item in thermal_payloads)
|
||||
)
|
||||
with self.assertRaisesRegex(Exception, "0x29E thermal control payload"):
|
||||
bus.send(0x29E, bytes(8))
|
||||
with self.assertRaisesRegex(Exception, "approved CAN0 control set"):
|
||||
bus.send(0x431, bytes(8))
|
||||
with self.assertRaisesRegex(Exception, "requires classic CAN"):
|
||||
|
||||
@@ -2,8 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.application.seat_service import SeatDemoService
|
||||
from cognitive_agent.application.mqtt_ota_seat_executor import (
|
||||
MqttOtaSeatExecutor,
|
||||
)
|
||||
@@ -11,6 +15,9 @@ from cognitive_agent.infrastructure.can.gateway import CanFrame
|
||||
from seat_domain import parse_local_intent
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _axis_frame(axis: int, value_x100: int, *, valid: bool = True) -> CanFrame:
|
||||
flags = (1 << 3) if valid else 0
|
||||
return CanFrame(
|
||||
@@ -57,6 +64,8 @@ class _FakeOtaBus:
|
||||
bitrate_switch=bitrate_switch,
|
||||
)
|
||||
self.sent.append(frame)
|
||||
if arbitration_id != 0x6A0:
|
||||
return frame
|
||||
opcode = data[2]
|
||||
job_state = (
|
||||
1 | (4 << 4) | (9 << 8)
|
||||
@@ -87,6 +96,30 @@ class _FakeOtaBus:
|
||||
|
||||
|
||||
class _StatusExecutor:
|
||||
class _Matrix:
|
||||
@staticmethod
|
||||
def encode(message: str, signals: dict[str, int]) -> dict[str, str]:
|
||||
if message == "T2G_Seat_Thermal_Mode_3CE":
|
||||
return {
|
||||
"frame_id": "0x3CE",
|
||||
"data_hex": f"{int(signals['Driver_Seat_Thermal_Mode']) << 4:02X} 00 00 00 00 00 00 00",
|
||||
}
|
||||
byte0 = (
|
||||
int(signals["Driver_Seat_Ventilation_On"])
|
||||
| (int(signals["Driver_Seat_Ventilation_Mode"]) << 2)
|
||||
| (int(signals["Driver_Seat_Heating_On"]) << 4)
|
||||
)
|
||||
byte2 = (
|
||||
int(signals["Driver_Seat_Ventilation_Gear"])
|
||||
| (int(signals["Driver_Seat_Heating_Gear"]) << 4)
|
||||
)
|
||||
return {
|
||||
"frame_id": "0x29E",
|
||||
"data_hex": f"{byte0:02X} 00 {byte2:02X} 00 00 00 00 00",
|
||||
}
|
||||
|
||||
matrix = _Matrix()
|
||||
|
||||
def read_physical_state(self) -> dict[str, object]:
|
||||
return {"position": {}, "complete": False}
|
||||
|
||||
@@ -97,6 +130,37 @@ class _StatusExecutor:
|
||||
) -> dict[str, object]:
|
||||
return {"position": {}, "complete": True, "missing": []}
|
||||
|
||||
def _execute_thermal(
|
||||
self,
|
||||
bus: _FakeOtaBus,
|
||||
command: dict[str, object],
|
||||
operations: list[dict[str, object]],
|
||||
) -> list[dict[str, object]]:
|
||||
encoded = self.matrix.encode(
|
||||
str(command["message"]),
|
||||
command["signals"], # type: ignore[arg-type]
|
||||
)
|
||||
sent = bus.send(
|
||||
int(encoded["frame_id"], 16),
|
||||
bytes.fromhex(encoded["data_hex"]),
|
||||
)
|
||||
feedback = bus.receive(100)
|
||||
if feedback is None or feedback.arbitration_id != 0x4FA:
|
||||
raise RuntimeError("missing thermal feedback")
|
||||
return [
|
||||
{
|
||||
"ref": str(operation["id"]),
|
||||
"domain": "seat",
|
||||
"status": "succeeded",
|
||||
"message": "thermal feedback confirmed",
|
||||
"telemetry": {
|
||||
"command_frame": sent.public(),
|
||||
"feedback_frame": feedback.public(),
|
||||
},
|
||||
}
|
||||
for operation in operations
|
||||
]
|
||||
|
||||
|
||||
def _function_config(capability: str, parameters: dict[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
@@ -115,6 +179,65 @@ def _function_config(capability: str, parameters: dict[str, object]) -> dict[str
|
||||
|
||||
|
||||
class MqttOtaSeatExecutorTests(unittest.TestCase):
|
||||
def test_wireless_hardware_service_skips_local_t2g_profile_and_bench(self) -> None:
|
||||
bus = _FakeOtaBus([_axis_frame(0, 4_000)])
|
||||
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
|
||||
_StatusExecutor(),
|
||||
lambda: bus,
|
||||
)
|
||||
service = SeatDemoService(REPOSITORY_ROOT)
|
||||
service.vehicle_mqtt_settings = SimpleNamespace(
|
||||
enabled=True,
|
||||
configured=True,
|
||||
authorize=lambda _token: None,
|
||||
)
|
||||
validated_events: list[dict[str, object]] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
service.artifact_root = Path(temporary_directory)
|
||||
with (
|
||||
patch.object(
|
||||
type(service.hardware_settings),
|
||||
"validate_function_config",
|
||||
side_effect=AssertionError(
|
||||
"wireless CAN0 must not check the local combined profile"
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
service.hardware_execution,
|
||||
"reserve",
|
||||
side_effect=AssertionError(
|
||||
"wireless CAN0 must not reserve the local physical bench"
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
service,
|
||||
"_new_hardware_executor",
|
||||
return_value=executor,
|
||||
) as executor_factory,
|
||||
):
|
||||
result = service.process(
|
||||
"把副驾座椅水平位置调到35%",
|
||||
provider="local",
|
||||
conversation_id="conv-wireless-can0-no-local-hardware",
|
||||
execution_mode="hardware",
|
||||
control_transport="wireless_mqtt",
|
||||
_on_commands_validated=validated_events.append,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(list(result["command_plans"]), ["seat"])
|
||||
self.assertEqual(len(validated_events), 1)
|
||||
self.assertEqual(list(validated_events[0]["command_plans"]), ["seat"])
|
||||
self.assertEqual(
|
||||
result["command_plans"]["seat"]["spec"]["commands"][0]["message"],
|
||||
"T2G_Seat_Move_Command_300",
|
||||
)
|
||||
self.assertEqual(bus.sent[0].arbitration_id, 0x6A0)
|
||||
self.assertEqual(bus.sent[0].data[2], 0x64)
|
||||
self.assertEqual(int.from_bytes(bus.sent[0].data[4:8], "little"), 350)
|
||||
self.assertTrue(executor_factory.call_args.kwargs["remote_transport"])
|
||||
|
||||
def test_seat_height_language_maps_to_internal_axis_one_capability(self) -> None:
|
||||
parsed = parse_local_intent("副驾座椅高度调到60%")
|
||||
|
||||
@@ -161,7 +284,7 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
|
||||
self.assertEqual(int.from_bytes(bus.sent[0].data[4:8], "little"), 500)
|
||||
self.assertEqual(
|
||||
bus.debug_commands,
|
||||
["seat passenger longitudinal position 50"],
|
||||
["seat passenger horizontal position 50"],
|
||||
)
|
||||
self.assertEqual(
|
||||
report["spec"]["results"][0]["telemetry"]["effective_position_pct"],
|
||||
@@ -203,8 +326,10 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
|
||||
self.assertNotIn(0x61, [frame.data[2] for frame in bus.sent])
|
||||
self.assertNotIn(0x62, [frame.data[2] for frame in bus.sent])
|
||||
|
||||
def test_wireless_thermal_command_fails_before_publish(self) -> None:
|
||||
bus = _FakeOtaBus([])
|
||||
def test_wireless_thermal_command_uses_native_firmware_frames(self) -> None:
|
||||
bus = _FakeOtaBus(
|
||||
[CanFrame(0x4FA, bytes((0x11, 0, 0, 0, 0, 0, 0, 0)), channel=0)]
|
||||
)
|
||||
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
|
||||
_StatusExecutor(),
|
||||
lambda: bus,
|
||||
@@ -212,10 +337,21 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
|
||||
plan = {
|
||||
"spec": {
|
||||
"commands": [
|
||||
{
|
||||
"operation_ref": "operation-1",
|
||||
"message": "T2G_Seat_Thermal_Mode_3CE",
|
||||
"signals": {"Driver_Seat_Thermal_Mode": 1},
|
||||
},
|
||||
{
|
||||
"operation_ref": "operation-1",
|
||||
"message": "T2G_Seat_Thermal_Control_29E",
|
||||
"signals": {},
|
||||
"signals": {
|
||||
"Driver_Seat_Ventilation_On": 2,
|
||||
"Driver_Seat_Ventilation_Mode": 1,
|
||||
"Driver_Seat_Heating_On": 1,
|
||||
"Driver_Seat_Ventilation_Gear": 0,
|
||||
"Driver_Seat_Heating_Gear": 1,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -226,9 +362,14 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
|
||||
_function_config("seat.set_heating", {"state": "on", "level": 1}),
|
||||
)
|
||||
|
||||
self.assertEqual(report["spec"]["status"], "failed")
|
||||
self.assertIn("does not provide wireless thermal control", report["spec"]["results"][0]["message"])
|
||||
self.assertEqual(bus.sent, [])
|
||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||
self.assertEqual([frame.arbitration_id for frame in bus.sent], [0x3CE, 0x29E])
|
||||
self.assertEqual(bus.sent[0].data, bytes.fromhex("10 00 00 00 00 00 00 00"))
|
||||
self.assertEqual(bus.sent[1].data, bytes.fromhex("16 00 10 00 00 00 00 00"))
|
||||
self.assertEqual(
|
||||
bus.debug_commands,
|
||||
["seat passenger heat low", "seat passenger heat low"],
|
||||
)
|
||||
|
||||
def test_dynamic_semantic_modes_use_seat_ota_firmware_selectors(self) -> None:
|
||||
for semantic_mode, firmware_selector, mode_name in (
|
||||
@@ -267,7 +408,7 @@ class MqttOtaSeatExecutorTests(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
bus.debug_commands,
|
||||
[
|
||||
"seat passenger dynamic start "
|
||||
"seat passenger dynamic "
|
||||
+ ("rocking-horse" if semantic_mode == 1 else "cradle")
|
||||
],
|
||||
)
|
||||
|
||||
@@ -165,8 +165,10 @@ def test_vehicle_device_status_cards_prioritize_seats_and_expose_manual_controls
|
||||
|
||||
assert 'data-device-control-toggle="${device.id}"' in console
|
||||
assert 'data-device-control-apply="${device.id}"' in console
|
||||
assert 'new CustomEvent("cognitive-agent:vehicle-control-request"' in console
|
||||
assert "window.CognitiveAgentConsole?.executeUtterance?.(utterance" in console
|
||||
assert 'new CustomEvent("cognitive-agent:vehicle-can-control-request"' in console
|
||||
assert "window.CognitiveAgentConsole?.executeCanCommands" in console
|
||||
assert "device.commands(values).filter(Boolean)" in console
|
||||
assert 'controllable: false' in console
|
||||
assert '.v2-device-control-row input[type="range"]' in styles
|
||||
assert ".v2-device-control-select" in styles
|
||||
assert ".v2-device-control-apply" in styles
|
||||
@@ -229,7 +231,7 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
|
||||
"light ambient brightness ${level}",
|
||||
"shade position ${position}",
|
||||
"seat passenger ${axis[1]} position ${position}",
|
||||
"seat passenger dynamic start ${dynamic.name}",
|
||||
"seat passenger dynamic ${dynamic.name}",
|
||||
"seat passenger dynamic stop",
|
||||
"otaCan0Frame(0x64, axis[2], targetPermille)",
|
||||
"otaCan0Frame(0x66, dynamic.selector, 300)",
|
||||
|
||||
@@ -185,8 +185,9 @@ def test_frontend_two_stage_contract_and_source_badges() -> None:
|
||||
assert '"cognitive-agent:validated-can-commands"' in app
|
||||
assert "本地规则命令已执行;云端补充失败" in app
|
||||
assert 'class="v2-can-command-source"' in console
|
||||
assert 'item.parseSource === "direct_control" ? "设备控件"' in console
|
||||
assert 'item.parseSource === "local_rule" ? "本地规则" : "云端模型"' in console
|
||||
assert '"cognitive-agent:validated-can-commands"' in console
|
||||
assert '.v2-can-command-source[data-parse-source="local_rule"]' in styles
|
||||
assert 'path not in {"/api/requests", "/api/requests/preview"}' in server
|
||||
assert '"/api/can/commands"' in server
|
||||
assert '"type": "validated_commands"' in server
|
||||
|
||||
Reference in New Issue
Block a user