feat(n7web): sync N7 scene cards and optimize runtime
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user