diff --git a/README.md b/README.md index 5b23128..8bc7001 100755 --- a/README.md +++ b/README.md @@ -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)。 diff --git a/src/cognitive_agent.egg-info/SOURCES.txt b/src/cognitive_agent.egg-info/SOURCES.txt index 3517530..2f98f65 100755 --- a/src/cognitive_agent.egg-info/SOURCES.txt +++ b/src/cognitive_agent.egg-info/SOURCES.txt @@ -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 \ No newline at end of file diff --git a/src/cognitive_agent/application/mode_profile.py b/src/cognitive_agent/application/mode_profile.py index f2ca53e..2f0648b 100644 --- a/src/cognitive_agent/application/mode_profile.py +++ b/src/cognitive_agent/application/mode_profile.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) diff --git a/src/cognitive_agent/application/mode_scene_catalog.py b/src/cognitive_agent/application/mode_scene_catalog.py new file mode 100644 index 0000000..f41f10a --- /dev/null +++ b/src/cognitive_agent/application/mode_scene_catalog.py @@ -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 diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py index 5a4a7ab..1a722d7 100755 --- a/src/cognitive_agent/application/seat_service.py +++ b/src/cognitive_agent/application/seat_service.py @@ -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], diff --git a/src/cognitive_agent/lan_scene_server.py b/src/cognitive_agent/lan_scene_server.py new file mode 100644 index 0000000..bb2415c --- /dev/null +++ b/src/cognitive_agent/lan_scene_server.py @@ -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() diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index 0918f36..1b3ed83 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -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, diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index 1c19d9d..39e8bc5 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -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: diff --git a/tests/unit/test_mode_scene_catalog.py b/tests/unit/test_mode_scene_catalog.py new file mode 100644 index 0000000..f8ec607 --- /dev/null +++ b/tests/unit/test_mode_scene_catalog.py @@ -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 diff --git a/tools/start_n7web.py b/tools/start_n7web.py new file mode 100644 index 0000000..d7270c0 --- /dev/null +++ b/tools/start_n7web.py @@ -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())