添加模式卡片下发

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
+23
View File
@@ -172,6 +172,29 @@ config/gesture/default.yaml
同一局域网的其他电脑需要体验时,使用 `make dev-lan` 监听全部网络接口。服务启动日志会打印实际 LAN 地址,例如 `http://172.20.10.5:8000`。前端和 API 使用同源访问,不需要 CORS,也不会把 `OPENAI_API_KEY` 发送到浏览器。若没有启用 `.env.auth`,任何可达设备都能触发服务端 GPT 请求,因此未认证的 LAN 模式只应在可信网络内临时演示。
### N7 模式卡片局域网目录
服务正常启动时,会把当前车型可见的工程预设模式和用户自定义模式原子同步到相邻目录 `../scenses/demo-scenes.json`,并提供无需登录、只读的 `GET /n7/scenes`。YAML 模式库仍是唯一真源;创建、更新、删除、恢复模式都会刷新 JSON,原有模式执行和安全校验链路不变。JSON 保留 `n7-scenes` v1 的 `id/label/background_time/description/actions` 字段,同时在动作中保留完整的 `domain/capability/target_scope/operation/parameters`,因此座椅、空调、天幕和氛围灯配置不会在导出时丢失。可用 `N7_SCENE_CATALOG_PATH` 或启动参数 `--scene-catalog` 覆盖输出路径。
主项目和客户端只读目录服务可以用一个监督脚本同时启动:
```powershell
.\.venv\Scripts\python.exe tools\start_n7web.py
```
脚本自动设置 `PYTHONPATH`,主页面默认仅监听 `127.0.0.1:8000`,模式卡片 HTTP/UDP 服务监听 `0.0.0.0:8090`。需要让可信局域网内的设备同时访问主页面时,使用 `--host 0.0.0.0`;可通过 `--port`、`--scene-port`、`--catalog` 和 `--server-id` 覆盖其他参数。任一子服务异常退出或按一次 `Ctrl+C` 时,脚本会停止另一个服务。
同步由模式注册表的创建、更新、删除、恢复和显式重载事件触发,不会持续扫描目录。每次变更只重新生成并原子替换一份小型 JSON;独立目录服务在收到 HTTP 请求时才读取它。因此空闲时的额外开销主要是一个等待请求的 Python 进程和一个 UDP 发现线程,而不是持续的 CPU 或磁盘轮询。
如果只需单独启动 UDP 发现和只读目录服务,也可以在另一个 PowerShell 终端运行:
```powershell
$env:PYTHONPATH = "src;domains/seat/src"
.\.venv\Scripts\python.exe -m cognitive_agent.lan_scene_server --bind 0.0.0.0 --port 8090
```
客户端向 UDP `8090` 发送 `N7_SCENES_DISCOVER_V1`,收到 `N7_SCENES_V1` 后访问 `http://<服务端IP>:8090/n7/scenes`。独立服务每次请求都读取最新的 JSON,并为只读接口开放 CORS;它不提供模式创建、修改、删除或执行能力。
Linux 上使用 USBCANFD-200U 被动同步 CH1 车辆状态时,使用
`zlgcan-linux` 后端。USB 权限、60 MHz/500k/2M 位时序、只读配置和状态接口
验证见 [Linux ZLG 车辆状态接入](docs/workflows/linux-zlg-vehicle-state.md)。
+9
View File
@@ -9,19 +9,25 @@ src/cognitive_agent.egg-info/SOURCES.txt
src/cognitive_agent.egg-info/dependency_links.txt
src/cognitive_agent.egg-info/requires.txt
src/cognitive_agent.egg-info/top_level.txt
src/cognitive_agent/application/assistant_capabilities.py
src/cognitive_agent/application/assistant_plan_guard.py
src/cognitive_agent/application/capability_registry.py
src/cognitive_agent/application/chinese_values.py
src/cognitive_agent/application/codex_assistant.py
src/cognitive_agent/application/conversation_state.py
src/cognitive_agent/application/direct_can_control.py
src/cognitive_agent/application/domain_runtime.py
src/cognitive_agent/application/gesture_bridge.py
src/cognitive_agent/application/mode_profile.py
src/cognitive_agent/application/mode_profile_intent.py
src/cognitive_agent/application/model_plan_adapter.py
src/cognitive_agent/application/mqtt_ota_seat_executor.py
src/cognitive_agent/application/native_can_feedback.py
src/cognitive_agent/application/native_can_intent.py
src/cognitive_agent/application/native_can_relative.py
src/cognitive_agent/application/native_can_runtime.py
src/cognitive_agent/application/scene_composer.py
src/cognitive_agent/application/scene_templates.py
src/cognitive_agent/application/seat_calibration.py
src/cognitive_agent/application/seat_service.py
src/cognitive_agent/application/self_extension.py
@@ -51,6 +57,8 @@ src/cognitive_agent/infrastructure/can/matrix.py
src/cognitive_agent/infrastructure/can/mqtt_transport.py
src/cognitive_agent/infrastructure/can/zlgcan_bridge.py
src/cognitive_agent/infrastructure/can/zlgcan_linux_bridge.py
src/cognitive_agent/infrastructure/codex/app_server.py
src/cognitive_agent/infrastructure/codex/vehicle_mcp_server.py
src/cognitive_agent/infrastructure/firmware/__init__.py
src/cognitive_agent/infrastructure/firmware/t2g_seat.py
src/cognitive_agent/infrastructure/llm/openai_responses.py
@@ -68,6 +76,7 @@ src/cognitive_agent/infrastructure/speech/tracing.py
src/cognitive_agent/infrastructure/speech/understanding.py
src/cognitive_agent/infrastructure/speech/vad.py
src/cognitive_agent/infrastructure/speech/vocabulary.py
src/cognitive_agent/infrastructure/speech/wake_word.py
src/cognitive_agent/runtime/contracts.py
src/cognitive_agent/testing/__init__.py
src/cognitive_agent/testing/ch1_hil.py
@@ -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,
+29
View File
@@ -134,6 +134,35 @@ class SeatWebApiTests(unittest.TestCase):
unauthenticated.open(f"{self.base_url}/api/runtime", timeout=3)
self.assertEqual(caught.exception.code, 401)
def test_n7_mode_scene_catalog_is_public_and_read_only(self) -> None:
unauthenticated = urllib.request.build_opener()
with unauthenticated.open(f"{self.base_url}/n7/scenes", timeout=3) as response:
catalog = json.loads(response.read().decode("utf-8"))
self.assertEqual(response.status, 200)
self.assertEqual(response.headers["Cache-Control"], "no-store")
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
self.assertEqual(catalog["service"], "n7-scenes")
self.assertEqual(catalog["schema_version"], 1)
self.assertEqual(catalog["vehicle_id"], "denza_n7")
self.assertGreaterEqual(catalog["scene_count"], 13)
self.assertEqual(catalog["scene_count"], len(catalog["scenes"]))
office = next(scene for scene in catalog["scenes"] if scene["id"] == "office")
self.assertIn(office["origin"], {"preset", "customized"})
self.assertTrue(office["actions"])
self.assertIn("capability", office["actions"][0])
self.assertNotIn("command_flow", json.dumps(catalog, ensure_ascii=False))
preflight = urllib.request.Request(
f"{self.base_url}/n7/scenes",
method="OPTIONS",
)
with unauthenticated.open(preflight, timeout=3) as response:
self.assertEqual(response.status, 204)
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
self.assertIn("GET", response.headers["Access-Control-Allow-Methods"])
def test_user_mode_delete_endpoint_removes_only_user_profiles(self) -> None:
original_registry = self.service.mode_profiles
with tempfile.TemporaryDirectory() as directory:
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import json
from pathlib import Path
from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.mode_scene_catalog import (
ModeSceneCatalogPublisher,
build_mode_scene_catalog,
)
from cognitive_agent.application.seat_service import REPOSITORY_ROOT
def _write_office_preset(path: Path) -> None:
path.parent.mkdir(parents=True)
path.write_text(
"""
id: office
name: 办公模式
description: N7 办公场景。
icon: briefcase
scope:
vehicle_ids: [denza_n7]
operations:
- domain: climate
capability: climate.set_temperature
target_scope: passenger
operation: set
parameters:
temperature_c: 24
""".strip()
+ "\n",
encoding="utf-8",
)
def test_publisher_tracks_create_override_and_delete(tmp_path: Path) -> None:
preset_root = tmp_path / "presets"
user_root = tmp_path / "user"
_write_office_preset(
preset_root / "vehicles" / "denza_n7" / "preset" / "office.yaml"
)
registry = ModeProfileRegistry(preset_root, user_root=user_root)
catalog_path = tmp_path / "scenses" / "demo-scenes.json"
publisher = ModeSceneCatalogPublisher(
catalog_path,
"denza_n7",
server_id="test-n7",
)
registry.add_change_listener(publisher.publish)
initial = json.loads(catalog_path.read_text(encoding="utf-8"))
assert initial["service"] == "n7-scenes"
assert initial["schema_version"] == 1
assert initial["server_id"] == "test-n7"
assert initial["scene_count"] == 1
assert initial["scenes"][0]["origin"] == "preset"
assert initial["scenes"][0]["actions"] == [
{
"type": "climate_temperature",
"at_ms": 0,
"domain": "climate",
"capability": "climate.set_temperature",
"target_scope": "passenger",
"operation": "set",
"parameters": {"temperature_c": 24},
"zone": "passenger",
"celsius": 24,
}
]
created = registry.save_mode(
"阅读模式",
[
{
"domain": "ambient_light",
"capability": "ambient_light.set_illumination",
"target_scope": "vehicle",
"parameters": {"color_hex": "#E8F3FF", "brightness_pct": 40},
}
],
vehicle_id="denza_n7",
)
after_create = json.loads(catalog_path.read_text(encoding="utf-8"))
created_scene = next(
scene for scene in after_create["scenes"] if scene["id"] == created.id
)
assert after_create["scene_count"] == 2
assert created_scene["origin"] == "user"
assert created_scene["deletable"] is True
assert created_scene["actions"][0]["parameters"]["brightness_pct"] == 40
fan_mode = registry.save_mode(
"微风模式",
[
{
"domain": "climate",
"capability": "climate.set_fan_level",
"target_scope": "vehicle",
"parameters": {"level": 2},
}
],
vehicle_id="denza_n7",
)
after_fan_create = json.loads(catalog_path.read_text(encoding="utf-8"))
fan_scene = next(
scene for scene in after_fan_create["scenes"] if scene["id"] == fan_mode.id
)
assert fan_scene["actions"][0]["type"] == "climate_fan"
assert fan_scene["actions"][0]["level"] == 2
registry.save_mode(
"专注办公模式",
[
{
"domain": "climate",
"capability": "climate.set_temperature",
"target_scope": "passenger",
"parameters": {"temperature_c": 26},
}
],
mode_id="office",
vehicle_id="denza_n7",
)
after_override = json.loads(catalog_path.read_text(encoding="utf-8"))
office = next(scene for scene in after_override["scenes"] if scene["id"] == "office")
assert office["label"] == "专注办公模式"
assert office["origin"] == "customized"
assert office["preset_override"] is True
assert office["actions"][0]["celsius"] == 26
registry.delete_user_mode(created.id, "denza_n7")
registry.delete_user_mode(fan_mode.id, "denza_n7")
registry.delete_user_mode("office", "denza_n7")
after_delete = json.loads(catalog_path.read_text(encoding="utf-8"))
assert [scene["id"] for scene in after_delete["scenes"]] == ["office"]
assert after_delete["scenes"][0]["label"] == "办公模式"
assert after_delete["scenes"][0]["origin"] == "preset"
def test_repository_presets_are_exported_without_internal_command_flows() -> None:
registry = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes")
catalog = build_mode_scene_catalog(
registry,
"denza_n7",
generated_at="2026-09-09T00:00:00Z",
)
assert catalog["scene_count"] >= 13
assert len({scene["id"] for scene in catalog["scenes"]}) == catalog["scene_count"]
by_id = {scene["id"]: scene for scene in catalog["scenes"]}
assert by_id["office"]["operation_count"] == 10
assert by_id["rocking_horse"]["behavior"] == "cyclic"
assert by_id["rocking_horse"]["timeline"]["repeat"] == "until_stopped_or_timeout"
encoded = json.dumps(catalog, ensure_ascii=False)
assert "command_flow" not in encoded
assert "source_request_id" not in encoded
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""Start N7Web and its read-only LAN mode-card service together."""
from __future__ import annotations
import argparse
import json
import os
import shlex
import subprocess
import sys
import time
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CATALOG_PATH = REPOSITORY_ROOT.parent / "scenses" / "demo-scenes.json"
PYTHON_SOURCE_PATHS = (
REPOSITORY_ROOT / "src",
REPOSITORY_ROOT / "domains" / "seat" / "src",
)
@dataclass(frozen=True)
class ChildProcess:
name: str
process: subprocess.Popen[bytes]
def _port(value: str) -> int:
try:
port = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("port must be an integer") from exc
if not 1 <= port <= 65_535:
raise argparse.ArgumentTypeError("port must be between 1 and 65535")
return port
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Start the N7Web application and the read-only LAN mode-card service. "
"Mode-card synchronization is event-driven inside the main service."
)
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="main N7Web bind address (use 0.0.0.0 for trusted-LAN access)",
)
parser.add_argument("--port", type=_port, default=8000, help="main N7Web port")
parser.add_argument(
"--scene-bind",
default="0.0.0.0",
help="read-only mode-card HTTP/UDP bind address",
)
parser.add_argument(
"--scene-port",
type=_port,
default=8090,
help="read-only mode-card HTTP/UDP port",
)
parser.add_argument(
"--catalog",
type=Path,
default=DEFAULT_CATALOG_PATH,
help="synchronized n7-scenes JSON path",
)
parser.add_argument(
"--server-id",
default="n7-cognitive-agent",
help="server_id written to and served with the catalog",
)
parser.add_argument(
"--python",
type=Path,
help="child Python executable (defaults to the repository .venv)",
)
parser.add_argument(
"--catalog-ready-timeout",
type=float,
default=30.0,
help="seconds to wait for the main service to create a valid catalog",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="print the resolved commands without starting processes",
)
return parser
def _resolve_path(path: Path) -> Path:
expanded = path.expanduser()
if not expanded.is_absolute():
expanded = REPOSITORY_ROOT / expanded
return expanded.resolve()
def _resolve_python(configured: Path | None) -> Path:
if configured is not None:
executable = _resolve_path(configured)
if not executable.is_file():
raise ValueError(f"Python executable does not exist: {executable}")
return executable
candidates = (
REPOSITORY_ROOT / ".venv" / "Scripts" / "python.exe",
REPOSITORY_ROOT / ".venv" / "bin" / "python",
)
for candidate in candidates:
if candidate.is_file():
return candidate.resolve()
return Path(sys.executable).resolve()
def _child_environment(base: Mapping[str, str] | None = None) -> dict[str, str]:
environment = dict(os.environ if base is None else base)
existing = [
entry
for entry in environment.get("PYTHONPATH", "").split(os.pathsep)
if entry
]
combined = [str(path.resolve()) for path in PYTHON_SOURCE_PATHS]
seen = {os.path.normcase(os.path.abspath(entry)) for entry in combined}
for entry in existing:
normalized = os.path.normcase(os.path.abspath(entry))
if normalized not in seen:
combined.append(entry)
seen.add(normalized)
environment["PYTHONPATH"] = os.pathsep.join(combined)
environment.setdefault("PYTHONUNBUFFERED", "1")
return environment
def _commands(
args: argparse.Namespace,
python: Path,
catalog: Path,
) -> tuple[list[str], list[str]]:
main_command = [
str(python),
"-m",
"cognitive_agent.server",
"--host",
args.host,
"--port",
str(args.port),
"--scene-catalog",
str(catalog),
"--scene-server-id",
args.server_id,
]
scene_command = [
str(python),
"-m",
"cognitive_agent.lan_scene_server",
"--bind",
args.scene_bind,
"--port",
str(args.scene_port),
"--catalog",
str(catalog),
"--server-id",
args.server_id,
]
return main_command, scene_command
def _display_command(command: Sequence[str]) -> str:
if os.name == "nt":
return subprocess.list2cmdline(command)
return shlex.join(command)
def _catalog_is_ready(path: Path) -> bool:
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
return bool(
isinstance(document, dict)
and document.get("service") == "n7-scenes"
and document.get("schema_version") == 1
and isinstance(document.get("scenes"), list)
)
def _wait_for_catalog(
path: Path,
main_process: subprocess.Popen[bytes],
timeout: float,
) -> None:
if timeout <= 0:
raise ValueError("catalog-ready-timeout must be greater than zero")
deadline = time.monotonic() + timeout
while not _catalog_is_ready(path):
exit_code = main_process.poll()
if exit_code is not None:
raise RuntimeError(
f"main N7Web service exited before the catalog was ready "
f"(exit code {exit_code})"
)
if time.monotonic() >= deadline:
raise RuntimeError(
f"timed out waiting for the synchronized catalog: {path}"
)
time.sleep(0.1)
def _wait_for_exit(children: Sequence[ChildProcess]) -> tuple[str, int]:
while True:
for child in children:
exit_code = child.process.poll()
if exit_code is not None:
return child.name, exit_code
time.sleep(0.25)
def _stop_children(
children: Sequence[ChildProcess],
*,
interrupt_was_broadcast: bool,
) -> None:
# Console Ctrl+C is delivered to the parent and both children. Give the
# services time to run their own finally blocks before escalating.
if interrupt_was_broadcast:
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if all(child.process.poll() is not None for child in children):
return
time.sleep(0.1)
running = [child for child in children if child.process.poll() is None]
for child in running:
child.process.terminate()
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if all(child.process.poll() is not None for child in running):
return
time.sleep(0.1)
for child in running:
if child.process.poll() is None:
child.process.kill()
def run(argv: Sequence[str] | None = None) -> int:
args = _parser().parse_args(argv)
if not args.server_id.strip():
print("error: server-id must not be empty", file=sys.stderr)
return 2
try:
python = _resolve_python(args.python)
catalog = _resolve_path(args.catalog)
if args.catalog_ready_timeout <= 0:
raise ValueError("catalog-ready-timeout must be greater than zero")
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
environment = _child_environment()
main_command, scene_command = _commands(args, python, catalog)
print(f"Repository: {REPOSITORY_ROOT}", flush=True)
print(f"Catalog: {catalog}", flush=True)
print(f"Main: {_display_command(main_command)}", flush=True)
print(f"Cards: {_display_command(scene_command)}", flush=True)
if args.dry_run:
return 0
children: list[ChildProcess] = []
interrupted = False
try:
main_process = subprocess.Popen(
main_command,
cwd=REPOSITORY_ROOT,
env=environment,
)
children.append(ChildProcess("main N7Web service", main_process))
_wait_for_catalog(catalog, main_process, args.catalog_ready_timeout)
scene_process = subprocess.Popen(
scene_command,
cwd=REPOSITORY_ROOT,
env=environment,
)
children.append(ChildProcess("LAN mode-card service", scene_process))
print(
"Both services are running; press Ctrl+C once to stop both.",
flush=True,
)
name, exit_code = _wait_for_exit(children)
print(f"{name} exited (exit code {exit_code}); stopping the other service.")
return exit_code if exit_code != 0 else 1
except KeyboardInterrupt:
interrupted = True
print("\nStopping N7Web services...", flush=True)
return 0
except (OSError, RuntimeError, ValueError) as exc:
print(f"startup failed: {exc}", file=sys.stderr, flush=True)
return 1
finally:
_stop_children(children, interrupt_was_broadcast=interrupted)
if __name__ == "__main__":
raise SystemExit(run())