feat(n7web): enhance control workflows and status feedback
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
"""Bounded MQTT capability announcements for NFC-bound infrared extensions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 ability burst for each NFC card-detection edge."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
nfc_provider: Callable[[], Mapping[str, Any]],
|
||||
catalog_provider: Callable[[], list[dict[str, Any]]],
|
||||
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._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_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_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_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 _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 = ""
|
||||
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
|
||||
|
||||
if card_uid != latched_uid:
|
||||
if not card_uid:
|
||||
latched_uid = ""
|
||||
self._set_status(
|
||||
state="waiting_for_nfc",
|
||||
active_nfc_id=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:
|
||||
# Latch only after a successful catalog read. A card that
|
||||
# remains present never starts a second burst.
|
||||
latched_uid = card_uid
|
||||
if 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)
|
||||
Reference in New Issue
Block a user