feat(n7web): enhance control workflows and status feedback
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user