bfff1b515c
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.
433 lines
17 KiB
Python
433 lines
17 KiB
Python
"""Bounded MQTT capability announcements for NFC-bound infrared extensions."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import threading
|
||
import time
|
||
from collections.abc import Callable, Mapping
|
||
from copy import deepcopy
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
|
||
EXTENSION_TOY_MQTT_TOPIC = "extension/toy"
|
||
EXTENSION_TOY_BURST_COUNT = 3
|
||
EXTENSION_TOY_BURST_WINDOW_S = 1.0
|
||
_DEVICE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||
_NFC_ID_PATTERN = re.compile(r"^[0-9A-F]{4,64}$")
|
||
|
||
|
||
def _timestamp() -> str:
|
||
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||
|
||
|
||
def normalize_extension_nfc_id(value: Any) -> str:
|
||
"""Return an uppercase hexadecimal NFC UID, or an empty string if invalid."""
|
||
|
||
raw_value = str(value or "").strip()
|
||
if not raw_value or re.search(r"[^0-9a-f\s:-]", raw_value, flags=re.IGNORECASE):
|
||
return ""
|
||
normalized = re.sub(r"[\s:-]", "", raw_value).upper()
|
||
return normalized if _NFC_ID_PATTERN.fullmatch(normalized) else ""
|
||
|
||
|
||
def _extension_display_name(capability: Mapping[str, Any]) -> str:
|
||
"""Prefer the catalog's Chinese display identity for ``extension_name``."""
|
||
|
||
candidates: list[str] = [str(capability.get("device_name") or "").strip()]
|
||
for field in ("object_names", "device_aliases"):
|
||
values = capability.get(field)
|
||
if isinstance(values, str):
|
||
candidates.extend(re.split(r"[,,、;;|]", values))
|
||
elif isinstance(values, list):
|
||
candidates.extend(str(value) for value in values)
|
||
normalized = [candidate.strip() for candidate in candidates if candidate.strip()]
|
||
chinese = next(
|
||
(candidate for candidate in normalized if re.search(r"[\u4e00-\u9fff]", candidate)),
|
||
None,
|
||
)
|
||
return str(chinese or (normalized[0] if normalized else ""))[:64]
|
||
|
||
|
||
def build_extension_toy_messages(
|
||
card_uid: Any,
|
||
catalog: object,
|
||
) -> list[dict[str, Any]]:
|
||
"""Project enabled learned abilities bound to one detected NFC card.
|
||
|
||
One message is returned per stable English ``device_id``. Within a device,
|
||
actions are ordered by infrared slot and exact duplicate abilities are
|
||
removed. No action is executed by this projection.
|
||
"""
|
||
|
||
normalized_uid = normalize_extension_nfc_id(card_uid)
|
||
if not normalized_uid:
|
||
return []
|
||
if not isinstance(catalog, list):
|
||
raise ValueError("extension capability catalog must be an array")
|
||
|
||
candidates: list[tuple[str, int, str, str]] = []
|
||
seen: set[tuple[str, int, str]] = set()
|
||
for item in catalog:
|
||
if (
|
||
not isinstance(item, Mapping)
|
||
or item.get("status") != "learned"
|
||
or item.get("enabled") is not True
|
||
or normalize_extension_nfc_id(item.get("card_uid")) != normalized_uid
|
||
):
|
||
continue
|
||
device_id = str(item.get("device_id") or "").strip()
|
||
extension_name = _extension_display_name(item)
|
||
action_name = str(item.get("action") or "").strip()
|
||
raw_slot = item.get("slot")
|
||
if (
|
||
not _DEVICE_ID_PATTERN.fullmatch(device_id)
|
||
or not extension_name
|
||
or not action_name
|
||
or len(action_name) > 48
|
||
or isinstance(raw_slot, bool)
|
||
or not isinstance(raw_slot, int)
|
||
or not 1 <= raw_slot <= 248
|
||
):
|
||
continue
|
||
identity = (device_id, raw_slot, action_name)
|
||
if identity in seen:
|
||
continue
|
||
seen.add(identity)
|
||
candidates.append((device_id, raw_slot, action_name, extension_name))
|
||
|
||
grouped: dict[str, dict[str, Any]] = {}
|
||
for device_id, slot, action_name, extension_name in sorted(
|
||
candidates,
|
||
key=lambda item: (item[0], item[1], item[2]),
|
||
):
|
||
message = grouped.setdefault(
|
||
device_id,
|
||
{"extension_name": extension_name, "actions": []},
|
||
)
|
||
message["actions"].append(
|
||
{
|
||
"type": "infrared_emit",
|
||
"at_ms": 0,
|
||
"domain": "self_extension",
|
||
"capability": "infrared.emit",
|
||
"target_scope": device_id,
|
||
"operation": "emit",
|
||
"parameters": {"action": action_name, "slot": slot},
|
||
}
|
||
)
|
||
return [deepcopy(grouped[device_id]) for device_id in sorted(grouped)]
|
||
|
||
|
||
class NfcExtensionToyPublisher:
|
||
"""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,
|
||
poll_interval_s: float = 0.1,
|
||
) -> None:
|
||
if isinstance(burst_count, bool) or not isinstance(burst_count, int):
|
||
raise ValueError("burst_count must be an integer")
|
||
if burst_count <= 0:
|
||
raise ValueError("burst_count must be greater than zero")
|
||
if burst_window_s <= 0:
|
||
raise ValueError("burst_window_s must be greater than zero")
|
||
if poll_interval_s <= 0:
|
||
raise ValueError("poll_interval_s must be greater than zero")
|
||
|
||
self.burst_count = burst_count
|
||
self.burst_window_s = float(burst_window_s)
|
||
self.burst_interval_s = self.burst_window_s / self.burst_count
|
||
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()
|
||
self._thread: threading.Thread | None = None
|
||
self._revision = 0
|
||
self._status: dict[str, Any] = {
|
||
"state": "stopped",
|
||
"topic": EXTENSION_TOY_MQTT_TOPIC,
|
||
"qos": 1,
|
||
"retain": False,
|
||
"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,
|
||
"burst_frame_attempt_count": 0,
|
||
"burst_publish_attempt_count": 0,
|
||
"burst_published_count": 0,
|
||
"total_publish_count": 0,
|
||
"last_detection_at": None,
|
||
"last_burst_started_at": None,
|
||
"last_burst_completed_at": None,
|
||
"last_published_at": None,
|
||
"last_delivery_status": None,
|
||
"last_message_id": None,
|
||
"last_messages": [],
|
||
"last_error": None,
|
||
}
|
||
|
||
def start(self) -> None:
|
||
if self._thread and self._thread.is_alive():
|
||
return
|
||
self._stop.clear()
|
||
self._set_status(
|
||
state="waiting_for_nfc",
|
||
active_nfc_id=None,
|
||
active_catalog_revision=None,
|
||
active_extension_names=[],
|
||
active_target_scopes=[],
|
||
message_count=0,
|
||
last_error=None,
|
||
)
|
||
self._thread = threading.Thread(
|
||
target=self._run,
|
||
name="n7-extension-toy-mqtt-publisher",
|
||
daemon=True,
|
||
)
|
||
self._thread.start()
|
||
|
||
def close(self) -> None:
|
||
self._stop.set()
|
||
thread = self._thread
|
||
if thread and thread.is_alive():
|
||
thread.join(timeout=2.0)
|
||
self._thread = None
|
||
self._set_status(
|
||
state="stopped",
|
||
active_nfc_id=None,
|
||
active_catalog_revision=None,
|
||
active_extension_names=[],
|
||
active_target_scopes=[],
|
||
message_count=0,
|
||
)
|
||
|
||
def snapshot(self) -> dict[str, Any]:
|
||
with self._lock:
|
||
return {"revision": self._revision, **deepcopy(self._status)}
|
||
|
||
def _set_status(self, **changes: Any) -> None:
|
||
with self._lock:
|
||
next_status = {**self._status, **changes}
|
||
if next_status == self._status:
|
||
return
|
||
self._status = next_status
|
||
self._revision += 1
|
||
|
||
def _active_nfc_id(self) -> str:
|
||
snapshot = self._nfc_provider()
|
||
if not isinstance(snapshot, Mapping):
|
||
raise TypeError("NFC provider returned an invalid snapshot")
|
||
card = snapshot.get("card")
|
||
if not isinstance(card, Mapping):
|
||
return ""
|
||
if str(card.get("action") or "present") != "present":
|
||
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,
|
||
messages: list[dict[str, Any]],
|
||
) -> None:
|
||
started = time.monotonic()
|
||
publish_attempts = 0
|
||
published = 0
|
||
last_delivery_status: str | None = None
|
||
last_message_id: Any = None
|
||
last_error: str | None = None
|
||
with self._lock:
|
||
total_publish_count = int(self._status["total_publish_count"])
|
||
self._set_status(
|
||
state="bursting",
|
||
active_nfc_id=card_uid,
|
||
active_extension_names=[message["extension_name"] for message in messages],
|
||
active_target_scopes=[
|
||
message["actions"][0]["target_scope"] for message in messages
|
||
],
|
||
message_count=len(messages),
|
||
burst_frame_attempt_count=0,
|
||
burst_publish_attempt_count=0,
|
||
burst_published_count=0,
|
||
last_detection_at=_timestamp(),
|
||
last_burst_started_at=_timestamp(),
|
||
last_burst_completed_at=None,
|
||
last_delivery_status=None,
|
||
last_message_id=None,
|
||
last_messages=deepcopy(messages),
|
||
last_error=None,
|
||
)
|
||
|
||
completed_frames = 0
|
||
for frame_index in range(self.burst_count):
|
||
due_at = started + frame_index * self.burst_interval_s
|
||
if self._stop.wait(max(0.0, due_at - time.monotonic())):
|
||
break
|
||
completed_frames += 1
|
||
for message in messages:
|
||
publish_attempts += 1
|
||
try:
|
||
result = self._publisher(deepcopy(message))
|
||
if not isinstance(result, Mapping):
|
||
raise TypeError("extension/toy publisher returned an invalid result")
|
||
except Exception as exc: # noqa: BLE001 - finish the bounded burst
|
||
last_error = str(exc)[:240]
|
||
last_delivery_status = "failed"
|
||
else:
|
||
published += 1
|
||
total_publish_count += 1
|
||
last_delivery_status = str(
|
||
result.get("delivery_status") or "queued"
|
||
)
|
||
last_message_id = result.get("message_id")
|
||
self._set_status(last_published_at=_timestamp())
|
||
self._set_status(
|
||
state="bursting",
|
||
burst_frame_attempt_count=completed_frames,
|
||
burst_publish_attempt_count=publish_attempts,
|
||
burst_published_count=published,
|
||
total_publish_count=total_publish_count,
|
||
last_delivery_status=last_delivery_status,
|
||
last_message_id=last_message_id,
|
||
last_error=last_error,
|
||
)
|
||
|
||
expected_publications = self.burst_count * len(messages)
|
||
completed = completed_frames == self.burst_count
|
||
if completed and published != expected_publications:
|
||
last_delivery_status = "partial" if published else "failed"
|
||
self._set_status(
|
||
state=(
|
||
"stopped"
|
||
if self._stop.is_set() and not completed
|
||
else "burst_complete"
|
||
if published == expected_publications
|
||
else "burst_failed"
|
||
),
|
||
burst_frame_attempt_count=completed_frames,
|
||
burst_publish_attempt_count=publish_attempts,
|
||
burst_published_count=published,
|
||
total_publish_count=total_publish_count,
|
||
last_delivery_status=last_delivery_status,
|
||
last_message_id=last_message_id,
|
||
last_burst_completed_at=_timestamp() if completed else None,
|
||
last_error=last_error,
|
||
)
|
||
|
||
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()
|
||
except Exception as exc: # noqa: BLE001 - report and continue polling NFC
|
||
self._set_status(state="nfc_unavailable", last_error=str(exc)[:240])
|
||
self._stop.wait(self.poll_interval_s)
|
||
continue
|
||
|
||
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,
|
||
burst_frame_attempt_count=0,
|
||
burst_publish_attempt_count=0,
|
||
burst_published_count=0,
|
||
last_error=None,
|
||
)
|
||
else:
|
||
try:
|
||
catalog = self._catalog_provider()
|
||
messages = build_extension_toy_messages(card_uid, catalog)
|
||
except Exception as exc: # noqa: BLE001 - retry while detection remains
|
||
self._set_status(
|
||
state="catalog_unavailable",
|
||
active_nfc_id=card_uid,
|
||
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 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
|
||
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(
|
||
state="no_matching_extension",
|
||
active_nfc_id=card_uid,
|
||
active_extension_names=[],
|
||
active_target_scopes=[],
|
||
message_count=0,
|
||
burst_frame_attempt_count=0,
|
||
burst_publish_attempt_count=0,
|
||
burst_published_count=0,
|
||
last_detection_at=_timestamp(),
|
||
last_messages=[],
|
||
last_error=None,
|
||
)
|
||
|
||
self._stop.wait(self.poll_interval_s)
|