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
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import re
import threading
import time
@@ -121,13 +122,14 @@ def build_extension_toy_messages(
class NfcExtensionToyPublisher:
"""Publish one three-frame ability burst for each NFC card-detection edge."""
"""Publish one three-frame burst per NFC edge or active-card binding change."""
def __init__(
self,
*,
nfc_provider: Callable[[], Mapping[str, Any]],
catalog_provider: Callable[[], list[dict[str, Any]]],
catalog_revision_provider: Callable[[], object] | None = None,
publisher: Callable[[Mapping[str, Any]], Mapping[str, Any]],
burst_count: int = EXTENSION_TOY_BURST_COUNT,
burst_window_s: float = EXTENSION_TOY_BURST_WINDOW_S,
@@ -148,6 +150,7 @@ class NfcExtensionToyPublisher:
self.poll_interval_s = float(poll_interval_s)
self._nfc_provider = nfc_provider
self._catalog_provider = catalog_provider
self._catalog_revision_provider = catalog_revision_provider
self._publisher = publisher
self._lock = threading.RLock()
self._stop = threading.Event()
@@ -161,6 +164,7 @@ class NfcExtensionToyPublisher:
"burst_count": self.burst_count,
"burst_window_ms": round(self.burst_window_s * 1_000),
"active_nfc_id": None,
"active_catalog_revision": None,
"active_extension_names": [],
"active_target_scopes": [],
"message_count": 0,
@@ -185,6 +189,7 @@ class NfcExtensionToyPublisher:
self._set_status(
state="waiting_for_nfc",
active_nfc_id=None,
active_catalog_revision=None,
active_extension_names=[],
active_target_scopes=[],
message_count=0,
@@ -206,6 +211,7 @@ class NfcExtensionToyPublisher:
self._set_status(
state="stopped",
active_nfc_id=None,
active_catalog_revision=None,
active_extension_names=[],
active_target_scopes=[],
message_count=0,
@@ -234,6 +240,11 @@ class NfcExtensionToyPublisher:
return ""
return normalize_extension_nfc_id(card.get("uid"))
def _catalog_revision(self) -> str:
if self._catalog_revision_provider is None:
return ""
return str(self._catalog_revision_provider() or "").strip()[:128]
def _publish_burst(
self,
card_uid: str,
@@ -325,6 +336,8 @@ class NfcExtensionToyPublisher:
def _run(self) -> None:
latched_uid = ""
latched_catalog_revision = ""
latched_message_signature: str | None = None
while not self._stop.is_set():
try:
card_uid = self._active_nfc_id()
@@ -333,12 +346,32 @@ class NfcExtensionToyPublisher:
self._stop.wait(self.poll_interval_s)
continue
if card_uid != latched_uid:
try:
catalog_revision = self._catalog_revision() if card_uid else ""
except Exception as exc: # noqa: BLE001 - retry while the card remains
self._set_status(
state="catalog_unavailable",
active_nfc_id=card_uid or None,
last_error=str(exc)[:240],
)
self._stop.wait(self.poll_interval_s)
continue
catalog_changed = bool(
card_uid
and card_uid == latched_uid
and self._catalog_revision_provider is not None
and catalog_revision != latched_catalog_revision
)
if card_uid != latched_uid or catalog_changed:
if not card_uid:
latched_uid = ""
latched_catalog_revision = ""
latched_message_signature = None
self._set_status(
state="waiting_for_nfc",
active_nfc_id=None,
active_catalog_revision=None,
active_extension_names=[],
active_target_scopes=[],
message_count=0,
@@ -358,10 +391,28 @@ class NfcExtensionToyPublisher:
last_error=str(exc)[:240],
)
else:
message_signature = json.dumps(
messages,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
messages_changed = (
message_signature != latched_message_signature
)
# Latch only after a successful catalog read. A card that
# remains present never starts a second burst.
# remains present publishes again only when the abilities
# bound to that exact UID changed. Unrelated catalog
# revisions therefore do not duplicate a burst.
latched_uid = card_uid
if messages:
latched_catalog_revision = catalog_revision
latched_message_signature = message_signature
self._set_status(
active_catalog_revision=catalog_revision or None,
)
if not messages_changed:
pass
elif messages:
self._publish_burst(card_uid, messages)
else:
self._set_status(