feat(n7web): add pet NFC workflow and glass UI

This commit is contained in:
Miayui
2026-09-14 15:16:02 +08:00
parent 812e67422d
commit aa6f59d3a6
20 changed files with 2724 additions and 102 deletions
@@ -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,
+121 -3
View File
@@ -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()