优化了副驾座椅控制
This commit is contained in:
+26606
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -410,7 +410,7 @@
|
||||
],
|
||||
"rejection_cases": [
|
||||
{"id": "SAFE-DRIVER-THERMAL-LEVEL", "utterance": "主驾座椅通风4档", "expected_status": "needs_confirmation"},
|
||||
{"id": "SAFE-DRIVER-MASSAGE-GENERIC", "utterance": "打开主驾座椅按摩", "expected_status": "needs_confirmation"},
|
||||
{"id": "SAFE-DRIVER-THERMAL-MIDDLE", "utterance": "主驾座椅通风中档", "expected_status": "needs_confirmation"},
|
||||
{"id": "SAFE-DRIVER-MOTION", "utterance": "把驾驶位靠背调到50%", "expected_status": "needs_confirmation"},
|
||||
{"id": "SAFE-DRIVER-MODE", "utterance": "主驾进入零重力模式", "expected_status": "needs_confirmation"},
|
||||
{"id": "SAFE-FRONT-ROW", "utterance": "打开前排座椅加热", "expected_status": "needs_confirmation"},
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from cognitive_agent.application.mqtt_ota_seat_executor import MqttOtaSeatExecutor
|
||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||
|
||||
|
||||
CATALOG_PATH = (
|
||||
REPOSITORY_ROOT
|
||||
/ "tests"
|
||||
/ "fixtures"
|
||||
/ "natural_can_command_combination_suite.json"
|
||||
)
|
||||
HARDWARE_DISABLED_ENV = {
|
||||
"COGNITIVE_AGENT_CH1_HIL_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
|
||||
}
|
||||
|
||||
|
||||
class NaturalCanCommandCombinationTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.temporary_directory = tempfile.TemporaryDirectory()
|
||||
temporary_root = Path(cls.temporary_directory.name)
|
||||
memory_path = temporary_root / "driver-seat-memory.json"
|
||||
memory_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"positions": {
|
||||
str(slot): {
|
||||
"payload_15": "15 00 00 00 00 00 00 00",
|
||||
"payload_16": "16 00 00 00 00 00 00 00",
|
||||
}
|
||||
for slot in (1, 2, 3)
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cls.environment = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
**HARDWARE_DISABLED_ENV,
|
||||
"COGNITIVE_AGENT_DRIVER_SEAT_MEMORY_PATH": str(memory_path),
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
cls.environment.start()
|
||||
cls.service = SeatDemoService(REPOSITORY_ROOT)
|
||||
cls.service.artifact_root = temporary_root / "artifacts"
|
||||
cls.catalog = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.environment.stop()
|
||||
cls.temporary_directory.cleanup()
|
||||
|
||||
@staticmethod
|
||||
def _debug_commands(result: dict) -> list[str]:
|
||||
commands: list[str] = []
|
||||
for plan in result.get("command_plans", {}).values():
|
||||
for command in plan.get("spec", {}).get("commands", []):
|
||||
debug_command = command.get("signals", {}).get("debug_command")
|
||||
if isinstance(debug_command, str):
|
||||
commands.append(debug_command)
|
||||
return commands
|
||||
|
||||
@staticmethod
|
||||
def _can0_operations(result: dict, expected: list[dict]) -> Counter[str]:
|
||||
capabilities = {item["capability"] for item in expected}
|
||||
operations: list[dict] = []
|
||||
for config in result.get("function_configs", {}).values():
|
||||
for operation in config.get("spec", {}).get("operations", []):
|
||||
if operation.get("capability") not in capabilities:
|
||||
continue
|
||||
target = operation.get("target", {})
|
||||
operations.append(
|
||||
{
|
||||
"target": target.get("instance", target.get("zone")),
|
||||
"capability": operation["capability"],
|
||||
"parameters": operation.get("parameters", {}),
|
||||
}
|
||||
)
|
||||
return Counter(
|
||||
json.dumps(item, ensure_ascii=False, sort_keys=True)
|
||||
for item in operations
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _expected_can0_operations(expected: list[dict]) -> Counter[str]:
|
||||
return Counter(
|
||||
json.dumps(item, ensure_ascii=False, sort_keys=True)
|
||||
for item in expected
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _single_passenger_motion_operation(result: dict) -> dict:
|
||||
operations = [
|
||||
operation
|
||||
for config in result.get("function_configs", {}).values()
|
||||
for operation in config.get("spec", {}).get("operations", [])
|
||||
if operation.get("capability")
|
||||
in {
|
||||
"seat.set_longitudinal_position",
|
||||
"seat.set_cushion_tilt",
|
||||
"seat.set_backrest_recline",
|
||||
"seat.set_legrest_height",
|
||||
}
|
||||
]
|
||||
if len(operations) != 1:
|
||||
raise AssertionError(f"expected one passenger motion operation: {operations}")
|
||||
return operations[0]
|
||||
|
||||
def test_catalog_metadata_and_all_expanded_cases(self) -> None:
|
||||
cases = self.catalog["cases"]
|
||||
self.assertEqual(
|
||||
self.catalog["schema"],
|
||||
"natural-can-command-combination-suite/v1",
|
||||
)
|
||||
self.assertEqual(self.catalog["summary"]["total_cases"], len(cases))
|
||||
self.assertGreaterEqual(len(cases), 1000)
|
||||
self.assertEqual(len({item["id"] for item in cases}), len(cases))
|
||||
|
||||
for index, case in enumerate(cases):
|
||||
with self.subTest(case=case["id"], utterance=case["utterance"]):
|
||||
result = self.service.process(
|
||||
case["utterance"],
|
||||
provider="local",
|
||||
conversation_id=f"conv-natural-combination-{index:04d}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], case["expected_status"])
|
||||
self.assertEqual(
|
||||
self._debug_commands(result),
|
||||
case["expected_commands"],
|
||||
)
|
||||
expected_can0 = case["expected_can0_operations"]
|
||||
if expected_can0:
|
||||
self.assertEqual(
|
||||
self._can0_operations(result, expected_can0),
|
||||
self._expected_can0_operations(expected_can0),
|
||||
)
|
||||
|
||||
def test_can0_native_motion_and_dynamic_modes_combine_with_can1(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
"把副驾座椅水平位置调到35%,空调温度设置为24度,打开氛围灯",
|
||||
["ac temp driver 24", "light ambient on"],
|
||||
[{
|
||||
"target": "passenger",
|
||||
"capability": "seat.set_longitudinal_position",
|
||||
"parameters": {"position_pct": 35},
|
||||
}],
|
||||
),
|
||||
(
|
||||
"副驾坐盆向上调整10%,关闭后排车窗,空调风量2档",
|
||||
["window rear close", "ac fan 2"],
|
||||
[{
|
||||
"target": "passenger",
|
||||
"capability": "seat.set_cushion_tilt",
|
||||
"parameters": {"position_pct": 40},
|
||||
}],
|
||||
),
|
||||
(
|
||||
"开启木马模式并打开氛围灯",
|
||||
["light ambient on"],
|
||||
[{
|
||||
"target": "passenger",
|
||||
"capability": "seat.start_dynamic_mode",
|
||||
"parameters": {"mode_id": 1},
|
||||
}],
|
||||
),
|
||||
(
|
||||
"开启动态摇篮模式,空调24度,打开遮阳帘",
|
||||
["ac temp driver 24", "shade open"],
|
||||
[{
|
||||
"target": "passenger",
|
||||
"capability": "seat.start_dynamic_mode",
|
||||
"parameters": {"mode_id": 2},
|
||||
}],
|
||||
),
|
||||
(
|
||||
"副驾座椅前后调到20%、坐盆调到30%、靠背调到40%、腿托调到50%,空调风量3档,关闭全部车窗",
|
||||
["ac fan 3", "window all close"],
|
||||
[
|
||||
{
|
||||
"target": "passenger",
|
||||
"capability": capability,
|
||||
"parameters": {"position_pct": position},
|
||||
}
|
||||
for capability, position in (
|
||||
("seat.set_longitudinal_position", 20),
|
||||
("seat.set_cushion_tilt", 30),
|
||||
("seat.set_backrest_recline", 40),
|
||||
("seat.set_legrest_height", 50),
|
||||
)
|
||||
],
|
||||
),
|
||||
(
|
||||
"副驾靠背向后移动15%,主驾温度设为25度,遮阳帘打开",
|
||||
["ac temp driver 25", "shade open"],
|
||||
[{
|
||||
"target": "passenger",
|
||||
"capability": "seat.set_backrest_recline",
|
||||
"parameters": {"position_pct": 35},
|
||||
}],
|
||||
),
|
||||
(
|
||||
"副驾靠背往后转动10%,空调温度设置为24度",
|
||||
["ac temp driver 24"],
|
||||
[{
|
||||
"target": "passenger",
|
||||
"capability": "seat.set_backrest_recline",
|
||||
"parameters": {"position_pct": 30},
|
||||
}],
|
||||
),
|
||||
(
|
||||
"副驾腿托设置为40%,关闭副驾车窗",
|
||||
["window front-right close"],
|
||||
[{
|
||||
"target": "passenger",
|
||||
"capability": "seat.set_legrest_height",
|
||||
"parameters": {"position_pct": 40},
|
||||
}],
|
||||
),
|
||||
)
|
||||
|
||||
for index, (utterance, can1_commands, can0_operations) in enumerate(cases):
|
||||
with self.subTest(utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-can0-can1-targeted-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
self.assertEqual(self._debug_commands(result), can1_commands)
|
||||
self.assertEqual(
|
||||
self._can0_operations(result, can0_operations),
|
||||
self._expected_can0_operations(can0_operations),
|
||||
)
|
||||
|
||||
def test_passenger_relative_and_absolute_directional_commands_stay_distinct(self) -> None:
|
||||
cases = (
|
||||
("副驾座椅往上移动10%", "basin", "decrease", "up", 10),
|
||||
("副驾座椅往上移动20%", "basin", "decrease", "up", 20),
|
||||
("副驾往下移动10%", "basin", "increase", "down", 10),
|
||||
("副驾往下移动20%", "basin", "increase", "down", 20),
|
||||
("副驾座椅往前移动10%", "horizontal", "decrease", "forward", 10),
|
||||
("副驾座椅往前移动20%", "horizontal", "decrease", "forward", 20),
|
||||
("副驾往后移动10%", "horizontal", "increase", "backward", 10),
|
||||
("副驾往后移动20%", "horizontal", "increase", "backward", 20),
|
||||
("副驾座椅靠背往前移动10%", "backrest", "decrease", "forward", 10),
|
||||
("副驾座椅靠背往前移动30%", "backrest", "decrease", "forward", 30),
|
||||
("副驾靠背往后移动10%", "backrest", "increase", "backward", 10),
|
||||
("副驾靠背往后移动30%", "backrest", "increase", "backward", 30),
|
||||
("副驾推托往上移动20%", "legrest", "increase", "up", 20),
|
||||
("副驾推托往上移动40%", "legrest", "increase", "up", 40),
|
||||
("副驾推托往下移动20%", "legrest", "decrease", "down", 20),
|
||||
("副驾推托往下移动40%", "legrest", "decrease", "down", 40),
|
||||
)
|
||||
|
||||
for index, (utterance, axis, direction, command_direction, amount) in enumerate(
|
||||
cases
|
||||
):
|
||||
with self.subTest(kind="relative", utterance=utterance):
|
||||
result = self.service.process(
|
||||
utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-passenger-relative-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
operation = self._single_passenger_motion_operation(result)
|
||||
motion = operation["motion"]
|
||||
self.assertEqual(motion["mode"], "relative")
|
||||
self.assertEqual(motion["direction"], direction)
|
||||
self.assertEqual(
|
||||
motion.get(
|
||||
"requested_delta_position_pct",
|
||||
motion["delta_position_pct"],
|
||||
),
|
||||
amount,
|
||||
)
|
||||
self.assertTrue(
|
||||
MqttOtaSeatExecutor._operation_debug_command(operation).startswith(
|
||||
f"seat passenger {axis} relative {command_direction} "
|
||||
)
|
||||
)
|
||||
|
||||
absolute_utterance = utterance.replace("移动", "移动到")
|
||||
with self.subTest(kind="absolute", utterance=absolute_utterance):
|
||||
result = self.service.process(
|
||||
absolute_utterance,
|
||||
provider="local",
|
||||
conversation_id=f"conv-passenger-absolute-{index}",
|
||||
execution_mode="dry_run",
|
||||
)
|
||||
self.assertEqual(result["status"], "succeeded")
|
||||
operation = self._single_passenger_motion_operation(result)
|
||||
self.assertNotIn("motion", operation)
|
||||
self.assertEqual(operation["parameters"]["position_pct"], amount)
|
||||
self.assertEqual(
|
||||
MqttOtaSeatExecutor._operation_debug_command(operation),
|
||||
f"seat passenger {axis} position {amount}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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