添加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
+53 -16
View File
@@ -10,27 +10,33 @@ def source(name: str) -> str:
return (WEB / name).read_text(encoding="utf-8")
def test_unified_console_exposes_nfc_discovery_and_learning_flow() -> None:
def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
script = source("self-extension.js")
styles = source("self-extension.css")
html = source("index.html")
assert 'data-open-nfc-learning' in script
assert 'data-open-nfc-binding' in script
assert '靠近读卡器后' in script
assert '感应玩具 NFC' in script
assert 'NFC · DEVICE AUTHORIZATION' in script
assert 'NFC 设备授权' in script
assert 'api("nfc/status")' in script
assert 'function createToyFromNfc()' in script
assert 'function bindCardToDevice(device, cardUid)' in script
assert 'function infraredDeviceGroups()' in script
assert 'function configuredCapabilities(deviceId = null)' in script
assert 'data-nfc-device-select' in script
assert 'data-nfc-capability-enabled' in script
assert 'data-nfc-select-all' in script
assert '全部命令已默认选中' in script
assert 'function createToyFromNfc()' not in script
assert 'data-nfc-learn-capability' not in script
assert 'data-nfc-relearn-capability' not in script
assert 'data-nfc-continue-learning' not in script
assert 'function importLegacyProfiles(value)' in script
assert 'N7WEB_REQUEST_IR_TOY_PROFILES' in script
assert 'IR_TOY_LEGACY_PROFILES' in script
assert 'value.legacy_profiles' in script
assert 'value.legacy_bridge_addresses' in script
assert 'cardUid: normalizeCardUid(cardUid)' in script
assert 'data-nfc-run-capability' in script
assert 'data-nfc-learn-capability' in script
assert 'data-nfc-relearn-capability' in script
assert '重新学习' in script
assert 'device.cardUid = normalizedUid' in script
assert 'const targetSlot = relearning ? Number(capability.slot) : allocateSlot()' in script
assert 'sendGatewayCommand("read", { slot: learning.draft.slot })' in script
assert 'function infraredSignalFromResponse(value)' in script
@@ -38,19 +44,24 @@ def test_unified_console_exposes_nfc_discovery_and_learning_flow() -> None:
assert '学习指令已下发,但物理槽位回读为空' in script
assert 'signalSource: "n7web"' in script
assert 'if (capability.signalSource === "n7web") return' in script
assert '建立档案并开始学习' in script
assert 'const activeCatalog = sensedCardUid' in script
assert 'normalizeCardUid(item.card_uid) === sensedCardUid' in script
assert '&& item.status === "learned"' in script
assert 'slot: capability.slot' in script
assert 'device_aliases: device?.aliases || []' in script
assert 'async function hydratePeripheralCatalog()' in script
assert 'api("peripherals")' in script
assert 'signalSource: "backend_catalog"' in script
assert '当前未感应到外设' in script
assert '将已绑定且已学习的 NFC 外设靠近读卡器后显示能力' in script
assert '将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令' in script
assert '.v2-nfc-inline' in styles
assert '.v2-nfc-dialog' in styles
assert '.v2-nfc-capability-grid' in styles
assert 'self-extension.js?v=gscu-tcp-37' in html
assert 'self-extension.css?v=gscu-tcp-37' in html
assert '.v2-nfc-command-list' in styles
assert '.v2-nfc-command-option' in styles
assert '.v2-nfc-select-all' in styles
assert 'self-extension.js?v=gscu-tcp-40' in html
assert 'self-extension.css?v=gscu-tcp-39' in html
def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
@@ -59,7 +70,10 @@ def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
assert 'id="extensionGscuTcpHost"' in script
assert 'id="extensionGscuTcpPort"' in script
assert 'id="extensionGscuCommandName"' in script
assert 'id="extensionGscuDeviceId"' in script
assert 'id="extensionGscuObjectNames"' in script
assert 'id="extensionGscuActionNames"' in script
assert 'id="extensionGscuTcpResultDetails"' in script
assert 'id="extensionGscuNamedCommands"' in script
assert 'data-open-gscu-tcp' in script
assert 'api("gscu/config"' in script
@@ -71,7 +85,12 @@ def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
assert "学习指令已下发,但槽位回读失败" in script
assert "确认擦除 GSCU1B 中的全部红外码吗" in script
assert "GSCU1B 设备中的该红外码不会被擦除" in script
assert "function upsertNamedInfraredCommand(slot, name" in script
assert "async function upsertNamedInfraredCommand(" in script
assert "device_id: normalizedDeviceId" in script
assert "object_names: normalizedObjects" in script
assert "action_names: normalizedActions" in script
assert "object_names: objectNames" in script
assert "action_names: actionNames" in script
assert "button.disabled = true;" in script
assert "buttons.forEach((item) => { item.disabled = true; });" not in script
for action in (
@@ -92,6 +111,10 @@ def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
assert ".v2-gscu-config-form" in styles
assert "#extensionGscuTcpResult" in styles
assert "#extensionGscuNamedCommands" in styles
assert ".v2-gscu-device-group" in styles
assert ".v2-gscu-device-command-grid" in styles
assert ".v2-gscu-result" in styles
assert "max-height: min(42vh, 360px)" in styles
def test_server_exposes_nfc_snapshot_through_same_origin_extension_api() -> None:
@@ -99,7 +122,21 @@ def test_server_exposes_nfc_snapshot_through_same_origin_extension_api() -> None
assert 'nfc = NfcReaderService()' in server
assert 'path == "/api/extension/nfc/status"' in server
assert 'path == "/api/extension/peripherals"' in server
assert '"nfc": self.nfc.snapshot()' in server
assert 'SeatDemoHandler.nfc.start()' in server
assert 'SeatDemoHandler.nfc.close()' in server
assert "frame-src 'self' http://*:3001" in server
def test_main_parsed_control_panel_renders_validated_infrared_operations() -> None:
console = source("console-v2.js")
styles = source("console-v2.css")
html = source("index.html")
assert 'capability === "infrared.emit"' in console
assert 'channel: "IR"' in console
assert 'frameLabel: "GSCU1B TCP"' in console
assert "已解析控制指令" in console
assert 'li[data-can-channel="IR"]' in styles
assert "console-v2.js?v=delivery-state-49" in html
@@ -200,7 +200,9 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
assert "function parsedCanCommands(result = {})" in console
assert "getParsedCanCommands: (result) => parsedCanCommands(result)" in console
assert "const configurations = result.function_configs || {};" in console
assert "const plans = result.command_plans || {};" in console
assert "if (!configurationEntries.length && result.function_config)" in console
assert 'deliveryLabel: submitted ? "下发" : "未下发"' in console
assert 'data-delivery-state="${escapeHtml(item.deliveryState)}"' in console
assert 'const command = (category, text, channel = "CAN1", frame = "")' in console
assert 'data-can-channel="${escapeHtml(item.channel)}"' in console
assert 'class="v2-can-command-channel"' in console
@@ -243,6 +245,8 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
assert ".v2-can-command-panel[open]" in styles
assert ".v2-can-command-list li code" in styles
assert ".v2-can-command-channel" in styles
assert ".v2-can-command-delivery" in styles
assert '.v2-can-command-delivery[data-delivery-state="submitted"]' in styles
assert 'li[data-can-channel="CAN0"]' in styles
assert ".v2-sidebar-collapsed .v2-can-command-panel" in styles
@@ -306,16 +310,117 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
"command": "seat passenger backrest position 36",
"frame": "0x300 · 01 12 <SEQ_LO> <SEQ_HI> 10 0E 30 75",
"parseSource": "local_rule",
"deliveryState": "not_submitted",
"deliveryLabel": "未下发",
"deliveryTransport": "有线 CAN",
},
{
"channel": "CAN1",
"category": "空调温度",
"command": "ac temp driver 24",
"parseSource": "cloud_supplement",
"deliveryState": "not_submitted",
"deliveryLabel": "未下发",
"deliveryTransport": "有线 CAN",
},
]
def test_parsed_command_delivery_is_independent_of_transport_and_target() -> None:
node = shutil.which("node")
if node is None:
return
console = source("console-v2.js")
start = console.index(" const normalizedCanTarget")
end = console.index("\n\n function renderParsedCanCommands", start)
helpers = console[start:end]
base = {
"function_configs": {
"native_climate": {
"spec": {
"operations": [
{
"id": "climate-power",
"capability": "climate.set_power",
"target": {"zone": "vehicle"},
"parameters": {"state": "on"},
"parse_source": "local_rule",
}
]
}
}
}
}
samples = [
{**base, "execution_mode": "dry_run", "control_transport": "wired_can"},
{
**base,
"execution_mode": "dry_run",
"control_transport": "wireless_mqtt",
},
{
**base,
"execution_mode": "hardware",
"control_transport": "wired_can",
"execution_report": {
"spec": {
"results": [
{
"ref": "climate-power",
"status": "succeeded",
"telemetry": {"sent_frames": [{"frame_id": "0x1DE"}]},
}
]
}
},
},
{
**base,
"execution_mode": "hardware",
"control_transport": "wireless_mqtt",
"execution_report": {
"spec": {
"results": [
{
"ref": "climate-power",
"status": "succeeded",
"telemetry": {"delivery_status": "queued"},
}
]
}
},
},
]
script = (
f"{helpers}\n"
f"const samples = {json.dumps(samples)};\n"
"process.stdout.write(JSON.stringify(samples.map((sample) => "
"parsedCanCommands(sample))));"
)
completed = subprocess.run(
[node, "-e", script],
check=True,
capture_output=True,
text=True,
)
parsed = json.loads(completed.stdout)
assert [len(commands) for commands in parsed] == [1, 1, 1, 1]
assert [commands[0]["command"] for commands in parsed] == ["ac on"] * 4
assert [commands[0]["deliveryLabel"] for commands in parsed] == [
"未下发",
"未下发",
"下发",
"下发",
]
assert [commands[0]["deliveryTransport"] for commands in parsed] == [
"有线 CAN",
"无线 CAN",
"有线 CAN",
"无线 CAN",
]
def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences() -> None:
app = source("app.js")
styles = source("styles.css")
+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": "切换红灯",
+148
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from unittest.mock import patch
from cognitive_agent.application.seat_service import SeatDemoService
from cognitive_agent.server import SeatDemoHandler
ROOT = Path(__file__).resolve().parents[2]
@@ -187,7 +188,154 @@ def test_frontend_two_stage_contract_and_source_badges() -> None:
assert 'class="v2-can-command-source"' in console
assert 'item.parseSource === "direct_control" ? "设备控件"' in console
assert 'item.parseSource === "local_rule" ? "本地规则" : "云端模型"' in console
assert 'item.deliveryLabel' in console
assert 'data-delivery-state="${escapeHtml(item.deliveryState)}"' in console
assert '"cognitive-agent:validated-can-commands"' in console
assert '.v2-can-command-source[data-parse-source="local_rule"]' in styles
assert '.v2-can-command-delivery[data-delivery-state="submitted"]' in styles
assert '"/api/can/commands"' in server
assert '"type": "validated_commands"' in server
def test_cloud_supplement_preserves_validated_infrared_preview() -> None:
infrared_operation = {
"ref": "toy-001-light-on",
"domain": "self_extension",
"status": "succeeded",
}
preview = {
"request_id": "req-preview-ir",
"status": "succeeded",
"orchestration_route": "unified_can_infrared",
"function_configs": {
"self_extension": {
"spec": {
"operations": [
{
"capability": "infrared.emit",
"parse_source": "local_rule",
}
]
}
}
},
"command_plans": {"self_extension": {"spec": {"commands": []}}},
"decision_summary": ["红外本地规则已完成"],
"trace_events": [],
"validation": [],
"parsed_control_signatures": [],
"execution_report": {"spec": {"results": [infrared_operation]}},
"operation_results": [infrared_operation],
"infrared_actions": [{"capability_id": "toy-001-light-on", "slot": 1}],
"peripheral": {"id": "toy-001", "name": "玩具 001"},
"control_transports": {"vehicle": "wired_can", "infrared": "gscu_tcp"},
}
supplement = {
"request_id": "req-cloud-ir",
"status": "no_action",
"provider_used": "openai:test",
"function_configs": {},
"command_plans": {},
"decision_summary": [],
"trace_events": [],
"validation": [],
"parsed_control_signatures": [],
"execution_report": None,
"operation_results": [],
}
merged = SeatDemoService._merge_native_can_phases(preview, supplement)
assert merged["orchestration_route"] == "unified_can_infrared"
assert merged["infrared_actions"][0]["slot"] == 1
assert merged["peripheral"]["id"] == "toy-001"
assert merged["operation_results"] == [infrared_operation]
assert "local_self_extension" in merged["function_configs"]
def test_unified_result_preserves_cloud_infrared_attribution() -> None:
can_result = {
"status": "succeeded",
"provider_used": "local_rules",
"function_configs": {},
"command_plans": {},
"operation_results": [],
"decision_summary": [],
}
infrared_result = {
"status": "succeeded",
"provider_used": "gpt:gpt-5.6-luna",
"control_transport": "gscu_dry_run",
"function_configs": {},
"command_plans": {},
"operation_results": [],
"decision_summary": [],
}
entries = [
{
"index": 0,
"clause": "把闪烁灯调成喜庆颜色",
"capability": {
"id": "light-red",
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"action": "切换到红色",
"slot": 3,
"parse_source": "cloud_model",
},
}
]
merged = SeatDemoHandler._merge_can_and_peripheral_results(
"把闪烁灯调成喜庆颜色,打开空调",
can_result,
infrared_result,
entries,
)
assert merged["provider_used"] == "local_rules+gpt:gpt-5.6-luna"
assert merged["infrared_actions"][0]["parse_source"] == "cloud_model"
def test_nfc_command_denial_keeps_allowed_result_and_reports_partial() -> None:
result = {
"status": "succeeded",
"success": True,
"decision_summary": ["已解析并验证 4 条允许的控制命令。"],
"execution_report": {"spec": {"status": "succeeded"}},
}
filtered = SeatDemoHandler._apply_nfc_command_denials(
result,
[
{
"index": 4,
"clause": "颜色设置为蓝色",
"reason": "nfc_command_disabled",
"capability": {
"id": "light-blue",
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"action": "切换到蓝色",
"slot": 5,
},
}
],
)
assert filtered["status"] == "partially_succeeded"
assert filtered["success"] is False
assert filtered["authorization_filtered"] is True
assert filtered["execution_report"]["spec"]["status"] == "partially_succeeded"
assert filtered["denied_peripheral_actions"] == [
{
"index": 4,
"clause": "颜色设置为蓝色",
"capability_id": "light-blue",
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"action": "切换到蓝色",
"slot": 5,
"reason": "nfc_command_disabled",
}
]