Files
Cognitive_Agent/src/cognitive_agent/application/extension_toy_publisher.py
T
Miayui bfff1b515c 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.
2026-09-20 16:37:19 +08:00

433 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)