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