添加模式卡片下发
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user