添加模式卡片下发

This commit is contained in:
Miayui
2026-09-09 14:37:25 +08:00
parent 76057be044
commit 51ca12b624
10 changed files with 1169 additions and 0 deletions
@@ -5,6 +5,7 @@ from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from threading import RLock
@@ -525,6 +526,7 @@ class ModeProfileRegistry:
self.user_root = Path(user_root) if user_root is not None else self.profile_root
self.session_user_profiles = user_root is not None
self._profiles: list[ModeProfile] = []
self._change_listeners: list[Callable[["ModeProfileRegistry"], None]] = []
self._write_lock = RLock()
self.reload()
@@ -547,6 +549,30 @@ class ModeProfileRegistry:
if path.is_file()
)
self._profiles = profiles
self._notify_change_listeners()
def add_change_listener(
self,
listener: Callable[["ModeProfileRegistry"], None],
*,
notify: bool = True,
) -> None:
"""Run ``listener`` after every successful registry reload.
Mode profiles remain YAML-backed. Listeners are intended for derived,
read-only projections such as the LAN scene catalog; they do not replace
or alter the existing persistence path.
"""
with self._write_lock:
if listener not in self._change_listeners:
self._change_listeners.append(listener)
if notify:
listener(self)
def _notify_change_listeners(self) -> None:
for listener in tuple(self._change_listeners):
listener(self)
def profiles(self) -> list[ModeProfile]:
return list(self._profiles)
@@ -0,0 +1,248 @@
"""Project persisted mode cards into the read-only N7 scene catalog contract."""
from __future__ import annotations
import hashlib
import json
import os
import threading
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from cognitive_agent.application.mode_profile import (
ModeOperation,
ModeProfile,
ModeProfileRegistry,
)
N7_SCENE_SERVICE = "n7-scenes"
N7_SCENE_SCHEMA_VERSION = 1
DEFAULT_N7_SCENE_SERVER_ID = "n7-cognitive-agent"
DEFAULT_N7_SCENE_SERVER_NAME = "N7 模式卡片目录"
def _utc_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _background_time(profile: ModeProfile) -> str:
"""Choose a compatible visual hint when the mode schema has no time field."""
text = f"{profile.name} {profile.description}"
if any(keyword in text for keyword in ("晨", "早间", "清晨")):
return "morning"
if any(keyword in text for keyword in ("小憩", "午睡", "睡眠", "夜", "冥想")):
return "evening"
return "noon"
def _action_type(capability: str) -> str:
legacy_types = {
"climate.set_power": "climate_power",
"climate.set_temperature": "climate_temperature",
"climate.set_fan_level": "climate_fan",
}
if capability in legacy_types:
return legacy_types[capability]
domain, separator, action = capability.partition(".")
if not separator:
return capability.replace(".", "_")
if action.startswith("set_"):
action = action.removeprefix("set_")
return f"{domain}_{action}".replace(".", "_")
def _legacy_zone(target_scope: str) -> str:
return {
"vehicle": "both",
"all": "both",
"passenger": "passenger",
"front_right": "passenger",
"driver": "driver",
"front_left": "driver",
}.get(target_scope, target_scope)
def _scene_action(operation: ModeOperation) -> dict[str, Any]:
"""Preserve the complete operation and add fields understood by the v1 demo."""
parameters = deepcopy(operation.parameters)
action: dict[str, Any] = {
"type": _action_type(operation.capability),
"at_ms": 0,
"domain": operation.domain,
"capability": operation.capability,
"target_scope": operation.target_scope,
"operation": operation.operation,
"parameters": parameters,
}
# The original demo catalog flattened these three climate actions. Keep
# those aliases so an existing v1 client can continue reading them, while
# the generic fields above retain every seat/roof/light/custom operation.
if operation.capability == "climate.set_power" and "state" in parameters:
state = parameters["state"]
action["enabled"] = (
state
if isinstance(state, bool)
else str(state).strip().lower() in {"1", "true", "on", "enabled"}
)
elif (
operation.capability == "climate.set_temperature"
and "temperature_c" in parameters
):
action["zone"] = _legacy_zone(operation.target_scope)
action["celsius"] = parameters["temperature_c"]
elif operation.capability == "climate.set_fan_level" and "level" in parameters:
action["level"] = parameters["level"]
return action
def _scene_from_profile(
registry: ModeProfileRegistry,
profile: ModeProfile,
public_profile: dict[str, Any],
) -> dict[str, Any]:
scene = {
"id": profile.id,
"label": profile.name,
"name": profile.name,
"background_time": _background_time(profile),
"description": profile.description,
"icon": profile.icon,
"aliases": list(profile.aliases),
"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),
"scope": deepcopy(public_profile.get("scope") or {}),
"profile_revision": registry.profile_revision(profile),
"actions": [_scene_action(operation) for operation in profile.operations],
}
if public_profile.get("preset_override") is True:
scene["preset_override"] = True
if profile.timeline is not None:
scene["timeline"] = profile.timeline.public_dict()
return scene
def build_mode_scene_catalog(
registry: ModeProfileRegistry,
vehicle_id: str,
*,
server_id: str = DEFAULT_N7_SCENE_SERVER_ID,
name: str = DEFAULT_N7_SCENE_SERVER_NAME,
generated_at: str | None = None,
) -> dict[str, Any]:
"""Build a lossless, client-safe projection of effective mode profiles."""
clean_vehicle_id = str(vehicle_id).strip()
if not clean_vehicle_id:
raise ValueError("scene catalog vehicle_id must not be empty")
clean_server_id = str(server_id).strip()
if not clean_server_id:
raise ValueError("scene catalog server_id must not be empty")
public_profiles = registry.public_modes(clean_vehicle_id)
scenes = [
_scene_from_profile(
registry,
registry.get_profile(str(public_profile["id"]), clean_vehicle_id),
public_profile,
)
for public_profile in public_profiles
]
revision_source = {
"service": N7_SCENE_SERVICE,
"schema_version": N7_SCENE_SCHEMA_VERSION,
"server_id": clean_server_id,
"name": str(name).strip() or DEFAULT_N7_SCENE_SERVER_NAME,
"vehicle_id": clean_vehicle_id,
"scenes": scenes,
}
canonical = json.dumps(
revision_source,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return {
**revision_source,
"catalog_revision": hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16],
"generated_at": generated_at or _utc_now(),
"scene_count": len(scenes),
"read_only": True,
}
class ModeSceneCatalogPublisher:
"""Atomically maintain the JSON projection consumed by LAN clients."""
def __init__(
self,
path: Path,
vehicle_id: str,
*,
server_id: str = DEFAULT_N7_SCENE_SERVER_ID,
name: str = DEFAULT_N7_SCENE_SERVER_NAME,
) -> None:
self.path = Path(path)
self.vehicle_id = str(vehicle_id).strip()
self.server_id = str(server_id).strip() or DEFAULT_N7_SCENE_SERVER_ID
self.name = str(name).strip() or DEFAULT_N7_SCENE_SERVER_NAME
self._lock = threading.RLock()
self._catalog: dict[str, Any] | None = None
def publish(self, registry: ModeProfileRegistry) -> dict[str, Any]:
catalog = build_mode_scene_catalog(
registry,
self.vehicle_id,
server_id=self.server_id,
name=self.name,
)
with self._lock:
on_disk = self._read_catalog()
if (
on_disk is not None
and on_disk.get("catalog_revision") == catalog["catalog_revision"]
and {
key: value for key, value in on_disk.items() if key != "generated_at"
}
== {
key: value for key, value in catalog.items() if key != "generated_at"
}
):
self._catalog = on_disk
return deepcopy(on_disk)
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_name(
f".{self.path.name}.{os.getpid()}.{threading.get_ident()}.tmp"
)
try:
temporary.write_text(
json.dumps(catalog, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(self.path)
finally:
if temporary.exists():
temporary.unlink()
self._catalog = catalog
return deepcopy(catalog)
def snapshot(self) -> dict[str, Any] | None:
with self._lock:
return deepcopy(self._catalog)
def _read_catalog(self) -> dict[str, Any] | None:
if not self.path.is_file():
return None
try:
document = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return document if isinstance(document, dict) else None
@@ -51,6 +51,12 @@ from cognitive_agent.application.mode_profile import ( # noqa: E402
ModeProfile,
ModeProfileRegistry,
)
from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
DEFAULT_N7_SCENE_SERVER_ID,
DEFAULT_N7_SCENE_SERVER_NAME,
ModeSceneCatalogPublisher,
build_mode_scene_catalog,
)
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
is_mode_profile_query,
normalize_mode_profile_name,
@@ -414,6 +420,17 @@ class SeatDemoService:
repository_root / "config" / "modes",
user_root=self.mode_profile_user_root,
)
self._mode_scene_catalog_publisher: ModeSceneCatalogPublisher | None = None
self._mode_scene_catalog_status: dict[str, Any] = {
"enabled": False,
"state": "disabled",
"path": None,
"vehicle_id": self.vehicle_profiles.default().id,
"server_id": DEFAULT_N7_SCENE_SERVER_ID,
"catalog_revision": None,
"scene_count": 0,
"last_error": None,
}
self.scene_templates = SceneTemplateRegistry(
repository_root / "config" / "scene_templates"
)
@@ -432,6 +449,88 @@ class SeatDemoService:
if hasattr(self, "conversations"):
self.conversations.preset_root = self._artifact_root / "user-presets"
def configure_mode_scene_catalog(
self,
path: Path,
*,
server_id: str = DEFAULT_N7_SCENE_SERVER_ID,
name: str = DEFAULT_N7_SCENE_SERVER_NAME,
) -> dict[str, Any]:
"""Enable the derived JSON catalog without changing YAML persistence."""
catalog_path = Path(path).expanduser()
if not catalog_path.is_absolute():
catalog_path = self.root / catalog_path
vehicle_id = self.vehicle_profiles.default().id
self._mode_scene_catalog_publisher = ModeSceneCatalogPublisher(
catalog_path,
vehicle_id,
server_id=server_id,
name=name,
)
self._mode_scene_catalog_status = {
"enabled": True,
"state": "initializing",
"path": str(catalog_path),
"vehicle_id": vehicle_id,
"server_id": self._mode_scene_catalog_publisher.server_id,
"catalog_revision": None,
"scene_count": 0,
"last_error": None,
}
# The registry notifies after create, update, delete, restore, and any
# explicit reload. The callback only updates a derived projection; YAML
# remains the authoritative store used by every existing execution path.
self.mode_profiles.add_change_listener(self._synchronize_mode_scene_catalog)
return self.mode_scene_catalog_status()
def _synchronize_mode_scene_catalog(self, registry: ModeProfileRegistry) -> None:
publisher = self._mode_scene_catalog_publisher
if publisher is None:
return
try:
catalog = publisher.publish(registry)
except Exception as exc: # noqa: BLE001 - derived export must not block controls
self._mode_scene_catalog_status.update(
{
"state": "error",
"last_error": str(exc),
}
)
return
self._mode_scene_catalog_status.update(
{
"state": "ready",
"catalog_revision": catalog["catalog_revision"],
"scene_count": catalog["scene_count"],
"last_error": None,
}
)
def mode_scene_catalog(self, vehicle_id: str | None = None) -> dict[str, Any]:
"""Return the current read-only mode-card catalog for an N7 client."""
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
publisher = self._mode_scene_catalog_publisher
if publisher is not None and resolved_vehicle_id == publisher.vehicle_id:
self._synchronize_mode_scene_catalog(self.mode_profiles)
snapshot = publisher.snapshot()
if snapshot is not None and self._mode_scene_catalog_status["state"] == "ready":
return snapshot
return build_mode_scene_catalog(
self.mode_profiles,
resolved_vehicle_id,
server_id=(
publisher.server_id
if publisher is not None
else DEFAULT_N7_SCENE_SERVER_ID
),
name=(publisher.name if publisher is not None else DEFAULT_N7_SCENE_SERVER_NAME),
)
def mode_scene_catalog_status(self) -> dict[str, Any]:
return deepcopy(self._mode_scene_catalog_status)
@property
def driver_only_ch1_execution_active(self) -> bool:
"""Return whether deployment policy authorizes only the CH1 vehicle path."""
@@ -588,6 +687,7 @@ class SeatDemoService:
"scene_templates": self.scene_templates.public_templates(),
"mode_profile_storage": "persistent_yaml",
"mode_profile_user_root": str(self.mode_profile_user_root),
"mode_scene_catalog": self.mode_scene_catalog_status(),
"preset_storage": "conversation_durable",
"inferred_level_range": [1, 3],
"motion_position_range_pct": [0, 100],
+208
View File
@@ -0,0 +1,208 @@
"""Serve the synchronized N7 mode-card catalog over HTTP and UDP discovery."""
from __future__ import annotations
import argparse
import json
import os
import threading
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from socketserver import BaseRequestHandler, ThreadingUDPServer
from typing import Any
from urllib.parse import unquote, urlparse
DISCOVERY_REQUEST = b"N7_SCENES_DISCOVER_V1"
DISCOVERY_RESPONSE = b"N7_SCENES_V1"
DEFAULT_CATALOG_PATH = (
Path(__file__).resolve().parents[2].parent / "scenses" / "demo-scenes.json"
)
class SceneCatalogError(ValueError):
"""Raised when the synchronized catalog cannot satisfy the public contract."""
class SceneCatalogSource:
def __init__(self, path: Path, server_id: str | None = None) -> None:
self.path = Path(path)
self.server_id = str(server_id or "").strip() or None
def load(self) -> dict[str, Any]:
try:
document = json.loads(self.path.read_text(encoding="utf-8"))
except OSError as exc:
raise SceneCatalogError(f"scene catalog is unavailable: {self.path}") from exc
except json.JSONDecodeError as exc:
raise SceneCatalogError(f"scene catalog is invalid JSON: {self.path}") from exc
if not isinstance(document, dict):
raise SceneCatalogError("scene catalog root must be an object")
if document.get("service") != "n7-scenes":
raise SceneCatalogError("scene catalog service must be n7-scenes")
if document.get("schema_version") != 1:
raise SceneCatalogError("scene catalog schema_version must be 1")
scenes = document.get("scenes")
if not isinstance(scenes, list):
raise SceneCatalogError("scene catalog scenes must be an array")
scene_ids = [
str(scene.get("id") or "").strip()
for scene in scenes
if isinstance(scene, dict)
]
if len(scene_ids) != len(scenes) or any(not scene_id for scene_id in scene_ids):
raise SceneCatalogError("every scene must be an object with a non-empty id")
if len(set(scene_ids)) != len(scene_ids):
raise SceneCatalogError("scene ids must be unique")
payload = dict(document)
if self.server_id is not None:
payload["server_id"] = self.server_id
payload["scene_count"] = len(scenes)
return payload
class N7SceneHttpServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(self, address: tuple[str, int], source: SceneCatalogSource) -> None:
self.catalog_source = source
super().__init__(address, N7SceneRequestHandler)
class N7SceneRequestHandler(BaseHTTPRequestHandler):
server_version = "N7SceneCatalog/1.0"
def do_GET(self) -> None: # noqa: N802 - stdlib handler API
path = unquote(urlparse(self.path).path)
if path == "/n7/scenes":
try:
self._json(self._catalog_source().load())
except SceneCatalogError as exc:
self._json(
{"error": "catalog_unavailable", "message": str(exc)},
HTTPStatus.SERVICE_UNAVAILABLE,
)
return
if path == "/n7/health":
try:
catalog = self._catalog_source().load()
except SceneCatalogError as exc:
self._json(
{"status": "unavailable", "message": str(exc)},
HTTPStatus.SERVICE_UNAVAILABLE,
)
return
self._json(
{
"status": "ok",
"service": catalog["service"],
"server_id": catalog.get("server_id"),
"catalog_revision": catalog.get("catalog_revision"),
"scene_count": catalog["scene_count"],
}
)
return
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
def do_OPTIONS(self) -> None: # noqa: N802 - stdlib handler API
path = unquote(urlparse(self.path).path)
if path not in {"/n7/scenes", "/n7/health"}:
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
return
self.send_response(HTTPStatus.NO_CONTENT)
self._common_headers()
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Accept, Content-Type")
self.send_header("Content-Length", "0")
self.end_headers()
def _catalog_source(self) -> SceneCatalogSource:
source = getattr(self.server, "catalog_source", None)
if not isinstance(source, SceneCatalogSource):
raise SceneCatalogError("scene catalog source is not configured")
return source
def _json(
self,
payload: object,
status: HTTPStatus = HTTPStatus.OK,
) -> None:
content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self._common_headers()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
def _common_headers(self) -> None:
self.send_header("Cache-Control", "no-store")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("X-Content-Type-Options", "nosniff")
class N7SceneUdpServer(ThreadingUDPServer):
daemon_threads = True
allow_reuse_address = True
class N7SceneDiscoveryHandler(BaseRequestHandler):
def handle(self) -> None:
data, udp_socket = self.request
if bytes(data).strip() == DISCOVERY_REQUEST:
udp_socket.sendto(DISCOVERY_RESPONSE, self.client_address)
def main() -> None:
parser = argparse.ArgumentParser(description="Serve the N7 LAN mode scene catalog")
parser.add_argument("--bind", default=os.getenv("N7_SCENE_BIND", "0.0.0.0"))
parser.add_argument(
"--port",
type=int,
default=int(os.getenv("N7_SCENE_PORT", "8090")),
)
parser.add_argument(
"--server-id",
default=os.getenv("N7_SCENE_SERVER_ID", "n7-cognitive-agent"),
)
parser.add_argument(
"--catalog",
type=Path,
default=Path(
os.getenv("N7_SCENE_CATALOG_PATH", "").strip() or DEFAULT_CATALOG_PATH
),
)
args = parser.parse_args()
source = SceneCatalogSource(args.catalog, args.server_id)
source.load() # Fail before binding when the shared file is missing or malformed.
http_server = N7SceneHttpServer((args.bind, args.port), source)
udp_server = N7SceneUdpServer(
(args.bind, http_server.server_port),
N7SceneDiscoveryHandler,
)
udp_thread = threading.Thread(
target=udp_server.serve_forever,
name="n7-scene-discovery",
daemon=True,
)
udp_thread.start()
print(
f"N7 scene catalog: http://{args.bind}:{http_server.server_port}/n7/scenes",
flush=True,
)
try:
http_server.serve_forever()
except KeyboardInterrupt:
pass
finally:
http_server.server_close()
udp_server.shutdown()
udp_server.server_close()
udp_thread.join(timeout=2)
if __name__ == "__main__":
main()
+57
View File
@@ -10,6 +10,7 @@ import mimetypes
import os
import re
import socket
import sys
import threading
import time
from collections.abc import Callable, Mapping
@@ -84,6 +85,9 @@ from cognitive_agent.infrastructure.speech import ( # noqa: E402
)
STATIC_ROOT = REPOSITORY_ROOT / "apps" / "web-console"
DEFAULT_MODE_SCENE_CATALOG_PATH = (
REPOSITORY_ROOT.parent / "scenses" / "demo-scenes.json"
)
mimetypes.add_type("application/manifest+json", ".webmanifest")
mimetypes.add_type("model/gltf-binary", ".glb")
REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]+$")
@@ -258,6 +262,13 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
HTTPStatus.OK if assistant_available else HTTPStatus.SERVICE_UNAVAILABLE,
)
return
if path == "/n7/scenes":
vehicle_id = str(parse_qs(parsed_url.query).get("vehicle_id", [""])[0]) or None
self._json(
self.service.mode_scene_catalog(vehicle_id),
headers={"Access-Control-Allow-Origin": "*"},
)
return
if path in {"/login", "/login.html"}:
if self.auth.config.enabled and self._session_username() is not None:
self._redirect("/")
@@ -440,6 +451,20 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
return
self._serve_static(path, parsed_url.query)
def do_OPTIONS(self) -> None: # noqa: N802 - stdlib handler API
path = unquote(urlparse(self.path).path)
if path != "/n7/scenes":
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
return
self.send_response(HTTPStatus.NO_CONTENT)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Accept, Content-Type")
self.send_header("Content-Length", "0")
self.send_header("Cache-Control", "no-store")
self._send_security_headers()
self.end_headers()
def do_POST(self) -> None: # noqa: N802 - stdlib handler API
path = unquote(urlparse(self.path).path)
if not self._require_same_origin():
@@ -3306,7 +3331,25 @@ def main() -> None:
parser = argparse.ArgumentParser(description="Run the cabin orchestration demo")
parser.add_argument("--host", default=os.getenv("HOST", "127.0.0.1"))
parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000")))
parser.add_argument(
"--scene-catalog",
type=Path,
default=Path(
os.getenv("N7_SCENE_CATALOG_PATH", "").strip()
or DEFAULT_MODE_SCENE_CATALOG_PATH
),
help="JSON path synchronized from preset and user-created mode cards",
)
parser.add_argument(
"--scene-server-id",
default=os.getenv("N7_SCENE_SERVER_ID", "n7-cognitive-agent"),
help="server_id written to the N7 scene catalog",
)
args = parser.parse_args()
scene_catalog_status = SeatDemoHandler.service.configure_mode_scene_catalog(
args.scene_catalog,
server_id=args.scene_server_id,
)
server = ThreadingHTTPServer((args.host, args.port), SeatDemoHandler)
SeatDemoHandler.speech.start()
SeatDemoHandler.assistant.start(
@@ -3331,6 +3374,20 @@ def main() -> None:
)
else:
print(f"Cabin orchestration demo: http://{args.host}:{args.port}", flush=True)
if scene_catalog_status["state"] == "ready":
print(
"N7 mode scene catalog: "
f"{scene_catalog_status['path']} "
f"({scene_catalog_status['scene_count']} scenes)",
flush=True,
)
else:
print(
"N7 mode scene catalog synchronization unavailable: "
f"{scene_catalog_status.get('last_error') or 'unknown error'}",
file=sys.stderr,
flush=True,
)
hardware_execution_ready = any(
(
SeatDemoHandler.service.hardware_settings.configured,