"""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)