feat(n7web): enhance control workflows and status feedback

This commit is contained in:
Miayui
2026-09-16 12:52:07 +08:00
parent 84a3cd73bb
commit 0e4328f548
45 changed files with 7317 additions and 1001 deletions
@@ -1850,7 +1850,7 @@ class AssistantCapabilityCatalog:
]
if not peripherals:
return CapabilityAnswer(
"工程支持通过 NFC 绑定并学习红外外设;当前没有同时满足“已启用且对应 NFC 卡在场”的可执行外设能力。",
"工程支持通过 NFC 绑定并学习红外外设;当前没有同时满足“已学习、已启用且保留有效 NFC 绑定”的可执行外设能力。",
"peripherals",
)
labels = [
@@ -2321,7 +2321,9 @@ class AssistantCapabilityCatalog:
try:
nfc = dict(self._nfc_provider()) if self._nfc_provider else {}
except Exception: # noqa: BLE001 - optional NFC reader may be offline
return [], "unavailable"
# The reader only reports current presence. Durable bindings remain
# callable even while the reader is offline or no card is present.
nfc = {}
card = nfc.get("card") if isinstance(nfc.get("card"), Mapping) else {}
active_uid = _normalized_nfc_uid(card.get("uid")) if isinstance(card, Mapping) else ""
peripherals: list[dict[str, object]] = []
@@ -2350,7 +2352,7 @@ class AssistantCapabilityCatalog:
active_uid and card_uid and active_uid == card_uid
),
"assistant_callable": bool(
learned and enabled and active_uid and card_uid and active_uid == card_uid
learned and enabled and card_uid
),
}
)
@@ -0,0 +1,638 @@
"""Receive, authorize and execute car-originated infrared control requests."""
from __future__ import annotations
import json
import math
import queue
import re
import threading
import time
import uuid
from collections.abc import Callable, Mapping
from copy import deepcopy
from datetime import UTC, datetime
from typing import Any
from cognitive_agent.application.extension_toy_publisher import (
normalize_extension_nfc_id,
)
EXTENSION_CONTROL_TOPIC = "extension/ctl"
MAX_EXTENSION_CONTROL_ACTIONS = 64
MAX_EXTENSION_CONTROL_AT_MS = 60_000
MAX_EXTENSION_CONTROL_PAYLOAD_BYTES = 256 * 1024
DEFAULT_EXTENSION_CONTROL_QUEUE_SIZE = 32
DEFAULT_ADJACENT_EMIT_INTERVAL_S = 0.8
_ACTION_REQUIRED_KEYS = frozenset(
{
"type",
"at_ms",
"domain",
"capability",
"target_scope",
"operation",
"parameters",
}
)
_DEVICE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
class ExtensionControlError(ValueError):
"""Base error for an invalid or unauthorized ``extension/ctl`` request."""
class ExtensionControlAuthorizationError(ExtensionControlError):
"""Raised when an action is absent from the local durable allowlist."""
def _timestamp() -> str:
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _json_object_without_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise ExtensionControlError(f"extension/ctl contains duplicate key: {key}")
result[key] = value
return result
def parse_extension_control_payload(raw_payload: bytes | str) -> dict[str, Any]:
"""Parse the fixed car-to-PC control envelope and normalize its actions."""
if isinstance(raw_payload, bytes):
encoded = raw_payload
try:
text = raw_payload.decode("utf-8")
except UnicodeDecodeError as exc:
raise ExtensionControlError("extension/ctl payload must be UTF-8 JSON") from exc
elif isinstance(raw_payload, str):
text = raw_payload
encoded = raw_payload.encode("utf-8")
else:
raise ExtensionControlError("extension/ctl payload must be UTF-8 JSON")
if not encoded or len(encoded) > MAX_EXTENSION_CONTROL_PAYLOAD_BYTES:
raise ExtensionControlError("extension/ctl payload must contain 1-256 KiB")
try:
payload = json.loads(text, object_pairs_hook=_json_object_without_duplicate_keys)
except ExtensionControlError:
raise
except (json.JSONDecodeError, TypeError, ValueError) as exc:
raise ExtensionControlError("extension/ctl payload must be valid JSON") from exc
if not isinstance(payload, Mapping) or set(payload) != {
"domain",
"operation_count",
"actions",
}:
raise ExtensionControlError(
"extension/ctl requires exactly domain, operation_count and actions"
)
if payload.get("domain") != "self_extension":
raise ExtensionControlError("extension/ctl domain must be self_extension")
operation_count = payload.get("operation_count")
actions = payload.get("actions")
if (
isinstance(operation_count, bool)
or not isinstance(operation_count, int)
or not 1 <= operation_count <= MAX_EXTENSION_CONTROL_ACTIONS
):
raise ExtensionControlError(
f"extension/ctl operation_count must be in range 1-{MAX_EXTENSION_CONTROL_ACTIONS}"
)
if not isinstance(actions, list) or len(actions) != operation_count:
raise ExtensionControlError(
"extension/ctl operation_count must equal the actions length"
)
normalized_actions: list[dict[str, Any]] = []
for index, raw_action in enumerate(actions):
if not isinstance(raw_action, Mapping):
raise ExtensionControlError(f"extension/ctl action {index} must be an object")
missing = sorted(_ACTION_REQUIRED_KEYS - set(raw_action))
if missing:
raise ExtensionControlError(
f"extension/ctl action {index} is missing: {', '.join(missing)}"
)
if raw_action.get("type") != "infrared_emit":
raise ExtensionControlError(
f"extension/ctl action {index} type must be infrared_emit"
)
at_ms = raw_action.get("at_ms")
if (
isinstance(at_ms, bool)
or not isinstance(at_ms, int)
or not 0 <= at_ms <= MAX_EXTENSION_CONTROL_AT_MS
):
raise ExtensionControlError(
f"extension/ctl action {index} at_ms must be in range "
f"0-{MAX_EXTENSION_CONTROL_AT_MS}"
)
if raw_action.get("domain") != "self_extension":
raise ExtensionControlError(
f"extension/ctl action {index} domain must be self_extension"
)
if raw_action.get("capability") != "infrared.emit":
raise ExtensionControlError(
f"extension/ctl action {index} capability must be infrared.emit"
)
if raw_action.get("operation") != "emit":
raise ExtensionControlError(
f"extension/ctl action {index} operation must be emit"
)
target_scope = raw_action.get("target_scope")
if not isinstance(target_scope, str) or not _DEVICE_ID_PATTERN.fullmatch(
target_scope
):
raise ExtensionControlError(
f"extension/ctl action {index} target_scope must be an English device id"
)
parameters = raw_action.get("parameters")
if not isinstance(parameters, Mapping) or set(parameters) != {"action", "slot"}:
raise ExtensionControlError(
f"extension/ctl action {index} parameters require exactly action and slot"
)
action_name = parameters.get("action")
slot = parameters.get("slot")
if (
not isinstance(action_name, str)
or not action_name.strip()
or len(action_name.strip()) > 48
):
raise ExtensionControlError(
f"extension/ctl action {index} action name must contain 1-48 characters"
)
if isinstance(slot, bool) or not isinstance(slot, int) or not 1 <= slot <= 248:
raise ExtensionControlError(
f"extension/ctl action {index} slot must be in range 1-248"
)
normalized_actions.append(
{
"type": "infrared_emit",
"at_ms": at_ms,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": target_scope,
"operation": "emit",
"parameters": {"action": action_name.strip(), "slot": slot},
}
)
return {
"domain": "self_extension",
"operation_count": operation_count,
"actions": normalized_actions,
}
def authorize_extension_control_actions(
request: Mapping[str, Any],
catalog: object,
) -> list[dict[str, Any]]:
"""Authorize every action before any TCP command may be sent."""
actions = request.get("actions")
if not isinstance(actions, list):
raise ExtensionControlError("extension/ctl actions must be an array")
if not isinstance(catalog, list):
raise ExtensionControlError("local extension capability catalog is unavailable")
allowlist: dict[tuple[str, int, str], dict[str, Any]] = {}
for capability in catalog:
if (
not isinstance(capability, Mapping)
or capability.get("status") != "learned"
or capability.get("enabled") is not True
or not normalize_extension_nfc_id(capability.get("card_uid"))
):
continue
device_id = str(capability.get("device_id") or "").strip()
action_name = str(capability.get("action") or "").strip()
slot = capability.get("slot")
if (
not _DEVICE_ID_PATTERN.fullmatch(device_id)
or not action_name
or isinstance(slot, bool)
or not isinstance(slot, int)
or not 1 <= slot <= 248
):
continue
identity = (device_id, slot, action_name)
current = allowlist.get(identity)
if current is None or str(capability.get("id") or "") < str(
current.get("id") or ""
):
allowlist[identity] = dict(capability)
authorized: list[dict[str, Any]] = []
for index, action in enumerate(actions):
parameters = action.get("parameters") if isinstance(action, Mapping) else None
if not isinstance(parameters, Mapping):
raise ExtensionControlError(f"extension/ctl action {index} is invalid")
target_scope = str(action.get("target_scope") or "").strip()
action_name = str(parameters.get("action") or "").strip()
slot = parameters.get("slot")
identity = (target_scope, slot, action_name)
capability = allowlist.get(identity)
if capability is None:
raise ExtensionControlAuthorizationError(
"extension/ctl action is not authorized by the local learned allowlist: "
f"{target_scope} / {action_name} / IR {slot}"
)
authorized.append(
{
"index": index,
"at_ms": int(action.get("at_ms", 0)),
"action": deepcopy(dict(action)),
"capability_id": str(capability.get("id") or ""),
"extension_name": str(capability.get("device_name") or ""),
}
)
return authorized
class ExtensionControlConsumer:
"""Serialize validated car commands onto the computer's infrared TCP gateway."""
def __init__(
self,
*,
catalog_provider: Callable[[], list[dict[str, Any]]],
executor: Callable[[Mapping[str, Any]], Mapping[str, Any]],
queue_size: int = DEFAULT_EXTENSION_CONTROL_QUEUE_SIZE,
adjacent_emit_interval_s: float = DEFAULT_ADJACENT_EMIT_INTERVAL_S,
) -> None:
if queue_size <= 0:
raise ValueError("queue_size must be greater than zero")
if adjacent_emit_interval_s < 0:
raise ValueError("adjacent_emit_interval_s must not be negative")
self._catalog_provider = catalog_provider
self._executor = executor
self._queue: queue.Queue[tuple[bytes, float, str]] = queue.Queue(queue_size)
self._adjacent_emit_interval_s = float(adjacent_emit_interval_s)
self._lock = threading.RLock()
self._changed = threading.Condition(self._lock)
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._revision = 0
self._status: dict[str, Any] = {
"state": "stopped",
"topic": EXTENSION_CONTROL_TOPIC,
"source": "vehicle_mqtt_extension",
"source_label": "车端 MQTT",
"request_count": 0,
"processed_count": 0,
"rejected_count": 0,
"failed_count": 0,
"executed_action_count": 0,
"last_request_id": None,
"last_received_at": None,
"last_started_at": None,
"last_completed_at": None,
"last_latency_ms": None,
"last_request": None,
"action_statuses": [],
"active_action_index": None,
"last_rejection_kind": None,
"last_results": [],
"last_error": None,
}
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._set_status(state="waiting", last_error=None)
self._thread = threading.Thread(
target=self._run,
name="n7-extension-control-consumer",
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")
def submit(self, raw_payload: bytes) -> None:
"""Queue raw broker bytes without blocking the MQTT network loop."""
if self._stop.is_set():
raise RuntimeError("extension control consumer is stopped")
payload = bytes(raw_payload)
if not payload or len(payload) > MAX_EXTENSION_CONTROL_PAYLOAD_BYTES:
raise ExtensionControlError("extension/ctl payload must contain 1-256 KiB")
received_at = _timestamp()
try:
self._queue.put_nowait((payload, time.monotonic(), received_at))
except queue.Full as exc:
self._set_status(state="queue_full", last_error="extension/ctl queue is full")
raise RuntimeError("extension/ctl queue is full") from exc
with self._lock:
request_count = int(self._status["request_count"]) + 1
current_state = str(self._status["state"])
self._set_status(
state="queued" if current_state not in {"validating", "executing"} else current_state,
request_count=request_count,
last_received_at=received_at,
last_error=None,
)
def snapshot(self) -> dict[str, Any]:
with self._lock:
return self._snapshot_locked()
def reject_transport_message(
self,
reason: str,
rejection_kind: str = "transport",
) -> None:
"""Project an MQTT-layer rejection without executing any action."""
error = str(reason or "extension/ctl message was rejected").strip()[:240]
kind = str(rejection_kind or "transport").strip()[:48]
timestamp = _timestamp()
with self._lock:
request_count = int(self._status["request_count"]) + 1
processed_count = int(self._status["processed_count"]) + 1
rejected_count = int(self._status["rejected_count"]) + 1
self._set_status(
state="rejected",
request_count=request_count,
processed_count=processed_count,
rejected_count=rejected_count,
last_request_id=f"extension-ctl-{uuid.uuid4().hex}",
last_received_at=timestamp,
last_started_at=timestamp,
last_completed_at=timestamp,
last_latency_ms=0,
last_request=None,
action_statuses=[],
active_action_index=None,
last_rejection_kind=kind,
last_results=[],
last_error=error,
)
def wait_for_snapshot(
self,
after_revision: int,
*,
timeout_s: float = 25.0,
) -> dict[str, Any]:
"""Long-poll until the display projection changes or the timeout expires."""
if isinstance(after_revision, bool) or not isinstance(after_revision, int):
raise ValueError("after_revision must be an integer")
if after_revision < -1:
raise ValueError("after_revision must be at least -1")
if (
isinstance(timeout_s, bool)
or not isinstance(timeout_s, (int, float))
or not math.isfinite(float(timeout_s))
or not 0 <= float(timeout_s) <= 30
):
raise ValueError("timeout_s must be between 0 and 30 seconds")
with self._changed:
self._changed.wait_for(
lambda: self._revision > after_revision or self._stop.is_set(),
timeout=float(timeout_s),
)
return self._snapshot_locked()
def _snapshot_locked(self) -> dict[str, Any]:
return {
"revision": self._revision,
**deepcopy(self._status),
"queue_depth": self._queue.qsize(),
"queue_capacity": self._queue.maxsize,
}
def _set_status(self, **changes: Any) -> None:
with self._changed:
next_status = {**self._status, **changes}
if next_status == self._status:
return
self._status = next_status
self._revision += 1
self._changed.notify_all()
def _record_rejection(
self,
error: Exception,
*,
request: Mapping[str, Any] | None = None,
rejection_kind: str = "validation",
) -> None:
with self._lock:
processed = int(self._status["processed_count"]) + 1
rejected = int(self._status["rejected_count"]) + 1
self._set_status(
state="rejected",
processed_count=processed,
rejected_count=rejected,
last_completed_at=_timestamp(),
last_request=deepcopy(dict(request)) if request is not None else None,
action_statuses=[],
active_action_index=None,
last_rejection_kind=rejection_kind,
last_results=[],
last_error=str(error)[:240],
)
def _process(self, item: tuple[bytes, float, str]) -> None:
raw_payload, received_monotonic, received_at = item
request_id = f"extension-ctl-{uuid.uuid4().hex}"
self._set_status(
state="validating",
last_request_id=request_id,
last_received_at=received_at,
last_started_at=_timestamp(),
last_completed_at=None,
last_latency_ms=None,
last_request=None,
action_statuses=[],
active_action_index=None,
last_rejection_kind=None,
last_results=[],
last_error=None,
)
request: dict[str, Any] | None = None
try:
request = parse_extension_control_payload(raw_payload)
actions = authorize_extension_control_actions(
request,
self._catalog_provider(),
)
except Exception as exc: # noqa: BLE001 - reject complete request atomically
self._record_rejection(exc, request=request)
return
ordered_actions = sorted(actions, key=lambda item: (item["at_ms"], item["index"]))
action_statuses = [
{
"index": authorized["index"],
"execution_order": execution_order,
"at_ms": authorized["at_ms"],
"capability_id": authorized["capability_id"],
"extension_name": authorized["extension_name"],
"target_scope": authorized["action"]["target_scope"],
"action": authorized["action"]["parameters"]["action"],
"slot": authorized["action"]["parameters"]["slot"],
"state": "pending",
"state_label": "等待下发",
"state_detail": f"计划在接收后 {authorized['at_ms']} ms 下发",
"transport": "tcp",
"gateway_acknowledged": False,
"physical_state_confirmed": False,
"started_at": None,
"completed_at": None,
}
for execution_order, authorized in enumerate(ordered_actions)
]
self._set_status(
state="executing",
last_request_id=request_id,
last_request=deepcopy(request),
action_statuses=deepcopy(action_statuses),
)
results: list[dict[str, Any]] = []
previous_emit_monotonic: float | None = None
failure: Exception | None = None
for item_index, authorized in enumerate(ordered_actions):
due_at = received_monotonic + authorized["at_ms"] / 1_000
if previous_emit_monotonic is not None:
due_at = max(
due_at,
previous_emit_monotonic + self._adjacent_emit_interval_s,
)
if self._stop.wait(max(0.0, due_at - time.monotonic())):
failure = RuntimeError("extension control consumer stopped")
action_statuses[item_index].update(
state="failed",
state_label="下发失败",
state_detail=str(failure),
completed_at=_timestamp(),
)
break
action_statuses[item_index].update(
state="executing",
state_label="正在下发",
state_detail="正在通过本机 TCP 红外网关下发",
started_at=_timestamp(),
)
self._set_status(
state="executing",
active_action_index=authorized["index"],
action_statuses=deepcopy(action_statuses),
)
try:
result = self._executor(deepcopy(authorized["action"]))
if not isinstance(result, Mapping) or result.get("success") is False:
raise RuntimeError("infrared TCP gateway did not accept the command")
except Exception as exc: # noqa: BLE001 - abort remaining control flow
failure = exc
action_statuses[item_index].update(
state="failed",
state_label="下发失败",
state_detail=str(exc)[:240],
completed_at=_timestamp(),
)
break
previous_emit_monotonic = time.monotonic()
gateway_acknowledged = result.get("gateway_acknowledged") is True
physical_state_confirmed = result.get("physical_state_confirmed") is True
action_statuses[item_index].update(
state=(
"verified_success"
if physical_state_confirmed
else "submitted_unverified"
),
state_label=(
"执行已确认"
if physical_state_confirmed
else "已下发 · 物理效果未确认"
),
state_detail=(
"设备物理效果已确认"
if physical_state_confirmed
else "网关已确认;物理效果未确认"
if gateway_acknowledged
else "TCP 已提交;设备未返回 ACK,物理效果未确认"
),
transport=str(result.get("transport") or "tcp"),
gateway_acknowledged=gateway_acknowledged,
physical_state_confirmed=physical_state_confirmed,
completed_at=_timestamp(),
)
results.append(
{
"index": authorized["index"],
"at_ms": authorized["at_ms"],
"capability_id": authorized["capability_id"],
"extension_name": authorized["extension_name"],
"target_scope": authorized["action"]["target_scope"],
"action": authorized["action"]["parameters"]["action"],
"slot": authorized["action"]["parameters"]["slot"],
"transport": str(result.get("transport") or "tcp"),
"gateway_acknowledged": gateway_acknowledged,
"physical_state_confirmed": physical_state_confirmed,
"executed_at": _timestamp(),
"execution_order": item_index,
}
)
self._set_status(
action_statuses=deepcopy(action_statuses),
last_results=deepcopy(results),
)
if failure is not None:
for pending in action_statuses:
if pending["state"] == "pending":
pending.update(
state="aborted",
state_label="已中止",
state_detail="前序红外指令失败,未执行",
completed_at=_timestamp(),
)
completed_at = _timestamp()
latency_ms = round(max(0.0, time.monotonic() - received_monotonic) * 1_000)
with self._lock:
processed = int(self._status["processed_count"]) + 1
failed = int(self._status["failed_count"]) + (1 if failure else 0)
executed = int(self._status["executed_action_count"]) + len(results)
self._set_status(
state="failed" if failure else "completed",
processed_count=processed,
failed_count=failed,
executed_action_count=executed,
active_action_index=None,
action_statuses=deepcopy(action_statuses),
last_completed_at=completed_at,
last_latency_ms=latency_ms,
last_results=deepcopy(results),
last_error=str(failure)[:240] if failure else None,
)
def _run(self) -> None:
while not self._stop.is_set():
try:
item = self._queue.get(timeout=0.1)
except queue.Empty:
continue
try:
self._process(item)
except Exception as exc: # noqa: BLE001 - keep the consumer alive
self._record_rejection(exc)
finally:
self._queue.task_done()
@@ -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)
@@ -6,6 +6,9 @@ import hashlib
import json
import os
import threading
import time
from collections import Counter
from collections.abc import Callable, Mapping
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
@@ -21,6 +24,8 @@ N7_SCENE_SERVICE = "n7-scenes"
N7_SCENE_SCHEMA_VERSION = 1
DEFAULT_N7_SCENE_SERVER_ID = "n7-cognitive-agent"
DEFAULT_N7_SCENE_SERVER_NAME = "N7 模式卡片目录"
MODE_SCENE_MQTT_BURST_COUNT = 3
MODE_SCENE_MQTT_BURST_WINDOW_S = 1.0
def _utc_now() -> str:
@@ -65,7 +70,12 @@ def _legacy_zone(target_scope: str) -> str:
}.get(target_scope, target_scope)
def _scene_action(operation: ModeOperation) -> dict[str, Any]:
def _scene_action(
operation: ModeOperation,
*,
target: Mapping[str, Any] | None = None,
operation_ref: str = "",
) -> dict[str, Any]:
"""Preserve the complete operation and add fields understood by the v1 demo."""
parameters = deepcopy(operation.parameters)
@@ -78,6 +88,14 @@ def _scene_action(operation: ModeOperation) -> dict[str, Any]:
"operation": operation.operation,
"parameters": parameters,
}
if target:
# FunctionConfig targets carry details which cannot be represented by
# the legacy ``target_scope`` string (notably an infrared device and
# its persisted NFC card UID). Keep that compact target object in the
# public scene action without exposing the complete command flow.
action["target"] = deepcopy(dict(target))
if operation_ref:
action["operation_ref"] = operation_ref
# The original demo catalog flattened these three climate actions. Keep
# those aliases so an existing v1 client can continue reading them, while
@@ -100,11 +118,130 @@ def _scene_action(operation: ModeOperation) -> dict[str, Any]:
return action
def _operation_verb(capability: str) -> str:
action = capability.partition(".")[2].strip()
return action.partition("_")[0] or "set"
def _operation_identity(operation: ModeOperation) -> str:
"""Return a stable semantic key across profile and FunctionConfig shapes."""
target_scope = {
"driver": "front_left",
"passenger": "front_right",
}.get(operation.target_scope, operation.target_scope)
return json.dumps(
{
"domain": operation.domain,
"capability": operation.capability,
"target_scope": target_scope,
"parameters": operation.parameters,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
def _command_flow_operations(
profile: ModeProfile,
) -> list[tuple[ModeOperation, dict[str, Any], str]]:
"""Project the latest saved FunctionConfigs into scene operations.
The top-level ``profile.operations`` list is retained for compatibility
with mode matching and native CAN execution. A saved editor flow can also
contain executable self-extension operations, so the client-facing scene
projection must read the complete FunctionConfig set used by the editor.
Malformed optional flow entries are ignored and later filled from the
canonical top-level operations.
"""
command_flow = profile.command_flow
if not isinstance(command_flow, dict):
return []
configurations = command_flow.get("function_configs")
if not isinstance(configurations, dict):
return []
projected: list[tuple[ModeOperation, dict[str, Any], str]] = []
for configuration in configurations.values():
if not isinstance(configuration, dict):
continue
spec = configuration.get("spec")
if not isinstance(spec, dict):
continue
raw_operations = spec.get("operations")
if not isinstance(raw_operations, list):
continue
configured_domain = str(spec.get("domain") or "").strip()
for raw_operation in raw_operations:
if not isinstance(raw_operation, dict):
continue
capability = str(raw_operation.get("capability") or "").strip()
if not capability:
continue
domain = str(
raw_operation.get("domain")
or configured_domain
or capability.partition(".")[0]
).strip()
raw_parameters = raw_operation.get("parameters") or {}
raw_target = raw_operation.get("target") or {}
if not domain or not isinstance(raw_parameters, dict):
continue
target = dict(raw_target) if isinstance(raw_target, dict) else {}
target_scope = str(
raw_operation.get("target_scope")
or target.get("zone")
or target.get("scope")
or target.get("device")
or "vehicle"
).strip()
operation = ModeOperation(
domain=domain,
capability=capability,
target_scope=target_scope,
operation=str(
raw_operation.get("operation") or _operation_verb(capability)
).strip(),
parameters=deepcopy(raw_parameters),
reason=str(raw_operation.get("reason") or ""),
)
projected.append(
(operation, target, str(raw_operation.get("id") or "").strip())
)
return projected
def _effective_scene_operations(
profile: ModeProfile,
) -> list[tuple[ModeOperation, dict[str, Any], str]]:
"""Use the latest editor flow first, then fill any non-duplicated fallback."""
effective = _command_flow_operations(profile)
if not effective:
return [(operation, {}, "") for operation in profile.operations]
# Keep the authoritative flow exactly as ordered, including intentional
# repeated steps. Top-level operations only fill semantic occurrences
# absent from that flow, so the two persistence shapes are not double-counted.
represented = Counter(_operation_identity(operation) for operation, _, _ in effective)
profile_occurrences: Counter[str] = Counter()
for operation in profile.operations:
identity = _operation_identity(operation)
profile_occurrences[identity] += 1
if profile_occurrences[identity] <= represented[identity]:
continue
effective.append((operation, {}, ""))
return effective
def _scene_from_profile(
registry: ModeProfileRegistry,
profile: ModeProfile,
public_profile: dict[str, Any],
) -> dict[str, Any]:
effective_operations = _effective_scene_operations(profile)
scene = {
"id": profile.id,
"label": profile.name,
@@ -116,11 +253,18 @@ def _scene_from_profile(
"origin": public_profile.get("origin", "preset"),
"deletable": bool(public_profile.get("deletable", False)),
"behavior": public_profile.get("behavior", "static"),
"domains": list(public_profile.get("domains") or []),
"operation_count": len(profile.operations),
"domains": sorted({operation.domain for operation, _, _ in effective_operations}),
"operation_count": len(effective_operations),
"scope": deepcopy(public_profile.get("scope") or {}),
"profile_revision": registry.profile_revision(profile),
"actions": [_scene_action(operation) for operation in profile.operations],
"actions": [
_scene_action(
operation,
target=target,
operation_ref=operation_ref,
)
for operation, target, operation_ref in effective_operations
],
}
if public_profile.get("preset_override") is True:
scene["preset_override"] = True
@@ -246,3 +390,177 @@ class ModeSceneCatalogPublisher:
except (OSError, json.JSONDecodeError):
return None
return document if isinstance(document, dict) else None
class ModeSceneMqttBurstPublisher:
"""Publish one newly saved scene three times without blocking its HTTP save."""
def __init__(
self,
publisher: Callable[[Mapping[str, Any]], Mapping[str, Any]],
*,
topic: str,
burst_count: int = MODE_SCENE_MQTT_BURST_COUNT,
burst_window_s: float = MODE_SCENE_MQTT_BURST_WINDOW_S,
) -> 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")
clean_topic = str(topic).strip()
if not clean_topic:
raise ValueError("topic must not be empty")
self._publisher = publisher
self.topic = clean_topic
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._lock = threading.RLock()
self._stop = threading.Event()
self._threads: set[threading.Thread] = set()
self._status: dict[str, Any] = {
"state": "idle",
"topic": self.topic,
"scene_id": None,
"burst_count": self.burst_count,
"burst_window_ms": round(self.burst_window_s * 1_000),
"burst_attempt_count": 0,
"burst_published_count": 0,
"last_delivery_status": None,
"last_message_id": None,
"last_error": None,
"last_burst_started_at": None,
"last_burst_completed_at": None,
}
def queue(self, scene: Mapping[str, Any]) -> dict[str, Any]:
"""Queue an immutable copy of exactly one scene-card object."""
if not isinstance(scene, Mapping):
raise ValueError("scene must be an object")
payload = deepcopy(dict(scene))
scene_id = str(payload.get("id") or "").strip()
if not scene_id:
raise ValueError("scene id must not be empty")
if "scenes" in payload or "service" in payload:
raise ValueError("scene burst accepts one scene card, not a catalog")
if self._stop.is_set():
raise RuntimeError("mode scene MQTT publisher is stopped")
thread = threading.Thread(
target=self._run_burst,
args=(payload,),
name=f"n7-mode-scene-mqtt-{scene_id}",
daemon=True,
)
with self._lock:
self._threads.add(thread)
self._status.update(
state="queued",
scene_id=scene_id,
burst_attempt_count=0,
burst_published_count=0,
last_delivery_status=None,
last_message_id=None,
last_error=None,
last_burst_started_at=None,
last_burst_completed_at=None,
)
try:
thread.start()
except Exception:
with self._lock:
self._threads.discard(thread)
raise
return {
"state": "queued",
"topic": self.topic,
"scene_id": scene_id,
"burst_count": self.burst_count,
"burst_window_ms": round(self.burst_window_s * 1_000),
}
def _run_burst(self, scene: dict[str, Any]) -> None:
started = time.monotonic()
attempts = 0
published = 0
last_delivery_status: str | None = None
last_message_id: Any = None
last_error: str | None = None
with self._lock:
self._status.update(
state="bursting",
last_burst_started_at=_utc_now(),
)
try:
for index in range(self.burst_count):
due_at = started + index * self.burst_interval_s
if self._stop.wait(max(0.0, due_at - time.monotonic())):
break
attempts += 1
try:
result = self._publisher(deepcopy(scene))
if not isinstance(result, Mapping):
raise TypeError("mode scene 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
last_delivery_status = str(
result.get("delivery_status") or "queued"
)
last_message_id = result.get("message_id")
with self._lock:
self._status.update(
state="bursting",
burst_attempt_count=attempts,
burst_published_count=published,
last_delivery_status=last_delivery_status,
last_message_id=last_message_id,
last_error=last_error,
)
finally:
completed = attempts == self.burst_count
if completed and published != self.burst_count:
last_delivery_status = "partial" if published else "failed"
with self._lock:
self._status.update(
state=(
"stopped"
if self._stop.is_set() and not completed
else "burst_complete"
if published == self.burst_count
else "burst_failed"
),
burst_attempt_count=attempts,
burst_published_count=published,
last_delivery_status=last_delivery_status,
last_message_id=last_message_id,
last_error=last_error,
last_burst_completed_at=_utc_now() if completed else None,
)
self._threads.discard(threading.current_thread())
def snapshot(self) -> dict[str, Any]:
with self._lock:
return deepcopy(self._status)
def start(self) -> None:
with self._lock:
self._stop.clear()
if self._status["state"] == "stopped":
self._status["state"] = "idle"
def close(self) -> None:
self._stop.set()
with self._lock:
threads = tuple(self._threads)
deadline = time.monotonic() + 1.1
for thread in threads:
thread.join(timeout=max(0.0, deadline - time.monotonic()))
with self._lock:
self._status["state"] = "stopped"
@@ -55,6 +55,7 @@ from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
DEFAULT_N7_SCENE_SERVER_ID,
DEFAULT_N7_SCENE_SERVER_NAME,
ModeSceneCatalogPublisher,
ModeSceneMqttBurstPublisher,
build_mode_scene_catalog,
)
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
@@ -129,6 +130,7 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
VehicleMqttSettings,
)
from cognitive_agent.infrastructure.can.mqtt_transport import ( # noqa: E402
MQTT_MODE_SCENE_TOPIC,
MqttCanTransport,
)
from cognitive_agent.infrastructure.llm.openai_responses import ( # noqa: E402
@@ -440,6 +442,10 @@ class SeatDemoService:
"scene_count": 0,
"last_error": None,
}
self._mode_scene_mqtt_publisher = ModeSceneMqttBurstPublisher(
lambda scene: self._vehicle_mqtt_transport.publish_mode_scene(scene),
topic=MQTT_MODE_SCENE_TOPIC,
)
self.scene_templates = SceneTemplateRegistry(
repository_root / "config" / "scene_templates"
)
@@ -540,6 +546,51 @@ class SeatDemoService:
def mode_scene_catalog_status(self) -> dict[str, Any]:
return deepcopy(self._mode_scene_catalog_status)
def publish_saved_mode_scene(
self,
mode_id: str,
vehicle_id: str | None = None,
) -> dict[str, Any]:
"""Queue the just-saved card from the canonical scene projection.
Both a new user mode and a user override of a repository preset are
projected by the same registry. Reading the card back from that
projection guarantees that MQTT and ``/n7/scenes`` expose exactly the
same post-save JSON object.
"""
clean_mode_id = str(mode_id).strip()
if not clean_mode_id:
raise ValueError("mode scene id must not be empty")
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
catalog = self.mode_scene_catalog(resolved_vehicle_id)
catalog_publisher = self._mode_scene_catalog_publisher
if catalog_publisher is not None and (
catalog_publisher.vehicle_id != resolved_vehicle_id
or self._mode_scene_catalog_status.get("state") != "ready"
):
raise RuntimeError("mode scene catalog is not synchronized for this vehicle")
scene = next(
(
item
for item in catalog.get("scenes", [])
if isinstance(item, dict) and item.get("id") == clean_mode_id
),
None,
)
if scene is None:
raise KeyError(clean_mode_id)
return self._mode_scene_mqtt_publisher.queue(scene)
def publish_created_mode_scene(
self,
mode_id: str,
vehicle_id: str | None = None,
) -> dict[str, Any]:
"""Backward-compatible alias for callers predating update publishing."""
return self.publish_saved_mode_scene(mode_id, vehicle_id)
@property
def driver_only_ch1_execution_active(self) -> bool:
"""Return whether deployment policy authorizes only the CH1 vehicle path."""
@@ -697,6 +748,7 @@ class SeatDemoService:
"mode_profile_storage": "persistent_yaml",
"mode_profile_user_root": str(self.mode_profile_user_root),
"mode_scene_catalog": self.mode_scene_catalog_status(),
"mode_scene_mqtt": self._mode_scene_mqtt_publisher.snapshot(),
"preset_storage": "conversation_durable",
"inferred_level_range": [1, 3],
"motion_position_range_pct": [0, 100],
@@ -1326,11 +1378,13 @@ class SeatDemoService:
def start(self) -> None:
if self._vehicle_state_monitor is not None:
self._vehicle_state_monitor.start()
self._mode_scene_mqtt_publisher.start()
self._vehicle_mqtt_transport.start()
def close(self) -> None:
if self._vehicle_state_monitor is not None:
self._vehicle_state_monitor.close()
self._mode_scene_mqtt_publisher.close()
self._vehicle_mqtt_transport.close()
def publish_pet_message(self, payload: dict[str, str]) -> dict[str, Any]:
@@ -1338,6 +1392,30 @@ class SeatDemoService:
return self._vehicle_mqtt_transport.publish_pet_message(payload)
def publish_extension_toy_message(
self,
payload: dict[str, Any],
) -> dict[str, Any]:
"""Publish one NFC-detected extension capability announcement."""
return self._vehicle_mqtt_transport.publish_extension_toy_message(payload)
def set_extension_control_handler(
self,
handler: Callable[[bytes], None] | None,
) -> None:
"""Route ``extension/ctl`` payloads from the shared broker connection."""
self._vehicle_mqtt_transport.set_extension_control_handler(handler)
def set_extension_control_rejection_handler(
self,
handler: Callable[[str, str], None] | None,
) -> None:
"""Project broker-level ``extension/ctl`` rejections into the UI status."""
self._vehicle_mqtt_transport.set_extension_control_rejection_handler(handler)
def _synchronize_vehicle_state_once(
self,
conversation_id: str,
+123 -37
View File
@@ -12,6 +12,7 @@ import ssl
import threading
import time
import uuid
from collections.abc import Mapping
from contextlib import closing
from datetime import UTC, datetime
from pathlib import Path
@@ -955,7 +956,7 @@ def _peripheral_execution_identity_authorized(
capability: dict[str, Any],
) -> bool:
# Saved modes execute an immutable, previously validated command flow and
# are rebound to the same live NFC/device/slot tuple before reaching here.
# are rebound to the same persisted NFC/device/slot tuple before reaching here.
if capability.get("parse_source") == "saved_mode_command_flow":
return True
return bool(_device_match_score(_compact_match_text(utterance), capability))
@@ -1089,7 +1090,7 @@ def _deterministic_peripheral_steps(
utterance: str,
catalog: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Build an ordered live-peripheral plan when every phrase is unambiguous."""
"""Build an ordered bound-peripheral plan when every phrase is unambiguous."""
compact_utterance = _compact_match_text(utterance)
learned = [
@@ -1229,11 +1230,13 @@ def resolve_active_peripheral_command(
catalog: list[dict[str, Any]],
nfc_snapshot: dict[str, Any],
) -> dict[str, Any] | None:
"""Resolve an exact learned action for the NFC card that is present now.
"""Resolve an exact learned action from the durable NFC-bound catalog.
An exact peripheral name is required so generic vehicle phrases such as
``打开氛围灯`` keep using the native vehicle domain. Action synonyms are
accepted only after the peripheral identity and live card binding match.
accepted only after the peripheral identity and persisted binding match.
``nfc_snapshot`` is retained in the public signature for compatibility;
card presence is intentionally not an execution prerequisite.
"""
return _resolve_bound_peripheral_command(
@@ -1251,20 +1254,17 @@ def _resolve_bound_peripheral_command(
*,
require_enabled: bool,
) -> dict[str, Any] | None:
"""Resolve a card-bound command, optionally including disabled entries.
"""Resolve a persistently card-bound command, including disabled entries.
Disabled entries are inspected only so the partitioner can report that a
requested action was filtered by the NFC command allowlist. Execution
requested action was filtered by the persisted command allowlist. Execution
continues to use :func:`resolve_active_peripheral_command`, which always
requires ``enabled`` to be true.
"""
del nfc_snapshot # Presence is display state, not persistent authorization.
compact_utterance = _compact_match_text(utterance)
card = nfc_snapshot.get("card") if isinstance(nfc_snapshot, dict) else None
active_uid = _normalized_card_uid(
card.get("uid") if isinstance(card, dict) else None
)
if not compact_utterance or not active_uid:
if not compact_utterance:
return None
matches: list[tuple[int, int, dict[str, Any]]] = []
@@ -1278,7 +1278,8 @@ def _resolve_bound_peripheral_command(
)
):
continue
if _normalized_card_uid(raw_item.get("card_uid")) != active_uid:
bound_uid = _normalized_card_uid(raw_item.get("card_uid"))
if not bound_uid:
continue
device_score = _device_match_score(compact_utterance, raw_item)
if not device_score:
@@ -1308,7 +1309,7 @@ def _resolve_bound_peripheral_command(
resolved = dict(matches[0][2])
resolved["slot"] = validate_slot(resolved.get("slot"))
resolved["card_uid"] = active_uid
resolved["card_uid"] = _normalized_card_uid(resolved.get("card_uid"))
return resolved
@@ -1326,17 +1327,13 @@ def partition_active_peripheral_intent(
"""Claim only explicit learned-peripheral clauses and preserve CAN text.
The function is deliberately conservative: a clause must contain both a
live NFC-bound device identity and one learned action. When no peripheral
persistently NFC-bound device identity and one learned action. When no peripheral
clause is claimed, ``residual_utterance`` is byte-for-byte the original
trimmed input so the existing CAN parser observes exactly its old input.
"""
original = str(utterance or "").strip()
card = nfc_snapshot.get("card") if isinstance(nfc_snapshot, dict) else None
active_uid = _normalized_card_uid(
card.get("uid") if isinstance(card, dict) else None
)
if not original or not active_uid:
if not original:
return {
"peripheral_actions": [],
"residual_utterance": original,
@@ -1350,7 +1347,7 @@ def partition_active_peripheral_intent(
for item in catalog[:MAX_CATALOG_ITEMS]
if isinstance(item, dict)
and item.get("status") == "learned"
and _normalized_card_uid(item.get("card_uid")) == active_uid
and bool(_normalized_card_uid(item.get("card_uid")))
]
active_catalog = [
item
@@ -1541,14 +1538,11 @@ def active_peripheral_plan_catalog(
catalog: list[dict[str, Any]],
nfc_snapshot: dict[str, Any],
) -> list[dict[str, Any]]:
"""Return the live-card catalog when a phrase needs an ordered plan."""
"""Return the durable bound catalog when a phrase needs an ordered plan."""
del nfc_snapshot # Presence is display state, not persistent authorization.
compact_utterance = _compact_match_text(utterance)
card = nfc_snapshot.get("card") if isinstance(nfc_snapshot, dict) else None
active_uid = _normalized_card_uid(
card.get("uid") if isinstance(card, dict) else None
)
if not compact_utterance or not active_uid:
if not compact_utterance:
return []
has_timing = bool(
re.search(r"\d+(?:\.\d+)?(?:ms|s|毫秒|秒|分钟|分)", compact_utterance)
@@ -1568,13 +1562,14 @@ def active_peripheral_plan_catalog(
or raw_item.get("enabled", True) is False
):
continue
if _normalized_card_uid(raw_item.get("card_uid")) != active_uid:
bound_uid = _normalized_card_uid(raw_item.get("card_uid"))
if not bound_uid:
continue
if not _device_match_score(compact_utterance, raw_item):
continue
item = dict(raw_item)
item["slot"] = validate_slot(item.get("slot"))
item["card_uid"] = active_uid
item["card_uid"] = bound_uid
active.append(item)
if _has_unshadowed_vehicle_device_phrase(compact_utterance, active):
return []
@@ -2378,6 +2373,61 @@ class SelfExtensionService:
result["learning_capture_confirmed"] = False
return result
def execute_bound_infrared_action(
self,
action: Mapping[str, Any],
) -> dict[str, Any]:
"""Re-authorize and emit one car-originated ``extension/ctl`` action."""
if (
not isinstance(action, Mapping)
or action.get("type") != "infrared_emit"
or action.get("domain") != "self_extension"
or action.get("capability") != "infrared.emit"
or action.get("operation") != "emit"
):
raise ExtensionIntegrationError("车端外设控制动作格式无效。")
device_id = str(action.get("target_scope") or "").strip()
parameters = action.get("parameters")
if not device_id or not isinstance(parameters, Mapping):
raise ExtensionIntegrationError("车端外设控制动作缺少设备或参数。")
slot = validate_slot(parameters.get("slot"))
action_name = str(parameters.get("action") or "").strip()
if not action_name:
raise ExtensionIntegrationError("车端外设控制动作名称不能为空。")
capability = next(
(
item
for item in self.list_peripheral_capabilities()
if isinstance(item, dict)
and item.get("status") == "learned"
and item.get("enabled") is True
and bool(_normalized_card_uid(item.get("card_uid")))
and str(item.get("device_id") or "").strip() == device_id
and item.get("slot") == slot
and str(item.get("action") or "").strip() == action_name
),
None,
)
if capability is None:
raise ExtensionIntegrationError(
"车端外设控制动作未通过本机持久能力鉴权:"
f"{device_id} / {action_name} / IR {slot}。"
)
result = self.execute_gateway("emit", {"slot": slot})
return {
**result,
"authorization": {
"source": "local_persistent_extension_allowlist",
"capability_id": capability.get("id"),
"device_id": device_id,
"action": action_name,
"slot": slot,
},
}
def execute_peripheral_request(
self,
utterance: str,
@@ -2389,7 +2439,7 @@ class SelfExtensionService:
provider_requested: str = "auto",
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Execute a live-NFC peripheral match through the main request API."""
"""Execute a persistently NFC-bound match through the main request API."""
if not _peripheral_execution_identity_authorized(utterance, capability):
raise ExtensionIntegrationError(
@@ -2536,14 +2586,14 @@ class SelfExtensionService:
"execution_mode": execution_mode,
"control_transport": control_transport,
"decision_summary": [
f"检测到在场 NFC {capability.get('card_uid')},已优先匹配绑定外设。",
f"已通过持久 NFC 绑定 {capability.get('card_uid')} 匹配外设能力。",
f"命中已学习动作:{capability.get('device_name')} · {capability.get('action')}(槽位 {slot})。",
],
"trace_events": [
{
"stage": "nfc_peripheral_route",
"status": "succeeded",
"summary": "在场 NFC、外设名称与已学习动作均匹配",
"summary": "持久 NFC 绑定、外设名称与已学习动作均匹配",
"duration_ms": 0,
}
],
@@ -2592,13 +2642,13 @@ class SelfExtensionService:
provider_requested: str = "auto",
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Execute a validated ordered peripheral/delay plan for the live NFC."""
"""Execute a validated ordered plan from durable NFC-bound abilities."""
steps = plan_result.get("steps", [])
if not isinstance(steps, list) or not steps:
raise ExtensionIntegrationError("GPT 没有生成可执行的外设动作。")
if any(step.get("kind") == "vehicle" for step in steps if isinstance(step, dict)):
raise ExtensionIntegrationError("当前主编排只允许执行在场 NFC 外设计划。")
raise ExtensionIntegrationError("当前主编排只允许执行持久绑定的 NFC 外设计划。")
catalog_by_id = {
str(item.get("id")): item
for item in catalog
@@ -2639,9 +2689,14 @@ class SelfExtensionService:
raise ExtensionIntegrationError("GPT 计划包含未知步骤类型。")
capability_id = str(step.get("capability_id") or "")
capability = catalog_by_id.get(capability_id)
if capability is None or capability.get("status") != "learned":
if (
capability is None
or capability.get("status") != "learned"
or capability.get("enabled", True) is False
or not _normalized_card_uid(capability.get("card_uid"))
):
raise ExtensionIntegrationError(
"GPT 计划引用了不在当前 NFC 目录中的能力。"
"GPT 计划引用了未学习、未启用或缺少持久 NFC 绑定的能力。"
)
if not _peripheral_execution_identity_authorized(utterance, capability):
raise ExtensionIntegrationError(
@@ -2880,6 +2935,37 @@ class SelfExtensionService:
}
operations.append(operation)
if interrupted:
for remaining_index in range(index + 1, len(steps)):
remaining_step = steps[remaining_index]
if remaining_step.get("kind") != "peripheral":
continue
(
_remaining_capability_id,
_remaining_capability,
remaining_slot,
remaining_operation_ref,
) = compiled_peripheral_steps[remaining_index]
operations.append(
{
"ref": remaining_operation_ref,
"domain": "self_extension",
"status": "aborted",
"message": "前序红外指令失败,未执行",
"telemetry": {
"transport": transport,
"slot": remaining_slot,
"delivery_status": "not_submitted",
"transport_submitted": False,
"gateway_acknowledged": False,
"physical_effect_confirmed": False,
"command": build_gateway_command(
"emit", {"slot": remaining_slot}
),
"effect_mode": "one_shot",
"abort_reason": "prior_operation_failed",
},
}
)
break
if (
hardware_request
@@ -2943,10 +3029,10 @@ class SelfExtensionService:
else "GPT 已生成外设顺序计划。"
)
),
f"计划仅引用当前在场 NFC {first_capability.get('card_uid')} 的已学习能力。",
f"计划仅引用持久 NFC 绑定 {first_capability.get('card_uid')} 的已学习能力。",
*(
[
"当前 NFC 未学习专用闪烁能力;为避免灯具停在关闭态及学习器持续蜂鸣,"
"当前绑定未学习专用闪烁能力;为避免灯具停在关闭态及学习器持续蜂鸣,"
"闪烁已安全降级为定色保持。"
]
if steady_blink_fallbacks
@@ -37,6 +37,12 @@ from cognitive_agent.infrastructure.can.hardware import VehicleMqttSettings
MQTT_CAN_SCHEMA = "n7.can.frame.v1"
MQTT_PET_TOPIC = "n7/pet"
MQTT_EXTENSION_TOY_TOPIC = "extension/toy"
MQTT_EXTENSION_TOY_QOS = 1
MQTT_EXTENSION_CONTROL_TOPIC = "extension/ctl"
MQTT_EXTENSION_CONTROL_QOS = 1
MQTT_EXTENSION_CONTROL_MAX_PAYLOAD_BYTES = 256 * 1024
MQTT_MODE_SCENE_TOPIC = "from_pc/scenes"
MQTT_POLL_INTERVAL_MS = 500
WIRELESS_MQTT_CH1_SOURCE = "wireless_mqtt_ch1"
MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset(
@@ -500,7 +506,7 @@ def mqtt_control_payload(
def mqtt_pet_payload(payload: Mapping[str, Any]) -> str:
"""Serialize the only application-level MQTT message emitted by N7Web."""
"""Serialize one NFC-authorized pet presence message."""
if set(payload) != {"pet_name", "nfc_id", "action"}:
raise CanGatewayError(
@@ -524,6 +530,208 @@ def mqtt_pet_payload(payload: Mapping[str, Any]) -> str:
)
_EXTENSION_TOY_ACTION_KEYS = frozenset(
{
"type",
"at_ms",
"domain",
"capability",
"target_scope",
"operation",
"parameters",
}
)
def mqtt_extension_toy_payload(payload: Mapping[str, Any]) -> str:
"""Serialize one NFC-detected extension capability announcement."""
if not isinstance(payload, Mapping) or set(payload) != {
"extension_name",
"actions",
}:
raise CanGatewayError(
"extension/toy MQTT payload requires exactly extension_name and actions"
)
extension_name = payload.get("extension_name")
if (
not isinstance(extension_name, str)
or not extension_name.strip()
or len(extension_name.strip()) > 64
):
raise CanGatewayError(
"extension_name must contain between 1 and 64 characters"
)
raw_actions = payload.get("actions")
if not isinstance(raw_actions, list) or not 1 <= len(raw_actions) <= 248:
raise CanGatewayError("extension actions must contain between 1 and 248 items")
actions: list[dict[str, Any]] = []
for raw_action in raw_actions:
if not isinstance(raw_action, Mapping) or set(raw_action) != _EXTENSION_TOY_ACTION_KEYS:
raise CanGatewayError(
"extension action requires exactly type, at_ms, domain, capability, "
"target_scope, operation and parameters"
)
if raw_action.get("type") != "infrared_emit":
raise CanGatewayError("extension action type must be infrared_emit")
at_ms = raw_action.get("at_ms")
if isinstance(at_ms, bool) or not isinstance(at_ms, int) or at_ms < 0:
raise CanGatewayError("extension action at_ms must be a non-negative integer")
if raw_action.get("domain") != "self_extension":
raise CanGatewayError("extension action domain must be self_extension")
if raw_action.get("capability") != "infrared.emit":
raise CanGatewayError("extension action capability must be infrared.emit")
if raw_action.get("operation") != "emit":
raise CanGatewayError("extension action operation must be emit")
target_scope = raw_action.get("target_scope")
if (
not isinstance(target_scope, str)
or not target_scope
or len(target_scope) > 128
or not target_scope[0].isalnum()
or any(
not (character.isascii() and (character.isalnum() or character in "._:-"))
for character in target_scope
)
):
raise CanGatewayError(
"extension action target_scope must be an English stable device id"
)
parameters = raw_action.get("parameters")
if not isinstance(parameters, Mapping) or set(parameters) != {"action", "slot"}:
raise CanGatewayError(
"extension action parameters require exactly action and slot"
)
action_name = parameters.get("action")
if (
not isinstance(action_name, str)
or not action_name.strip()
or len(action_name.strip()) > 48
):
raise CanGatewayError(
"extension action parameter action must contain between 1 and 48 characters"
)
slot = parameters.get("slot")
if isinstance(slot, bool) or not isinstance(slot, int) or not 1 <= slot <= 248:
raise CanGatewayError("extension action parameter slot must be in range 1-248")
actions.append(
{
"type": "infrared_emit",
"at_ms": at_ms,
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": target_scope,
"operation": "emit",
"parameters": {"action": action_name.strip(), "slot": slot},
}
)
return json.dumps(
{"extension_name": extension_name.strip(), "actions": actions},
ensure_ascii=False,
separators=(",", ":"),
allow_nan=False,
)
_MODE_SCENE_REQUIRED_KEYS = frozenset(
{
"id",
"label",
"name",
"background_time",
"description",
"icon",
"aliases",
"origin",
"deletable",
"behavior",
"domains",
"operation_count",
"scope",
"profile_revision",
"actions",
}
)
_MODE_SCENE_ACTION_REQUIRED_KEYS = frozenset(
{
"type",
"at_ms",
"domain",
"capability",
"target_scope",
"operation",
"parameters",
}
)
def mqtt_mode_scene_payload(payload: Mapping[str, Any]) -> str:
"""Serialize exactly one ``/n7/scenes`` card, never the catalog wrapper."""
if not isinstance(payload, Mapping):
raise CanGatewayError("mode scene MQTT payload must be a JSON object")
keys = set(payload)
if {"service", "scenes", "scene_count"} & keys:
raise CanGatewayError("mode scene MQTT payload must contain one scene only")
missing = sorted(_MODE_SCENE_REQUIRED_KEYS - keys)
if missing:
raise CanGatewayError(
f"mode scene MQTT payload is missing required fields: {', '.join(missing)}"
)
scene_id = payload.get("id")
if not isinstance(scene_id, str) or not scene_id.strip() or len(scene_id) > 64:
raise CanGatewayError("mode scene id must contain between 1 and 64 characters")
if not isinstance(payload.get("name"), str) or not str(payload["name"]).strip():
raise CanGatewayError("mode scene name must not be empty")
if not isinstance(payload.get("aliases"), list):
raise CanGatewayError("mode scene aliases must be an array")
if not isinstance(payload.get("domains"), list):
raise CanGatewayError("mode scene domains must be an array")
if not isinstance(payload.get("scope"), Mapping):
raise CanGatewayError("mode scene scope must be an object")
operation_count = payload.get("operation_count")
actions = payload.get("actions")
if (
isinstance(operation_count, bool)
or not isinstance(operation_count, int)
or operation_count < 1
):
raise CanGatewayError("mode scene operation_count must be a positive integer")
if not isinstance(actions, list) or len(actions) != operation_count:
raise CanGatewayError("mode scene actions must match operation_count")
for action in actions:
if not isinstance(action, Mapping):
raise CanGatewayError("mode scene actions must contain JSON objects")
action_missing = sorted(_MODE_SCENE_ACTION_REQUIRED_KEYS - set(action))
if action_missing:
raise CanGatewayError(
"mode scene action is missing required fields: "
+ ", ".join(action_missing)
)
if not isinstance(action.get("parameters"), Mapping):
raise CanGatewayError("mode scene action parameters must be an object")
try:
raw_payload = json.dumps(
deepcopy(dict(payload)),
ensure_ascii=False,
separators=(",", ":"),
allow_nan=False,
)
except (TypeError, ValueError) as exc:
raise CanGatewayError("mode scene MQTT payload is not valid JSON") from exc
if len(raw_payload.encode("utf-8")) > 512 * 1024:
raise CanGatewayError("mode scene MQTT payload exceeds 512 KiB")
return raw_payload
def _validate_native_seat_motion_control(data: bytes) -> None:
"""Validate the firmware-native 0x300 semantic runtime subset.
@@ -644,6 +852,17 @@ class MqttCanTransport:
self._latest_bus_frames: dict[
tuple[int, int, int], tuple[float, CanFrame]
] = {}
self._extension_control_handler: Callable[[bytes], None] | None = None
self._extension_control_rejection_handler: (
Callable[[str, str], None] | None
) = None
self._extension_control_received_count = 0
self._extension_control_dispatched_count = 0
self._extension_control_rejected_count = 0
self._extension_control_duplicate_count = 0
self._extension_control_retained_count = 0
self._extension_control_last_received_at: str | None = None
self._extension_control_last_error: str | None = None
def _build_client(self) -> Any:
if self.client_factory is not None:
@@ -725,6 +944,40 @@ class MqttCanTransport:
self._state = "stopped"
self._condition.notify_all()
def set_extension_control_handler(
self,
handler: Callable[[bytes], None] | None,
) -> None:
"""Register the non-blocking application queue for ``extension/ctl``."""
if handler is not None and not callable(handler):
raise TypeError("extension control handler must be callable")
with self._condition:
self._extension_control_handler = handler
self._condition.notify_all()
def set_extension_control_rejection_handler(
self,
handler: Callable[[str, str], None] | None,
) -> None:
"""Register a status observer for MQTT-layer control rejections."""
if handler is not None and not callable(handler):
raise TypeError("extension control rejection handler must be callable")
with self._condition:
self._extension_control_rejection_handler = handler
self._condition.notify_all()
def _notify_extension_control_rejection(self, error: str, kind: str) -> None:
with self._condition:
handler = self._extension_control_rejection_handler
if handler is None:
return
try:
handler(error[:240], kind)
except Exception: # noqa: BLE001, S110 - display observer is best effort
pass
@staticmethod
def _reason_code_value(reason_code: Any) -> int:
try:
@@ -747,15 +1000,37 @@ class MqttCanTransport:
self._last_error = f"MQTT connection refused with reason {code}"
self._condition.notify_all()
return
result = client.subscribe(self.settings.condition_topic, qos=self.settings.qos)
result_code = result[0] if isinstance(result, tuple) else getattr(result, "rc", 0)
subscriptions = (
(self.settings.condition_topic, self.settings.qos),
(MQTT_EXTENSION_CONTROL_TOPIC, MQTT_EXTENSION_CONTROL_QOS),
)
failed_subscription: tuple[str, int] | None = None
try:
for topic, qos in subscriptions:
result = client.subscribe(topic, qos=qos)
result_code = (
result[0] if isinstance(result, tuple) else getattr(result, "rc", 0)
)
if int(result_code) != 0:
failed_subscription = (topic, int(result_code))
break
except Exception as exc: # noqa: BLE001 - normalize client callback failures
failed_subscription = ("subscription", -1)
subscription_error = str(exc)
else:
subscription_error = ""
with self._condition:
if int(result_code) == 0:
if failed_subscription is None:
self._state = "online"
self._last_error = None
else:
self._state = "error"
self._last_error = f"MQTT subscribe failed with code {result_code}"
failed_topic, failed_code = failed_subscription
self._last_error = (
f"MQTT subscribe failed for {failed_topic}: {subscription_error}"
if subscription_error
else f"MQTT subscribe failed for {failed_topic} with code {failed_code}"
)
self._condition.notify_all()
def _on_disconnect(
@@ -775,7 +1050,11 @@ class MqttCanTransport:
self._condition.notify_all()
def _on_message(self, _client: Any, _userdata: Any, message: Any) -> None:
if str(getattr(message, "topic", "")) != self.settings.condition_topic:
topic = str(getattr(message, "topic", ""))
if topic == MQTT_EXTENSION_CONTROL_TOPIC:
self._dispatch_extension_control_message(message)
return
if topic != self.settings.condition_topic:
return
try:
frames = parse_mqtt_can_payload(bytes(message.payload))
@@ -787,6 +1066,74 @@ class MqttCanTransport:
self._last_error = str(exc)
self._condition.notify_all()
def _dispatch_extension_control_message(self, message: Any) -> None:
"""Forward a fresh control payload to the application queue without blocking."""
received_at = _now()
transport_rejection: tuple[str, str] | None = None
with self._condition:
self._extension_control_received_count += 1
self._extension_control_last_received_at = received_at
if bool(getattr(message, "retain", False)):
self._extension_control_retained_count += 1
self._extension_control_rejected_count += 1
self._extension_control_last_error = (
"retained extension/ctl messages are not executable"
)
transport_rejection = (
self._extension_control_last_error,
"retained",
)
self._condition.notify_all()
elif bool(getattr(message, "dup", False)):
self._extension_control_duplicate_count += 1
self._extension_control_rejected_count += 1
self._extension_control_last_error = (
"duplicate extension/ctl delivery was ignored"
)
transport_rejection = (
self._extension_control_last_error,
"duplicate",
)
self._condition.notify_all()
else:
handler = self._extension_control_handler
if transport_rejection is not None:
self._notify_extension_control_rejection(*transport_rejection)
return
try:
payload = bytes(message.payload)
except (AttributeError, TypeError, ValueError) as exc:
error = f"extension/ctl payload is not binary data: {exc}"
rejection_kind = "payload"
else:
if len(payload) > MQTT_EXTENSION_CONTROL_MAX_PAYLOAD_BYTES:
error = "extension/ctl payload exceeds 256 KiB"
rejection_kind = "payload"
elif handler is None:
error = "extension/ctl handler is not configured"
rejection_kind = "unavailable"
else:
try:
handler(payload)
except Exception as exc: # noqa: BLE001 - isolate MQTT network loop
error = str(exc)[:240]
rejection_kind = "dispatch"
else:
with self._condition:
self._extension_control_dispatched_count += 1
self._extension_control_last_error = None
self._condition.notify_all()
return
with self._condition:
self._extension_control_rejected_count += 1
self._extension_control_last_error = error[:240]
self._condition.notify_all()
self._notify_extension_control_rejection(error, rejection_kind)
def _record_frame(self, frame: CanFrame) -> None:
if (
frame.channel not in MQTT_ACCEPTED_FEEDBACK_IDS
@@ -1023,6 +1370,67 @@ class MqttCanTransport:
"message_id": getattr(result, "mid", None),
}
def publish_extension_toy_message(
self,
payload: Mapping[str, Any],
) -> dict[str, Any]:
"""Queue one validated extension capability announcement."""
raw_payload = mqtt_extension_toy_payload(payload)
with self._condition:
client = self._client
state = self._state
if client is None or state != "online":
raise CanGatewayError("extension/toy MQTT publisher is not connected")
result = client.publish(
MQTT_EXTENSION_TOY_TOPIC,
raw_payload,
qos=MQTT_EXTENSION_TOY_QOS,
retain=False,
)
if int(getattr(result, "rc", 0)) != 0:
raise CanGatewayError(
f"extension/toy 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_EXTENSION_TOY_TOPIC,
"qos": MQTT_EXTENSION_TOY_QOS,
"retain": False,
"delivery_status": "published" if broker_confirmed else "queued",
"message_id": getattr(result, "mid", None),
}
def publish_mode_scene(self, payload: Mapping[str, Any]) -> dict[str, Any]:
"""Queue one exact N7 scene-card projection on its fixed sync topic."""
raw_payload = mqtt_mode_scene_payload(payload)
with self._condition:
client = self._client
state = self._state
if client is None or state != "online":
raise CanGatewayError("mode scene MQTT publisher is not connected")
result = client.publish(
MQTT_MODE_SCENE_TOPIC,
raw_payload,
qos=self.settings.qos,
retain=False,
)
if int(getattr(result, "rc", 0)) != 0:
raise CanGatewayError(
f"mode scene 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_MODE_SCENE_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,
@@ -1076,6 +1484,22 @@ class MqttCanTransport:
"stale_frames": self._stale_frames,
"last_error": self._last_error,
}
info["extension_control"] = {
"mode": "persistent_mqtt_subscription",
"topic": MQTT_EXTENSION_CONTROL_TOPIC,
"qos": MQTT_EXTENSION_CONTROL_QOS,
"handler_configured": self._extension_control_handler is not None,
"rejection_handler_configured": (
self._extension_control_rejection_handler is not None
),
"received_count": self._extension_control_received_count,
"dispatched_count": self._extension_control_dispatched_count,
"rejected_count": self._extension_control_rejected_count,
"duplicate_count": self._extension_control_duplicate_count,
"retained_count": self._extension_control_retained_count,
"last_received_at": self._extension_control_last_received_at,
"last_error": self._extension_control_last_error,
}
return info
+123 -23
View File
@@ -43,11 +43,23 @@ from cognitive_agent.application.codex_assistant import ( # noqa: E402
CodexAssistantService,
CodexVehicleToolError,
)
from cognitive_agent.application.extension_control import ( # noqa: E402
ExtensionControlConsumer,
)
from cognitive_agent.application.extension_toy_publisher import ( # noqa: E402
EXTENSION_TOY_BURST_COUNT,
EXTENSION_TOY_BURST_WINDOW_S,
NfcExtensionToyPublisher,
)
from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
normalize_mode_profile_name,
parse_mode_profile_update,
)
from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
MODE_SCENE_MQTT_BURST_COUNT,
MODE_SCENE_MQTT_BURST_WINDOW_S,
)
from cognitive_agent.application.pet_cards import PetCardService # noqa: E402
from cognitive_agent.application.seat_service import ( # noqa: E402
LOCAL_CONTROL_BUDGET_MS,
@@ -78,6 +90,11 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
HardwareBusyError,
HardwareSafetyError,
)
from cognitive_agent.infrastructure.can.mqtt_transport import ( # noqa: E402
MQTT_EXTENSION_CONTROL_TOPIC,
MQTT_EXTENSION_TOY_TOPIC,
MQTT_MODE_SCENE_TOPIC,
)
from cognitive_agent.infrastructure.codex.app_server import ( # noqa: E402
CodexAppServerClient,
CodexAppServerError,
@@ -89,7 +106,13 @@ from cognitive_agent.infrastructure.speech import ( # noqa: E402
SpeechTranscriber,
)
STATIC_ROOT = REPOSITORY_ROOT / "apps" / "web-console"
WEB_CONSOLE_SOURCE_ROOT = REPOSITORY_ROOT / "apps" / "web-console"
WEB_CONSOLE_DIST_ROOT = WEB_CONSOLE_SOURCE_ROOT / "dist"
STATIC_ROOT = (
WEB_CONSOLE_DIST_ROOT
if (WEB_CONSOLE_DIST_ROOT / "index.html").is_file()
else WEB_CONSOLE_SOURCE_ROOT
)
DEFAULT_MODE_SCENE_CATALOG_PATH = (
REPOSITORY_ROOT.parent / "scenses" / "demo-scenes.json"
)
@@ -222,6 +245,19 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
nfc_provider=nfc.snapshot,
publisher=service.publish_pet_message,
)
extension_toys = NfcExtensionToyPublisher(
nfc_provider=nfc.snapshot,
catalog_provider=extension.list_peripheral_capabilities,
publisher=service.publish_extension_toy_message,
)
extension_control = ExtensionControlConsumer(
catalog_provider=extension.list_peripheral_capabilities,
executor=extension.execute_bound_infrared_action,
)
service.set_extension_control_handler(extension_control.submit)
service.set_extension_control_rejection_handler(
extension_control.reject_transport_message
)
calibration = CalibrationService(
REPOSITORY_ROOT / "artifacts" / "tmp" / "self-calibration",
repository_root=REPOSITORY_ROOT,
@@ -328,6 +364,27 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if path == "/api/vehicle-display-sync":
self._json(self.vehicle_display_sync.snapshot(self._extension_health()))
return
if path == "/api/extension/control/status":
query = parse_qs(parsed_url.query)
try:
after_revision = int(query.get("after_revision", ["-1"])[0])
timeout_ms = int(query.get("timeout_ms", ["25000"])[0])
if after_revision < -1:
raise ValueError("after_revision must be at least -1")
if not 0 <= timeout_ms <= 30_000:
raise ValueError("timeout_ms must be between 0 and 30000")
snapshot = self.extension_control.wait_for_snapshot(
after_revision,
timeout_s=timeout_ms / 1_000,
)
except (TypeError, ValueError) as exc:
self._json(
{"error": "invalid_request", "message": str(exc)},
HTTPStatus.BAD_REQUEST,
)
return
self._json(snapshot)
return
if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}:
query = parse_qs(parsed_url.query)
conversation_id = str(query.get("conversation_id", [""])[0])
@@ -403,6 +460,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"success": True,
**self.nfc.snapshot(),
"pet_cards": self.pets.snapshot(),
"extension_toy": self.extension_toys.snapshot(),
}
)
return
@@ -885,7 +943,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
on_commands_validated: Callable[[dict[str, object]], None] | None = None,
natural_language_routing: Mapping[str, object] | None = None,
) -> dict[str, object] | None:
"""Route a command to the NFC-bound peripheral when one is present."""
"""Route a command to a persistently NFC-bound peripheral ability."""
# The browser may disappear or reload between learning and execution.
# Natural-language routing therefore reads the durable, validated
@@ -986,7 +1044,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
Vehicle speech keeps using its cached structured semantic payload. A
learned peripheral cannot be represented by the static vehicle
capability catalog, so it may instead reuse the exact server-owned ASR
transcript only after the live NFC binding and learned action resolve.
transcript only after the durable NFC binding and learned action resolve.
"""
text = str(utterance or "").strip()
@@ -1328,7 +1386,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
result: dict[str, object],
entries: list[dict[str, object]],
) -> dict[str, object]:
"""Expose command-level NFC denials without discarding allowed work."""
"""Expose disabled bound commands without discarding allowed work."""
if not entries:
return result
@@ -1358,7 +1416,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
for item in denied_actions
)
summaries.append(
f"当前 NFC 未授权 {len(denied_actions)} 条红外命令,已过滤:{labels}。"
f"持久绑定中有 {len(denied_actions)} 条红外能力未启用,已过滤:{labels}。"
)
result["decision_summary"] = summaries
@@ -1487,7 +1545,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
re.search(
r"然后|随后|接着|之后|"
r"(?:\d+(?:\.\d+)?|[一二三四五六七八九十两半]+)"
r"(?:毫秒|秒|分钟)",
r"(?:ms|s|毫秒|秒|分钟)",
residual,
)
)
@@ -1777,7 +1835,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if isinstance(item, dict)
)
raise ExtensionIntegrationError(
f"当前 NFC 未授权执行以下红外命令:{labels}。"
f"以下持久绑定红外能力未启用,无法执行:{labels}。"
)
result["natural_language_routing"] = deepcopy(natural_language_routing)
@@ -2653,6 +2711,24 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if create_mode
else self.service.save_mode_profile_card(mode_id, *arguments)
)
saved_profile = result.get("mode_profile") or {}
saved_mode_id = str(saved_profile.get("id") or "").strip()
try:
result["mode_scene_mqtt"] = self.service.publish_saved_mode_scene(
saved_mode_id,
str(result.get("vehicle_id") or "") or None,
)
except Exception as exc: # noqa: BLE001 - card persistence must survive MQTT loss
result["mode_scene_mqtt"] = {
"state": "queue_failed",
"topic": MQTT_MODE_SCENE_TOPIC,
"scene_id": saved_mode_id or None,
"burst_count": MODE_SCENE_MQTT_BURST_COUNT,
"burst_window_ms": round(
MODE_SCENE_MQTT_BURST_WINDOW_S * 1_000
),
"last_error": str(exc)[:240],
}
self._json(result, HTTPStatus.CREATED if create_mode else HTTPStatus.OK)
except KeyError:
self._json(
@@ -2711,18 +2787,6 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if not operations:
return []
snapshot = self.nfc.snapshot()
card = snapshot.get("card") if isinstance(snapshot, dict) else None
active_uid = re.sub(
r"[^0-9A-Fa-f]",
"",
str((card or {}).get("uid") or ""),
).upper()
if not active_uid or str((card or {}).get("action") or "present") != "present":
raise ExtensionIntegrationError(
"该模式包含 NFC 红外指令,请先放置生成此模式时绑定的 NFC 卡。"
)
catalog = self.extension.list_peripheral_capabilities()
entries: list[dict[str, object]] = []
for index, operation in enumerate(operations):
@@ -2737,6 +2801,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"已保存模式包含无效的红外槽位。"
) from exc
device_id = str(target.get("device") or "").strip()
bound_uid = re.sub(
r"[^0-9A-Fa-f]",
"",
str(target.get("card_uid") or ""),
).upper()
if not bound_uid:
raise ExtensionIntegrationError(
f"已保存模式中的红外命令缺少持久 NFC 绑定:{device_id} / IR {slot}。"
)
capability = next(
(
item
@@ -2750,13 +2823,13 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"",
str(item.get("card_uid") or ""),
).upper()
== active_uid
== bound_uid
),
None,
)
if capability is None:
raise ExtensionIntegrationError(
f"当前 NFC 未授权模式中的红外命令:{device_id} / IR {slot}。"
f"持久绑定目录未授权模式中的红外命令:{device_id} / IR {slot}。"
)
entries.append(
{
@@ -2798,8 +2871,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
utterance=utterance,
source=source,
)
# Validate the complete saved flow, including the live NFC
# binding, before any transport is allowed to execute.
# Validate the complete saved flow against the durable NFC binding
# before any transport is allowed to execute.
entries = self._saved_mode_infrared_entries(profile)
can_result = self.service.execute_mode_profile_card(
mode_id,
@@ -3321,6 +3394,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
**self.extension.health(),
"nfc": self.nfc.snapshot(),
"pet_cards": self.pets.snapshot(),
"extension_toy": self.extension_toys.snapshot(),
"extension_control": self.extension_control.snapshot(),
}
def _handle_hardware_access_post(self, path: str) -> None:
@@ -3963,8 +4038,10 @@ def main() -> None:
)
SeatDemoHandler.service.start()
SeatDemoHandler.extension.start()
SeatDemoHandler.extension_control.start()
SeatDemoHandler.nfc.start()
SeatDemoHandler.pets.start()
SeatDemoHandler.extension_toys.start()
SeatDemoHandler.calibration.start()
SeatDemoHandler.repair.start()
if args.host in {"0.0.0.0", "::"}:
@@ -3995,6 +4072,25 @@ def main() -> None:
file=sys.stderr,
flush=True,
)
print(
"N7 new-mode MQTT sync: "
f"{MQTT_MODE_SCENE_TOPIC} "
f"({MODE_SCENE_MQTT_BURST_COUNT} frames within "
f"{round(MODE_SCENE_MQTT_BURST_WINDOW_S * 1_000)} ms)",
flush=True,
)
print(
"N7 extension capability MQTT: "
f"{MQTT_EXTENSION_TOY_TOPIC} "
f"({EXTENSION_TOY_BURST_COUNT} frames within "
f"{round(EXTENSION_TOY_BURST_WINDOW_S * 1_000)} ms per NFC detection)",
flush=True,
)
print(
"N7 extension control MQTT: "
f"subscribe {MQTT_EXTENSION_CONTROL_TOPIC} -> local infrared gateway",
flush=True,
)
hardware_execution_ready = any(
(
SeatDemoHandler.service.hardware_settings.configured,
@@ -4017,6 +4113,10 @@ def main() -> None:
server.server_close()
SeatDemoHandler.speech.close()
SeatDemoHandler.assistant.close()
SeatDemoHandler.service.set_extension_control_handler(None)
SeatDemoHandler.service.set_extension_control_rejection_handler(None)
SeatDemoHandler.extension_control.close()
SeatDemoHandler.extension_toys.close()
SeatDemoHandler.pets.close()
SeatDemoHandler.nfc.close()
SeatDemoHandler.service.close()