feat(n7web): sync N7 scene cards and optimize runtime

This commit is contained in:
Miayui
2026-09-17 11:04:05 +08:00
parent 75ce391312
commit 7d196db02b
31 changed files with 2924 additions and 126 deletions
@@ -2225,8 +2225,24 @@ class AssistantCapabilityCatalog:
operations = _safe_mode_operations(
[item for item in requests if isinstance(item, Mapping)]
)
callable_mode = bool(operation_ids) and all(
capability_id in callable_capabilities for capability_id in operation_ids
n7_transfer_operation_ids = [
capability_id
for capability_id in operation_ids
if capability_id not in callable_capabilities
]
command_flow = raw.get("command_flow")
command_plans = (
command_flow.get("plans")
if isinstance(command_flow, Mapping)
else None
)
n7_transfer_ready = (
raw.get("source_topic") == "from_n7/scenes"
and isinstance(command_plans, Mapping)
and bool(command_plans)
)
callable_mode = bool(operation_ids) and (
not n7_transfer_operation_ids or n7_transfer_ready
)
modes.append(
{
@@ -2240,6 +2256,9 @@ class AssistantCapabilityCatalog:
"operation_ids": operation_ids,
"operations": operations,
"assistant_callable": callable_mode,
"n7_transfer_operation_ids": (
n7_transfer_operation_ids if n7_transfer_ready else []
),
"all_operations_have_native_binding": bool(operation_ids)
and all(
capability_id in native_capabilities for capability_id in operation_ids
+114 -3
View File
@@ -14,8 +14,31 @@ from typing import Any
import yaml
_MATCH_FORMATTING = re.compile(r"[\s()()]+")
_MODE_INVOCATION_FORMATTING = re.compile(r"[\s,。!?,.!?::;;()()]+")
_NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+")
_MODE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
_EXACT_MODE_INVOCATION_PREFIXES = (
"",
"进入",
"执行",
"执行一下",
"开始",
"开始执行",
"启动",
"打开",
"切换到",
"切换进入",
"请进入",
"请执行",
"请执行一下",
"请启动",
"请打开",
"帮我进入",
"帮我执行",
"请帮我进入",
"请帮我执行",
)
_EXACT_MODE_INVOCATION_SUFFIXES = ("", "吧", "一下", "试试")
_MODE_REFERENCE_VERBS = (
"切换进入",
"切换到",
@@ -79,6 +102,34 @@ def _normalize_match_text(value: str) -> str:
return _MATCH_FORMATTING.sub("", value.casefold())
def _exact_mode_invocation_matches(
utterance: str,
candidates: list[object],
) -> bool:
"""Accept a synchronized card only as the complete control intent.
N7 can legitimately name a card ``影院模式`` while the same words are a
parameter of another capability (for example an ambient-light mode). A
synchronized card therefore uses an exact, bounded invocation grammar
rather than ordinary substring matching.
"""
compact = _MODE_INVOCATION_FORMATTING.sub("", str(utterance)).casefold()
if not compact:
return False
invocations = {
_MODE_INVOCATION_FORMATTING.sub(
"",
f"{prefix}{candidate}{suffix}",
).casefold()
for candidate in candidates
if str(candidate).strip()
for prefix in _EXACT_MODE_INVOCATION_PREFIXES
for suffix in _EXACT_MODE_INVOCATION_SUFFIXES
}
return compact in invocations
def _mode_candidate_matches(utterance: str, candidate: object) -> bool:
"""Match a mode reference without treating device nouns as mode aliases.
@@ -156,6 +207,20 @@ def _clean_command_flow(value: Any) -> dict[str, Any] | None:
return json.loads(encoded)
def _clean_sync_origin(value: Any) -> str:
cleaned = str(value or "").strip()
if cleaned not in {"", "from_n7/scenes"}:
raise ValueError("mode sync_origin is not supported")
return cleaned
def _clean_upstream_origin(value: Any) -> str:
cleaned = str(value or "").strip()
if len(cleaned) > 64:
raise ValueError("mode upstream_origin must be at most 64 characters")
return cleaned
def _mode_aliases(name: str, aliases: list[str] | None = None) -> list[str]:
values = [name, *(aliases or [])]
if name.endswith("模式") and len(name) > 2:
@@ -451,6 +516,8 @@ class ModeProfile:
user_defined: bool = False
instruction: str = ""
command_flow: dict[str, Any] | None = None
sync_origin: str = ""
upstream_origin: str = ""
@classmethod
def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile":
@@ -487,6 +554,10 @@ class ModeProfile:
user_defined=_is_user_profile_path(path, profile_root),
instruction=_clean_instruction(document.get("instruction", "")),
command_flow=_clean_command_flow(document.get("command_flow")),
sync_origin=_clean_sync_origin(document.get("sync_origin")),
upstream_origin=_clean_upstream_origin(
document.get("upstream_origin")
),
)
def public_dict(self) -> dict[str, Any]:
@@ -508,11 +579,20 @@ class ModeProfile:
"vehicle_ids": list(self.vehicle_ids),
},
"behavior": "cyclic" if is_cyclic else "static",
"origin": "user" if self.user_defined else "preset",
"origin": (
"n7_mqtt"
if self.sync_origin == "from_n7/scenes"
else "user"
if self.user_defined
else "preset"
),
"deletable": self.user_defined,
"instruction": self.instruction,
"command_flow": self.command_flow,
}
if self.sync_origin:
result["source_topic"] = self.sync_origin
result["upstream_origin"] = self.upstream_origin or None
if self.timeline is not None:
result["timeline"] = self.timeline.public_dict()
return result
@@ -588,7 +668,11 @@ class ModeProfileRegistry:
public = []
for profile in profiles:
item = profile.public_dict()
if profile.user_defined and profile.id in preset_ids:
if (
profile.user_defined
and profile.id in preset_ids
and not profile.sync_origin
):
item["origin"] = "customized"
item["preset_override"] = True
public.append(item)
@@ -628,6 +712,19 @@ class ModeProfileRegistry:
matched_ids: set[str] = set()
for profile in self._visible_candidates_for_vehicle(vehicle_id):
candidates = [profile.id, profile.name, *profile.aliases]
effective_profile = selected[profile.id]
if (
effective_profile.sync_origin == "from_n7/scenes"
and not _exact_mode_invocation_matches(
utterance,
[
effective_profile.id,
effective_profile.name,
*effective_profile.aliases,
],
)
):
continue
if any(
candidate and _mode_candidate_matches(haystack, candidate)
for candidate in candidates
@@ -635,7 +732,7 @@ class ModeProfileRegistry:
if profile.id in matched_ids:
continue
matched_ids.add(profile.id)
matches.append(selected[profile.id])
matches.append(effective_profile)
return matches
def resolve_exact_name(
@@ -684,6 +781,8 @@ class ModeProfileRegistry:
"timeline": profile.timeline.to_yaml() if profile.timeline is not None else None,
"instruction": profile.instruction,
"command_flow": profile.command_flow,
"sync_origin": profile.sync_origin,
"upstream_origin": profile.upstream_origin,
}
canonical = json.dumps(
document,
@@ -714,6 +813,8 @@ class ModeProfileRegistry:
instruction: str = "",
command_flow: dict[str, Any] | None = None,
timeline: ModeTimeline | None = None,
sync_origin: str = "",
upstream_origin: str = "",
expected_revision: str | None = None,
create_only: bool = False,
) -> ModeProfile:
@@ -729,6 +830,8 @@ class ModeProfileRegistry:
profile_id = _clean_mode_id(mode_id or _slugify_mode_id(clean_name))
clean_instruction = _clean_instruction(instruction)
clean_command_flow = _clean_command_flow(command_flow)
clean_sync_origin = _clean_sync_origin(sync_origin)
clean_upstream_origin = _clean_upstream_origin(upstream_origin)
scope = (
{"vehicle_ids": [vehicle_id]}
if vehicle_id
@@ -751,6 +854,9 @@ class ModeProfileRegistry:
document["command_flow"] = clean_command_flow
if timeline is not None:
document["timeline"] = timeline.to_yaml()
if clean_sync_origin:
document["sync_origin"] = clean_sync_origin
document["upstream_origin"] = clean_upstream_origin
path = (
self.user_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml"
if vehicle_id
@@ -891,6 +997,8 @@ class ModeProfileRegistry:
instruction=profile.instruction,
command_flow=profile.command_flow,
timeline=profile.timeline,
sync_origin=profile.sync_origin,
upstream_origin=profile.upstream_origin,
)
backup_path = _initial_backup_path(Path(updated.source))
if not backup_path.exists():
@@ -1024,6 +1132,9 @@ def _mode_profile_document(
document["instruction"] = profile.instruction
if profile.command_flow is not None:
document["command_flow"] = profile.command_flow
if profile.sync_origin:
document["sync_origin"] = profile.sync_origin
document["upstream_origin"] = profile.upstream_origin
return document
@@ -242,11 +242,41 @@ def _scene_from_profile(
public_profile: dict[str, Any],
) -> dict[str, Any]:
effective_operations = _effective_scene_operations(profile)
source_scene = (
profile.command_flow.get("source_scene")
if profile.sync_origin == "from_n7/scenes"
and isinstance(profile.command_flow, dict)
else None
)
source_actions = (
source_scene.get("actions")
if isinstance(source_scene, dict)
and isinstance(source_scene.get("actions"), list)
else None
)
actions = (
deepcopy(source_actions)
if source_actions is not None
and len(source_actions) == len(effective_operations)
else [
_scene_action(
operation,
target=target,
operation_ref=operation_ref,
)
for operation, target, operation_ref in effective_operations
]
)
scene = {
"id": profile.id,
"label": profile.name,
"name": profile.name,
"background_time": _background_time(profile),
"background_time": (
str(source_scene.get("background_time") or "").strip()
if isinstance(source_scene, dict)
else ""
)
or _background_time(profile),
"description": profile.description,
"icon": profile.icon,
"aliases": list(profile.aliases),
@@ -257,19 +287,15 @@ def _scene_from_profile(
"operation_count": len(effective_operations),
"scope": deepcopy(public_profile.get("scope") or {}),
"profile_revision": registry.profile_revision(profile),
"actions": [
_scene_action(
operation,
target=target,
operation_ref=operation_ref,
)
for operation, target, operation_ref in effective_operations
],
"actions": actions,
}
if public_profile.get("preset_override") is True:
scene["preset_override"] = True
if profile.timeline is not None:
scene["timeline"] = profile.timeline.public_dict()
if profile.sync_origin:
scene["source_topic"] = profile.sync_origin
scene["upstream_origin"] = profile.upstream_origin or None
return scene
@@ -0,0 +1,449 @@
"""Validate and persist N7-originated mode cards outside the MQTT callback."""
from __future__ import annotations
import hashlib
import json
import queue
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
from cognitive_agent.infrastructure.can.gateway import CanGatewayError
from cognitive_agent.infrastructure.can.mqtt_transport import (
MQTT_MODE_SCENE_INGRESS_TOPIC,
MQTT_MODE_SCENE_MAX_PAYLOAD_BYTES,
mqtt_mode_scene_payload,
)
DEFAULT_MODE_SCENE_INGRESS_QUEUE_SIZE = 32
DEFAULT_MODE_SCENE_DEDUPLICATION_WINDOW_S = 5.0
MAX_MODE_SCENE_ACTIONS = 256
MAX_MODE_SCENE_AT_MS = 24 * 60 * 60 * 1000
_MODE_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
class ModeSceneIngressError(ValueError):
"""Raised when an inbound ``from_n7/scenes`` card is not safe to import."""
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 ModeSceneIngressError(
f"from_n7/scenes contains duplicate key: {key}"
)
result[key] = value
return result
def _required_text(
value: Any,
*,
field: str,
maximum: int,
) -> str:
if not isinstance(value, str):
raise ModeSceneIngressError(f"from_n7/scenes {field} must be text")
cleaned = value.strip()
if not cleaned or len(cleaned) > maximum:
raise ModeSceneIngressError(
f"from_n7/scenes {field} must contain between 1 and {maximum} characters"
)
return cleaned
def parse_mode_scene_ingress_payload(raw_payload: bytes | str) -> dict[str, Any]:
"""Parse one card using the exact outbound scene contract plus hard limits."""
if isinstance(raw_payload, bytes):
encoded = raw_payload
try:
text = raw_payload.decode("utf-8")
except UnicodeDecodeError as exc:
raise ModeSceneIngressError(
"from_n7/scenes payload must be UTF-8 JSON"
) from exc
elif isinstance(raw_payload, str):
text = raw_payload
encoded = raw_payload.encode("utf-8")
else:
raise ModeSceneIngressError(
"from_n7/scenes payload must be UTF-8 JSON"
)
if not encoded or len(encoded) > MQTT_MODE_SCENE_MAX_PAYLOAD_BYTES:
raise ModeSceneIngressError(
"from_n7/scenes payload must contain 1-512 KiB"
)
try:
scene = json.loads(
text,
object_pairs_hook=_json_object_without_duplicate_keys,
)
except ModeSceneIngressError:
raise
except (json.JSONDecodeError, TypeError, ValueError) as exc:
raise ModeSceneIngressError(
"from_n7/scenes payload must be valid JSON"
) from exc
if not isinstance(scene, Mapping):
raise ModeSceneIngressError(
"from_n7/scenes payload must contain one scene object"
)
try:
mqtt_mode_scene_payload(scene)
except CanGatewayError as exc:
raise ModeSceneIngressError(str(exc)) from exc
normalized = deepcopy(dict(scene))
scene_id = _required_text(
normalized.get("id"), field="id", maximum=64
)
if not _MODE_ID_PATTERN.fullmatch(scene_id):
raise ModeSceneIngressError(
"from_n7/scenes id must use lowercase letters, numbers, underscores, or hyphens"
)
normalized["id"] = scene_id
normalized["name"] = _required_text(
normalized.get("name"), field="name", maximum=64
)
normalized["label"] = _required_text(
normalized.get("label"), field="label", maximum=64
)
normalized["icon"] = _required_text(
normalized.get("icon"), field="icon", maximum=64
)
normalized["background_time"] = _required_text(
normalized.get("background_time"),
field="background_time",
maximum=32,
)
normalized["origin"] = _required_text(
normalized.get("origin"), field="origin", maximum=64
)
normalized["behavior"] = _required_text(
normalized.get("behavior"), field="behavior", maximum=32
)
normalized["profile_revision"] = _required_text(
normalized.get("profile_revision"),
field="profile_revision",
maximum=128,
)
if not isinstance(normalized.get("deletable"), bool):
raise ModeSceneIngressError(
"from_n7/scenes deletable must be boolean"
)
description = normalized.get("description")
if not isinstance(description, str) or len(description) > 500:
raise ModeSceneIngressError(
"from_n7/scenes description must be text with at most 500 characters"
)
aliases = normalized.get("aliases")
if (
not isinstance(aliases, list)
or len(aliases) > 64
or any(
not isinstance(alias, str)
or not alias.strip()
or len(alias.strip()) > 64
for alias in aliases
)
):
raise ModeSceneIngressError(
"from_n7/scenes aliases must contain at most 64 non-empty text values"
)
normalized["aliases"] = [alias.strip() for alias in aliases]
domains = normalized.get("domains")
if (
not isinstance(domains, list)
or not 1 <= len(domains) <= 64
or any(
not isinstance(domain, str)
or not domain.strip()
or len(domain.strip()) > 64
for domain in domains
)
):
raise ModeSceneIngressError(
"from_n7/scenes domains must contain 1-64 non-empty text values"
)
normalized["domains"] = [domain.strip() for domain in domains]
scope = normalized.get("scope")
if not isinstance(scope, Mapping):
raise ModeSceneIngressError("from_n7/scenes scope must be an object")
vehicle_ids = scope.get("vehicle_ids", [])
if "common" in scope and not isinstance(scope.get("common"), bool):
raise ModeSceneIngressError(
"from_n7/scenes scope.common must be boolean"
)
if not isinstance(vehicle_ids, list) or len(vehicle_ids) > 64 or any(
not isinstance(vehicle_id, str)
or not vehicle_id.strip()
or len(vehicle_id.strip()) > 64
for vehicle_id in vehicle_ids
):
raise ModeSceneIngressError(
"from_n7/scenes scope.vehicle_ids must be an array of vehicle ids"
)
actions = normalized.get("actions")
if not isinstance(actions, list) or not 1 <= len(actions) <= MAX_MODE_SCENE_ACTIONS:
raise ModeSceneIngressError(
f"from_n7/scenes actions must contain 1-{MAX_MODE_SCENE_ACTIONS} items"
)
for index, action in enumerate(actions):
if not isinstance(action, Mapping):
raise ModeSceneIngressError(
f"from_n7/scenes action {index} must be an object"
)
at_ms = action.get("at_ms")
if (
isinstance(at_ms, bool)
or not isinstance(at_ms, int)
or not 0 <= at_ms <= MAX_MODE_SCENE_AT_MS
):
raise ModeSceneIngressError(
f"from_n7/scenes action {index} at_ms must be in range 0-{MAX_MODE_SCENE_AT_MS}"
)
for field, maximum in (
("type", 64),
("domain", 64),
("capability", 128),
("target_scope", 128),
("operation", 64),
):
action[field] = _required_text(
action.get(field),
field=f"actions[{index}].{field}",
maximum=maximum,
)
if not isinstance(action.get("parameters"), Mapping):
raise ModeSceneIngressError(
f"from_n7/scenes action {index} parameters must be an object"
)
target = action.get("target")
if target is not None and not isinstance(target, Mapping):
raise ModeSceneIngressError(
f"from_n7/scenes action {index} target must be an object"
)
operation_ref = action.get("operation_ref")
if operation_ref is not None and (
not isinstance(operation_ref, str)
or not operation_ref.strip()
or len(operation_ref.strip()) > 128
):
raise ModeSceneIngressError(
f"from_n7/scenes action {index} operation_ref must be non-empty text"
)
return normalized
class ModeSceneIngressConsumer:
"""Deduplicate and persist inbound cards on one bounded worker thread."""
def __init__(
self,
importer: Callable[[Mapping[str, Any]], Mapping[str, Any]],
*,
queue_size: int = DEFAULT_MODE_SCENE_INGRESS_QUEUE_SIZE,
deduplication_window_s: float = DEFAULT_MODE_SCENE_DEDUPLICATION_WINDOW_S,
) -> None:
if queue_size <= 0:
raise ValueError("queue_size must be greater than zero")
if deduplication_window_s <= 0:
raise ValueError("deduplication_window_s must be greater than zero")
self._importer = importer
self._queue: queue.Queue[tuple[bytes, float, str]] = queue.Queue(queue_size)
self._deduplication_window_s = float(deduplication_window_s)
self._recent_digests: dict[str, float] = {}
self._lock = threading.RLock()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._status: dict[str, Any] = {
"state": "stopped",
"topic": MQTT_MODE_SCENE_INGRESS_TOPIC,
"received_count": 0,
"processed_count": 0,
"imported_count": 0,
"duplicate_count": 0,
"rejected_count": 0,
"last_scene_id": None,
"last_received_at": None,
"last_completed_at": None,
"last_latency_ms": None,
"last_result": None,
"last_error": None,
}
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
with self._lock:
self._status.update(state="waiting", last_error=None)
self._thread = threading.Thread(
target=self._run,
name="n7-mode-scene-ingress",
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
with self._lock:
self._status["state"] = "stopped"
def submit(self, raw_payload: bytes) -> None:
"""Queue broker bytes immediately; parsing and disk I/O happen later."""
if self._stop.is_set():
raise RuntimeError("mode scene ingress consumer is stopped")
payload = bytes(raw_payload)
if not payload or len(payload) > MQTT_MODE_SCENE_MAX_PAYLOAD_BYTES:
raise ModeSceneIngressError(
"from_n7/scenes payload must contain 1-512 KiB"
)
received_at = _timestamp()
try:
self._queue.put_nowait((payload, time.monotonic(), received_at))
except queue.Full as exc:
with self._lock:
self._status.update(
state="queue_full",
last_error="from_n7/scenes queue is full",
)
raise RuntimeError("from_n7/scenes queue is full") from exc
with self._lock:
self._status.update(
state="queued",
received_count=int(self._status["received_count"]) + 1,
last_received_at=received_at,
last_error=None,
)
def snapshot(self) -> dict[str, Any]:
with self._lock:
return {
**deepcopy(self._status),
"queue_depth": self._queue.qsize(),
"queue_capacity": self._queue.maxsize,
"deduplication_window_ms": round(
self._deduplication_window_s * 1000
),
}
def _is_duplicate(self, digest: str, now: float) -> bool:
cutoff = now - self._deduplication_window_s
self._recent_digests = {
key: observed_at
for key, observed_at in self._recent_digests.items()
if observed_at >= cutoff
}
previous = self._recent_digests.get(digest)
return previous is not None and previous >= cutoff
def _process(self, item: tuple[bytes, float, str]) -> None:
raw_payload, received_monotonic, received_at = item
with self._lock:
self._status.update(
state="validating",
last_received_at=received_at,
last_error=None,
)
try:
scene = parse_mode_scene_ingress_payload(raw_payload)
canonical = json.dumps(
scene,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
)
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
now = time.monotonic()
with self._lock:
duplicate = self._is_duplicate(digest, now)
if duplicate:
with self._lock:
self._status.update(
state="duplicate_ignored",
processed_count=int(self._status["processed_count"]) + 1,
duplicate_count=int(self._status["duplicate_count"]) + 1,
last_scene_id=scene["id"],
last_completed_at=_timestamp(),
last_latency_ms=round(
max(0.0, now - received_monotonic) * 1000
),
last_error=None,
)
return
result = self._importer(deepcopy(scene))
if not isinstance(result, Mapping):
raise TypeError("mode scene importer returned an invalid result")
with self._lock:
self._recent_digests[digest] = time.monotonic()
except Exception as exc: # noqa: BLE001 - reject one card, keep worker alive
with self._lock:
self._status.update(
state="rejected",
processed_count=int(self._status["processed_count"]) + 1,
rejected_count=int(self._status["rejected_count"]) + 1,
last_completed_at=_timestamp(),
last_latency_ms=round(
max(0.0, time.monotonic() - received_monotonic) * 1000
),
last_result=None,
last_error=str(exc)[:240],
)
return
with self._lock:
self._status.update(
state="imported",
processed_count=int(self._status["processed_count"]) + 1,
imported_count=int(self._status["imported_count"]) + 1,
last_scene_id=scene["id"],
last_completed_at=_timestamp(),
last_latency_ms=round(
max(0.0, time.monotonic() - received_monotonic) * 1000
),
last_result=deepcopy(dict(result)),
last_error=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)
finally:
self._queue.task_done()
with self._lock:
if self._queue.empty() and self._status["state"] in {
"imported",
"duplicate_ignored",
}:
# Keep the outcome visible while accurately reporting that
# the worker is ready for the next broker message.
self._status["worker_state"] = "waiting"
+454 -9
View File
@@ -2,14 +2,16 @@
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
import threading
import time
import uuid
from collections import Counter
from collections.abc import Callable
from collections.abc import Callable, Mapping
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from datetime import UTC, datetime
@@ -50,6 +52,10 @@ from cognitive_agent.application.domain_runtime import ConfiguredDomainRuntime
from cognitive_agent.application.mode_profile import ( # noqa: E402
ModeProfile,
ModeProfileRegistry,
ModeTimeline,
)
from cognitive_agent.application.mode_scene_ingress import ( # noqa: E402
ModeSceneIngressConsumer,
)
from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
DEFAULT_N7_SCENE_SERVER_ID,
@@ -130,7 +136,9 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
VehicleMqttSettings,
)
from cognitive_agent.infrastructure.can.mqtt_transport import ( # noqa: E402
MQTT_MODE_SCENE_INGRESS_TOPIC,
MQTT_MODE_SCENE_TOPIC,
MQTT_N7_CONTROL_TRANSFER_TOPIC,
MqttCanTransport,
)
from cognitive_agent.infrastructure.llm.openai_responses import ( # noqa: E402
@@ -446,6 +454,14 @@ class SeatDemoService:
lambda scene: self._vehicle_mqtt_transport.publish_mode_scene(scene),
topic=MQTT_MODE_SCENE_TOPIC,
)
self._mode_scene_ingress = ModeSceneIngressConsumer(
self.import_n7_mode_scene,
)
self._vehicle_mqtt_transport.set_mode_scene_ingress_handler(
self._mode_scene_ingress.submit
)
self._n7_transfer_lock = threading.RLock()
self._n7_transfer_publications: dict[str, float] = {}
self.scene_templates = SceneTemplateRegistry(
repository_root / "config" / "scene_templates"
)
@@ -546,6 +562,18 @@ class SeatDemoService:
def mode_scene_catalog_status(self) -> dict[str, Any]:
return deepcopy(self._mode_scene_catalog_status)
def mode_profile_sync_status(self) -> dict[str, Any]:
"""Return the lightweight state used by the browser's scene watcher."""
catalog = self.mode_scene_catalog_status()
return {
"catalog_revision": catalog.get("catalog_revision"),
"scene_count": int(catalog.get("scene_count") or 0),
"catalog_state": str(catalog.get("state") or "unknown"),
"catalog_error": catalog.get("last_error"),
"mode_scene_ingress": self._mode_scene_ingress.snapshot(),
}
def publish_saved_mode_scene(
self,
mode_id: str,
@@ -591,6 +619,142 @@ class SeatDemoService:
return self.publish_saved_mode_scene(mode_id, vehicle_id)
@staticmethod
def _imported_scene_function_configs(
scene: dict[str, Any],
) -> dict[str, dict[str, Any]]:
"""Retain only local peripheral metadata needed for later authorization."""
extension_operations: list[dict[str, Any]] = []
for index, action in enumerate(scene.get("actions", [])):
if (
not isinstance(action, dict)
or action.get("domain") != "self_extension"
or action.get("capability") != "infrared.emit"
):
continue
target = action.get("target")
target_document = (
deepcopy(target)
if isinstance(target, dict)
else {"device": str(action.get("target_scope") or "")}
)
extension_operations.append(
{
"id": str(action.get("operation_ref") or f"n7-ir-{index + 1}"),
"domain": "self_extension",
"capability": "infrared.emit",
"target_scope": str(action.get("target_scope") or ""),
"target": target_document,
"operation": str(action.get("operation") or "emit"),
"parameters": deepcopy(dict(action.get("parameters") or {})),
"parse_source": "from_n7_scene",
}
)
if not extension_operations:
return {}
return {
"self_extension": {
"apiVersion": "cognitive-agent.io/v1alpha1",
"kind": "FunctionConfig",
"metadata": {
"id": f"from-n7-{scene['id']}-self-extension",
"name": f"{scene['name']} imported infrared actions",
"labels": {"source_topic": MQTT_MODE_SCENE_INGRESS_TOPIC},
},
"spec": {
"domain": "self_extension",
"operations": extension_operations,
},
}
}
def import_n7_mode_scene(
self,
scene: Mapping[str, Any],
) -> dict[str, Any]:
"""Upsert one validated N7 card without publishing it back to the broker."""
document = deepcopy(dict(scene))
actions = document.get("actions")
if not isinstance(actions, list) or not actions:
raise ValueError("from_n7/scenes card must contain executable actions")
operations = [
{
"domain": str(action["domain"]),
"capability": str(action["capability"]),
"target_scope": str(action["target_scope"]),
"operation": str(action["operation"]),
"parameters": deepcopy(dict(action["parameters"])),
"reason": (
f"由 {MQTT_MODE_SCENE_INGRESS_TOPIC} 导入的模式动作。"
),
}
for action in actions
]
timeline = None
raw_timeline = document.get("timeline")
if raw_timeline is not None:
if not isinstance(raw_timeline, dict):
raise ValueError("from_n7/scenes timeline must be an object")
timeline = ModeTimeline.from_dict(raw_timeline)
command_flow = {
"schema": "cognitive-agent-mode-command-flow/v1",
"source_topic": MQTT_MODE_SCENE_INGRESS_TOPIC,
"imported_at": _now(),
"source_scene": document,
"function_configs": self._imported_scene_function_configs(document),
# The execution path recompiles the canonical ModeOperation list
# against this PC's capability registry. This marker makes the
# card directly executable while avoiding trust in foreign CAN bytes.
"plans": {
"from_n7_scene": {
"spec": {
"commands": [
{
"operation_ref": str(
action.get("operation_ref") or f"action-{index + 1}"
),
"at_ms": int(action.get("at_ms", 0)),
}
for index, action in enumerate(actions)
]
}
}
},
"execution_steps": [],
}
scope = document.get("scope") or {}
vehicle_ids = scope.get("vehicle_ids", []) if isinstance(scope, dict) else []
common = bool(scope.get("common", not vehicle_ids)) if isinstance(scope, dict) else False
resolved_vehicle_id = (
None
if common and not vehicle_ids
else self.vehicle_profiles.default().id
)
saved = self.mode_profiles.save_mode(
str(document["name"]),
operations,
mode_id=str(document["id"]),
aliases=[str(alias) for alias in document.get("aliases", [])],
vehicle_id=resolved_vehicle_id,
description=str(document.get("description") or "由 N7 同步的模式卡片。"),
icon=str(document.get("icon") or "radio-tower"),
instruction=f"进入{document['name']}",
command_flow=command_flow,
timeline=timeline,
sync_origin=MQTT_MODE_SCENE_INGRESS_TOPIC,
upstream_origin=str(document.get("origin") or ""),
)
return {
"status": "imported",
"scene_id": saved.id,
"scene_name": saved.name,
"source_topic": MQTT_MODE_SCENE_INGRESS_TOPIC,
"profile_revision": self.mode_profiles.profile_revision(saved),
"storage": "persistent_yaml",
}
@property
def driver_only_ch1_execution_active(self) -> bool:
"""Return whether deployment policy authorizes only the CH1 vehicle path."""
@@ -646,6 +810,7 @@ class SeatDemoService:
"last_error": None,
}
vehicle_mqtt_info = self._vehicle_mqtt_transport.public_info()
vehicle_mqtt_info["mode_scene_import"] = self._mode_scene_ingress.snapshot()
return {
"service": "cabin-cognitive-orchestrator",
"domain": "cabin",
@@ -807,6 +972,207 @@ class SeatDemoService:
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
return self.mode_profiles.get_profile(mode_id, resolved_vehicle_id)
@staticmethod
def _fallback_scene_action(
operation: Any,
*,
index: int,
) -> dict[str, Any]:
return {
"type": "control",
"at_ms": 0,
"domain": operation.domain,
"capability": operation.capability,
"target_scope": operation.target_scope,
"operation": operation.operation,
"parameters": deepcopy(operation.parameters),
"operation_ref": f"action-{index + 1}",
}
def _mode_requests_and_n7_transfers(
self,
profile: ModeProfile,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Separate locally understood operations from N7-only capabilities."""
requests = self.mode_profiles.requests_for_profiles([profile])
if profile.sync_origin != MQTT_MODE_SCENE_INGRESS_TOPIC:
return requests, []
source_scene = (
profile.command_flow.get("source_scene")
if isinstance(profile.command_flow, dict)
else None
)
source_actions = (
source_scene.get("actions")
if isinstance(source_scene, dict)
and isinstance(source_scene.get("actions"), list)
else []
)
local_requests: list[dict[str, Any]] = []
transfer_actions: list[dict[str, Any]] = []
for index, (operation, request) in enumerate(
zip(profile.operations, requests, strict=True)
):
if (
operation.domain == "self_extension"
and operation.capability == "infrared.emit"
):
# The server's durable NFC allowlist owns this local TCP path.
# Authorization failures must not be converted into N7 retries.
continue
registered = self.model_plan_adapter.capabilities.get(
operation.capability
)
native_candidate = {
"domain": operation.domain,
"capability": operation.capability,
"target": operation.target_scope,
"parameters": deepcopy(operation.parameters),
"operation": operation.operation,
}
if registered is not None or self.native_can_runtime.supports(
native_candidate
):
local_requests.append(request)
continue
raw_action = (
source_actions[index]
if index < len(source_actions)
and isinstance(source_actions[index], dict)
else None
)
action = (
deepcopy(raw_action)
if raw_action is not None
else self._fallback_scene_action(operation, index=index)
)
transfer_actions.append(action)
return local_requests, transfer_actions
@staticmethod
def _has_local_mode_peripheral(profile: ModeProfile) -> bool:
return any(
operation.domain == "self_extension"
and operation.capability == "infrared.emit"
for operation in profile.operations
)
@staticmethod
def _n7_transfer_deferred_requests(
actions: list[dict[str, Any]],
) -> list[dict[str, Any]]:
return [
{
"domain": str(action.get("domain") or ""),
"capability": str(action.get("capability") or ""),
"target_scope": str(action.get("target_scope") or ""),
"desired_change": str(action.get("operation") or "set"),
"parameters": deepcopy(dict(action.get("parameters") or {})),
"reason": "当前 PC 未注册该能力,将由 N7 端继续处理。",
"execution_backend": "n7_vehicle_transfer",
"rejection_code": "PC_CAPABILITY_UNAVAILABLE",
"transport_submitted": False,
}
for action in actions
]
def _finalize_n7_control_transfer(
self,
result: dict[str, Any],
*,
execution_mode: str,
local_preview: bool,
) -> dict[str, Any]:
"""Forward only N7-originated actions never submitted by this PC."""
actions = result.get("n7_transfer_candidates")
scene = result.get("n7_source_scene")
if (
execution_mode != "hardware"
or local_preview
or not isinstance(actions, list)
or not actions
or not isinstance(scene, dict)
):
return result
if str(result.get("status") or "") not in {
"succeeded",
"partially_succeeded",
"capability_unavailable",
}:
return result
if result.get("execution_outcome") == "failed_or_blocked":
return result
request_id = str(result.get("request_id") or "").strip()
scene_id = str(scene.get("id") or "").strip()
scene_name = str(scene.get("name") or "").strip()
if not request_id or not scene_id or not scene_name:
return result
transfer_actions = [deepcopy(action) for action in actions if isinstance(action, dict)]
if not transfer_actions:
return result
payload = {
"scene_id": scene_id,
"scene_name": scene_name,
"request_id": request_id,
"operation_count": len(transfer_actions),
"actions": transfer_actions,
}
digest = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
publication_key = f"{request_id}:{hashlib.sha256(digest.encode('utf-8')).hexdigest()}"
now = time.monotonic()
with self._n7_transfer_lock:
cutoff = now - 300.0
self._n7_transfer_publications = {
key: published_at
for key, published_at in self._n7_transfer_publications.items()
if published_at >= cutoff
}
if publication_key in self._n7_transfer_publications:
result["n7_control_transfer"] = {
"state": "duplicate_ignored",
"topic": MQTT_N7_CONTROL_TRANSFER_TOPIC,
"scene_id": scene_id,
"operation_count": len(transfer_actions),
}
return result
try:
publication = self._vehicle_mqtt_transport.publish_n7_control_transfer(
payload
)
except Exception as exc: # noqa: BLE001 - local execution result remains valid
result["n7_control_transfer"] = {
"state": "publish_failed",
"topic": MQTT_N7_CONTROL_TRANSFER_TOPIC,
"scene_id": scene_id,
"operation_count": len(transfer_actions),
"last_error": str(exc)[:240],
}
return result
with self._n7_transfer_lock:
self._n7_transfer_publications[publication_key] = now
result["n7_control_transfer"] = {
"state": "published",
"scene_id": scene_id,
"operation_count": len(transfer_actions),
"payload": payload,
**dict(publication),
}
result["decision_summary"] = [
*list(result.get("decision_summary") or []),
(
f"当前 PC 未注册的 {len(transfer_actions)} 条 N7 来源动作已发送至 "
f"{MQTT_N7_CONTROL_TRANSFER_TOPIC};PC 已提交或被安全门禁拦截的动作未转发。"
),
]
return result
def execute_mode_profile_card(
self,
mode_id: str,
@@ -1183,6 +1549,10 @@ class SeatDemoService:
instruction=clean_instruction,
command_flow=command_flow,
timeline=profile.timeline if profile is not None else None,
sync_origin=profile.sync_origin if profile is not None else "",
upstream_origin=(
profile.upstream_origin if profile is not None else ""
),
expected_revision=draft_existing_mode_revision or None,
create_only=not bool(clean_mode_id),
)
@@ -1379,6 +1749,7 @@ class SeatDemoService:
if self._vehicle_state_monitor is not None:
self._vehicle_state_monitor.start()
self._mode_scene_mqtt_publisher.start()
self._mode_scene_ingress.start()
self._vehicle_mqtt_transport.start()
def close(self) -> None:
@@ -1386,6 +1757,7 @@ class SeatDemoService:
self._vehicle_state_monitor.close()
self._mode_scene_mqtt_publisher.close()
self._vehicle_mqtt_transport.close()
self._mode_scene_ingress.close()
def publish_pet_message(self, payload: dict[str, str]) -> dict[str, Any]:
"""Publish one NFC-authorized pet card through the shared vehicle broker."""
@@ -2416,7 +2788,7 @@ class SeatDemoService:
# The head unit owns both physical CAN channels in wireless mode.
# Local T2G/ZLG hardware access, firmware profiles and the local
# physical-bench lease are unrelated to MQTT relay execution.
return self._process_once(
result = self._process_once(
utterance,
provider,
conversation_id,
@@ -2438,11 +2810,16 @@ class SeatDemoService:
mode_profile_draft_context=_mode_profile_draft_context,
cloud_instruction_context=_cloud_instruction_context,
)
return self._finalize_n7_control_transfer(
result,
execution_mode=execution_mode,
local_preview=_local_preview,
)
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
control_transport
):
with self.hardware_execution.reserve():
return self._process_once(
result = self._process_once(
utterance,
provider,
conversation_id,
@@ -2464,10 +2841,15 @@ class SeatDemoService:
mode_profile_draft_context=_mode_profile_draft_context,
cloud_instruction_context=_cloud_instruction_context,
)
return self._finalize_n7_control_transfer(
result,
execution_mode=execution_mode,
local_preview=_local_preview,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
with self.hardware_access.use(authorized_client_id):
return self._process_once(
result = self._process_once(
utterance,
provider,
conversation_id,
@@ -2489,6 +2871,11 @@ class SeatDemoService:
mode_profile_draft_context=_mode_profile_draft_context,
cloud_instruction_context=_cloud_instruction_context,
)
return self._finalize_n7_control_transfer(
result,
execution_mode=execution_mode,
local_preview=_local_preview,
)
def _process_once(
self,
@@ -2768,8 +3155,8 @@ class SeatDemoService:
traces,
)
if direct_mode_profile is not None:
mode_requests = self.mode_profiles.requests_for_profiles(
[direct_mode_profile]
mode_requests, n7_transfer_actions = (
self._mode_requests_and_n7_transfers(direct_mode_profile)
)
detected_domains = list(
dict.fromkeys(
@@ -2814,6 +3201,28 @@ class SeatDemoService:
*list(parsed.get("native_can_actions", [])),
*saved_native_actions,
]
if n7_transfer_actions:
parsed["deferred_requests"] = [
*list(parsed.get("deferred_requests", [])),
*self._n7_transfer_deferred_requests(
n7_transfer_actions
),
]
parsed["_n7_transfer_actions"] = n7_transfer_actions
parsed["_n7_scene_profile"] = {
"id": direct_mode_profile.id,
"name": direct_mode_profile.name,
}
parsed["detected_domains"] = self._merge_domains(
list(parsed.get("detected_domains", [])),
[
str(action.get("domain") or "")
for action in n7_transfer_actions
if str(action.get("domain") or "")
],
)
if self._has_local_mode_peripheral(direct_mode_profile):
parsed["_saved_mode_local_peripheral"] = True
provider_used = "saved_mode_command_flow"
provider_notice = None
elif direct_can_batch is not None:
@@ -2951,6 +3360,12 @@ class SeatDemoService:
)
),
)
n7_transfer_actions = parsed.get("_n7_transfer_actions", [])
n7_scene_profile = parsed.get("_n7_scene_profile")
if isinstance(n7_transfer_actions, list) and n7_transfer_actions:
result["n7_transfer_candidates"] = deepcopy(n7_transfer_actions)
if isinstance(n7_scene_profile, dict):
result["n7_source_scene"] = deepcopy(n7_scene_profile)
result["parsed_control_signatures"] = [
list(self._control_signature(action))
for action in [
@@ -2969,7 +3384,10 @@ class SeatDemoService:
)
)
if (
allow_empty_control_result
(
allow_empty_control_result
or parsed.get("_saved_mode_local_peripheral") is True
)
and not has_control
and not parsed.get("ambiguities")
):
@@ -3019,7 +3437,11 @@ class SeatDemoService:
"hardware mode refuses virtual or unverified vehicle actions: "
+ ", ".join(unsupported)
)
if not parsed.get("actions") and not parsed.get("native_can_actions"):
if (
not parsed.get("actions")
and not parsed.get("native_can_actions")
and not parsed.get("_n7_transfer_actions")
):
raise HardwareSafetyError(
"hardware mode requires at least one verified seat or vehicle CAN operation"
)
@@ -4070,7 +4492,10 @@ class SeatDemoService:
if safety_ambiguities:
return parsed
mode_requests = self.mode_profiles.requests_for_profiles(matched_modes)
matched_profile = matched_modes[0]
mode_requests, n7_transfer_actions = (
self._mode_requests_and_n7_transfers(matched_profile)
)
detected_domains = []
for request in mode_requests:
if request["domain"] not in detected_domains:
@@ -4136,6 +4561,26 @@ class SeatDemoService:
float(mode_parsed.get("confidence", 0)),
float(parsed.get("confidence", 0)),
)
if n7_transfer_actions:
merged["deferred_requests"] = self._merge_unique(
list(merged.get("deferred_requests", [])),
self._n7_transfer_deferred_requests(n7_transfer_actions),
)
merged["_n7_transfer_actions"] = deepcopy(n7_transfer_actions)
merged["_n7_scene_profile"] = {
"id": matched_profile.id,
"name": matched_profile.name,
}
merged["detected_domains"] = self._merge_domains(
list(merged.get("detected_domains", [])),
[
str(action.get("domain") or "")
for action in n7_transfer_actions
if str(action.get("domain") or "")
],
)
if self._has_local_mode_peripheral(matched_profile):
merged["_saved_mode_local_peripheral"] = True
return merged
@staticmethod