feat(n7web): add pet NFC workflow and glass UI
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
"""Durable pet cards driven by the currently present NFC tag."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import closing
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PET_MQTT_TOPIC = "n7/pet"
|
||||
PET_BURST_COUNT = 3
|
||||
PET_BURST_WINDOW_S = 1.0
|
||||
DEFAULT_PET_CARD_DB_PATH = (
|
||||
Path(__file__).resolve().parents[3] / "artifacts" / "server" / "pet_cards.sqlite3"
|
||||
)
|
||||
MAX_PET_CARDS = 64
|
||||
MAX_PET_NAME_LENGTH = 64
|
||||
MAX_PET_ACTION_LENGTH = 160
|
||||
_PET_ID_PATTERN = re.compile(r"^pet-[0-9a-f]{32}$")
|
||||
_NFC_ID_PATTERN = re.compile(r"^[0-9A-F]{4,64}$")
|
||||
|
||||
|
||||
def normalize_nfc_id(value: Any) -> str:
|
||||
"""Return the canonical uppercase hexadecimal UID used by the NFC reader."""
|
||||
|
||||
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 _required_text(value: Any, *, field: str, limit: int) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
raise ValueError(f"{field} 不能为空。")
|
||||
if len(text) > limit:
|
||||
raise ValueError(f"{field} 不能超过 {limit} 个字符。")
|
||||
return text
|
||||
|
||||
|
||||
def _timestamp() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
class PetCardService:
|
||||
"""Persist pet cards and publish one bounded burst per NFC detection."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
nfc_provider: Callable[[], Mapping[str, Any]] | None = None,
|
||||
publisher: Callable[[dict[str, str]], Mapping[str, Any]] | None = None,
|
||||
database_path: str | Path | None = None,
|
||||
burst_count: int = PET_BURST_COUNT,
|
||||
burst_window_s: float = PET_BURST_WINDOW_S,
|
||||
) -> None:
|
||||
if isinstance(burst_count, bool) or int(burst_count) != burst_count:
|
||||
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")
|
||||
self.database_path = Path(
|
||||
database_path or DEFAULT_PET_CARD_DB_PATH
|
||||
).expanduser()
|
||||
self.burst_count = int(burst_count)
|
||||
self.burst_window_s = float(burst_window_s)
|
||||
self.burst_interval_s = self.burst_window_s / self.burst_count
|
||||
self._nfc_provider = nfc_provider or (lambda: {})
|
||||
self._publisher = publisher
|
||||
self._lock = threading.RLock()
|
||||
self._stop = threading.Event()
|
||||
self._wakeup = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._revision = 0
|
||||
self._cards: dict[str, dict[str, Any]] = {}
|
||||
self._publisher_status: dict[str, Any] = {
|
||||
"state": "stopped",
|
||||
"topic": PET_MQTT_TOPIC,
|
||||
"burst_count": self.burst_count,
|
||||
"burst_window_ms": round(self.burst_window_s * 1_000),
|
||||
"burst_attempt_count": 0,
|
||||
"burst_published_count": 0,
|
||||
"active_pet_id": None,
|
||||
"active_nfc_id": None,
|
||||
"last_burst_started_at": None,
|
||||
"last_burst_completed_at": None,
|
||||
"last_published_at": None,
|
||||
"last_delivery_status": None,
|
||||
"last_message_id": None,
|
||||
"publish_count": 0,
|
||||
"last_error": None,
|
||||
}
|
||||
self._initialize()
|
||||
self._load()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.database_path, timeout=5.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with closing(self._connect()) as connection, connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pet_cards (
|
||||
id TEXT PRIMARY KEY,
|
||||
pet_name TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
nfc_id TEXT UNIQUE,
|
||||
is_saved INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {
|
||||
str(row[1])
|
||||
for row in connection.execute("PRAGMA table_info(pet_cards)").fetchall()
|
||||
}
|
||||
if "is_saved" not in columns:
|
||||
# Cards created by an earlier release were already persisted through
|
||||
# the explicit Save action, so preserve their binding eligibility.
|
||||
connection.execute(
|
||||
"ALTER TABLE pet_cards "
|
||||
"ADD COLUMN is_saved INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
|
||||
def _load(self) -> None:
|
||||
with closing(self._connect()) as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT id, pet_name, action, nfc_id, is_saved, created_at, updated_at "
|
||||
"FROM pet_cards ORDER BY created_at, id"
|
||||
).fetchall()
|
||||
with self._lock:
|
||||
self._cards = {
|
||||
str(row["id"]): {
|
||||
"id": str(row["id"]),
|
||||
"pet_name": str(row["pet_name"]),
|
||||
"action": str(row["action"]),
|
||||
"nfc_id": str(row["nfc_id"]) if row["nfc_id"] else None,
|
||||
"saved": bool(row["is_saved"]),
|
||||
"created_at": str(row["created_at"]),
|
||||
"updated_at": str(row["updated_at"]),
|
||||
}
|
||||
for row in rows
|
||||
if _PET_ID_PATTERN.fullmatch(str(row["id"]))
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _validate_pet_id(pet_id: Any) -> str:
|
||||
value = str(pet_id or "").strip()
|
||||
if not _PET_ID_PATTERN.fullmatch(value):
|
||||
raise ValueError("宠物卡片 ID 格式无效。")
|
||||
return value
|
||||
|
||||
def _require_card_locked(self, pet_id: str) -> dict[str, Any]:
|
||||
card = self._cards.get(pet_id)
|
||||
if card is None:
|
||||
raise KeyError(pet_id)
|
||||
return card
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._wakeup.clear()
|
||||
self._set_publisher_status(
|
||||
state="waiting_for_nfc",
|
||||
active_pet_id=None,
|
||||
active_nfc_id=None,
|
||||
last_error=None,
|
||||
)
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="n7-pet-mqtt-publisher",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def close(self) -> None:
|
||||
self._stop.set()
|
||||
self._wakeup.set()
|
||||
thread = self._thread
|
||||
if thread and thread.is_alive():
|
||||
thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
self._set_publisher_status(
|
||||
state="stopped",
|
||||
active_pet_id=None,
|
||||
active_nfc_id=None,
|
||||
)
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
cards = sorted(
|
||||
(deepcopy(card) for card in self._cards.values()),
|
||||
key=lambda card: (card["created_at"], card["id"]),
|
||||
)
|
||||
return {
|
||||
"revision": self._revision,
|
||||
"cards": cards,
|
||||
"count": len(cards),
|
||||
"publisher": deepcopy(self._publisher_status),
|
||||
}
|
||||
|
||||
def create_card(self, pet_name: Any, action: Any) -> dict[str, Any]:
|
||||
normalized_name = _required_text(
|
||||
pet_name,
|
||||
field="宠物名",
|
||||
limit=MAX_PET_NAME_LENGTH,
|
||||
)
|
||||
normalized_action = _required_text(
|
||||
action,
|
||||
field="宠物动作",
|
||||
limit=MAX_PET_ACTION_LENGTH,
|
||||
)
|
||||
now = _timestamp()
|
||||
card = {
|
||||
"id": f"pet-{uuid.uuid4().hex}",
|
||||
"pet_name": normalized_name,
|
||||
"action": normalized_action,
|
||||
"nfc_id": None,
|
||||
"saved": False,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
with self._lock:
|
||||
if len(self._cards) >= MAX_PET_CARDS:
|
||||
raise ValueError(f"宠物卡片最多只能创建 {MAX_PET_CARDS} 张。")
|
||||
with closing(self._connect()) as connection, connection:
|
||||
connection.execute(
|
||||
"INSERT INTO pet_cards "
|
||||
"(id, pet_name, action, nfc_id, is_saved, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, NULL, 0, ?, ?)",
|
||||
(
|
||||
card["id"],
|
||||
card["pet_name"],
|
||||
card["action"],
|
||||
card["created_at"],
|
||||
card["updated_at"],
|
||||
),
|
||||
)
|
||||
self._cards[card["id"]] = card
|
||||
self._revision += 1
|
||||
self._wakeup.set()
|
||||
return deepcopy(card)
|
||||
|
||||
def update_card(
|
||||
self,
|
||||
pet_id: Any,
|
||||
*,
|
||||
pet_name: Any,
|
||||
action: Any,
|
||||
) -> dict[str, Any]:
|
||||
normalized_id = self._validate_pet_id(pet_id)
|
||||
normalized_name = _required_text(
|
||||
pet_name,
|
||||
field="宠物名",
|
||||
limit=MAX_PET_NAME_LENGTH,
|
||||
)
|
||||
normalized_action = _required_text(
|
||||
action,
|
||||
field="宠物动作",
|
||||
limit=MAX_PET_ACTION_LENGTH,
|
||||
)
|
||||
now = _timestamp()
|
||||
with self._lock:
|
||||
card = self._require_card_locked(normalized_id)
|
||||
if (
|
||||
card["pet_name"] == normalized_name
|
||||
and card["action"] == normalized_action
|
||||
and card["saved"]
|
||||
):
|
||||
return deepcopy(card)
|
||||
with closing(self._connect()) as connection, connection:
|
||||
connection.execute(
|
||||
"UPDATE pet_cards SET pet_name = ?, action = ?, is_saved = 1, "
|
||||
"updated_at = ? "
|
||||
"WHERE id = ?",
|
||||
(normalized_name, normalized_action, now, normalized_id),
|
||||
)
|
||||
card.update(
|
||||
pet_name=normalized_name,
|
||||
action=normalized_action,
|
||||
saved=True,
|
||||
updated_at=now,
|
||||
)
|
||||
self._revision += 1
|
||||
result = deepcopy(card)
|
||||
self._wakeup.set()
|
||||
return result
|
||||
|
||||
def bind_current_nfc(self, pet_id: Any) -> dict[str, Any]:
|
||||
normalized_id = self._validate_pet_id(pet_id)
|
||||
snapshot = self._nfc_provider()
|
||||
raw_card = snapshot.get("card") if isinstance(snapshot, Mapping) else None
|
||||
raw_uid = raw_card.get("uid") if isinstance(raw_card, Mapping) else None
|
||||
nfc_id = normalize_nfc_id(raw_uid)
|
||||
if not nfc_id:
|
||||
raise ValueError("当前未识别到有效 NFC 卡片,无法完成绑定。")
|
||||
now = _timestamp()
|
||||
with self._lock:
|
||||
card = self._require_card_locked(normalized_id)
|
||||
if not card["saved"]:
|
||||
raise ValueError("请先保存宠物卡片,再绑定 NFC。")
|
||||
with closing(self._connect()) as connection, connection:
|
||||
connection.execute(
|
||||
"UPDATE pet_cards SET nfc_id = NULL, updated_at = ? "
|
||||
"WHERE nfc_id = ? AND id <> ?",
|
||||
(now, nfc_id, normalized_id),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE pet_cards SET nfc_id = ?, updated_at = ? WHERE id = ?",
|
||||
(nfc_id, now, normalized_id),
|
||||
)
|
||||
for other in self._cards.values():
|
||||
if other["id"] != normalized_id and other.get("nfc_id") == nfc_id:
|
||||
other["nfc_id"] = None
|
||||
other["updated_at"] = now
|
||||
card["nfc_id"] = nfc_id
|
||||
card["updated_at"] = now
|
||||
self._revision += 1
|
||||
result = deepcopy(card)
|
||||
self._wakeup.set()
|
||||
return result
|
||||
|
||||
def unbind_card(self, pet_id: Any) -> dict[str, Any]:
|
||||
normalized_id = self._validate_pet_id(pet_id)
|
||||
now = _timestamp()
|
||||
with self._lock:
|
||||
card = self._require_card_locked(normalized_id)
|
||||
if card.get("nfc_id") is None:
|
||||
return deepcopy(card)
|
||||
with closing(self._connect()) as connection, connection:
|
||||
connection.execute(
|
||||
"UPDATE pet_cards SET nfc_id = NULL, updated_at = ? WHERE id = ?",
|
||||
(now, normalized_id),
|
||||
)
|
||||
card["nfc_id"] = None
|
||||
card["updated_at"] = now
|
||||
self._revision += 1
|
||||
result = deepcopy(card)
|
||||
self._wakeup.set()
|
||||
return result
|
||||
|
||||
def delete_card(self, pet_id: Any) -> dict[str, Any]:
|
||||
normalized_id = self._validate_pet_id(pet_id)
|
||||
with self._lock:
|
||||
card = self._require_card_locked(normalized_id)
|
||||
with closing(self._connect()) as connection, connection:
|
||||
connection.execute("DELETE FROM pet_cards WHERE id = ?", (normalized_id,))
|
||||
del self._cards[normalized_id]
|
||||
self._revision += 1
|
||||
result = deepcopy(card)
|
||||
self._wakeup.set()
|
||||
return result
|
||||
|
||||
def _set_publisher_status(self, **changes: Any) -> None:
|
||||
with self._lock:
|
||||
next_status = {**self._publisher_status, **changes}
|
||||
if next_status == self._publisher_status:
|
||||
return
|
||||
self._publisher_status = next_status
|
||||
self._revision += 1
|
||||
|
||||
def _active_card(self) -> tuple[str, dict[str, Any] | None]:
|
||||
try:
|
||||
snapshot = self._nfc_provider()
|
||||
except Exception: # noqa: BLE001 - NFC failures become publisher status
|
||||
return "", None
|
||||
raw_card = snapshot.get("card") if isinstance(snapshot, Mapping) else None
|
||||
raw_uid = raw_card.get("uid") if isinstance(raw_card, Mapping) else None
|
||||
nfc_id = normalize_nfc_id(raw_uid)
|
||||
if not nfc_id:
|
||||
return "", None
|
||||
with self._lock:
|
||||
card = next(
|
||||
(
|
||||
deepcopy(candidate)
|
||||
for candidate in self._cards.values()
|
||||
if candidate.get("nfc_id") == nfc_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
return nfc_id, card
|
||||
|
||||
def _run(self) -> None:
|
||||
active_identity: tuple[str, str] | None = None
|
||||
burst_started_at = 0.0
|
||||
burst_attempt_count = 0
|
||||
burst_published_count = 0
|
||||
burst_error: str | None = None
|
||||
next_publish_at = 0.0
|
||||
while not self._stop.is_set():
|
||||
nfc_id, card = self._active_card()
|
||||
identity = (str(card["id"]), nfc_id) if card else None
|
||||
now = time.monotonic()
|
||||
if identity != active_identity:
|
||||
active_identity = identity
|
||||
burst_attempt_count = 0
|
||||
burst_published_count = 0
|
||||
burst_error = None
|
||||
if card is not None:
|
||||
burst_started_at = now
|
||||
next_publish_at = now
|
||||
self._set_publisher_status(
|
||||
state="bursting",
|
||||
active_pet_id=card["id"],
|
||||
active_nfc_id=nfc_id,
|
||||
burst_attempt_count=0,
|
||||
burst_published_count=0,
|
||||
last_burst_started_at=_timestamp(),
|
||||
last_burst_completed_at=None,
|
||||
last_delivery_status=None,
|
||||
last_error=None,
|
||||
)
|
||||
else:
|
||||
self._set_publisher_status(
|
||||
state="waiting_for_nfc",
|
||||
active_pet_id=None,
|
||||
active_nfc_id=nfc_id or None,
|
||||
burst_attempt_count=0,
|
||||
burst_published_count=0,
|
||||
last_error=None,
|
||||
)
|
||||
|
||||
if (
|
||||
card is not None
|
||||
and burst_attempt_count < self.burst_count
|
||||
and now >= next_publish_at
|
||||
):
|
||||
burst_attempt_count += 1
|
||||
payload = {
|
||||
"pet_name": str(card["pet_name"]),
|
||||
"nfc_id": nfc_id,
|
||||
"action": str(card["action"]),
|
||||
}
|
||||
result: Mapping[str, Any] | None = None
|
||||
published_at: str | None = None
|
||||
publish_count: int | None = None
|
||||
try:
|
||||
if self._publisher is None:
|
||||
raise RuntimeError("宠物消息 MQTT 发布器未配置。")
|
||||
result = self._publisher(payload)
|
||||
except Exception as exc: # noqa: BLE001 - finish the bounded burst safely
|
||||
burst_error = str(exc)[:240]
|
||||
else:
|
||||
burst_published_count += 1
|
||||
published_at = _timestamp()
|
||||
with self._lock:
|
||||
publish_count = int(self._publisher_status["publish_count"]) + 1
|
||||
burst_finished = burst_attempt_count >= self.burst_count
|
||||
burst_succeeded = burst_published_count == self.burst_count
|
||||
if burst_finished:
|
||||
state = "burst_complete" if burst_succeeded else "burst_failed"
|
||||
delivery_status = (
|
||||
str(result.get("delivery_status") or "queued")
|
||||
if burst_succeeded
|
||||
else "partial" if burst_published_count else "failed"
|
||||
)
|
||||
else:
|
||||
state = "bursting"
|
||||
delivery_status = (
|
||||
str(result.get("delivery_status") or "queued")
|
||||
if result is not None
|
||||
else "failed"
|
||||
)
|
||||
changes: dict[str, Any] = {
|
||||
"state": state,
|
||||
"active_pet_id": card["id"],
|
||||
"active_nfc_id": nfc_id,
|
||||
"burst_attempt_count": burst_attempt_count,
|
||||
"burst_published_count": burst_published_count,
|
||||
"last_delivery_status": delivery_status,
|
||||
"last_error": burst_error,
|
||||
}
|
||||
if result is not None and published_at is not None:
|
||||
changes.update(
|
||||
last_published_at=published_at,
|
||||
last_message_id=result.get("message_id"),
|
||||
publish_count=publish_count,
|
||||
)
|
||||
if burst_finished:
|
||||
changes["last_burst_completed_at"] = _timestamp()
|
||||
self._set_publisher_status(**changes)
|
||||
if not burst_finished:
|
||||
next_publish_at = (
|
||||
burst_started_at
|
||||
+ burst_attempt_count * self.burst_interval_s
|
||||
)
|
||||
|
||||
timeout = 0.2
|
||||
if card is not None and burst_attempt_count < self.burst_count:
|
||||
timeout = max(0.01, min(timeout, next_publish_at - time.monotonic()))
|
||||
self._wakeup.wait(timeout)
|
||||
self._wakeup.clear()
|
||||
@@ -1333,6 +1333,11 @@ class SeatDemoService:
|
||||
self._vehicle_state_monitor.close()
|
||||
self._vehicle_mqtt_transport.close()
|
||||
|
||||
def publish_pet_message(self, payload: dict[str, str]) -> dict[str, Any]:
|
||||
"""Publish one NFC-authorized pet card through the shared vehicle broker."""
|
||||
|
||||
return self._vehicle_mqtt_transport.publish_pet_message(payload)
|
||||
|
||||
def _synchronize_vehicle_state_once(
|
||||
self,
|
||||
conversation_id: str,
|
||||
|
||||
@@ -19,7 +19,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
@@ -36,6 +36,7 @@ from cognitive_agent.infrastructure.can.gateway import (
|
||||
from cognitive_agent.infrastructure.can.hardware import VehicleMqttSettings
|
||||
|
||||
MQTT_CAN_SCHEMA = "n7.can.frame.v1"
|
||||
MQTT_PET_TOPIC = "n7/pet"
|
||||
MQTT_POLL_INTERVAL_MS = 500
|
||||
WIRELESS_MQTT_CH1_SOURCE = "wireless_mqtt_ch1"
|
||||
MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset(
|
||||
@@ -498,6 +499,31 @@ def mqtt_control_payload(
|
||||
)
|
||||
|
||||
|
||||
def mqtt_pet_payload(payload: Mapping[str, Any]) -> str:
|
||||
"""Serialize the only application-level MQTT message emitted by N7Web."""
|
||||
|
||||
if set(payload) != {"pet_name", "nfc_id", "action"}:
|
||||
raise CanGatewayError(
|
||||
"pet MQTT payload requires exactly pet_name, nfc_id and action"
|
||||
)
|
||||
pet_name = str(payload["pet_name"]).strip()
|
||||
nfc_id = str(payload["nfc_id"]).strip().upper()
|
||||
action = str(payload["action"]).strip()
|
||||
if not pet_name or len(pet_name) > 64:
|
||||
raise CanGatewayError("pet_name must contain between 1 and 64 characters")
|
||||
if not action or len(action) > 160:
|
||||
raise CanGatewayError("pet action must contain between 1 and 160 characters")
|
||||
if not 4 <= len(nfc_id) <= 64 or any(
|
||||
character not in "0123456789ABCDEF" for character in nfc_id
|
||||
):
|
||||
raise CanGatewayError("pet nfc_id must be an uppercase hexadecimal NFC UID")
|
||||
return json.dumps(
|
||||
{"pet_name": pet_name, "nfc_id": nfc_id, "action": action},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def _validate_native_seat_motion_control(data: bytes) -> None:
|
||||
"""Validate the firmware-native 0x300 semantic runtime subset.
|
||||
|
||||
@@ -970,6 +996,33 @@ class MqttCanTransport:
|
||||
"payload": json.loads(raw_payload),
|
||||
}
|
||||
|
||||
def publish_pet_message(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Queue one validated pet-presence message on the fixed ``n7/pet`` topic."""
|
||||
|
||||
raw_payload = mqtt_pet_payload(payload)
|
||||
with self._condition:
|
||||
client = self._client
|
||||
state = self._state
|
||||
if client is None or state != "online":
|
||||
raise CanGatewayError("pet MQTT publisher is not connected")
|
||||
result = client.publish(
|
||||
MQTT_PET_TOPIC,
|
||||
raw_payload,
|
||||
qos=self.settings.qos,
|
||||
retain=False,
|
||||
)
|
||||
if int(getattr(result, "rc", 0)) != 0:
|
||||
raise CanGatewayError(f"pet MQTT publish failed with code {result.rc}")
|
||||
is_published = getattr(result, "is_published", None)
|
||||
broker_confirmed = bool(is_published()) if callable(is_published) else False
|
||||
return {
|
||||
"topic": MQTT_PET_TOPIC,
|
||||
"qos": self.settings.qos,
|
||||
"retain": False,
|
||||
"delivery_status": "published" if broker_confirmed else "queued",
|
||||
"message_id": getattr(result, "mid", None),
|
||||
}
|
||||
|
||||
def _receive_after(
|
||||
self,
|
||||
cursor: int,
|
||||
|
||||
@@ -48,6 +48,7 @@ from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
|
||||
normalize_mode_profile_name,
|
||||
parse_mode_profile_update,
|
||||
)
|
||||
from cognitive_agent.application.pet_cards import PetCardService # noqa: E402
|
||||
from cognitive_agent.application.seat_service import ( # noqa: E402
|
||||
LOCAL_CONTROL_BUDGET_MS,
|
||||
SeatDemoService,
|
||||
@@ -217,6 +218,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
gesture = GestureCapabilityBridge(REPOSITORY_ROOT)
|
||||
extension = SelfExtensionService()
|
||||
nfc = NfcReaderService()
|
||||
pets = PetCardService(
|
||||
nfc_provider=nfc.snapshot,
|
||||
publisher=service.publish_pet_message,
|
||||
)
|
||||
calibration = CalibrationService(
|
||||
REPOSITORY_ROOT / "artifacts" / "tmp" / "self-calibration",
|
||||
repository_root=REPOSITORY_ROOT,
|
||||
@@ -393,7 +398,16 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
self._json(self._extension_health())
|
||||
return
|
||||
if path == "/api/extension/nfc/status":
|
||||
self._json({"success": True, **self.nfc.snapshot()})
|
||||
self._json(
|
||||
{
|
||||
"success": True,
|
||||
**self.nfc.snapshot(),
|
||||
"pet_cards": self.pets.snapshot(),
|
||||
}
|
||||
)
|
||||
return
|
||||
if path == "/api/extension/pets":
|
||||
self._json({"success": True, **self.pets.snapshot()})
|
||||
return
|
||||
if path == "/api/extension/gscu/slots":
|
||||
try:
|
||||
@@ -2488,6 +2502,41 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if not self._require_same_origin() or not self._require_json_content_type():
|
||||
return
|
||||
pet_match = re.fullmatch(
|
||||
r"/api/extension/pets/(?P<pet_id>pet-[0-9a-f]{32})",
|
||||
path,
|
||||
)
|
||||
if pet_match is not None:
|
||||
try:
|
||||
payload = self._read_json_body(max_bytes=4096)
|
||||
unexpected = set(payload) - {"pet_name", "action"}
|
||||
if unexpected:
|
||||
if "nfc_id" in unexpected:
|
||||
raise ValueError(
|
||||
"nfc_id 只能通过当前已识别的 NFC 卡片完成绑定。"
|
||||
)
|
||||
raise ValueError("宠物卡片包含不支持的字段。")
|
||||
card = self.pets.update_card(
|
||||
pet_match.group("pet_id"),
|
||||
pet_name=payload.get("pet_name"),
|
||||
action=payload.get("action"),
|
||||
)
|
||||
self._json(
|
||||
{"success": True, "card": card, **self.pets.snapshot()},
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except KeyError:
|
||||
self._json(
|
||||
{"success": False, "error": "not_found", "message": "未找到宠物卡片。"},
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self._json(
|
||||
{"success": False, "error": "invalid_request", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
return
|
||||
|
||||
match = re.fullmatch(r"/api/extension/gscu/slots/(?P<slot>\d+)", path)
|
||||
if match is None:
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
@@ -2524,6 +2573,28 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
return
|
||||
pet_match = re.fullmatch(
|
||||
r"/api/extension/pets/(?P<pet_id>pet-[0-9a-f]{32})",
|
||||
path,
|
||||
)
|
||||
if pet_match is not None:
|
||||
try:
|
||||
deleted = self.pets.delete_card(pet_match.group("pet_id"))
|
||||
self._json(
|
||||
{"success": True, "deleted": deleted, **self.pets.snapshot()},
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except KeyError:
|
||||
self._json(
|
||||
{"success": False, "error": "not_found", "message": "未找到宠物卡片。"},
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._json(
|
||||
{"success": False, "error": "invalid_request", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
return
|
||||
prefix = "/api/mode-profiles/"
|
||||
if not path.startswith(prefix):
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
@@ -2833,6 +2904,47 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
}
|
||||
try:
|
||||
payload = self._read_json_body(max_bytes=256 * 1024)
|
||||
if path == "/api/extension/pets":
|
||||
if "nfc_id" in payload:
|
||||
raise ValueError(
|
||||
"nfc_id 只能通过当前已识别的 NFC 卡片完成绑定。"
|
||||
)
|
||||
card = self.pets.create_card(
|
||||
payload.get("pet_name"),
|
||||
payload.get("action"),
|
||||
)
|
||||
self._json(
|
||||
{"success": True, "card": card, **self.pets.snapshot()},
|
||||
HTTPStatus.CREATED,
|
||||
)
|
||||
return
|
||||
pet_binding_match = re.fullmatch(
|
||||
r"/api/extension/pets/(?P<pet_id>pet-[0-9a-f]{32})/"
|
||||
r"(?P<action>bind-current-nfc|unbind)",
|
||||
path,
|
||||
)
|
||||
if pet_binding_match is not None:
|
||||
pet_id = pet_binding_match.group("pet_id")
|
||||
try:
|
||||
if pet_binding_match.group("action") == "bind-current-nfc":
|
||||
card = self.pets.bind_current_nfc(pet_id)
|
||||
else:
|
||||
card = self.pets.unbind_card(pet_id)
|
||||
except KeyError:
|
||||
self._json(
|
||||
{
|
||||
"success": False,
|
||||
"error": "not_found",
|
||||
"message": "未找到宠物卡片。",
|
||||
},
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
return
|
||||
self._json(
|
||||
{"success": True, "card": card, **self.pets.snapshot()},
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
return
|
||||
if path in {
|
||||
"/api/extension/gscu/erase",
|
||||
"/api/extension/gscu/erase-all",
|
||||
@@ -3205,7 +3317,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
)
|
||||
|
||||
def _extension_health(self) -> dict[str, object]:
|
||||
return {**self.extension.health(), "nfc": self.nfc.snapshot()}
|
||||
return {
|
||||
**self.extension.health(),
|
||||
"nfc": self.nfc.snapshot(),
|
||||
"pet_cards": self.pets.snapshot(),
|
||||
}
|
||||
|
||||
def _handle_hardware_access_post(self, path: str) -> None:
|
||||
local_path = path.startswith("/api/local/")
|
||||
@@ -3848,6 +3964,7 @@ def main() -> None:
|
||||
SeatDemoHandler.service.start()
|
||||
SeatDemoHandler.extension.start()
|
||||
SeatDemoHandler.nfc.start()
|
||||
SeatDemoHandler.pets.start()
|
||||
SeatDemoHandler.calibration.start()
|
||||
SeatDemoHandler.repair.start()
|
||||
if args.host in {"0.0.0.0", "::"}:
|
||||
@@ -3900,9 +4017,10 @@ def main() -> None:
|
||||
server.server_close()
|
||||
SeatDemoHandler.speech.close()
|
||||
SeatDemoHandler.assistant.close()
|
||||
SeatDemoHandler.pets.close()
|
||||
SeatDemoHandler.nfc.close()
|
||||
SeatDemoHandler.service.close()
|
||||
SeatDemoHandler.extension.close()
|
||||
SeatDemoHandler.nfc.close()
|
||||
SeatDemoHandler.calibration.close()
|
||||
SeatDemoHandler.repair.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user