feat: integrate learned peripherals into mode creation

Expose safe NFC-bound infrared actions to semantic scene composition, require flashing-light actions for romantic and Mid-Autumn modes, and preserve mixed CAN/IR mode flows.

Fix NFC device reassignment and retry extension/toy MQTT publication when an active card's binding catalog changes.
This commit is contained in:
Miayui
2026-09-20 16:37:19 +08:00
parent 904f5f8a6c
commit bfff1b515c
25 changed files with 3073 additions and 102 deletions
+80
View File
@@ -106,6 +106,19 @@ class SelfExtensionTests(unittest.TestCase):
)
self.assertEqual(synced["count"], 1)
self.assertRegex(synced["catalog_revision"], r"^[0-9a-f]{16}$")
self.assertEqual(
synced["catalog_revision"], service.peripheral_catalog_revision()
)
with self.assertRaisesRegex(GatewayError, "其他会话更新"):
service.sync_peripheral_catalog(
[],
expected_revision="stale-revision",
)
self.assertEqual(
[item["id"] for item in service.list_peripheral_capabilities()],
["toy-001-light-on"],
)
with closing(sqlite3.connect(database_path)) as connection, connection:
connection.execute(
"UPDATE infrared_slots SET device_id = '', "
@@ -696,6 +709,73 @@ class SelfExtensionTests(unittest.TestCase):
self.assertEqual(changed["device_id"], "toy-light-v2")
self.assertEqual(service.list_peripheral_capabilities(), [])
def test_nfc_card_can_be_reassigned_from_existing_to_new_device(self) -> None:
with TemporaryDirectory() as directory:
service = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(directory, "infrared-slots.sqlite3"),
)
service.save_infrared_slot(
1,
{
"device_id": "old-light",
"object_names": ["旧闪烁灯"],
"action_names": ["打开"],
"source": "tcp",
},
)
service.save_infrared_slot(
21,
{
"device_id": "new-light",
"object_names": ["新灯带"],
"action_names": ["打开"],
"source": "tcp",
},
)
initial = service.sync_peripheral_catalog(
[
{
"id": "ir-slot:old-light:1",
"status": "learned",
"enabled": True,
"device_id": "old-light",
"device_name": "旧闪烁灯",
"object_names": ["旧闪烁灯"],
"card_uid": "E113B203",
"action": "打开",
"action_names": ["打开"],
"slot": 1,
}
]
)
rebound = service.sync_peripheral_catalog(
[
{
"id": "ir-slot:new-light:21",
"status": "learned",
"enabled": True,
"device_id": "new-light",
"device_name": "新灯带",
"object_names": ["新灯带"],
"card_uid": "E113B203",
"action": "打开",
"action_names": ["打开"],
"slot": 21,
}
],
expected_revision=initial["catalog_revision"],
)
self.assertEqual(rebound["count"], 1)
self.assertEqual(
[
(item["device_id"], item["slot"], item["card_uid"])
for item in service.list_peripheral_capabilities()
],
[("new-light", 21, "E113B203")],
)
def test_new_structured_slot_is_parseable_before_nfc_authorization(self) -> None:
with TemporaryDirectory() as directory:
service = SelfExtensionService(