添加NFC绑定红外设备方式,融合车端设备和扩展设备统一控制

This commit is contained in:
minghanchen
2026-09-01 11:04:59 +08:00
parent 6d75944c0f
commit ca26259e63
14 changed files with 3694 additions and 316 deletions
+595 -15
View File
@@ -33,6 +33,7 @@ class SeatWebApiTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temporary_directory = tempfile.TemporaryDirectory()
cls.extension_temporary_directory = tempfile.TemporaryDirectory()
# Web regression tests must never inherit a developer's live bench
# enablement, especially when local debug mode intentionally skips the
# per-request approval token.
@@ -50,6 +51,13 @@ class SeatWebApiTests(unittest.TestCase):
cls.service = service
cls.original_service = SeatDemoHandler.service
SeatDemoHandler.service = service
cls.original_extension = SeatDemoHandler.extension
SeatDemoHandler.extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
cls.extension_temporary_directory.name, "gscu-ir-catalog.sqlite3"
),
)
cls.original_auth = SeatDemoHandler.auth
SeatDemoHandler.auth = AuthRuntime(
AuthConfig(
@@ -79,7 +87,9 @@ class SeatWebApiTests(unittest.TestCase):
cls.server.server_close()
cls.thread.join(timeout=2)
SeatDemoHandler.service = cls.original_service
SeatDemoHandler.extension = cls.original_extension
SeatDemoHandler.auth = cls.original_auth
cls.extension_temporary_directory.cleanup()
cls.temporary_directory.cleanup()
def setUp(self) -> None:
@@ -341,19 +351,19 @@ operations:
) as response:
gesture_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/console-v2.js?v=task-outcome-47", timeout=3
f"{self.base_url}/console-v2.js?v=delivery-state-49", timeout=3
) as response:
console_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/console-v2.css?v=sync-layout-41", timeout=3
f"{self.base_url}/console-v2.css?v=delivery-state-43", timeout=3
) as response:
console_styles = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/self-extension.js?v=gscu-tcp-37", timeout=3
f"{self.base_url}/self-extension.js?v=gscu-tcp-40", timeout=3
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/self-extension.css?v=gscu-tcp-37", timeout=3
f"{self.base_url}/self-extension.css?v=gscu-tcp-39", timeout=3
) as response:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open(
@@ -388,13 +398,13 @@ operations:
self.assertIn('href="/styles.css?v=dual-seat-16"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
self.assertNotIn('class="vehicle-system-hud"', html)
self.assertIn('src="/console-v2.js?v=task-outcome-47"', html)
self.assertIn('href="/console-v2.css?v=sync-layout-41"', html)
self.assertIn('src="/console-v2.js?v=delivery-state-49"', html)
self.assertIn('href="/console-v2.css?v=delivery-state-43"', html)
self.assertIn('<body class="console-v2">', html)
self.assertIn('id="v2BootScreen"', html)
self.assertIn('正在加载车机控制与实时状态', html)
self.assertIn('src="/self-extension.js?v=gscu-tcp-37"', html)
self.assertIn('href="/self-extension.css?v=gscu-tcp-37"', html)
self.assertIn('src="/self-extension.js?v=gscu-tcp-40"', html)
self.assertIn('href="/self-extension.css?v=gscu-tcp-39"', html)
self.assertNotIn("self-calibration", html)
self.assertNotIn("self-repair", html)
self.assertNotIn("self-evolution", html)
@@ -639,7 +649,7 @@ operations:
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v214"', service_worker)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v218"', service_worker)
self.assertIn('"/app.js?v=dual-seat-107"', service_worker)
self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker)
self.assertIn(".then(() => self.skipWaiting())", service_worker)
@@ -1013,7 +1023,12 @@ operations:
def test_self_extension_gateway_uses_unified_authenticated_service(self) -> None:
original_extension = SeatDemoHandler.extension
SeatDemoHandler.extension = SelfExtensionService(SimulationGateway())
SeatDemoHandler.extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name, "gateway-auth.sqlite3"
),
)
try:
with self.opener.open(
f"{self.base_url}/api/extension/health", timeout=3
@@ -1039,7 +1054,12 @@ operations:
def test_self_extension_tcp_configuration_endpoint_updates_runtime(self) -> None:
original_extension = SeatDemoHandler.extension
extension = SelfExtensionService(SimulationGateway())
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name, "tcp-config.sqlite3"
),
)
SeatDemoHandler.extension = extension
try:
with tempfile.TemporaryDirectory() as directory:
@@ -1093,11 +1113,38 @@ operations:
with self.opener.open(request, timeout=3) as response:
self.assertEqual(response.status, 200)
structured_request = urllib.request.Request(
f"{self.base_url}/api/extension/gscu/slots/5",
method="PUT",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"device_id": "toy-light-001",
"object_names": ["闪烁灯", "闪灯玩具"],
"action_names": ["切换为蓝色", "设置为蓝色"],
"source": "tcp",
},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(structured_request, timeout=3) as response:
structured = json.loads(response.read().decode("utf-8"))
self.assertEqual(structured["slot"]["device_id"], "toy-light-001")
self.assertEqual(
structured["slot"]["object_names"], ["闪烁灯", "闪灯玩具"]
)
self.assertEqual(
structured["slot"]["action_names"],
["切换为蓝色", "设置为蓝色"],
)
with self.opener.open(
f"{self.base_url}/api/extension/gscu/slots", timeout=3
) as response:
catalog = json.loads(response.read().decode("utf-8"))
self.assertEqual([item["slot"] for item in catalog["slots"]], [3, 12])
self.assertEqual(
[item["slot"] for item in catalog["slots"]], [3, 5, 12]
)
delete_request = urllib.request.Request(
f"{self.base_url}/api/extension/gscu/slots/3",
@@ -1117,7 +1164,7 @@ operations:
with self.opener.open(erase_request, timeout=3) as response:
erased = json.loads(response.read().decode("utf-8"))
self.assertTrue(erased["catalog_cleared"])
self.assertEqual(erased["catalog_removed_count"], 1)
self.assertEqual(erased["catalog_removed_count"], 2)
with self.opener.open(
f"{self.base_url}/api/extension/gscu/slots", timeout=3
@@ -1130,7 +1177,12 @@ operations:
def test_main_request_routes_live_nfc_peripheral_to_gscu(self) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
SeatDemoHandler.extension = SelfExtensionService(SimulationGateway())
SeatDemoHandler.extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name, "main-nfc-route.sqlite3"
),
)
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {
"revision": 1,
@@ -1197,10 +1249,538 @@ operations:
self.assertEqual(operation["domain"], "self_extension")
self.assertTrue(operation["telemetry"]["gateway_acknowledged"])
def test_main_request_combines_explicit_ir_clause_with_existing_can_parser(self) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name, "mixed-nfc-can.sqlite3"
),
)
SeatDemoHandler.extension = extension
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {
"revision": 1,
"reader": {"online": True},
"card": {"action": "present", "uid": "2CC8B103"},
}
)
catalog_request = urllib.request.Request(
f"{self.base_url}/api/extension/display-sync",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"peripherals": [
{
"id": "toy-001-light-on",
"status": "learned",
"enabled": True,
"device_id": "toy-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": "开启",
"action_names": ["打开", "开启", "启动"],
"risk": "low",
"slot": 1,
},
{
"id": "toy-001-light-red",
"status": "learned",
"enabled": True,
"device_id": "toy-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": "切换到红色",
"action_names": ["切换到红色", "切换为红色", "设置为红色"],
"risk": "low",
"slot": 3,
},
]
},
ensure_ascii=False,
).encode("utf-8"),
)
request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/x-ndjson",
},
data=json.dumps(
{
"utterance": "打开闪烁灯,颜色切换到红色,打开空调",
"provider": "local",
"conversation_id": "conv-mixed-nfc-can",
"execution_mode": "dry_run",
"client_type": "web_console",
},
ensure_ascii=False,
).encode("utf-8"),
)
try:
with self.opener.open(catalog_request, timeout=3) as response:
synced = json.loads(response.read().decode("utf-8"))
with self.opener.open(request, timeout=3) as response:
events = [
json.loads(line)
for line in response.read().decode("utf-8").splitlines()
if line.strip()
]
result = events[-1]["payload"]
finally:
SeatDemoHandler.extension = original_extension
SeatDemoHandler.nfc = original_nfc
self.assertEqual(synced["learned_count"], 2)
self.assertEqual(events[-1]["type"], "result")
self.assertTrue(any(item["type"] == "validated_commands" for item in events))
validated_payloads = [
item["payload"]
for item in events
if item["type"] == "validated_commands"
]
self.assertTrue(validated_payloads)
self.assertTrue(
all(
"self_extension" in payload["function_configs"]
for payload in validated_payloads
)
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["orchestration_route"], "unified_can_infrared")
self.assertEqual(
[item["slot"] for item in result["infrared_actions"]], [1, 3]
)
self.assertEqual(
result["infrared_actions"][0]["parse_source"], "local_rule"
)
self.assertIn("self_extension", result["function_configs"])
self.assertTrue(
any(key != "self_extension" for key in result["function_configs"])
)
self.assertTrue(
any(
item.get("domain") == "self_extension"
for item in result["operation_results"]
)
)
def test_hardware_can_failure_keeps_infrared_in_validated_command_event(
self,
) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name,
"mixed-hardware-validation.sqlite3",
),
)
extension.sync_peripheral_catalog(
[
{
"id": "ir-slot:tony-light-001:1",
"status": "learned",
"enabled": True,
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": "打开",
"action_names": ["打开", "开启", "启动"],
"risk": "low",
"slot": 1,
},
{
"id": "ir-slot:tony-light-001:4",
"status": "learned",
"enabled": True,
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": "切换到绿色",
"action_names": ["切换到绿色", "切换为绿色", "设置为绿色"],
"risk": "low",
"slot": 4,
},
]
)
SeatDemoHandler.extension = extension
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {
"reader": {"online": True},
"card": {"action": "present", "uid": "2CC8B103"},
}
)
request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/x-ndjson",
},
data=json.dumps(
{
"utterance": (
"打开空调,温度设置为24度,风速调整为3档,"
"打开遮阳帘,开启闪光灯,颜色设置为绿色"
),
"provider": "local",
"conversation_id": "conv-hardware-mixed-validation",
"execution_mode": "hardware",
"control_transport": "wired_can",
"client_type": "web_console",
},
ensure_ascii=False,
).encode("utf-8"),
)
def fail_can_after_validation(*_args, **kwargs):
callback = kwargs.get("_on_commands_validated")
self.assertTrue(callable(callback))
callback(
{
"request_id": "req-can-validation-only",
"utterance": "打开空调,温度设置为24度,风速调整为3档,打开遮阳帘",
"status": "processing",
"execution_mode": "hardware",
"control_transport": "wired_can",
"provider_used": "local_rules",
"decision_summary": [],
"function_configs": {
"native_climate": {
"spec": {
"operations": [
{
"id": "vehicle_set_power_1",
"capability": "climate.set_power",
"target": {"zone": "vehicle"},
"parameters": {"state": "on"},
"parse_source": "local_rule",
}
]
}
}
},
"command_plans": {
"native_climate": {"spec": {"commands": []}}
},
"operation_results": [],
"execution_report": None,
}
)
raise CanGatewayError("simulated CAN transport failure")
try:
with patch.object(
self.service,
"process",
side_effect=fail_can_after_validation,
):
with self.opener.open(request, timeout=3) as response:
events = [
json.loads(line)
for line in response.read().decode("utf-8").splitlines()
if line.strip()
]
finally:
SeatDemoHandler.extension = original_extension
SeatDemoHandler.nfc = original_nfc
self.assertEqual(events[-1]["type"], "error")
self.assertEqual(events[-1]["error"], "can_transport_failed")
validated = next(
item["payload"]
for item in events
if item["type"] == "validated_commands"
)
self.assertEqual(validated["status"], "processing")
self.assertEqual(
set(validated["function_configs"]),
{"native_climate", "self_extension"},
)
self.assertEqual(
[item["slot"] for item in validated["infrared_actions"]],
[1, 4],
)
def test_disabled_nfc_ir_command_does_not_discard_allowed_can_and_ir(
self,
) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name,
"mixed-disabled-nfc-command.sqlite3",
),
)
extension.sync_peripheral_catalog(
[
{
"id": "ir-slot:tony-light-001:1",
"status": "learned",
"enabled": True,
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": "打开",
"action_names": ["打开", "开启", "启动"],
"risk": "low",
"slot": 1,
},
{
"id": "ir-slot:tony-light-001:5",
"status": "learned",
"enabled": False,
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": "切换到蓝色",
"action_names": ["切换到蓝色", "切换为蓝色", "设置为蓝色"],
"risk": "low",
"slot": 5,
},
]
)
SeatDemoHandler.extension = extension
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {
"revision": 1,
"reader": {"online": True},
"card": {"action": "present", "uid": "2CC8B103"},
}
)
request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/x-ndjson",
},
data=json.dumps(
{
"utterance": (
"打开空调,温度设置为24度,风速调整为3档,"
"开启闪光灯,颜色设置为蓝色"
),
"provider": "openai",
"conversation_id": "conv-disabled-nfc-command",
"execution_mode": "dry_run",
"client_type": "web_console",
},
ensure_ascii=False,
).encode("utf-8"),
)
try:
with patch.object(
extension,
"plan",
side_effect=AssertionError(
"a disabled exact command must not be sent to the cloud"
),
):
with self.opener.open(request, timeout=3) as response:
events = [
json.loads(line)
for line in response.read().decode("utf-8").splitlines()
if line.strip()
]
result = events[-1]["payload"]
finally:
SeatDemoHandler.extension = original_extension
SeatDemoHandler.nfc = original_nfc
self.assertEqual(events[-1]["type"], "result")
self.assertEqual(result["status"], "partially_succeeded")
self.assertEqual(result["orchestration_route"], "unified_can_infrared")
self.assertEqual(
[item["slot"] for item in result["infrared_actions"]], [1]
)
self.assertEqual(
[item["slot"] for item in result["denied_peripheral_actions"]],
[5],
)
self.assertEqual(
result["denied_peripheral_actions"][0]["reason"],
"nfc_command_disabled",
)
debug_commands = [
command["signals"]["debug_command"]
for plan in result["command_plans"].values()
for command in plan["spec"].get("commands", [])
if command.get("signals", {}).get("debug_command")
]
self.assertEqual(
debug_commands,
["ac on", "ac temp driver 24", "ac fan 3"],
)
infrared_slots = [
command["signals"]["slot"]
for command in result["command_plans"]["self_extension"]["spec"][
"commands"
]
]
self.assertEqual(infrared_slots, [1])
def test_generic_vehicle_light_text_never_routes_to_ir_alias(self) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name, "vehicle-light-not-ir.sqlite3"
),
)
extension.sync_peripheral_catalog(
[
{
"id": "toy-001-light-on",
"status": "learned",
"device_id": "toy-001",
"device_name": "玩具 001",
"device_aliases": ["氛围灯"],
"card_uid": "2CC8B103",
"action": "开启闪灯",
"aliases": ["打开"],
"risk": "low",
"slot": 1,
}
]
)
SeatDemoHandler.extension = extension
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {
"reader": {"online": True},
"card": {"action": "present", "uid": "2CC8B103"},
}
)
request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"utterance": "打开氛围灯,空调24度",
"provider": "local",
"conversation_id": "conv-vehicle-light-not-ir",
"execution_mode": "dry_run",
"client_type": "web_console",
},
ensure_ascii=False,
).encode("utf-8"),
)
try:
with self.opener.open(request, timeout=3) as response:
result = json.loads(response.read().decode("utf-8"))
finally:
SeatDemoHandler.extension = original_extension
SeatDemoHandler.nfc = original_nfc
self.assertNotIn("infrared_actions", result)
self.assertNotEqual(result.get("orchestration_route"), "unified_can_infrared")
self.assertNotIn("self_extension", result["function_configs"])
self.assertTrue(result["function_configs"])
def test_mixed_preview_reports_cloud_selected_infrared_capability(self) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name,
"mixed-cloud-nfc-can.sqlite3",
),
)
extension.sync_peripheral_catalog(
[
{
"id": "toy-001-light-red",
"status": "learned",
"enabled": True,
"device_id": "toy-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯"],
"card_uid": "2CC8B103",
"action": "切换到红色",
"action_names": ["切换到红色", "切换为红色"],
"slot": 3,
}
]
)
SeatDemoHandler.extension = extension
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {
"reader": {"online": True},
"card": {"action": "present", "uid": "2CC8B103"},
}
)
request = urllib.request.Request(
f"{self.base_url}/api/requests/preview",
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/x-ndjson",
},
data=json.dumps(
{
"utterance": "把闪烁灯调成喜庆颜色,打开空调",
"provider": "openai",
"conversation_id": "conv-mixed-cloud-ir",
"execution_mode": "dry_run",
"client_type": "web_console",
},
ensure_ascii=False,
).encode("utf-8"),
)
plan_result = {
"model": "gpt-5.6-luna",
"summary": "喜庆颜色对应已学习的红色动作。",
"needs_confirmation": False,
"steps": [
{"kind": "peripheral", "capability_id": "toy-001-light-red"}
],
}
try:
with patch.object(extension, "plan", return_value=plan_result) as planner:
with self.opener.open(request, timeout=3) as response:
events = [
json.loads(line)
for line in response.read().decode("utf-8").splitlines()
if line.strip()
]
result = events[-1]["payload"]
finally:
SeatDemoHandler.extension = original_extension
SeatDemoHandler.nfc = original_nfc
planner.assert_called_once()
self.assertEqual(result["orchestration_route"], "unified_can_infrared")
self.assertIn("gpt:gpt-5.6-luna", result["provider_used"])
self.assertEqual(
result["infrared_actions"][0]["parse_source"], "cloud_model"
)
self.assertFalse(result["supplement_required"])
def test_main_request_executes_gpt_nfc_sequence_without_vehicle_can(self) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
extension = SelfExtensionService(SimulationGateway())
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name, "main-nfc-sequence.sqlite3"
),
)
SeatDemoHandler.extension = extension
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {