添加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
+554
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import os
import socket
import sqlite3
import threading
import time
import unittest
@@ -22,6 +23,7 @@ from cognitive_agent.application.self_extension import (
active_peripheral_plan_catalog,
build_gateway_command,
create_gateway,
partition_active_peripheral_intent,
resolve_active_peripheral_command,
validate_slot,
)
@@ -78,6 +80,216 @@ class SelfExtensionTests(unittest.TestCase):
with self.assertRaises(GatewayError):
build_gateway_command("erase", {"confirm": "wrong"})
def test_learned_peripheral_whitelist_is_durable_and_normalized(self) -> None:
with TemporaryDirectory() as directory:
database_path = Path(directory, "infrared-slots.sqlite3")
service = SelfExtensionService(
SimulationGateway(), catalog_db_path=database_path
)
synced = service.sync_peripheral_catalog(
[
{
"id": "toy-001-light-on",
"status": "learned",
"enabled": True,
"device_id": "toy-001",
"device_name": "玩具 001",
"device_aliases": ["闪灯玩具", "闪灯玩具"],
"card_uid": "2c:c8:b1:03",
"action": "开启闪灯",
"aliases": ["打开闪灯", "点亮"],
"risk": "low",
"slot": 1,
}
]
)
self.assertEqual(synced["count"], 1)
with sqlite3.connect(database_path) as connection:
connection.execute(
"UPDATE infrared_slots SET device_id = '', "
"object_names_json = '[]', action_names_json = '[]'"
)
reloaded = SelfExtensionService(
SimulationGateway(), catalog_db_path=database_path
).list_peripheral_capabilities()
self.assertEqual(len(reloaded), 1)
self.assertEqual(reloaded[0]["card_uid"], "2CC8B103")
self.assertEqual(reloaded[0]["device_aliases"], ["闪灯玩具"])
self.assertEqual(reloaded[0]["object_names"], ["玩具 001", "闪灯玩具"])
self.assertEqual(
reloaded[0]["action_names"], ["开启闪灯", "打开闪灯", "点亮"]
)
self.assertEqual(reloaded[0]["slot"], 1)
slot = service.list_infrared_slots()["slots"][0]
self.assertEqual(slot["device_id"], "toy-001")
self.assertEqual(slot["object_names"], reloaded[0]["object_names"])
self.assertEqual(slot["action_names"], reloaded[0]["action_names"])
def test_partition_claims_explicit_ir_clause_and_preserves_can_residual(self) -> None:
catalog = [
{
"id": "toy-001-light-on",
"status": "learned",
"enabled": True,
"device_id": "toy-001",
"device_name": "玩具 001",
"device_aliases": ["闪灯玩具", "氛围灯"],
"card_uid": "2CC8B103",
"action": "开启闪灯",
"aliases": ["打开闪灯", "点亮"],
"risk": "low",
"slot": 1,
}
]
present = {"card": {"action": "present", "uid": "2CC8B103"}}
partition = partition_active_peripheral_intent(
"打开闪灯玩具,同时把空调调到24度,风量2档",
catalog,
present,
)
self.assertEqual(
[item["capability"]["id"] for item in partition["peripheral_actions"]],
["toy-001-light-on"],
)
self.assertEqual(partition["residual_utterance"], "把空调调到24度,风量2档")
self.assertEqual(partition["unresolved_peripheral_clauses"], [])
def test_partition_inherits_explicit_device_for_followup_ir_action(self) -> None:
catalog = [
{
"id": capability_id,
"status": "learned",
"enabled": True,
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": action,
"action_names": aliases,
"slot": slot,
}
for capability_id, action, aliases, slot in (
("light-on", "开启", ["打开", "开启", "启动"], 1),
(
"light-red",
"切换到红色",
["切换到红色", "切换为红色", "设置为红色"],
3,
),
("light-off", "关闭", ["关闭", "关掉", "熄灭"], 2),
)
]
partition = partition_active_peripheral_intent(
"打开闪烁灯,颜色切换到红色,打开空调",
catalog,
{"card": {"uid": "2CC8B103"}},
)
self.assertEqual(
[item["capability"]["id"] for item in partition["peripheral_actions"]],
["light-on", "light-red"],
)
self.assertEqual(
[item["clause"] for item in partition["peripheral_actions"]],
["打开闪烁灯", "颜色切换到红色"],
)
self.assertEqual(partition["residual_utterance"], "打开空调")
self.assertEqual(partition["unresolved_peripheral_clauses"], [])
vehicle_breaks_context = partition_active_peripheral_intent(
"打开闪烁灯,打开空调,然后关闭",
catalog,
{"card": {"uid": "2CC8B103"}},
)
self.assertEqual(
[
item["capability"]["id"]
for item in vehicle_breaks_context["peripheral_actions"]
],
["light-on"],
)
self.assertEqual(
vehicle_breaks_context["residual_utterance"], "打开空调,关闭"
)
def test_partition_filters_disabled_followup_without_losing_allowed_work(
self,
) -> None:
catalog = [
{
"id": capability_id,
"status": "learned",
"enabled": enabled,
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": action,
"action_names": aliases,
"slot": slot,
}
for capability_id, action, aliases, slot, enabled in (
("light-on", "开启", ["打开", "开启", "启动"], 1, True),
(
"light-blue",
"切换到蓝色",
["切换到蓝色", "切换为蓝色", "设置为蓝色"],
5,
False,
),
)
]
partition = partition_active_peripheral_intent(
"打开空调,温度设置为24度,风速调整为3档,开启闪光灯,颜色设置为蓝色",
catalog,
{"card": {"uid": "2CC8B103"}},
)
self.assertEqual(
[item["capability"]["id"] for item in partition["peripheral_actions"]],
["light-on"],
)
self.assertEqual(
partition["residual_utterance"],
"打开空调,温度设置为24度,风速调整为3档",
)
self.assertEqual(partition["unresolved_peripheral_clauses"], [])
self.assertEqual(
[
(item["clause"], item["capability"]["id"], item["reason"])
for item in partition["denied_peripheral_actions"]
],
[("颜色设置为蓝色", "light-blue", "nfc_command_disabled")],
)
def test_partition_never_claims_generic_vehicle_light_request(self) -> None:
catalog = [
{
"id": "toy-001-light-on",
"status": "learned",
"device_id": "toy-001",
"device_name": "玩具 001",
"device_aliases": ["闪灯玩具", "氛围灯"],
"card_uid": "2CC8B103",
"action": "开启闪灯",
"slot": 1,
}
]
partition = partition_active_peripheral_intent(
"打开氛围灯,空调24度",
catalog,
{"card": {"uid": "2CC8B103"}},
)
self.assertEqual(partition["peripheral_actions"], [])
self.assertEqual(partition["residual_utterance"], "打开氛围灯,空调24度")
def test_infrared_slot_catalog_is_durable_sorted_and_device_safe(self) -> None:
with TemporaryDirectory() as directory:
database_path = Path(directory, "infrared-slots.sqlite3")
@@ -111,6 +323,127 @@ class SelfExtensionTests(unittest.TestCase):
[12],
)
def test_infrared_slot_catalog_migrates_legacy_schema_without_data_loss(self) -> None:
with TemporaryDirectory() as directory:
database_path = Path(directory, "infrared-slots.sqlite3")
with sqlite3.connect(database_path) as connection:
connection.execute(
"""
CREATE TABLE infrared_slots (
slot INTEGER PRIMARY KEY,
name TEXT NOT NULL,
source TEXT NOT NULL,
learned_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
connection.execute(
"INSERT INTO infrared_slots VALUES (?, ?, ?, ?, ?)",
(1, "打开闪烁灯", "tcp", "before", "before"),
)
service = SelfExtensionService(
SimulationGateway(), catalog_db_path=database_path
)
slots = service.list_infrared_slots()["slots"]
self.assertEqual(slots[0]["name"], "打开闪烁灯")
self.assertEqual(slots[0]["device_id"], "")
self.assertEqual(slots[0]["object_names"], [])
self.assertEqual(slots[0]["action_names"], [])
with sqlite3.connect(database_path) as connection:
columns = {
row[1]
for row in connection.execute(
"PRAGMA table_info(infrared_slots)"
).fetchall()
}
self.assertIn("object_names_json", columns)
self.assertIn("action_names_json", columns)
self.assertIn("device_id", columns)
def test_structured_slot_names_persist_and_update_bound_whitelist(self) -> None:
with TemporaryDirectory() as directory:
database_path = Path(directory, "infrared-slots.sqlite3")
service = SelfExtensionService(
SimulationGateway(), catalog_db_path=database_path
)
service.sync_peripheral_catalog(
[
{
"id": "toy-light-on",
"status": "learned",
"device_id": "toy-light",
"device_name": "灯光玩具",
"card_uid": "2CC8B103",
"action": "开启灯光",
"slot": 1,
}
]
)
saved = service.save_infrared_slot(
1,
{
"device_id": "toy-light",
"object_names": "闪烁灯,闪光灯、闪灯玩具",
"action_names": ["打开", "开启", "启动"],
"source": "tcp",
},
)["slot"]
self.assertEqual(saved["device_id"], "toy-light")
self.assertEqual(saved["object_names"], ["闪烁灯", "闪光灯", "闪灯玩具"])
self.assertEqual(saved["action_names"], ["打开", "开启", "启动"])
reloaded = SelfExtensionService(
SimulationGateway(), catalog_db_path=database_path
)
self.assertEqual(
reloaded.list_infrared_slots()["slots"][0]["object_names"],
["闪烁灯", "闪光灯", "闪灯玩具"],
)
whitelist = reloaded.list_peripheral_capabilities()
self.assertEqual(whitelist[0]["object_names"], saved["object_names"])
self.assertEqual(whitelist[0]["action_names"], saved["action_names"])
resolved = resolve_active_peripheral_command(
"打开闪灯玩具",
whitelist,
{"card": {"uid": "2CC8B103"}},
)
self.assertEqual(resolved["id"], "toy-light-on")
changed = service.save_infrared_slot(
1,
{
"device_id": "toy-light-v2",
"object_names": ["新版闪烁灯"],
"action_names": ["打开"],
"source": "tcp",
},
)["slot"]
self.assertEqual(changed["device_id"], "toy-light-v2")
self.assertEqual(service.list_peripheral_capabilities(), [])
def test_structured_slot_rejects_invalid_or_missing_device_id(self) -> None:
with TemporaryDirectory() as directory:
service = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(directory, "infrared-slots.sqlite3"),
)
for device_id in (None, "含 空格", "-bad-prefix"):
with self.subTest(device_id=device_id), self.assertRaisesRegex(
GatewayError, "设备 ID"
):
service.save_infrared_slot(
1,
{
"device_id": device_id,
"object_names": ["闪烁灯"],
"action_names": ["打开"],
},
)
def test_erase_all_clears_catalog_only_after_valid_device_submission(self) -> None:
with TemporaryDirectory() as directory:
service = SelfExtensionService(
@@ -374,6 +707,47 @@ class SelfExtensionTests(unittest.TestCase):
)
)
def test_structured_color_actions_require_a_unique_result_value(self) -> None:
present = {"card": {"uid": "2CC8B103"}}
catalog = [
{
"id": capability_id,
"status": "learned",
"device_name": "灯光玩具",
"object_names": ["闪烁灯", "闪灯玩具"],
"card_uid": "2CC8B103",
"action": color,
"action_names": [f"设置为{color}", f"切换到{color}", f"切换为{color}"],
"slot": slot,
}
for capability_id, color, slot in (
("blue", "蓝色", 5),
("red", "红色", 6),
)
]
resolved = resolve_active_peripheral_command(
"闪烁灯切换为蓝色", catalog, present
)
self.assertEqual(resolved["id"], "blue")
self.assertIsNone(
resolve_active_peripheral_command(
"闪烁灯切换为蓝色",
[
{**item, "enabled": item["id"] != "blue"}
for item in catalog
],
present,
)
)
ambiguous = [
{**item, "action_names": ["切换为"]}
for item in catalog
]
with self.assertRaisesRegex(ExtensionIntegrationError, "同时匹配多个"):
resolve_active_peripheral_command("闪烁灯切换为", ambiguous, present)
def test_timed_phrase_selects_live_nfc_catalog_for_gpt_plan(self) -> None:
catalog = [
{
@@ -410,6 +784,38 @@ class SelfExtensionTests(unittest.TestCase):
[],
)
def test_exact_infrared_sequence_uses_local_rules_without_cloud(self) -> None:
service = SelfExtensionService(SimulationGateway())
catalog = [
{
"id": capability_id,
"status": "learned",
"device_name": "第三方氛围灯",
"action": action,
"aliases": aliases,
"slot": slot,
}
for capability_id, action, aliases, slot in (
("on", "开启灯光", ["打开"], 1),
("off", "关闭灯光", ["关掉"], 2),
)
]
with patch.dict(os.environ, {"OPENAI_API_KEY": ""}, clear=False):
result = service.plan(
{
"utterance": "打开第三方氛围灯,5秒后关闭第三方氛围灯",
"peripherals": catalog,
"vehicle_devices": [],
}
)
self.assertEqual(result["provider_used"], "infrared_local_rules")
self.assertEqual(
[step["kind"] for step in result["steps"]],
["peripheral", "delay", "peripheral"],
)
def test_main_peripheral_result_uses_gscu_without_vehicle_can(self) -> None:
service = SelfExtensionService(SimulationGateway())
@@ -502,6 +908,151 @@ class SelfExtensionTests(unittest.TestCase):
sleep.assert_any_call(5.0)
self.assertNotIn(0.5, [call.args[0] for call in sleep.call_args_list])
def test_failed_first_emit_retains_complete_compiled_infrared_plan(self) -> None:
service = SelfExtensionService(SimulationGateway())
catalog = [
{
"id": capability_id,
"status": "learned",
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"card_uid": "2CC8B103",
"action": action,
"slot": slot,
}
for capability_id, action, slot in (
("light-on", "打开", 1),
("light-green", "切换到绿色", 4),
)
]
plan_result = {
"provider_used": "infrared_local_rules",
"summary": "打开闪光灯并切换为绿色",
"steps": [
{"kind": "peripheral", "capability_id": "light-on"},
{"kind": "peripheral", "capability_id": "light-green"},
],
}
with patch.object(
service,
"execute_gateway",
side_effect=GatewayError("simulated infrared transport failure"),
) as execute_gateway:
result = service.execute_peripheral_plan_request(
"开启闪光灯,颜色设置为绿色",
plan_result,
catalog,
conversation_id="conv-failed-first-infrared-emit",
execution_mode="hardware",
)
execute_gateway.assert_called_once_with("emit", {"slot": 1})
self.assertEqual(result["status"], "failed")
self.assertEqual(result["sequence"]["action_count"], 2)
self.assertEqual(result["sequence"]["emit_count"], 0)
self.assertEqual(
[
item["parameters"]["slot"]
for item in result["function_config"]["spec"]["operations"]
],
[1, 4],
)
self.assertEqual(
[
item["signals"]["slot"]
for item in result["command_plan"]["spec"]["commands"]
],
[1, 4],
)
self.assertEqual(
[item["status"] for item in result["operation_results"]],
["failed"],
)
def test_submitted_emit_without_ack_continues_complete_infrared_plan(self) -> None:
service = SelfExtensionService(SimulationGateway())
catalog = [
{
"id": capability_id,
"status": "learned",
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"card_uid": "2CC8B103",
"action": action,
"slot": slot,
}
for capability_id, action, slot in (
("light-on", "打开", 1),
("light-green", "切换到绿色", 4),
)
]
def submit_without_ack(_action, payload):
command = build_gateway_command("emit", payload)
return {
"success": True,
"transport": "tcp",
"request": command,
"device_response": {
"success": True,
"delivery_status": "submitted",
"transport_submitted": True,
"gateway_acknowledged": False,
},
"gateway_acknowledged": False,
}
with (
patch.object(
service,
"execute_gateway",
side_effect=submit_without_ack,
) as execute_gateway,
patch("cognitive_agent.application.self_extension.time.sleep"),
):
result = service.execute_peripheral_plan_request(
"开启闪光灯,颜色设置为绿色",
{
"provider_used": "infrared_local_rules",
"summary": "打开闪光灯并切换为绿色",
"steps": [
{"kind": "peripheral", "capability_id": "light-on"},
{"kind": "peripheral", "capability_id": "light-green"},
],
},
catalog,
conversation_id="conv-submitted-unacknowledged-infrared",
execution_mode="hardware",
)
self.assertEqual(execute_gateway.call_count, 2)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["sequence"]["emit_count"], 2)
self.assertEqual(
[item["telemetry"]["slot"] for item in result["operation_results"]],
[1, 4],
)
self.assertEqual(
[
item["telemetry"]["delivery_status"]
for item in result["operation_results"]
],
["submitted", "submitted"],
)
self.assertTrue(
all(
item["telemetry"]["transport_submitted"]
for item in result["operation_results"]
)
)
self.assertTrue(
all(
item["telemetry"]["gateway_acknowledged"] is False
for item in result["operation_results"]
)
)
def test_color_sequence_never_cycles_the_power_off_capability(self) -> None:
service = SelfExtensionService(SimulationGateway())
catalog = [
@@ -645,6 +1196,9 @@ class SelfExtensionTests(unittest.TestCase):
) -> str:
self.assertEqual(peripherals[0]["id"], "peripheral_1")
self.assertNotIn(opaque_id, [item["id"] for item in peripherals])
self.assertNotIn("slot", peripherals[0])
self.assertNotIn("card_uid", peripherals[0])
self.assertNotIn("transport", peripherals[0])
return json.dumps(
{
"summary": "切换红灯",