c4efdbe22f
# Conflicts: # apps/web-console/service-worker.js # src/cognitive_agent/application/mode_profile.py # src/cognitive_agent/application/mode_scene_catalog.py # src/cognitive_agent/application/seat_service.py # tests/e2e/test_seat_web_api.py # tests/unit/test_assistant_frontend_contract.py # tests/unit/test_vue_frontend_contract.py
4456 lines
181 KiB
Python
Executable File
4456 lines
181 KiB
Python
Executable File
"""Dependency-light HTTP server for the cabin orchestration demo."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import hmac
|
||
import ipaddress
|
||
import json
|
||
import mimetypes
|
||
import os
|
||
import re
|
||
import socket
|
||
import sys
|
||
import threading
|
||
import time
|
||
from collections.abc import Callable, Mapping
|
||
from copy import deepcopy
|
||
from http import HTTPStatus
|
||
from http.cookies import CookieError, SimpleCookie
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
from urllib.parse import ParseResult, parse_qs, quote, unquote, urlparse
|
||
|
||
from cognitive_agent.config_loader import load_local_env
|
||
|
||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||
# Repository-local launch configuration is deliberate and must not be shadowed
|
||
# by stale variables inherited from an IDE or an earlier model experiment.
|
||
load_local_env(REPOSITORY_ROOT / ".env", override=True)
|
||
load_local_env(REPOSITORY_ROOT / ".env.auth", override=True)
|
||
|
||
from cognitive_agent.application.assistant_capabilities import ( # noqa: E402
|
||
AssistantCapabilityCatalog,
|
||
)
|
||
from cognitive_agent.application.calibration.service import ( # noqa: E402
|
||
ApiError as CalibrationApiError,
|
||
)
|
||
from cognitive_agent.application.calibration.service import ( # noqa: E402
|
||
CalibrationService,
|
||
)
|
||
from cognitive_agent.application.codex_assistant import ( # noqa: E402
|
||
CodexAssistantError,
|
||
CodexAssistantService,
|
||
CodexVehicleToolError,
|
||
)
|
||
from cognitive_agent.application.extension_control import ( # noqa: E402
|
||
ExtensionControlConsumer,
|
||
)
|
||
from cognitive_agent.application.extension_toy_publisher import ( # noqa: E402
|
||
EXTENSION_TOY_BURST_COUNT,
|
||
EXTENSION_TOY_BURST_WINDOW_S,
|
||
NfcExtensionToyPublisher,
|
||
)
|
||
from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402
|
||
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
|
||
normalize_mode_profile_name,
|
||
parse_mode_profile_update,
|
||
)
|
||
from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
|
||
MODE_SCENE_MQTT_BURST_COUNT,
|
||
MODE_SCENE_MQTT_BURST_WINDOW_S,
|
||
)
|
||
from cognitive_agent.application.pet_cards import PetCardService # noqa: E402
|
||
from cognitive_agent.application.mode_schedule_service import public_schedule_status # noqa: E402
|
||
from cognitive_agent.application.seat_service import ( # noqa: E402
|
||
LOCAL_CONTROL_BUDGET_MS,
|
||
SeatDemoService,
|
||
WIRELESS_MQTT_TRANSPORT,
|
||
)
|
||
from cognitive_agent.application.self_extension import ( # noqa: E402
|
||
ExtensionIntegrationError,
|
||
GatewayError,
|
||
SelfExtensionService,
|
||
active_peripheral_plan_catalog,
|
||
partition_active_peripheral_intent,
|
||
resolve_active_peripheral_command,
|
||
)
|
||
from cognitive_agent.application.self_repair import SelfRepairService # noqa: E402
|
||
from cognitive_agent.auth import ( # noqa: E402
|
||
AuthConfig,
|
||
AuthRuntime,
|
||
is_json_content_type,
|
||
origin_matches_host,
|
||
verify_password,
|
||
)
|
||
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
|
||
HardwareAccessRequiredError,
|
||
)
|
||
from cognitive_agent.infrastructure.can.gateway import CanGatewayError # noqa: E402
|
||
from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
|
||
HardwareBusyError,
|
||
HardwareSafetyError,
|
||
)
|
||
from cognitive_agent.infrastructure.can.mqtt_transport import ( # noqa: E402
|
||
MQTT_EXTENSION_CONTROL_TOPIC,
|
||
MQTT_EXTENSION_TOY_TOPIC,
|
||
MQTT_MODE_SCENE_TOPIC,
|
||
)
|
||
from cognitive_agent.infrastructure.codex.app_server import ( # noqa: E402
|
||
CodexAppServerClient,
|
||
CodexAppServerError,
|
||
)
|
||
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError # noqa: E402
|
||
from cognitive_agent.infrastructure.nfc import NfcReaderService # noqa: E402
|
||
from cognitive_agent.infrastructure.speech import ( # noqa: E402
|
||
SpeechRecognitionError,
|
||
SpeechTranscriber,
|
||
)
|
||
|
||
WEB_CONSOLE_SOURCE_ROOT = REPOSITORY_ROOT / "apps" / "web-console"
|
||
WEB_CONSOLE_DIST_ROOT = WEB_CONSOLE_SOURCE_ROOT / "dist"
|
||
STATIC_ROOT = (
|
||
WEB_CONSOLE_DIST_ROOT
|
||
if (WEB_CONSOLE_DIST_ROOT / "index.html").is_file()
|
||
else WEB_CONSOLE_SOURCE_ROOT
|
||
)
|
||
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-]+$")
|
||
PUBLIC_AUTH_PATHS = {
|
||
"/login",
|
||
"/login.html",
|
||
"/login.css",
|
||
"/login.js",
|
||
"/network.js",
|
||
"/api/auth/status",
|
||
"/api/auth/login",
|
||
}
|
||
NON_DISPATCH_DISPLAY_SOURCES = frozenset({"mode_editor_compile"})
|
||
|
||
|
||
class VehicleDisplaySync:
|
||
"""Process-local snapshot consumed by passive vehicle display clients."""
|
||
|
||
def __init__(self) -> None:
|
||
self._lock = threading.Lock()
|
||
self._revision = 0
|
||
self._snapshot: dict[str, object] = {
|
||
"revision": 0,
|
||
"updated_at": None,
|
||
"task": {
|
||
"status": "idle",
|
||
"utterance": "",
|
||
"source": "head_unit",
|
||
"result": None,
|
||
"error": None,
|
||
},
|
||
"extension": {
|
||
"catalog": [],
|
||
"last_action": None,
|
||
"result": None,
|
||
},
|
||
}
|
||
|
||
@staticmethod
|
||
def _safe_copy(value: object) -> object:
|
||
return json.loads(json.dumps(value, ensure_ascii=False, default=str))
|
||
|
||
def update_task(
|
||
self,
|
||
*,
|
||
status: str,
|
||
utterance: str,
|
||
source: str = "head_unit",
|
||
result: object = None,
|
||
error: object = None,
|
||
) -> None:
|
||
with self._lock:
|
||
self._revision += 1
|
||
self._snapshot["revision"] = self._revision
|
||
self._snapshot["updated_at"] = time.time()
|
||
self._snapshot["task"] = {
|
||
"status": status,
|
||
"utterance": utterance,
|
||
"source": source or "head_unit",
|
||
"result": self._safe_copy(result),
|
||
"error": self._safe_copy(error),
|
||
}
|
||
|
||
def update_extension(
|
||
self,
|
||
*,
|
||
catalog: object | None = None,
|
||
action: object | None = None,
|
||
result: object | None = None,
|
||
) -> None:
|
||
with self._lock:
|
||
self._revision += 1
|
||
current = dict(self._snapshot.get("extension", {}))
|
||
if catalog is not None:
|
||
current["catalog"] = self._safe_copy(catalog)
|
||
if action is not None:
|
||
current["last_action"] = self._safe_copy(action)
|
||
if result is not None:
|
||
current["result"] = self._safe_copy(result)
|
||
self._snapshot["revision"] = self._revision
|
||
self._snapshot["updated_at"] = time.time()
|
||
self._snapshot["extension"] = current
|
||
|
||
def snapshot(self, health: dict[str, object]) -> dict[str, object]:
|
||
with self._lock:
|
||
payload = self._safe_copy(self._snapshot)
|
||
assert isinstance(payload, dict)
|
||
payload["extension_health"] = self._safe_copy(health)
|
||
return payload
|
||
|
||
def extension_catalog(self) -> list[dict[str, object]]:
|
||
with self._lock:
|
||
extension = self._snapshot.get("extension", {})
|
||
catalog = extension.get("catalog", []) if isinstance(extension, dict) else []
|
||
payload = self._safe_copy(catalog)
|
||
if not isinstance(payload, list):
|
||
return []
|
||
return [item for item in payload if isinstance(item, dict)]
|
||
|
||
|
||
def _lan_ipv4_address() -> str | None:
|
||
"""Return the IPv4 address selected by the host's default route."""
|
||
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
try:
|
||
probe.connect(("192.0.2.1", 80))
|
||
address = str(probe.getsockname()[0])
|
||
return None if address.startswith("127.") else address
|
||
except OSError:
|
||
return None
|
||
finally:
|
||
probe.close()
|
||
|
||
|
||
class SeatDemoHandler(BaseHTTPRequestHandler):
|
||
service = SeatDemoService()
|
||
# Codex defaults to its own empty temporary workspace. A deployment may
|
||
# opt into another isolated directory through CODEX_ASSISTANT_WORKSPACE,
|
||
# but it must never inherit access to this application's source tree.
|
||
assistant = CodexAssistantService(
|
||
CodexAppServerClient(),
|
||
fast_implicit_controls=True,
|
||
)
|
||
gesture = GestureCapabilityBridge(REPOSITORY_ROOT)
|
||
extension = SelfExtensionService()
|
||
nfc = NfcReaderService()
|
||
pets = PetCardService(
|
||
nfc_provider=nfc.snapshot,
|
||
publisher=service.publish_pet_message,
|
||
)
|
||
extension_toys = NfcExtensionToyPublisher(
|
||
nfc_provider=nfc.snapshot,
|
||
catalog_provider=extension.list_peripheral_capabilities,
|
||
publisher=service.publish_extension_toy_message,
|
||
)
|
||
extension_control = ExtensionControlConsumer(
|
||
catalog_provider=extension.list_peripheral_capabilities,
|
||
executor=extension.execute_bound_infrared_action,
|
||
)
|
||
service.set_extension_control_handler(extension_control.submit)
|
||
service.set_extension_control_rejection_handler(
|
||
extension_control.reject_transport_message
|
||
)
|
||
calibration = CalibrationService(
|
||
REPOSITORY_ROOT / "artifacts" / "tmp" / "self-calibration",
|
||
repository_root=REPOSITORY_ROOT,
|
||
)
|
||
repair = SelfRepairService()
|
||
speech = SpeechTranscriber(REPOSITORY_ROOT)
|
||
# Reject excess live-preview work before reading request bodies. The
|
||
# transcriber owns a second, stricter gate so non-HTTP callers are covered too.
|
||
speech_preview_http_slots = threading.BoundedSemaphore(6)
|
||
auth = AuthRuntime(AuthConfig.from_env())
|
||
vehicle_display_sync = VehicleDisplaySync()
|
||
assistant_capability_catalog: AssistantCapabilityCatalog | None = None
|
||
server_version = "CognitiveAgentCabinDemo/2.0"
|
||
|
||
def do_GET(self) -> None: # noqa: N802 - stdlib handler API
|
||
parsed_url = urlparse(self.path)
|
||
path = unquote(parsed_url.path)
|
||
if path == "/favicon.ico":
|
||
self.send_response(HTTPStatus.NO_CONTENT)
|
||
self._send_security_headers()
|
||
self.end_headers()
|
||
return
|
||
if path == "/api/auth/status":
|
||
username = self._session_username()
|
||
self._json(
|
||
{
|
||
"enabled": self.auth.config.enabled,
|
||
"authenticated": not self.auth.config.enabled or username is not None,
|
||
"username": username,
|
||
"session_ttl_seconds": self.auth.config.session_ttl_seconds,
|
||
"requests_per_minute": self.auth.config.requests_per_minute,
|
||
}
|
||
)
|
||
return
|
||
if path == "/api/health":
|
||
assistant_status = self.assistant.status()
|
||
assistant_available = bool(assistant_status.get("available"))
|
||
self._json(
|
||
{
|
||
"status": "ok" if assistant_available else "unavailable",
|
||
"service": "cabin-cognitive-orchestrator",
|
||
"assistant": {
|
||
"engine": str(assistant_status.get("engine") or "codex-app-server"),
|
||
"status": str(assistant_status.get("status") or "unknown"),
|
||
"available": assistant_available,
|
||
},
|
||
},
|
||
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("/")
|
||
return
|
||
self._serve_static_file("login.html")
|
||
return
|
||
if not self._authorize(path):
|
||
return
|
||
if path in {"/hardware-control", "/hardware-control.html"}:
|
||
if not self._require_local_decision_point():
|
||
return
|
||
self._serve_static_file("hardware-control.html")
|
||
return
|
||
if path in {"/hardware-control.js", "/hardware-control.css"}:
|
||
if not self._require_local_decision_point():
|
||
return
|
||
self._serve_static_file(path.lstrip("/"))
|
||
return
|
||
if path == "/api/local/hardware-access":
|
||
if not self._require_local_decision_point():
|
||
return
|
||
self._json(self.service.hardware_access.admin_snapshot())
|
||
return
|
||
if path == "/api/hardware/access/status":
|
||
query = parse_qs(parsed_url.query)
|
||
try:
|
||
self._json(
|
||
self.service.hardware_access.status(
|
||
str(query.get("client_id", [""])[0])
|
||
)
|
||
)
|
||
except ValueError as exc:
|
||
self._json(
|
||
{"error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
return
|
||
if path == "/api/assistant/status":
|
||
self._json(self.assistant.status())
|
||
return
|
||
if path == "/api/assistant/capabilities":
|
||
self._json(self._assistant_capability_catalog().snapshot())
|
||
return
|
||
if path == "/api/mode-profiles/status":
|
||
self._json(self.service.mode_profile_sync_status())
|
||
return
|
||
if path == "/api/runtime":
|
||
self._json(self.service.runtime_info())
|
||
return
|
||
if path == "/api/modes/schedule/status":
|
||
query = parse_qs(parsed_url.query)
|
||
try:
|
||
vehicle = self.service.vehicle_profiles.get(str(query.get("vehicle_id", [""])[0]) or None)
|
||
self._json(public_schedule_status(self.service.mode_schedule_runner.status(vehicle.id)))
|
||
except ValueError as exc:
|
||
self._json({"error": "invalid_request", "message": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||
return
|
||
if path == "/api/vehicle-display-sync":
|
||
self._json(self.vehicle_display_sync.snapshot(self._extension_health()))
|
||
return
|
||
if path == "/api/extension/control/status":
|
||
query = parse_qs(parsed_url.query)
|
||
try:
|
||
after_revision = int(query.get("after_revision", ["-1"])[0])
|
||
timeout_ms = int(query.get("timeout_ms", ["25000"])[0])
|
||
if after_revision < -1:
|
||
raise ValueError("after_revision must be at least -1")
|
||
if not 0 <= timeout_ms <= 30_000:
|
||
raise ValueError("timeout_ms must be between 0 and 30000")
|
||
snapshot = self.extension_control.wait_for_snapshot(
|
||
after_revision,
|
||
timeout_s=timeout_ms / 1_000,
|
||
)
|
||
except (TypeError, ValueError) as exc:
|
||
self._json(
|
||
{"error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
return
|
||
self._json(snapshot)
|
||
return
|
||
if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}:
|
||
query = parse_qs(parsed_url.query)
|
||
conversation_id = str(query.get("conversation_id", [""])[0])
|
||
vehicle_id = str(query.get("vehicle_id", [""])[0]) or None
|
||
control_transport = str(
|
||
query.get("control_transport", ["wired_can"])[0]
|
||
)
|
||
hardware_client_id = str(query.get("hardware_client_id", [""])[0]) or None
|
||
try:
|
||
self._json(
|
||
(
|
||
self.service.synchronize_vehicle_state
|
||
if path == "/api/hardware/vehicle-state"
|
||
else self.service.synchronize_hardware_state
|
||
)(
|
||
conversation_id,
|
||
vehicle_id,
|
||
control_transport=control_transport,
|
||
hardware_client_id=hardware_client_id,
|
||
hardware_access_bypass=(
|
||
self._passive_state_access_bypass_allowed(
|
||
control_transport
|
||
)
|
||
),
|
||
)
|
||
)
|
||
except HardwareAccessRequiredError as exc:
|
||
self._json(
|
||
{
|
||
"error": "hardware_access_required",
|
||
"message": str(exc),
|
||
"access_state": exc.state,
|
||
},
|
||
HTTPStatus.FORBIDDEN,
|
||
)
|
||
except HardwareBusyError as exc:
|
||
self._json(
|
||
{
|
||
"error": "hardware_busy",
|
||
"message": str(exc),
|
||
"retry_after_seconds": exc.retry_after_seconds,
|
||
},
|
||
HTTPStatus.CONFLICT,
|
||
{"Retry-After": str(exc.retry_after_seconds)},
|
||
)
|
||
except ValueError as exc:
|
||
self._json(
|
||
{"error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
except Exception as exc: # noqa: BLE001 - surface passive CAN availability
|
||
self.log_error("physical CAN state synchronization failed: %s", exc)
|
||
self._json(
|
||
{
|
||
"error": "hardware_state_unavailable",
|
||
"message": str(exc),
|
||
},
|
||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||
)
|
||
return
|
||
if path == "/api/gesture/runtime":
|
||
self._json(self.gesture.runtime_info())
|
||
return
|
||
if path == "/api/gesture/model":
|
||
self._serve_gesture_model()
|
||
return
|
||
if path == "/api/extension/health":
|
||
self._json(self._extension_health())
|
||
return
|
||
if path == "/api/extension/nfc/status":
|
||
self._json(
|
||
{
|
||
"success": True,
|
||
**self.nfc.snapshot(),
|
||
"pet_cards": self.pets.snapshot(),
|
||
"extension_toy": self.extension_toys.snapshot(),
|
||
}
|
||
)
|
||
return
|
||
if path == "/api/extension/pets":
|
||
self._json({"success": True, **self.pets.snapshot()})
|
||
return
|
||
if path == "/api/extension/gscu/slots":
|
||
try:
|
||
self._json(self.extension.list_infrared_slots())
|
||
except GatewayError as exc:
|
||
self._json(
|
||
{"success": False, "error": "catalog_unavailable", "message": str(exc)},
|
||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||
)
|
||
return
|
||
if path == "/api/extension/peripherals":
|
||
try:
|
||
catalog = self.extension.list_peripheral_capabilities()
|
||
self._json(
|
||
{
|
||
"success": True,
|
||
"catalog": catalog,
|
||
"count": len(catalog),
|
||
}
|
||
)
|
||
except GatewayError as exc:
|
||
self._json(
|
||
{
|
||
"success": False,
|
||
"error": "catalog_unavailable",
|
||
"message": str(exc),
|
||
},
|
||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||
)
|
||
return
|
||
if path == "/api/extension/gscu/info":
|
||
self._handle_extension_gateway("info", {})
|
||
return
|
||
if path.startswith("/api/calibration/"):
|
||
if not self._require_calibration_admin(parsed_url):
|
||
return
|
||
if path == "/api/calibration/state":
|
||
self._json(self.calibration.snapshot())
|
||
return
|
||
if path == "/api/calibration/events":
|
||
self._serve_calibration_events()
|
||
return
|
||
if path == "/api/calibration/video/stream":
|
||
self._serve_calibration_video()
|
||
return
|
||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||
return
|
||
if path.startswith("/api/self-repair/"):
|
||
if not self._require_self_repair_admin(parsed_url):
|
||
return
|
||
if path == "/api/self-repair/state":
|
||
self._json(self.repair.snapshot())
|
||
return
|
||
if path == "/api/self-repair/health":
|
||
self._json(self.repair.health())
|
||
return
|
||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||
return
|
||
if path == "/api/speech/status":
|
||
self._json(self.speech.status())
|
||
return
|
||
if path.startswith("/api/requests/"):
|
||
self._serve_request(path.removeprefix("/api/requests/"))
|
||
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():
|
||
return
|
||
if path == "/api/internal/codex/vehicle-control":
|
||
if not self._require_json_content_type():
|
||
return
|
||
self._handle_codex_vehicle_control()
|
||
return
|
||
if path == "/api/auth/login":
|
||
if not self._require_json_content_type():
|
||
return
|
||
self._login()
|
||
return
|
||
if path == "/api/auth/logout":
|
||
self._logout()
|
||
return
|
||
if not self._authorize(path):
|
||
return
|
||
if path == "/api/assistant/stream":
|
||
if not self._require_json_content_type():
|
||
return
|
||
self._handle_assistant_stream()
|
||
return
|
||
if path == "/api/speech/transcribe":
|
||
self._handle_speech_transcription()
|
||
return
|
||
if path == "/api/speech/preview":
|
||
self._handle_speech_preview()
|
||
return
|
||
if not self._require_json_content_type():
|
||
return
|
||
if path in {
|
||
"/api/hardware/access/request",
|
||
"/api/hardware/access/release",
|
||
"/api/local/hardware-access/approve",
|
||
"/api/local/hardware-access/deny",
|
||
"/api/local/hardware-access/revoke",
|
||
}:
|
||
self._handle_hardware_access_post(path)
|
||
return
|
||
if path.startswith("/api/extension/"):
|
||
self._handle_extension_post(path)
|
||
return
|
||
if path.startswith("/api/calibration/"):
|
||
self._handle_calibration_post(path)
|
||
return
|
||
if path.startswith("/api/self-repair/"):
|
||
self._handle_self_repair_post(path)
|
||
return
|
||
if path == "/api/mode-profile-drafts/trial":
|
||
self._handle_mode_profile_draft_trial()
|
||
return
|
||
if path == "/api/mode-profiles" or path.startswith("/api/mode-profiles/"):
|
||
self._handle_mode_profile_post(path)
|
||
return
|
||
supplement_match = re.fullmatch(
|
||
r"/api/requests/(?P<request_id>req-[a-z0-9-]+)/supplement",
|
||
path,
|
||
)
|
||
if (
|
||
path not in {
|
||
"/api/requests",
|
||
"/api/requests/preview",
|
||
"/api/can/commands",
|
||
}
|
||
and not supplement_match
|
||
):
|
||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||
return
|
||
|
||
utterance = ""
|
||
source = "head_unit"
|
||
sync_vehicle_task = True
|
||
|
||
def fail_display_sync(code: str, error: Exception) -> None:
|
||
if not utterance or not sync_vehicle_task:
|
||
return
|
||
self.vehicle_display_sync.update_task(
|
||
status="failed",
|
||
utterance=utterance,
|
||
source=source,
|
||
error={
|
||
"code": code,
|
||
"message": str(error),
|
||
"outcome": "failed_or_blocked",
|
||
},
|
||
)
|
||
|
||
try:
|
||
username = self._session_username() or "local"
|
||
retry_after = self.auth.api_requests.retry_after(username)
|
||
if self.auth.config.enabled and retry_after:
|
||
self._json(
|
||
{
|
||
"error": "rate_limited",
|
||
"message": "请求过于频繁,请稍后再试。",
|
||
},
|
||
HTTPStatus.TOO_MANY_REQUESTS,
|
||
{"Retry-After": str(retry_after)},
|
||
)
|
||
return
|
||
if self.auth.config.enabled:
|
||
self.auth.api_requests.record(username)
|
||
payload = self._read_json_body()
|
||
direct_can_commands = (
|
||
payload.get("commands") if path == "/api/can/commands" else None
|
||
)
|
||
utterance = (
|
||
";".join(
|
||
item if isinstance(item, str) else repr(item)
|
||
for item in direct_can_commands
|
||
)
|
||
if isinstance(direct_can_commands, list)
|
||
else str(payload.get("utterance", ""))
|
||
)
|
||
source = str(payload.get("client_type", "head_unit"))
|
||
sync_vehicle_task = source not in NON_DISPATCH_DISPLAY_SOURCES
|
||
if utterance and sync_vehicle_task:
|
||
self.vehicle_display_sync.update_task(
|
||
status="running",
|
||
utterance=utterance,
|
||
source=source,
|
||
)
|
||
hardware_client_id = (
|
||
str(payload["hardware_client_id"])
|
||
if payload.get("hardware_client_id")
|
||
else None
|
||
)
|
||
reasoning_effort = (
|
||
str(payload["reasoning_effort"])
|
||
if payload.get("reasoning_effort") is not None
|
||
else None
|
||
)
|
||
provider = str(payload.get("provider", "auto"))
|
||
execution_mode = str(payload.get("execution_mode", "dry_run"))
|
||
conversation_id = (
|
||
str(payload["conversation_id"])
|
||
if payload.get("conversation_id")
|
||
else None
|
||
)
|
||
common_arguments = {
|
||
"utterance": utterance,
|
||
"provider": provider,
|
||
"conversation_id": conversation_id,
|
||
"execution_mode": execution_mode,
|
||
# Hardware authorization is a server deployment policy. The
|
||
# browser never receives or transmits this secret.
|
||
"approval_token": os.getenv(
|
||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
|
||
),
|
||
"vehicle_id": (
|
||
str(payload["vehicle_id"])
|
||
if payload.get("vehicle_id")
|
||
else None
|
||
),
|
||
"simulation_fault": str(
|
||
payload.get("simulation_fault", "normal")
|
||
),
|
||
"hardware_client_id": hardware_client_id,
|
||
"hardware_access_bypass": self._hardware_access_bypass_allowed(),
|
||
"reasoning_effort": reasoning_effort,
|
||
"control_transport": str(
|
||
payload.get("control_transport", "wired_can")
|
||
),
|
||
}
|
||
speech_utterance_id = str(
|
||
payload.get("speech_utterance_id", "")
|
||
).strip()
|
||
speech_understanding: dict[str, object] | None = None
|
||
speech_peripheral_route = False
|
||
if speech_utterance_id:
|
||
if path != "/api/requests":
|
||
raise ValueError(
|
||
"speech semantic execution is only accepted by /api/requests"
|
||
)
|
||
speech_understanding = self.speech.cached_result(
|
||
speech_utterance_id
|
||
)
|
||
clean_text = str(
|
||
speech_understanding.get("clean_text", "")
|
||
).strip()
|
||
raw_text = str(
|
||
speech_understanding.get("raw_text", "")
|
||
).strip()
|
||
peripheral_routing = self._speech_peripheral_routing(raw_text)
|
||
peripheral_transcript = bool(
|
||
peripheral_routing.get("routing_eligible") and raw_text
|
||
)
|
||
expected_text = raw_text if peripheral_transcript else clean_text
|
||
if utterance != expected_text:
|
||
raise SpeechRecognitionError(
|
||
"speech_text_changed",
|
||
"语音文本已被修改,请确认修改后的内容再执行。",
|
||
status=409,
|
||
)
|
||
speech_peripheral_route = peripheral_transcript
|
||
if (
|
||
speech_understanding.get("execution_eligible") is not True
|
||
and not speech_peripheral_route
|
||
):
|
||
raise SpeechRecognitionError(
|
||
"speech_not_executable",
|
||
"语音意图尚未通过能力或参数校验,已拒绝执行。",
|
||
status=422,
|
||
)
|
||
provider = "local"
|
||
common_arguments["provider"] = provider
|
||
if not speech_peripheral_route:
|
||
common_arguments["_speech_understanding"] = speech_understanding
|
||
if (
|
||
path != "/api/can/commands"
|
||
and "application/x-ndjson" in self.headers.get("Accept", "")
|
||
):
|
||
self._stream_control_request(
|
||
path,
|
||
supplement_match,
|
||
payload,
|
||
common_arguments,
|
||
source=source,
|
||
)
|
||
return
|
||
if path == "/api/can/commands":
|
||
result = self.service.process_can_commands(
|
||
direct_can_commands,
|
||
conversation_id=(
|
||
str(payload["conversation_id"])
|
||
if payload.get("conversation_id")
|
||
else None
|
||
),
|
||
execution_mode=str(payload.get("execution_mode", "dry_run")),
|
||
approval_token=os.getenv(
|
||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
|
||
),
|
||
vehicle_id=(
|
||
str(payload["vehicle_id"])
|
||
if payload.get("vehicle_id")
|
||
else None
|
||
),
|
||
simulation_fault=str(
|
||
payload.get("simulation_fault", "normal")
|
||
),
|
||
hardware_client_id=hardware_client_id,
|
||
hardware_access_bypass=self._hardware_access_bypass_allowed(),
|
||
control_transport=str(
|
||
payload.get("control_transport", "wired_can")
|
||
),
|
||
)
|
||
else:
|
||
result = None
|
||
if path in {"/api/requests", "/api/requests/preview"}:
|
||
result = self._execute_request_with_peripherals(
|
||
path,
|
||
common_arguments,
|
||
)
|
||
if result is None:
|
||
if path == "/api/requests/preview":
|
||
result = self.service.preview_native_can(**common_arguments)
|
||
elif supplement_match:
|
||
result = self.service.supplement_native_can(
|
||
supplement_match.group("request_id"),
|
||
provider=(
|
||
str(payload["provider"])
|
||
if payload.get("provider")
|
||
else None
|
||
),
|
||
approval_token=os.getenv(
|
||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
|
||
),
|
||
hardware_client_id=hardware_client_id,
|
||
hardware_access_bypass=self._hardware_access_bypass_allowed(),
|
||
reasoning_effort=reasoning_effort,
|
||
)
|
||
else:
|
||
result = self.service.process(**common_arguments)
|
||
if speech_peripheral_route and speech_understanding is not None:
|
||
result["speech_understanding"] = {
|
||
"utterance_id": speech_utterance_id,
|
||
"raw_text": speech_understanding.get("raw_text"),
|
||
"clean_text": speech_understanding.get("clean_text"),
|
||
"semantic_status": speech_understanding.get("semantic_status"),
|
||
"routing": "validated_peripheral_text",
|
||
}
|
||
utterance = str(result.get("utterance") or utterance)
|
||
if sync_vehicle_task:
|
||
self.vehicle_display_sync.update_task(
|
||
status=str(result.get("status", "succeeded")),
|
||
utterance=utterance,
|
||
source=source,
|
||
result=result,
|
||
)
|
||
self._json(result, HTTPStatus.CREATED)
|
||
except HardwareAccessRequiredError as exc:
|
||
fail_display_sync("hardware_access_required", exc)
|
||
self._json(
|
||
{
|
||
"error": "hardware_access_required",
|
||
"message": str(exc),
|
||
"outcome": "failed_or_blocked",
|
||
"access_state": exc.state,
|
||
},
|
||
HTTPStatus.FORBIDDEN,
|
||
)
|
||
except HardwareBusyError as exc:
|
||
fail_display_sync("hardware_busy", exc)
|
||
self._json(
|
||
{
|
||
"error": "hardware_busy",
|
||
"message": str(exc),
|
||
"outcome": "failed_or_blocked",
|
||
"retry_after_seconds": exc.retry_after_seconds,
|
||
},
|
||
HTTPStatus.CONFLICT,
|
||
{"Retry-After": str(exc.retry_after_seconds)},
|
||
)
|
||
except HardwareSafetyError as exc:
|
||
fail_display_sync("hardware_safety_gate", exc)
|
||
self._json(
|
||
{
|
||
"error": "hardware_safety_gate",
|
||
"message": str(exc),
|
||
"outcome": "failed_or_blocked",
|
||
},
|
||
HTTPStatus.FORBIDDEN,
|
||
)
|
||
except CanGatewayError as exc:
|
||
fail_display_sync("can_transport_failed", exc)
|
||
self._json(
|
||
{
|
||
"error": "can_transport_failed",
|
||
"message": str(exc),
|
||
"outcome": "failed_or_blocked",
|
||
},
|
||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||
)
|
||
except GatewayError as exc:
|
||
fail_display_sync("gscu_unavailable", exc)
|
||
self._json(
|
||
{"error": "gscu_unavailable", "message": str(exc)},
|
||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||
)
|
||
except ExtensionIntegrationError as exc:
|
||
fail_display_sync("extension_request_ambiguous", exc)
|
||
self._json(
|
||
{"error": "extension_request_ambiguous", "message": str(exc)},
|
||
HTTPStatus.UNPROCESSABLE_ENTITY,
|
||
)
|
||
except SpeechRecognitionError as exc:
|
||
fail_display_sync(exc.code, exc)
|
||
self._json(
|
||
{"error": exc.code, "message": str(exc)},
|
||
HTTPStatus(exc.status),
|
||
)
|
||
except (ValueError, json.JSONDecodeError) as exc:
|
||
fail_display_sync("invalid_request", exc)
|
||
self._json(
|
||
{
|
||
"error": "invalid_request",
|
||
"message": str(exc),
|
||
"outcome": "failed_or_blocked",
|
||
},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
except OpenAIIntentError as exc:
|
||
fail_display_sync("openai_request_failed", exc)
|
||
self.log_error("OpenAI intent request failed: %s", exc)
|
||
self._json(
|
||
{
|
||
"error": "openai_request_failed",
|
||
"message": "GPT 意图解析失败,请检查 API Key、模型权限或网络状态。",
|
||
"outcome": "failed_or_blocked",
|
||
},
|
||
HTTPStatus.BAD_GATEWAY,
|
||
)
|
||
except Exception as exc: # noqa: BLE001 - keep the demo API responsive
|
||
fail_display_sync("internal_error", exc)
|
||
self.log_error("request failed: %s", exc)
|
||
self._json(
|
||
{
|
||
"error": "internal_error",
|
||
"message": "请求处理失败,请查看服务端日志。",
|
||
"outcome": "failed_or_blocked",
|
||
"detail": str(exc),
|
||
},
|
||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||
)
|
||
|
||
def _peripheral_routing_catalog(self) -> list[dict[str, object]]:
|
||
"""Read the parse catalog while remaining compatible with test doubles."""
|
||
|
||
provider = getattr(
|
||
self.extension,
|
||
"list_peripheral_routing_capabilities",
|
||
None,
|
||
)
|
||
if callable(provider):
|
||
return list(provider())
|
||
return list(self.extension.list_peripheral_capabilities())
|
||
|
||
def _execute_active_peripheral_request(
|
||
self,
|
||
utterance: str,
|
||
*,
|
||
provider: str,
|
||
conversation_id: str | None,
|
||
execution_mode: str,
|
||
on_commands_validated: Callable[[dict[str, object]], None] | None = None,
|
||
natural_language_routing: Mapping[str, object] | None = None,
|
||
) -> dict[str, object] | None:
|
||
"""Route a command to a persistently NFC-bound peripheral ability."""
|
||
|
||
# The browser may disappear or reload between learning and execution.
|
||
# Natural-language routing therefore reads the durable, validated
|
||
# whitelist instead of trusting the display-sync snapshot.
|
||
extension_catalog = self.extension.list_peripheral_capabilities()
|
||
nfc_snapshot = self.nfc.snapshot()
|
||
plan_catalog = active_peripheral_plan_catalog(
|
||
utterance,
|
||
extension_catalog,
|
||
nfc_snapshot,
|
||
)
|
||
peripheral = None
|
||
if not plan_catalog:
|
||
peripheral = resolve_active_peripheral_command(
|
||
utterance,
|
||
extension_catalog,
|
||
nfc_snapshot,
|
||
)
|
||
if not plan_catalog and peripheral is None:
|
||
return None
|
||
if conversation_id and not re.fullmatch(
|
||
r"conv-[a-z0-9-]+", conversation_id
|
||
):
|
||
raise ValueError(
|
||
"conversation_id must start with conv- and contain only "
|
||
"lowercase letters, numbers, or hyphens"
|
||
)
|
||
conversation_turn = 1
|
||
if conversation_id:
|
||
context = self.service.conversations.snapshot(conversation_id)
|
||
conversation_turn = int(context.get("turn_count", 0)) + 1
|
||
if plan_catalog:
|
||
plan_result = self.extension.plan(
|
||
{
|
||
"utterance": utterance,
|
||
"peripherals": plan_catalog,
|
||
"vehicle_devices": [],
|
||
"provider": provider,
|
||
**(
|
||
{
|
||
"natural_language_routing": dict(
|
||
natural_language_routing
|
||
)
|
||
}
|
||
if natural_language_routing is not None
|
||
else {}
|
||
),
|
||
}
|
||
)
|
||
if plan_result.get("needs_confirmation"):
|
||
raise ExtensionIntegrationError(
|
||
str(plan_result.get("question") or "请明确要控制的外设。")
|
||
)
|
||
result = self.extension.execute_peripheral_plan_request(
|
||
utterance,
|
||
plan_result,
|
||
plan_catalog,
|
||
conversation_id=conversation_id,
|
||
conversation_turn=conversation_turn,
|
||
execution_mode=execution_mode,
|
||
provider_requested=provider,
|
||
on_commands_validated=on_commands_validated,
|
||
)
|
||
self.vehicle_display_sync.update_extension(
|
||
action={
|
||
"type": "sequence_emit",
|
||
"source": "main_orchestration",
|
||
"step_count": len(plan_result.get("steps", [])),
|
||
},
|
||
result=result,
|
||
)
|
||
return result
|
||
|
||
assert peripheral is not None
|
||
result = self.extension.execute_peripheral_request(
|
||
utterance,
|
||
peripheral,
|
||
conversation_id=conversation_id,
|
||
conversation_turn=conversation_turn,
|
||
execution_mode=execution_mode,
|
||
provider_requested=provider,
|
||
on_commands_validated=on_commands_validated,
|
||
)
|
||
self.vehicle_display_sync.update_extension(
|
||
action={
|
||
"type": "emit",
|
||
"source": "main_orchestration",
|
||
"slot": peripheral["slot"],
|
||
"capability_id": peripheral.get("id"),
|
||
},
|
||
result=result,
|
||
)
|
||
return result
|
||
|
||
def _speech_peripheral_routing(self, utterance: str) -> dict[str, object]:
|
||
"""Validate whether an ASR transcript may enter peripheral routing.
|
||
|
||
Vehicle speech keeps using its cached structured semantic payload. A
|
||
learned peripheral cannot be represented by the static vehicle
|
||
capability catalog, so it may instead reuse the exact server-owned ASR
|
||
transcript once the learned device and action resolve. Execution
|
||
authorization is checked separately so an unbound device can be shown
|
||
as parsed-and-blocked without ever reaching the infrared transport.
|
||
"""
|
||
|
||
text = str(utterance or "").strip()
|
||
if not text:
|
||
return {"execution_eligible": False, "action_count": 0}
|
||
try:
|
||
catalog = self._peripheral_routing_catalog()
|
||
nfc_snapshot = self.nfc.snapshot()
|
||
partition = partition_active_peripheral_intent(
|
||
text,
|
||
catalog,
|
||
nfc_snapshot,
|
||
)
|
||
entries = list(partition.get("peripheral_actions") or [])
|
||
unresolved = list(
|
||
partition.get("unresolved_peripheral_clauses") or []
|
||
)
|
||
denied = list(partition.get("denied_peripheral_actions") or [])
|
||
plan_catalog = active_peripheral_plan_catalog(
|
||
text,
|
||
catalog,
|
||
nfc_snapshot,
|
||
)
|
||
direct = None
|
||
if not entries and not plan_catalog and not unresolved and not denied:
|
||
direct = resolve_active_peripheral_command(
|
||
text,
|
||
catalog,
|
||
nfc_snapshot,
|
||
)
|
||
except (ExtensionIntegrationError, GatewayError, ValueError):
|
||
return {"execution_eligible": False, "action_count": 0}
|
||
action_count = len(entries) or (1 if direct is not None else 0)
|
||
if plan_catalog:
|
||
action_count = max(action_count, 1)
|
||
routing_eligible = bool(action_count or unresolved or denied)
|
||
return {
|
||
"execution_eligible": bool(action_count and not unresolved and not denied),
|
||
"routing_eligible": routing_eligible,
|
||
"action_count": action_count,
|
||
"denied_action_count": len(denied),
|
||
"has_ordered_plan": bool(plan_catalog),
|
||
}
|
||
|
||
@staticmethod
|
||
def _residual_has_vehicle_language(utterance: str) -> bool:
|
||
"""Recognize only obvious vehicle-domain text in a claimed mixed request."""
|
||
|
||
return bool(
|
||
re.search(
|
||
r"空调|温度|风量|循环|主驾|副驾|座椅|车窗|车门|后门|"
|
||
r"遮阳帘|阅读灯|氛围灯|室内灯|出风口|风口|"
|
||
r"\b(?:ac|seat|window|door|shade|light|outlet)\b",
|
||
utterance,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
)
|
||
|
||
def _execute_local_peripheral_actions(
|
||
self,
|
||
utterance: str,
|
||
entries: list[dict[str, object]],
|
||
*,
|
||
provider: str,
|
||
conversation_id: str | None,
|
||
execution_mode: str,
|
||
on_commands_validated: Callable[[dict[str, object]], None] | None = None,
|
||
) -> dict[str, object]:
|
||
"""Execute already validated infrared capabilities."""
|
||
|
||
capabilities = [
|
||
{
|
||
**capability,
|
||
"parse_source": str(capability.get("parse_source") or "local_rule"),
|
||
}
|
||
for entry in entries
|
||
if isinstance(entry, dict) and isinstance(entry.get("capability"), dict)
|
||
for capability in [dict(entry["capability"])]
|
||
]
|
||
if not capabilities:
|
||
raise ExtensionIntegrationError("没有可执行的已学习红外能力。")
|
||
conversation_turn = 1
|
||
if conversation_id:
|
||
context = self.service.conversations.snapshot(conversation_id)
|
||
conversation_turn = int(context.get("turn_count", 0)) + 1
|
||
if len(capabilities) == 1:
|
||
result = self.extension.execute_peripheral_request(
|
||
utterance,
|
||
capabilities[0],
|
||
conversation_id=conversation_id,
|
||
conversation_turn=conversation_turn,
|
||
execution_mode=execution_mode,
|
||
provider_requested=provider,
|
||
on_commands_validated=on_commands_validated,
|
||
)
|
||
else:
|
||
result = self.extension.execute_peripheral_plan_request(
|
||
utterance,
|
||
{
|
||
"summary": f"本地规则命中 {len(capabilities)} 个已学习红外动作。",
|
||
"provider_used": "infrared_local_rules",
|
||
"steps": [
|
||
{
|
||
"kind": "peripheral",
|
||
"capability_id": capability["id"],
|
||
}
|
||
for capability in capabilities
|
||
],
|
||
},
|
||
capabilities,
|
||
conversation_id=conversation_id,
|
||
conversation_turn=conversation_turn,
|
||
execution_mode=execution_mode,
|
||
provider_requested=provider,
|
||
on_commands_validated=on_commands_validated,
|
||
)
|
||
result["provider_used"] = "infrared_local_rules"
|
||
self.vehicle_display_sync.update_extension(
|
||
action={
|
||
"type": "emit_sequence" if len(capabilities) > 1 else "emit",
|
||
"source": "main_orchestration_local_rule",
|
||
"capability_ids": [item["id"] for item in capabilities],
|
||
"slots": [item["slot"] for item in capabilities],
|
||
},
|
||
result=result,
|
||
)
|
||
return result
|
||
|
||
@staticmethod
|
||
def _merge_can_and_peripheral_results(
|
||
utterance: str,
|
||
can_result: dict[str, object],
|
||
infrared_result: dict[str, object],
|
||
entries: list[dict[str, object]],
|
||
) -> dict[str, object]:
|
||
"""Create one response while retaining each transport's evidence."""
|
||
|
||
merged = deepcopy(can_result)
|
||
can_status = str(can_result.get("status") or "failed")
|
||
infrared_status = str(infrared_result.get("status") or "failed")
|
||
successful = {"succeeded", "partially_succeeded", "no_action"}
|
||
can_ok = can_status in successful
|
||
infrared_ok = infrared_status in successful
|
||
|
||
function_configs = deepcopy(can_result.get("function_configs") or {})
|
||
function_configs.update(
|
||
deepcopy(infrared_result.get("function_configs") or {})
|
||
)
|
||
command_plans = deepcopy(can_result.get("command_plans") or {})
|
||
command_plans.update(deepcopy(infrared_result.get("command_plans") or {}))
|
||
trace_events = [
|
||
*deepcopy(list(infrared_result.get("trace_events") or [])),
|
||
*deepcopy(list(can_result.get("trace_events") or [])),
|
||
]
|
||
can_operations = list(can_result.get("operation_results") or [])
|
||
infrared_operations = list(infrared_result.get("operation_results") or [])
|
||
operation_results = [*can_operations, *infrared_operations]
|
||
|
||
can_report = deepcopy(can_result.get("execution_report"))
|
||
infrared_report = deepcopy(infrared_result.get("execution_report"))
|
||
execution_report = can_report or infrared_report
|
||
if isinstance(execution_report, dict):
|
||
spec = execution_report.setdefault("spec", {})
|
||
if execution_report is can_report and isinstance(infrared_report, dict):
|
||
spec["results"] = [
|
||
*list(spec.get("results") or []),
|
||
*list((infrared_report.get("spec") or {}).get("results") or []),
|
||
]
|
||
spec["status"] = (
|
||
"succeeded" if can_ok and infrared_ok else "partially_succeeded"
|
||
)
|
||
metadata = execution_report.setdefault("metadata", {})
|
||
labels = metadata.setdefault("labels", {})
|
||
labels["route"] = "unified_can_infrared"
|
||
|
||
infrared_actions = [
|
||
{
|
||
"index": entry.get("index"),
|
||
"clause": entry.get("clause"),
|
||
"capability_id": capability.get("id"),
|
||
"device_id": capability.get("device_id"),
|
||
"device_name": capability.get("device_name"),
|
||
"action": capability.get("action"),
|
||
"slot": capability.get("slot"),
|
||
"parse_source": str(
|
||
capability.get("parse_source") or "local_rule"
|
||
),
|
||
"transport": str(infrared_result.get("control_transport") or "gscu_tcp"),
|
||
}
|
||
for entry in entries
|
||
for capability in [entry.get("capability")]
|
||
if isinstance(capability, dict)
|
||
]
|
||
merged.update(
|
||
{
|
||
"utterance": utterance,
|
||
"status": (
|
||
"succeeded"
|
||
if can_ok and infrared_ok
|
||
else "partially_succeeded"
|
||
if can_ok or infrared_ok
|
||
else "failed"
|
||
),
|
||
"success": can_ok and infrared_ok,
|
||
"orchestration_route": "unified_can_infrared",
|
||
"provider_used": (
|
||
f"{can_result.get('provider_used') or 'vehicle'}+"
|
||
f"{infrared_result.get('provider_used') or 'infrared_local_rules'}"
|
||
),
|
||
"decision_summary": [
|
||
*list(can_result.get("decision_summary") or []),
|
||
*list(infrared_result.get("decision_summary") or []),
|
||
"CAN 与红外动作已通过独立白名单校验并按各自传输链路执行。",
|
||
],
|
||
"function_configs": function_configs,
|
||
"command_plans": command_plans,
|
||
"function_config": next(iter(function_configs.values()), None),
|
||
"command_plan": next(iter(command_plans.values()), None),
|
||
"trace_events": trace_events,
|
||
"execution_report": execution_report,
|
||
"operation_results": operation_results,
|
||
"infrared_actions": infrared_actions,
|
||
"peripheral": infrared_result.get("peripheral"),
|
||
"control_transports": {
|
||
"vehicle": can_result.get("control_transport"),
|
||
"infrared": infrared_result.get("control_transport"),
|
||
},
|
||
"phase_execution_reports": {
|
||
**dict(can_result.get("phase_execution_reports") or {}),
|
||
"vehicle": can_report,
|
||
"infrared": infrared_report,
|
||
},
|
||
}
|
||
)
|
||
return merged
|
||
|
||
@staticmethod
|
||
def _merge_peripheral_phase_results(
|
||
utterance: str,
|
||
local_result: dict[str, object],
|
||
cloud_result: dict[str, object],
|
||
) -> dict[str, object]:
|
||
"""Merge local and hosted IR phases without replaying local actions."""
|
||
|
||
merged = deepcopy(cloud_result)
|
||
|
||
def phase_documents(
|
||
local_documents: object,
|
||
cloud_documents: object,
|
||
) -> dict[str, object]:
|
||
local_mapping = local_documents if isinstance(local_documents, Mapping) else {}
|
||
cloud_mapping = cloud_documents if isinstance(cloud_documents, Mapping) else {}
|
||
return {
|
||
**{
|
||
f"local_{key}": deepcopy(value)
|
||
for key, value in local_mapping.items()
|
||
},
|
||
**{
|
||
f"cloud_{key}": deepcopy(value)
|
||
for key, value in cloud_mapping.items()
|
||
},
|
||
}
|
||
|
||
function_configs = phase_documents(
|
||
local_result.get("function_configs"),
|
||
cloud_result.get("function_configs"),
|
||
)
|
||
command_plans = phase_documents(
|
||
local_result.get("command_plans"),
|
||
cloud_result.get("command_plans"),
|
||
)
|
||
successful = {"succeeded", "no_action"}
|
||
local_ok = str(local_result.get("status") or "") in successful
|
||
cloud_ok = str(cloud_result.get("status") or "") in successful
|
||
local_request_id = str(local_result.get("request_id") or "")
|
||
cloud_request_id = str(cloud_result.get("request_id") or "")
|
||
merged.update(
|
||
{
|
||
"utterance": utterance,
|
||
"status": (
|
||
"succeeded"
|
||
if local_ok and cloud_ok
|
||
else "partially_succeeded"
|
||
if local_ok or cloud_ok
|
||
else "failed"
|
||
),
|
||
"success": local_ok and cloud_ok,
|
||
"orchestration_route": "two_stage_nfc_peripheral",
|
||
"provider_used": (
|
||
f"{local_result.get('provider_used') or 'infrared_local_rules'}+"
|
||
f"{cloud_result.get('provider_used') or 'cloud_model'}"
|
||
),
|
||
"preview_request_id": local_request_id,
|
||
"supplement_of": local_request_id,
|
||
"supplement_request_id": cloud_request_id,
|
||
"decision_summary": [
|
||
*list(local_result.get("decision_summary") or []),
|
||
*list(cloud_result.get("decision_summary") or []),
|
||
"本地红外动作先行下发;云端只补充未解析子句。",
|
||
],
|
||
"function_configs": function_configs,
|
||
"command_plans": command_plans,
|
||
"function_config": next(iter(function_configs.values()), None),
|
||
"command_plan": next(iter(command_plans.values()), None),
|
||
"trace_events": [
|
||
*deepcopy(list(local_result.get("trace_events") or [])),
|
||
*deepcopy(list(cloud_result.get("trace_events") or [])),
|
||
],
|
||
"validation": [
|
||
*deepcopy(list(local_result.get("validation") or [])),
|
||
*deepcopy(list(cloud_result.get("validation") or [])),
|
||
],
|
||
"operation_results": [
|
||
*deepcopy(list(local_result.get("operation_results") or [])),
|
||
*deepcopy(list(cloud_result.get("operation_results") or [])),
|
||
],
|
||
"phase_execution_reports": {
|
||
"infrared_local": deepcopy(local_result.get("execution_report")),
|
||
"infrared_cloud": deepcopy(cloud_result.get("execution_report")),
|
||
},
|
||
"execution_report": deepcopy(cloud_result.get("execution_report"))
|
||
or deepcopy(local_result.get("execution_report")),
|
||
"peripheral": deepcopy(
|
||
cloud_result.get("peripheral") or local_result.get("peripheral")
|
||
),
|
||
}
|
||
)
|
||
for key in (
|
||
"execution_mode",
|
||
"control_transport",
|
||
"local_control_budget_ms",
|
||
"local_control_elapsed_ms",
|
||
"local_control_within_budget",
|
||
"natural_language_routing",
|
||
):
|
||
if local_result.get(key) is not None:
|
||
merged[key] = deepcopy(local_result[key])
|
||
return merged
|
||
|
||
@staticmethod
|
||
def _apply_nfc_command_denials(
|
||
result: dict[str, object],
|
||
entries: list[dict[str, object]],
|
||
) -> dict[str, object]:
|
||
"""Expose parsed-but-unauthorized IR commands without dispatching them."""
|
||
|
||
if not entries:
|
||
return result
|
||
denied_actions = [
|
||
{
|
||
"index": entry.get("index"),
|
||
"clause": entry.get("clause"),
|
||
"capability_id": capability.get("id"),
|
||
"device_id": capability.get("device_id"),
|
||
"device_name": capability.get("device_name"),
|
||
"action": capability.get("action"),
|
||
"slot": capability.get("slot"),
|
||
"reason": str(entry.get("reason") or "nfc_command_disabled"),
|
||
}
|
||
for entry in entries
|
||
for capability in [entry.get("capability")]
|
||
if isinstance(entry, dict) and isinstance(capability, dict)
|
||
]
|
||
if not denied_actions:
|
||
return result
|
||
|
||
result["denied_peripheral_actions"] = denied_actions
|
||
result["authorization_filtered"] = True
|
||
blocked_operations: list[dict[str, object]] = []
|
||
blocked_commands: list[dict[str, object]] = []
|
||
blocked_results: list[dict[str, object]] = []
|
||
for position, item in enumerate(denied_actions, start=1):
|
||
reason = str(item.get("reason") or "nfc_command_disabled")
|
||
device_name = str(
|
||
item.get("device_name") or item.get("device_id") or "未命名外设"
|
||
)
|
||
action_name = str(item.get("action") or "未命名动作")
|
||
operation_ref = (
|
||
f"{item.get('capability_id') or 'infrared'}-authorization-{position}"
|
||
)
|
||
message = (
|
||
f"已识别“{device_name} · {action_name}”,但设备尚未完成 NFC "
|
||
"绑定授权,未下发。"
|
||
if reason == "nfc_binding_required"
|
||
else f"已识别“{device_name} · {action_name}”,但该 NFC 能力已被禁用,未下发。"
|
||
)
|
||
blocked_operations.append(
|
||
{
|
||
"id": operation_ref,
|
||
"capability": "infrared.emit",
|
||
"target": {
|
||
"device": item.get("device_id"),
|
||
"name": item.get("device_name"),
|
||
},
|
||
"parameters": {
|
||
"action": item.get("action"),
|
||
"slot": item.get("slot"),
|
||
},
|
||
"parse_source": "local_rule",
|
||
"authorization": {
|
||
"authorized": False,
|
||
"reason": reason,
|
||
},
|
||
}
|
||
)
|
||
blocked_commands.append(
|
||
{
|
||
"operation_ref": operation_ref,
|
||
"bus": "GSCU_TCP",
|
||
"message": "infrared.emit",
|
||
"signals": {
|
||
"slot": item.get("slot"),
|
||
"authorization_status": "blocked",
|
||
"authorization_reason": reason,
|
||
},
|
||
}
|
||
)
|
||
blocked_results.append(
|
||
{
|
||
"ref": operation_ref,
|
||
"domain": "self_extension",
|
||
"status": "blocked",
|
||
"message": message,
|
||
"telemetry": {
|
||
"transport": "gscu_tcp",
|
||
"slot": item.get("slot"),
|
||
"delivery_status": "not_submitted",
|
||
"transport_submitted": False,
|
||
"gateway_acknowledged": False,
|
||
"physical_effect_confirmed": False,
|
||
"verification_status": "failed_or_blocked",
|
||
"authorization_reason": reason,
|
||
},
|
||
}
|
||
)
|
||
|
||
function_configs = deepcopy(result.get("function_configs") or {})
|
||
command_plans = deepcopy(result.get("command_plans") or {})
|
||
config_key = "self_extension_blocked"
|
||
suffix = 2
|
||
while config_key in function_configs or config_key in command_plans:
|
||
config_key = f"self_extension_blocked_{suffix}"
|
||
suffix += 1
|
||
blocked_config = {
|
||
"apiVersion": "cognitive-agent.io/v1alpha1",
|
||
"kind": "FunctionConfig",
|
||
"metadata": {
|
||
"name": config_key,
|
||
"labels": {
|
||
"execution_scope": "nfc_authorization_required",
|
||
},
|
||
},
|
||
"spec": {
|
||
"domain": "self_extension",
|
||
"operations": blocked_operations,
|
||
},
|
||
}
|
||
blocked_plan = {
|
||
"apiVersion": "cognitive-agent.io/v1alpha1",
|
||
"kind": "CommandPlan",
|
||
"metadata": {
|
||
"name": f"{config_key}-gscu",
|
||
"labels": {"authorization_status": "blocked"},
|
||
},
|
||
"spec": {
|
||
"domain": "self_extension",
|
||
"platform": "gscu1b",
|
||
"commands": blocked_commands,
|
||
},
|
||
}
|
||
function_configs[config_key] = blocked_config
|
||
command_plans[config_key] = blocked_plan
|
||
result["function_configs"] = function_configs
|
||
result["command_plans"] = command_plans
|
||
if not isinstance(result.get("function_config"), dict):
|
||
result["function_config"] = blocked_config
|
||
if not isinstance(result.get("command_plan"), dict):
|
||
result["command_plan"] = blocked_plan
|
||
result["operation_results"] = [
|
||
*deepcopy(list(result.get("operation_results") or [])),
|
||
*blocked_results,
|
||
]
|
||
result["trace_events"] = [
|
||
*deepcopy(list(result.get("trace_events") or [])),
|
||
{
|
||
"stage": "nfc_peripheral_authorization",
|
||
"status": "blocked",
|
||
"summary": "红外指令已由本地规则解析,但未通过 NFC 执行授权。",
|
||
"duration_ms": 0,
|
||
},
|
||
]
|
||
summaries = list(result.get("decision_summary") or [])
|
||
binding_required = [
|
||
item
|
||
for item in denied_actions
|
||
if item.get("reason") == "nfc_binding_required"
|
||
]
|
||
disabled = [
|
||
item
|
||
for item in denied_actions
|
||
if item.get("reason") != "nfc_binding_required"
|
||
]
|
||
if binding_required:
|
||
labels = "、".join(
|
||
str(item.get("clause") or item.get("action") or "未命名动作")
|
||
for item in binding_required
|
||
)
|
||
summaries.append(
|
||
f"已解析 {len(binding_required)} 条红外控制指令,但设备尚未完成 NFC "
|
||
f"绑定授权,未下发:{labels}。"
|
||
)
|
||
if disabled:
|
||
labels = "、".join(
|
||
str(item.get("clause") or item.get("action") or "未命名动作")
|
||
for item in disabled
|
||
)
|
||
summaries.append(
|
||
f"持久绑定中有 {len(disabled)} 条红外能力未启用,已过滤:{labels}。"
|
||
)
|
||
result["decision_summary"] = summaries
|
||
|
||
prior_status = str(result.get("status") or "failed")
|
||
if prior_status in {"succeeded", "partially_succeeded", "no_action"}:
|
||
result["status"] = "partially_succeeded"
|
||
result["success"] = False
|
||
result["outcome"] = "failed_or_blocked"
|
||
execution_report = result.get("execution_report")
|
||
if isinstance(execution_report, dict):
|
||
spec = execution_report.get("spec")
|
||
if isinstance(spec, dict):
|
||
spec["status"] = str(result.get("status") or "blocked")
|
||
return result
|
||
|
||
def _exact_n7_mode_invocation(
|
||
self,
|
||
utterance: str,
|
||
vehicle_id: str | None,
|
||
) -> object | None:
|
||
"""Resolve a standalone spoken/text invocation of one N7-synced card."""
|
||
|
||
if not str(utterance).strip():
|
||
return None
|
||
registry = getattr(self.service, "mode_profiles", None)
|
||
match_text = getattr(registry, "match_text", None)
|
||
if not callable(match_text):
|
||
# Lightweight service doubles used by peripheral-only routes do
|
||
# not own a mode registry. They must keep their existing routing
|
||
# behaviour instead of failing before the peripheral parser runs.
|
||
return None
|
||
matches = [
|
||
profile
|
||
for profile in match_text(
|
||
utterance,
|
||
vehicle_id=vehicle_id,
|
||
)
|
||
if getattr(profile, "sync_origin", "") == "from_n7/scenes"
|
||
]
|
||
# ``ModeProfileRegistry.match_text`` applies the bounded, exact
|
||
# invocation grammar to N7-synchronised cards. Keeping that rule in
|
||
# one place prevents text, speech and card-click routes from drifting.
|
||
return matches[0] if len(matches) == 1 else None
|
||
|
||
def _execute_n7_named_mode_request(
|
||
self,
|
||
path: str,
|
||
common_arguments: dict[str, object],
|
||
) -> dict[str, object] | None:
|
||
utterance = str(common_arguments.get("utterance") or "").strip()
|
||
vehicle_id = (
|
||
str(common_arguments["vehicle_id"])
|
||
if common_arguments.get("vehicle_id")
|
||
else None
|
||
)
|
||
profile = self._exact_n7_mode_invocation(utterance, vehicle_id)
|
||
if profile is None:
|
||
return None
|
||
execution_mode = (
|
||
"dry_run"
|
||
if path == "/api/requests/preview"
|
||
else str(common_arguments.get("execution_mode") or "dry_run")
|
||
)
|
||
conversation_id = (
|
||
str(common_arguments["conversation_id"])
|
||
if common_arguments.get("conversation_id")
|
||
else None
|
||
)
|
||
entries = self._saved_mode_infrared_entries(profile)
|
||
can_result = self.service.execute_mode_profile_card(
|
||
str(getattr(profile, "id")),
|
||
conversation_id=conversation_id,
|
||
execution_mode=execution_mode,
|
||
approval_token=(
|
||
str(common_arguments["approval_token"])
|
||
if common_arguments.get("approval_token")
|
||
else None
|
||
),
|
||
vehicle_id=vehicle_id,
|
||
simulation_fault=str(
|
||
common_arguments.get("simulation_fault") or "normal"
|
||
),
|
||
hardware_client_id=(
|
||
str(common_arguments["hardware_client_id"])
|
||
if common_arguments.get("hardware_client_id")
|
||
else None
|
||
),
|
||
hardware_access_bypass=bool(
|
||
common_arguments.get("hardware_access_bypass", True)
|
||
),
|
||
control_transport=str(
|
||
common_arguments.get("control_transport") or "wired_can"
|
||
),
|
||
)
|
||
if not entries:
|
||
can_result["utterance"] = utterance
|
||
return can_result
|
||
infrared_result = self._execute_local_peripheral_actions(
|
||
utterance,
|
||
entries,
|
||
provider="saved_mode_command_flow",
|
||
conversation_id=conversation_id,
|
||
execution_mode=execution_mode,
|
||
)
|
||
result = self._merge_can_and_peripheral_results(
|
||
utterance,
|
||
can_result,
|
||
infrared_result,
|
||
entries,
|
||
)
|
||
result["provider_used"] = "saved_mode_command_flow"
|
||
result["orchestration_route"] = "saved_mode_command_flow"
|
||
result["mode_profile"] = profile.public_dict()
|
||
self.service.persist_composed_result(result)
|
||
return result
|
||
|
||
def _execute_request_with_peripherals(
|
||
self,
|
||
path: str,
|
||
common_arguments: dict[str, object],
|
||
*,
|
||
on_commands_validated: object | None = None,
|
||
) -> dict[str, object] | None:
|
||
"""Route IR-only or mixed IR/CAN requests without changing CAN parsing."""
|
||
|
||
local_stage_started = time.perf_counter()
|
||
utterance = str(common_arguments.get("utterance") or "").strip()
|
||
if self.service.is_mode_profile_configuration_request(utterance):
|
||
# Creating, saving, updating, or restoring a reusable mode is
|
||
# configuration, never a live-control command. Bypass the
|
||
# NFC/peripheral executor so a learned device name embedded in the
|
||
# mode sentence cannot claim it before SeatDemoService handles it.
|
||
mode_arguments = {
|
||
**common_arguments,
|
||
"execution_mode": "dry_run",
|
||
}
|
||
callback = (
|
||
on_commands_validated
|
||
if callable(on_commands_validated)
|
||
else None
|
||
)
|
||
if path == "/api/requests/preview":
|
||
return self.service.preview_native_can(
|
||
**mode_arguments,
|
||
on_commands_validated=callback,
|
||
)
|
||
return self.service.process(
|
||
**mode_arguments,
|
||
_on_commands_validated=callback,
|
||
)
|
||
n7_mode_result = self._execute_n7_named_mode_request(
|
||
path,
|
||
common_arguments,
|
||
)
|
||
if n7_mode_result is not None:
|
||
return n7_mode_result
|
||
catalog = self._peripheral_routing_catalog()
|
||
nfc_snapshot = self.nfc.snapshot()
|
||
partition = partition_active_peripheral_intent(
|
||
utterance,
|
||
catalog,
|
||
nfc_snapshot,
|
||
)
|
||
entries = list(partition.get("peripheral_actions") or [])
|
||
residual = str(partition.get("residual_utterance") or "").strip()
|
||
unresolved = list(partition.get("unresolved_peripheral_clauses") or [])
|
||
denied = list(partition.get("denied_peripheral_actions") or [])
|
||
parsed_peripheral_clauses = list(
|
||
dict.fromkeys(
|
||
str(entry.get("clause") or "").strip()
|
||
for entry in [*entries, *denied]
|
||
if isinstance(entry, dict) and str(entry.get("clause") or "").strip()
|
||
)
|
||
)
|
||
unresolved_peripheral_clauses = list(
|
||
dict.fromkeys(str(item).strip() for item in unresolved if str(item).strip())
|
||
)
|
||
natural_language_routing: dict[str, object] = {
|
||
"original_utterance": utterance,
|
||
"parsed_clauses": parsed_peripheral_clauses,
|
||
"resolved_clauses": parsed_peripheral_clauses,
|
||
"unresolved_clauses": unresolved_peripheral_clauses,
|
||
}
|
||
provider = str(common_arguments.get("provider") or "auto")
|
||
conversation_id = (
|
||
str(common_arguments["conversation_id"])
|
||
if common_arguments.get("conversation_id")
|
||
else None
|
||
)
|
||
execution_mode = str(common_arguments.get("execution_mode") or "dry_run")
|
||
callback = on_commands_validated if callable(on_commands_validated) else None
|
||
|
||
peripheral_validation_published = False
|
||
peripheral_validation_elapsed_ms: float | None = None
|
||
|
||
def publish_peripheral_validation(result: dict[str, object]) -> None:
|
||
nonlocal peripheral_validation_elapsed_ms, peripheral_validation_published
|
||
peripheral_validation_published = True
|
||
peripheral_validation_elapsed_ms = round(
|
||
(time.perf_counter() - local_stage_started) * 1000,
|
||
3,
|
||
)
|
||
if callback is None:
|
||
return
|
||
validated = deepcopy(result)
|
||
validated["status"] = "processing"
|
||
validated["phase"] = "commands_validated"
|
||
validated.update(
|
||
{
|
||
"local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
|
||
"local_control_elapsed_ms": peripheral_validation_elapsed_ms,
|
||
"local_control_within_budget": (
|
||
peripheral_validation_elapsed_ms < LOCAL_CONTROL_BUDGET_MS
|
||
),
|
||
"natural_language_routing": deepcopy(
|
||
natural_language_routing
|
||
),
|
||
}
|
||
)
|
||
callback(validated)
|
||
|
||
infrared_result: dict[str, object] | None = None
|
||
can_result: dict[str, object] | None = None
|
||
cloud_supplement_error: str | None = None
|
||
|
||
# Preserve the legacy all-peripheral planner when there is no hosted
|
||
# ambiguity to resolve. It retains explicit delay/order semantics.
|
||
if (
|
||
entries
|
||
and residual
|
||
and not unresolved
|
||
and not self._residual_has_vehicle_language(residual)
|
||
and bool(
|
||
re.search(
|
||
r"然后|随后|接着|之后|"
|
||
r"(?:\d+(?:\.\d+)?|[一二三四五六七八九十两半]+)"
|
||
r"(?:毫秒|秒(?:钟)?|分钟|小时|ms(?![A-Za-z])|s(?![A-Za-z]))",
|
||
residual,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
)
|
||
):
|
||
planned = self._execute_active_peripheral_request(
|
||
utterance,
|
||
provider=provider,
|
||
conversation_id=conversation_id,
|
||
execution_mode=execution_mode,
|
||
on_commands_validated=publish_peripheral_validation,
|
||
natural_language_routing=natural_language_routing,
|
||
)
|
||
if planned is not None:
|
||
if not peripheral_validation_published:
|
||
publish_peripheral_validation(planned)
|
||
if peripheral_validation_elapsed_ms is not None:
|
||
planned.update(
|
||
{
|
||
"local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
|
||
"local_control_elapsed_ms": (
|
||
peripheral_validation_elapsed_ms
|
||
),
|
||
"local_control_within_budget": (
|
||
peripheral_validation_elapsed_ms
|
||
< LOCAL_CONTROL_BUDGET_MS
|
||
),
|
||
"natural_language_routing": deepcopy(
|
||
natural_language_routing
|
||
),
|
||
}
|
||
)
|
||
return planned
|
||
|
||
if not entries and not denied and not unresolved:
|
||
result = self._execute_active_peripheral_request(
|
||
utterance,
|
||
provider=provider,
|
||
conversation_id=conversation_id,
|
||
execution_mode=execution_mode,
|
||
on_commands_validated=publish_peripheral_validation,
|
||
natural_language_routing=natural_language_routing,
|
||
)
|
||
if result is not None:
|
||
if not peripheral_validation_published:
|
||
publish_peripheral_validation(result)
|
||
if peripheral_validation_elapsed_ms is not None:
|
||
result.update(
|
||
{
|
||
"local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
|
||
"local_control_elapsed_ms": (
|
||
peripheral_validation_elapsed_ms
|
||
),
|
||
"local_control_within_budget": (
|
||
peripheral_validation_elapsed_ms
|
||
< LOCAL_CONTROL_BUDGET_MS
|
||
),
|
||
"natural_language_routing": deepcopy(
|
||
natural_language_routing
|
||
),
|
||
}
|
||
)
|
||
return result
|
||
|
||
# Compile, publish and execute every deterministic IR action before
|
||
# any hosted planner is contacted.
|
||
if entries:
|
||
peripheral_validation_published = False
|
||
infrared_result = self._execute_local_peripheral_actions(
|
||
utterance,
|
||
entries,
|
||
provider=provider,
|
||
conversation_id=conversation_id,
|
||
execution_mode=execution_mode,
|
||
on_commands_validated=publish_peripheral_validation,
|
||
)
|
||
if not peripheral_validation_published:
|
||
publish_peripheral_validation(infrared_result)
|
||
if peripheral_validation_elapsed_ms is not None:
|
||
infrared_result.update(
|
||
{
|
||
"local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
|
||
"local_control_elapsed_ms": (
|
||
peripheral_validation_elapsed_ms
|
||
),
|
||
"local_control_within_budget": (
|
||
peripheral_validation_elapsed_ms
|
||
< LOCAL_CONTROL_BUDGET_MS
|
||
),
|
||
"natural_language_routing": deepcopy(
|
||
natural_language_routing
|
||
),
|
||
}
|
||
)
|
||
|
||
# The native preview is also strictly local. Its own manifest records
|
||
# unresolved CAN clauses for the later supplement endpoint.
|
||
if residual:
|
||
can_arguments = {
|
||
**common_arguments,
|
||
"utterance": residual,
|
||
"_cloud_instruction_context": natural_language_routing,
|
||
}
|
||
can_validation_callback = callback
|
||
if callback is not None and infrared_result is not None:
|
||
def publish_combined_validation(
|
||
validated_can: dict[str, object],
|
||
) -> None:
|
||
validated = self._merge_can_and_peripheral_results(
|
||
utterance,
|
||
validated_can,
|
||
infrared_result,
|
||
entries,
|
||
)
|
||
validated = self._apply_nfc_command_denials(validated, denied)
|
||
validated["status"] = "processing"
|
||
validated["phase"] = "commands_validated"
|
||
callback(validated)
|
||
|
||
can_validation_callback = publish_combined_validation
|
||
if path == "/api/requests/preview":
|
||
can_result = self.service.preview_native_can(
|
||
**can_arguments,
|
||
on_commands_validated=can_validation_callback,
|
||
)
|
||
else:
|
||
can_result = self.service.process(
|
||
**can_arguments,
|
||
_on_commands_validated=can_validation_callback,
|
||
)
|
||
|
||
routed = can_result.get("natural_language_routing")
|
||
if isinstance(routed, dict):
|
||
natural_language_routing = deepcopy(routed)
|
||
|
||
# Hosted IR planning is deliberately last. Only the unresolved IR
|
||
# text is submitted, while the trusted context retains the full
|
||
# sentence and the clauses already handled above.
|
||
if unresolved and provider != "local":
|
||
try:
|
||
candidates = list(partition.get("unresolved_candidates") or [])
|
||
if not candidates:
|
||
raise ExtensionIntegrationError(
|
||
"没有可供云端补充选择的已学习红外能力。"
|
||
)
|
||
cloud_utterance = ",".join(unresolved_peripheral_clauses)
|
||
plan_result = self.extension.plan(
|
||
{
|
||
"utterance": cloud_utterance,
|
||
"peripherals": candidates,
|
||
"vehicle_devices": [],
|
||
"provider": provider,
|
||
"natural_language_routing": {
|
||
**natural_language_routing,
|
||
"cloud_input_utterance": cloud_utterance,
|
||
},
|
||
}
|
||
)
|
||
if plan_result.get("needs_confirmation"):
|
||
raise ExtensionIntegrationError(
|
||
str(
|
||
plan_result.get("question")
|
||
or "请明确要控制的红外动作。"
|
||
)
|
||
)
|
||
cloud_steps = list(plan_result.get("steps") or [])
|
||
if not cloud_steps:
|
||
raise ExtensionIntegrationError(
|
||
"云端模型没有命中已学习红外能力。"
|
||
)
|
||
candidate_by_id = {
|
||
str(item.get("id")): {
|
||
**item,
|
||
"parse_source": "cloud_model",
|
||
}
|
||
for item in candidates
|
||
if isinstance(item, dict) and item.get("id")
|
||
}
|
||
selected_cloud = [
|
||
candidate_by_id[str(step.get("capability_id"))]
|
||
for step in cloud_steps
|
||
if isinstance(step, dict)
|
||
and step.get("kind") == "peripheral"
|
||
and str(step.get("capability_id")) in candidate_by_id
|
||
]
|
||
if not selected_cloud:
|
||
raise ExtensionIntegrationError(
|
||
"云端模型没有返回允许执行的已学习红外能力。"
|
||
)
|
||
conversation_turn = 1
|
||
if conversation_id:
|
||
context = self.service.conversations.snapshot(conversation_id)
|
||
conversation_turn = int(context.get("turn_count", 0)) + 1
|
||
cloud_infrared_result = (
|
||
self.extension.execute_peripheral_plan_request(
|
||
utterance,
|
||
plan_result,
|
||
selected_cloud,
|
||
conversation_id=conversation_id,
|
||
conversation_turn=conversation_turn,
|
||
execution_mode=execution_mode,
|
||
provider_requested=provider,
|
||
)
|
||
)
|
||
next_index = max(
|
||
[
|
||
int(entry.get("index", 0))
|
||
for entry in entries
|
||
if isinstance(entry, dict)
|
||
],
|
||
default=-1,
|
||
) + 1
|
||
entries.extend(
|
||
{
|
||
"index": next_index + index,
|
||
"clause": cloud_utterance,
|
||
"capability": capability,
|
||
}
|
||
for index, capability in enumerate(selected_cloud)
|
||
)
|
||
infrared_result = (
|
||
self._merge_peripheral_phase_results(
|
||
utterance,
|
||
infrared_result,
|
||
cloud_infrared_result,
|
||
)
|
||
if infrared_result is not None
|
||
else cloud_infrared_result
|
||
)
|
||
except ExtensionIntegrationError as exc:
|
||
cloud_supplement_error = str(exc)
|
||
if infrared_result is None and can_result is None:
|
||
raise
|
||
|
||
if can_result is not None and infrared_result is not None:
|
||
result = self._merge_can_and_peripheral_results(
|
||
utterance,
|
||
can_result,
|
||
infrared_result,
|
||
entries,
|
||
)
|
||
elif can_result is not None:
|
||
result = can_result
|
||
result["utterance"] = utterance
|
||
elif infrared_result is not None:
|
||
result = infrared_result
|
||
elif unresolved and provider == "local":
|
||
local_elapsed_ms = round(
|
||
(time.perf_counter() - local_stage_started) * 1000,
|
||
3,
|
||
)
|
||
request_id = f"req-local-preview-{os.urandom(8).hex()}"
|
||
result = {
|
||
"request_id": request_id,
|
||
"preview_request_id": request_id,
|
||
"utterance": utterance,
|
||
"conversation_id": conversation_id,
|
||
"status": "no_action",
|
||
"success": False,
|
||
"phase": "deterministic_preview",
|
||
"provider_requested": provider,
|
||
"provider_used": "local_rules",
|
||
"execution_mode": execution_mode,
|
||
"decision_summary": [
|
||
"本地规则未生成控制;仅未解析子句等待云端补充。"
|
||
],
|
||
"function_config": None,
|
||
"function_configs": {},
|
||
"command_plan": None,
|
||
"command_plans": {},
|
||
"trace_events": [],
|
||
"validation": [],
|
||
"execution_report": None,
|
||
"operation_results": [],
|
||
"parsed_control_signatures": [],
|
||
"preview_elapsed_ms": local_elapsed_ms,
|
||
"local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
|
||
"local_control_elapsed_ms": local_elapsed_ms,
|
||
"local_control_within_budget": (
|
||
local_elapsed_ms < LOCAL_CONTROL_BUDGET_MS
|
||
),
|
||
"supplement_required": True,
|
||
}
|
||
else:
|
||
local_elapsed_ms = round(
|
||
(time.perf_counter() - local_stage_started) * 1000,
|
||
3,
|
||
)
|
||
request_id = f"req-local-blocked-{os.urandom(8).hex()}"
|
||
result = {
|
||
"request_id": request_id,
|
||
"preview_request_id": request_id,
|
||
"utterance": utterance,
|
||
"conversation_id": conversation_id,
|
||
"status": "blocked",
|
||
"success": False,
|
||
"outcome": "failed_or_blocked",
|
||
"phase": "authorization_blocked",
|
||
"orchestration_route": "local_peripheral_authorization",
|
||
"provider_requested": provider,
|
||
"provider_used": "infrared_local_rules",
|
||
"execution_mode": execution_mode,
|
||
"control_transport": "gscu_tcp",
|
||
"decision_summary": [],
|
||
"function_config": None,
|
||
"function_configs": {},
|
||
"command_plan": None,
|
||
"command_plans": {},
|
||
"trace_events": [],
|
||
"validation": [],
|
||
"execution_report": None,
|
||
"operation_results": [],
|
||
"parsed_control_signatures": [],
|
||
"preview_elapsed_ms": local_elapsed_ms,
|
||
"local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
|
||
"local_control_elapsed_ms": local_elapsed_ms,
|
||
"local_control_within_budget": (
|
||
local_elapsed_ms < LOCAL_CONTROL_BUDGET_MS
|
||
),
|
||
"supplement_required": False,
|
||
}
|
||
|
||
result["natural_language_routing"] = deepcopy(natural_language_routing)
|
||
for key in ("parsed_clauses", "resolved_clauses", "unresolved_clauses"):
|
||
value = natural_language_routing.get(key)
|
||
if isinstance(value, list):
|
||
result[key] = deepcopy(value)
|
||
if cloud_supplement_error:
|
||
result["cloud_supplement_error"] = {
|
||
"stage": "unresolved_peripheral_clauses",
|
||
"message": cloud_supplement_error,
|
||
}
|
||
result["status"] = "partially_succeeded"
|
||
result["success"] = False
|
||
result["decision_summary"] = [
|
||
*list(result.get("decision_summary") or []),
|
||
"明确控制已由本地规则先行处理;未解析的红外子句云端补充失败。",
|
||
]
|
||
result = self._apply_nfc_command_denials(result, denied)
|
||
self.service.persist_composed_result(result)
|
||
if callback is not None:
|
||
callback(result)
|
||
return result
|
||
|
||
def _stream_control_request(
|
||
self,
|
||
path: str,
|
||
supplement_match: re.Match[str] | None,
|
||
payload: dict[str, object],
|
||
common_arguments: dict[str, object],
|
||
*,
|
||
source: str,
|
||
) -> None:
|
||
"""Stream validated command plans before physical execution finishes."""
|
||
|
||
sync_vehicle_task = source not in NON_DISPATCH_DISPLAY_SOURCES
|
||
|
||
self.send_response(HTTPStatus.OK)
|
||
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.send_header("X-Accel-Buffering", "no")
|
||
self._send_security_headers()
|
||
self.end_headers()
|
||
stream_open = True
|
||
|
||
def emit(event: dict[str, object]) -> None:
|
||
nonlocal stream_open
|
||
if not stream_open:
|
||
return
|
||
content = (json.dumps(event, ensure_ascii=False) + "\n").encode(
|
||
"utf-8"
|
||
)
|
||
try:
|
||
self.wfile.write(content)
|
||
self.wfile.flush()
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
# The submitted control request must continue even if the
|
||
# browser closes while the physical execution is in progress.
|
||
stream_open = False
|
||
|
||
def on_commands_validated(result: dict[str, object]) -> None:
|
||
emit({"type": "validated_commands", "payload": result})
|
||
|
||
def emit_terminal_error(
|
||
code: str,
|
||
message: str,
|
||
*,
|
||
detail: str | None = None,
|
||
extra: dict[str, object] | None = None,
|
||
) -> None:
|
||
"""Publish one terminal failure using can_read_trans outcome terms."""
|
||
|
||
error = {
|
||
"code": code,
|
||
"message": message,
|
||
"outcome": "failed_or_blocked",
|
||
**({"detail": detail} if detail else {}),
|
||
}
|
||
if sync_vehicle_task:
|
||
self.vehicle_display_sync.update_task(
|
||
status="failed",
|
||
utterance=utterance,
|
||
source=source,
|
||
error=error,
|
||
)
|
||
emit({
|
||
"type": "error",
|
||
"error": code,
|
||
"message": message,
|
||
"outcome": "failed_or_blocked",
|
||
**({"detail": detail} if detail else {}),
|
||
**(extra or {}),
|
||
})
|
||
|
||
utterance = str(common_arguments.get("utterance") or "")
|
||
try:
|
||
result = None
|
||
if path in {"/api/requests", "/api/requests/preview"}:
|
||
result = self._execute_request_with_peripherals(
|
||
path,
|
||
common_arguments,
|
||
on_commands_validated=on_commands_validated,
|
||
)
|
||
if result is None:
|
||
if path == "/api/requests/preview":
|
||
result = self.service.preview_native_can(
|
||
**common_arguments,
|
||
on_commands_validated=on_commands_validated,
|
||
)
|
||
elif supplement_match:
|
||
result = self.service.supplement_native_can(
|
||
supplement_match.group("request_id"),
|
||
provider=(
|
||
str(payload["provider"])
|
||
if payload.get("provider")
|
||
else None
|
||
),
|
||
approval_token=os.getenv(
|
||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
|
||
),
|
||
hardware_client_id=(
|
||
str(payload["hardware_client_id"])
|
||
if payload.get("hardware_client_id")
|
||
else None
|
||
),
|
||
hardware_access_bypass=self._hardware_access_bypass_allowed(),
|
||
reasoning_effort=(
|
||
str(payload["reasoning_effort"])
|
||
if payload.get("reasoning_effort") is not None
|
||
else None
|
||
),
|
||
on_commands_validated=on_commands_validated,
|
||
)
|
||
else:
|
||
result = self.service.process(
|
||
**common_arguments,
|
||
_on_commands_validated=on_commands_validated,
|
||
)
|
||
utterance = str(result.get("utterance") or utterance)
|
||
if sync_vehicle_task:
|
||
self.vehicle_display_sync.update_task(
|
||
status=str(result.get("status", "succeeded")),
|
||
utterance=utterance,
|
||
source=source,
|
||
result=result,
|
||
)
|
||
emit({"type": "result", "payload": result})
|
||
except HardwareAccessRequiredError as exc:
|
||
emit_terminal_error(
|
||
"hardware_access_required",
|
||
str(exc),
|
||
extra={"access_state": exc.state},
|
||
)
|
||
except HardwareBusyError as exc:
|
||
emit_terminal_error(
|
||
"hardware_busy",
|
||
str(exc),
|
||
extra={"retry_after_seconds": exc.retry_after_seconds},
|
||
)
|
||
except HardwareSafetyError as exc:
|
||
emit_terminal_error("hardware_safety_gate", str(exc))
|
||
except CanGatewayError as exc:
|
||
emit_terminal_error("can_transport_failed", str(exc))
|
||
except GatewayError as exc:
|
||
emit_terminal_error("gscu_unavailable", str(exc))
|
||
except ExtensionIntegrationError as exc:
|
||
emit_terminal_error("extension_request_ambiguous", str(exc))
|
||
except (ValueError, json.JSONDecodeError) as exc:
|
||
emit_terminal_error("invalid_request", str(exc))
|
||
except OpenAIIntentError as exc:
|
||
self.log_error("OpenAI intent request failed: %s", exc)
|
||
emit_terminal_error(
|
||
"openai_request_failed",
|
||
"GPT 意图解析失败,请检查 API Key、模型权限或网络状态。",
|
||
detail=str(exc),
|
||
)
|
||
except Exception as exc: # noqa: BLE001 - stream a terminal event
|
||
self.log_error("streaming request failed: %s", exc)
|
||
emit_terminal_error(
|
||
"internal_error",
|
||
"请求处理失败,请查看服务端日志。",
|
||
detail=str(exc),
|
||
)
|
||
|
||
def _assistant_capability_catalog(self) -> AssistantCapabilityCatalog:
|
||
"""Bind the assistant to the same live registries used by every service."""
|
||
|
||
if self.assistant_capability_catalog is not None:
|
||
return self.assistant_capability_catalog
|
||
vehicle_service = (
|
||
self.service
|
||
if hasattr(self.service, "capability_registry")
|
||
else SeatDemoHandler.service
|
||
)
|
||
return AssistantCapabilityCatalog(
|
||
vehicle_service,
|
||
peripheral_provider=self.extension.list_peripheral_capabilities,
|
||
nfc_provider=self.nfc.snapshot,
|
||
speech_provider=self.speech.status,
|
||
gesture_provider=self.gesture.runtime_info,
|
||
calibration_provider=self.calibration.snapshot,
|
||
self_repair_provider=self.repair.health,
|
||
)
|
||
|
||
def _handle_assistant_stream(self) -> None:
|
||
"""Serve one unified answer/control turn as incremental NDJSON."""
|
||
|
||
try:
|
||
username = self._session_username()
|
||
principal = username or f"anonymous:{self._client_key()}"
|
||
retry_after = self.auth.api_requests.retry_after(principal)
|
||
if retry_after:
|
||
self._json(
|
||
{"error": "rate_limited", "message": "请求过于频繁,请稍后再试。"},
|
||
HTTPStatus.TOO_MANY_REQUESTS,
|
||
{"Retry-After": str(retry_after)},
|
||
)
|
||
return
|
||
self.auth.api_requests.record(principal)
|
||
payload = self._read_json_body(max_bytes=64 * 1024)
|
||
except (ValueError, json.JSONDecodeError) as exc:
|
||
self._json(
|
||
{"error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
return
|
||
|
||
message = str(payload.get("message") or payload.get("utterance") or "").strip()
|
||
conversation_id = str(payload.get("conversation_id") or "").strip()
|
||
if not conversation_id:
|
||
conversation_id = f"conv-{os.urandom(16).hex()}"
|
||
history_payload = payload.get("history")
|
||
history = history_payload if isinstance(history_payload, list) else []
|
||
source = str(payload.get("client_type") or "web_console_assistant")
|
||
hardware_client_id = (
|
||
str(payload["hardware_client_id"]) if payload.get("hardware_client_id") else None
|
||
)
|
||
reasoning_effort = (
|
||
str(payload["reasoning_effort"])
|
||
if payload.get("reasoning_effort") is not None
|
||
else "low"
|
||
)
|
||
mode_profile_draft_context = payload.get("mode_profile_draft_context")
|
||
parsed_mode_update = parse_mode_profile_update(
|
||
message,
|
||
expected_mode_name=(
|
||
mode_profile_draft_context.get("name")
|
||
if isinstance(mode_profile_draft_context, Mapping)
|
||
else None
|
||
),
|
||
)
|
||
matching_mode_profile_draft_context = (
|
||
dict(mode_profile_draft_context)
|
||
if isinstance(mode_profile_draft_context, Mapping)
|
||
and parsed_mode_update is not None
|
||
and normalize_mode_profile_name(parsed_mode_update[0])
|
||
== normalize_mode_profile_name(
|
||
mode_profile_draft_context.get("name")
|
||
)
|
||
else None
|
||
)
|
||
requested_vehicle_id = (
|
||
str(payload["vehicle_id"]) if payload.get("vehicle_id") else None
|
||
)
|
||
common_arguments: dict[str, object] = {
|
||
"utterance": message,
|
||
# Explicit controls take the deterministic preview path first.
|
||
# ``auto`` permits a second phase only when that preview reports
|
||
# genuinely unresolved clauses; fully local requests never invoke
|
||
# a hosted model.
|
||
"provider": "auto",
|
||
"conversation_id": conversation_id,
|
||
"execution_mode": str(payload.get("execution_mode") or "dry_run"),
|
||
"approval_token": os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"),
|
||
"vehicle_id": requested_vehicle_id,
|
||
"simulation_fault": str(payload.get("simulation_fault") or "normal"),
|
||
"hardware_client_id": hardware_client_id,
|
||
"hardware_access_bypass": self._hardware_access_bypass_allowed(),
|
||
"reasoning_effort": reasoning_effort,
|
||
"control_transport": str(payload.get("control_transport") or "wired_can"),
|
||
**(
|
||
{
|
||
"mode_profile_draft_context": (
|
||
matching_mode_profile_draft_context
|
||
)
|
||
}
|
||
if matching_mode_profile_draft_context is not None
|
||
else {}
|
||
),
|
||
}
|
||
capability_catalog = self._assistant_capability_catalog()
|
||
try:
|
||
capability_snapshot = capability_catalog.snapshot(
|
||
vehicle_id=requested_vehicle_id
|
||
)
|
||
except ValueError as exc:
|
||
self._json(
|
||
{"error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
return
|
||
capability_context = capability_catalog.compact_context(capability_snapshot)
|
||
scene_capability_context = capability_catalog.scene_composition_context(
|
||
capability_snapshot
|
||
)
|
||
fast_answer = capability_catalog.fast_answer(message, capability_snapshot)
|
||
quick_control_provider = getattr(capability_catalog, "quick_controls", None)
|
||
quick_controls = (
|
||
quick_control_provider(capability_snapshot)
|
||
if callable(quick_control_provider)
|
||
else ()
|
||
)
|
||
|
||
self.send_response(HTTPStatus.OK)
|
||
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.send_header("X-Accel-Buffering", "no")
|
||
self._send_security_headers()
|
||
self.end_headers()
|
||
stream_open = True
|
||
|
||
def emit(event: dict[str, object]) -> bool:
|
||
nonlocal stream_open
|
||
if not stream_open:
|
||
return False
|
||
content = (json.dumps(event, ensure_ascii=False) + "\n").encode("utf-8")
|
||
try:
|
||
self.wfile.write(content)
|
||
self.wfile.flush()
|
||
return True
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
stream_open = False
|
||
return False
|
||
|
||
def record_control_failure(code: str, public_message: str) -> None:
|
||
self.vehicle_display_sync.update_task(
|
||
status="failed",
|
||
utterance=message,
|
||
source=source,
|
||
error={"code": code, "message": public_message},
|
||
)
|
||
|
||
local_preview_result: dict[str, object] | None = None
|
||
|
||
def preview_control(
|
||
instruction: str,
|
||
on_commands_validated: Callable[[dict[str, object]], None],
|
||
) -> dict[str, object]:
|
||
"""Run only deterministic phases and retain their merge evidence."""
|
||
|
||
nonlocal local_preview_result
|
||
self.vehicle_display_sync.update_task(
|
||
status="running",
|
||
utterance=message,
|
||
source=source,
|
||
)
|
||
preview_arguments = {
|
||
**common_arguments,
|
||
"utterance": instruction,
|
||
"provider": "local",
|
||
}
|
||
result = self._execute_request_with_peripherals(
|
||
"/api/requests/preview",
|
||
preview_arguments,
|
||
on_commands_validated=on_commands_validated,
|
||
)
|
||
if result is None:
|
||
result = self.service.preview_native_can(
|
||
**preview_arguments,
|
||
on_commands_validated=on_commands_validated,
|
||
)
|
||
local_preview_result = result
|
||
self.vehicle_display_sync.update_task(
|
||
status=str(result.get("status") or "succeeded"),
|
||
utterance=message,
|
||
source=source,
|
||
result=result,
|
||
)
|
||
return result
|
||
|
||
def supplement_control(
|
||
instruction: str,
|
||
on_commands_validated: Callable[[dict[str, object]], None],
|
||
assistant_plan: Mapping[str, object] | None = None,
|
||
) -> dict[str, object]:
|
||
"""Execute a Codex proposal for unresolved text without replay."""
|
||
|
||
preview = local_preview_result
|
||
if preview is None:
|
||
raise CodexAssistantError("local control preview is missing")
|
||
routing_value = preview.get("natural_language_routing")
|
||
routing = (
|
||
deepcopy(routing_value)
|
||
if isinstance(routing_value, Mapping)
|
||
else {
|
||
"original_utterance": message,
|
||
"parsed_clauses": [],
|
||
"resolved_clauses": [],
|
||
"unresolved_clauses": [instruction],
|
||
}
|
||
)
|
||
routing["cloud_input_utterance"] = instruction
|
||
excluded_signatures = {
|
||
tuple(item)
|
||
for item in preview.get("parsed_control_signatures", [])
|
||
if isinstance(item, list)
|
||
}
|
||
|
||
def publish_supplement(validated: dict[str, object]) -> None:
|
||
merged_event = self.service._merge_native_can_phases(
|
||
preview,
|
||
validated,
|
||
)
|
||
merged_event.update(
|
||
{
|
||
"phase": "supplement_commands_validated",
|
||
"preview_request_id": preview.get("request_id"),
|
||
"supplement_of": preview.get("request_id"),
|
||
"natural_language_routing": deepcopy(routing),
|
||
}
|
||
)
|
||
on_commands_validated(merged_event)
|
||
|
||
process_arguments = {
|
||
key: value
|
||
for key, value in common_arguments.items()
|
||
if key
|
||
not in {
|
||
"utterance",
|
||
"provider",
|
||
"mode_profile_draft_context",
|
||
}
|
||
}
|
||
if assistant_plan is not None:
|
||
supplement = self.service.process(
|
||
instruction,
|
||
provider="local",
|
||
**process_arguments,
|
||
_excluded_control_signatures=excluded_signatures,
|
||
_model_parse_source="codex_supplement",
|
||
_allow_empty_control_result=True,
|
||
_on_commands_validated=publish_supplement,
|
||
_assistant_plan=dict(assistant_plan),
|
||
_cloud_instruction_context=routing,
|
||
)
|
||
else:
|
||
supplement_arguments = {
|
||
**common_arguments,
|
||
"utterance": instruction,
|
||
"provider": "local",
|
||
}
|
||
supplement = self._execute_request_with_peripherals(
|
||
"/api/requests",
|
||
supplement_arguments,
|
||
on_commands_validated=publish_supplement,
|
||
)
|
||
if supplement is None:
|
||
supplement = self.service.process(
|
||
instruction,
|
||
provider="local",
|
||
**process_arguments,
|
||
_excluded_control_signatures=excluded_signatures,
|
||
_model_parse_source="codex_supplement",
|
||
_allow_empty_control_result=True,
|
||
_on_commands_validated=publish_supplement,
|
||
_cloud_instruction_context=routing,
|
||
)
|
||
|
||
merged = self.service._merge_native_can_phases(preview, supplement)
|
||
merged.update(
|
||
{
|
||
"phase": "two_stage_complete",
|
||
"utterance": message,
|
||
"preview_request_id": preview.get("request_id"),
|
||
"supplement_of": preview.get("request_id"),
|
||
"supplement_request_id": supplement.get("request_id"),
|
||
"supplement_required": False,
|
||
"natural_language_routing": routing,
|
||
}
|
||
)
|
||
self.service.persist_composed_result(merged)
|
||
self.vehicle_display_sync.update_task(
|
||
status=str(merged.get("status") or "succeeded"),
|
||
utterance=message,
|
||
source=source,
|
||
result=merged,
|
||
)
|
||
return merged
|
||
|
||
def execute_control(
|
||
instruction: str,
|
||
on_commands_validated: Callable[[dict[str, object]], None],
|
||
assistant_plan: Mapping[str, object] | None = None,
|
||
) -> dict[str, object]:
|
||
self.vehicle_display_sync.update_task(
|
||
status="running",
|
||
utterance=message,
|
||
source=source,
|
||
)
|
||
turn_arguments = {
|
||
**common_arguments,
|
||
"utterance": instruction,
|
||
# Codex has already normalized this agent-tool request. Keep
|
||
# the safety execution pass deterministic; explicit user text
|
||
# uses preview_control/supplement_control above instead.
|
||
"provider": "local",
|
||
**({"assistant_plan": dict(assistant_plan)} if assistant_plan is not None else {}),
|
||
}
|
||
try:
|
||
result = self._execute_request_with_peripherals(
|
||
"/api/requests/preview",
|
||
turn_arguments,
|
||
on_commands_validated=on_commands_validated,
|
||
)
|
||
if result is None:
|
||
result = self.service.preview_native_can(
|
||
**turn_arguments,
|
||
on_commands_validated=on_commands_validated,
|
||
)
|
||
if result.get("supplement_required"):
|
||
result = self.service.supplement_native_can(
|
||
str(result["request_id"]),
|
||
provider="local",
|
||
approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"),
|
||
hardware_client_id=hardware_client_id,
|
||
hardware_access_bypass=self._hardware_access_bypass_allowed(),
|
||
reasoning_effort=reasoning_effort,
|
||
on_commands_validated=on_commands_validated,
|
||
)
|
||
except HardwareAccessRequiredError:
|
||
record_control_failure("hardware_access_required", "真实车控当前不可用。")
|
||
raise
|
||
except HardwareBusyError:
|
||
record_control_failure("hardware_busy", "车辆控制通道正在使用中。")
|
||
raise
|
||
except HardwareSafetyError:
|
||
record_control_failure("hardware_safety_gate", "操作未通过车辆安全校验。")
|
||
raise
|
||
except CanGatewayError:
|
||
record_control_failure("can_transport_failed", "车辆控制通道当前不可用。")
|
||
raise
|
||
except (GatewayError, ExtensionIntegrationError):
|
||
record_control_failure("device_control_failed", "外设控制没有完成。")
|
||
raise
|
||
except Exception:
|
||
record_control_failure("vehicle_control_failed", "车辆控制没有完成。")
|
||
raise
|
||
self.vehicle_display_sync.update_task(
|
||
status=str(result.get("status") or "succeeded"),
|
||
utterance=message,
|
||
source=source,
|
||
result=result,
|
||
)
|
||
return result
|
||
|
||
try:
|
||
self.assistant.stream(
|
||
message,
|
||
conversation_id=conversation_id,
|
||
principal=principal,
|
||
history=history,
|
||
emit=emit,
|
||
execute_control=execute_control,
|
||
preview_control=preview_control,
|
||
supplement_control=supplement_control,
|
||
capability_snapshot=capability_snapshot,
|
||
capability_context=capability_context,
|
||
scene_capability_context=scene_capability_context,
|
||
control_terms=capability_catalog.control_terms(capability_snapshot),
|
||
query_capabilities=capability_catalog.query,
|
||
authoritative_answer=fast_answer.answer if fast_answer else None,
|
||
authoritative_topic=(
|
||
getattr(fast_answer, "topic", None) if fast_answer else None
|
||
),
|
||
authoritative_suggestions=(
|
||
[
|
||
item.as_dict()
|
||
for item in getattr(fast_answer, "suggestions", ())
|
||
]
|
||
if fast_answer
|
||
else []
|
||
),
|
||
quick_control_commands=[item.command for item in quick_controls],
|
||
mode_profile_draft_context=mode_profile_draft_context,
|
||
)
|
||
except HardwareAccessRequiredError as exc:
|
||
self.vehicle_display_sync.update_task(
|
||
status="failed",
|
||
utterance=message,
|
||
source=source,
|
||
error={"code": "hardware_access_required", "message": str(exc)},
|
||
)
|
||
emit(
|
||
{
|
||
"type": "error",
|
||
"error": "hardware_access_required",
|
||
"message": f"暂时不能执行真实车控:{exc}",
|
||
"access_state": exc.state,
|
||
}
|
||
)
|
||
except HardwareBusyError as exc:
|
||
record_control_failure("hardware_busy", "车辆控制通道正在使用中。")
|
||
emit(
|
||
{
|
||
"type": "error",
|
||
"error": "hardware_busy",
|
||
"message": f"车辆控制通道正在使用中:{exc}",
|
||
"retry_after_seconds": exc.retry_after_seconds,
|
||
}
|
||
)
|
||
except HardwareSafetyError as exc:
|
||
record_control_failure("hardware_safety_gate", "操作未通过车辆安全校验。")
|
||
emit(
|
||
{
|
||
"type": "error",
|
||
"error": "hardware_safety_gate",
|
||
"message": f"该操作未通过车辆安全校验:{exc}",
|
||
}
|
||
)
|
||
except CanGatewayError as exc:
|
||
record_control_failure("can_transport_failed", "车辆控制通道当前不可用。")
|
||
emit(
|
||
{
|
||
"type": "error",
|
||
"error": "can_transport_failed",
|
||
"message": f"车辆控制通道当前不可用:{exc}",
|
||
}
|
||
)
|
||
except (GatewayError, ExtensionIntegrationError) as exc:
|
||
record_control_failure("device_control_failed", "外设控制没有完成。")
|
||
emit(
|
||
{
|
||
"type": "error",
|
||
"error": "device_control_failed",
|
||
"message": f"外设控制没有完成:{exc}",
|
||
}
|
||
)
|
||
except (CodexAppServerError, CodexAssistantError, OpenAIIntentError) as exc:
|
||
self.log_error("assistant model request failed: %s", exc)
|
||
emit(
|
||
{
|
||
"type": "error",
|
||
"error": "assistant_model_failed",
|
||
"message": "小迪暂时没有响应,请稍后再试。",
|
||
}
|
||
)
|
||
except (ValueError, json.JSONDecodeError) as exc:
|
||
emit({"type": "error", "error": "invalid_request", "message": str(exc)})
|
||
except Exception as exc: # noqa: BLE001 - stream one safe terminal event
|
||
self.log_error("assistant request failed: %s", exc)
|
||
emit(
|
||
{
|
||
"type": "error",
|
||
"error": "internal_error",
|
||
"message": "请求处理失败,请稍后再试。",
|
||
}
|
||
)
|
||
|
||
def _handle_codex_vehicle_control(self) -> None:
|
||
"""Run an allowlisted MCP tool over the authenticated loopback bridge."""
|
||
|
||
try:
|
||
content_length = int(self.headers.get("Content-Length", "0"))
|
||
except ValueError:
|
||
content_length = 0
|
||
if not self._is_unproxied_loopback_request():
|
||
self._json(
|
||
{"error": "local_access_required", "message": "仅允许本机 Codex 工具调用。"},
|
||
HTTPStatus.FORBIDDEN,
|
||
)
|
||
self._drain_rejected_request_body(content_length, max_bytes=32 * 1024)
|
||
return
|
||
if not self.assistant.authenticate_internal_request(
|
||
self.headers.get("X-N7-Internal-Token", "")
|
||
):
|
||
self._json(
|
||
{"error": "invalid_internal_token", "message": "Codex 工具认证失败。"},
|
||
HTTPStatus.FORBIDDEN,
|
||
)
|
||
self._drain_rejected_request_body(content_length, max_bytes=32 * 1024)
|
||
return
|
||
try:
|
||
payload = self._read_json_body(max_bytes=32 * 1024)
|
||
tool_name = str(payload.get("tool") or "execute_cockpit_request")
|
||
request_token = str(payload.get("request_token") or "")
|
||
if tool_name == "get_n7web_capabilities":
|
||
result = self.assistant.get_n7web_capabilities(
|
||
request_token,
|
||
str(payload.get("query") or ""),
|
||
)
|
||
elif tool_name == "execute_cockpit_request":
|
||
plan = payload.get("plan")
|
||
if plan is not None and not isinstance(plan, dict):
|
||
raise ValueError("plan must be an object")
|
||
result = self.assistant.execute_cockpit_request(
|
||
request_token,
|
||
plan,
|
||
)
|
||
else:
|
||
raise ValueError("unsupported Codex application tool")
|
||
self._json({"success": True, "result": result})
|
||
except (ValueError, json.JSONDecodeError) as exc:
|
||
self._json(
|
||
{"error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
except CodexVehicleToolError as exc:
|
||
self._json(
|
||
{"error": "vehicle_control_failed", "message": str(exc)},
|
||
HTTPStatus.CONFLICT,
|
||
)
|
||
except Exception as exc: # noqa: BLE001 - return a safe MCP failure
|
||
self.log_error("Codex vehicle tool failed: %s", exc)
|
||
self._json(
|
||
{"error": "vehicle_control_failed", "message": str(exc)},
|
||
HTTPStatus.CONFLICT,
|
||
)
|
||
|
||
def do_PUT(self) -> None: # noqa: N802 - stdlib handler API
|
||
path = unquote(urlparse(self.path).path)
|
||
if not self._authorize(path):
|
||
return
|
||
if not self._require_same_origin() or not self._require_json_content_type():
|
||
return
|
||
pet_match = re.fullmatch(
|
||
r"/api/extension/pets/(?P<pet_id>pet-[0-9a-f]{32})",
|
||
path,
|
||
)
|
||
if pet_match is not None:
|
||
try:
|
||
payload = self._read_json_body(max_bytes=4096)
|
||
unexpected = set(payload) - {"pet_name", "action"}
|
||
if unexpected:
|
||
if "nfc_id" in unexpected:
|
||
raise ValueError(
|
||
"nfc_id 只能通过当前已识别的 NFC 卡片完成绑定。"
|
||
)
|
||
raise ValueError("宠物卡片包含不支持的字段。")
|
||
card = self.pets.update_card(
|
||
pet_match.group("pet_id"),
|
||
pet_name=payload.get("pet_name"),
|
||
action=payload.get("action"),
|
||
)
|
||
self._json(
|
||
{"success": True, "card": card, **self.pets.snapshot()},
|
||
HTTPStatus.OK,
|
||
)
|
||
except KeyError:
|
||
self._json(
|
||
{"success": False, "error": "not_found", "message": "未找到宠物卡片。"},
|
||
HTTPStatus.NOT_FOUND,
|
||
)
|
||
except (ValueError, json.JSONDecodeError) as exc:
|
||
self._json(
|
||
{"success": False, "error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
return
|
||
|
||
match = re.fullmatch(r"/api/extension/gscu/slots/(?P<slot>\d+)", path)
|
||
if match is None:
|
||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||
return
|
||
try:
|
||
payload = self._read_json_body(max_bytes=4096)
|
||
self._json(
|
||
self.extension.save_infrared_slot(match.group("slot"), payload),
|
||
HTTPStatus.OK,
|
||
)
|
||
except (GatewayError, ValueError, json.JSONDecodeError) as exc:
|
||
self._json(
|
||
{"success": False, "error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
|
||
def do_DELETE(self) -> None: # noqa: N802 - stdlib handler API
|
||
parsed_url = urlparse(self.path)
|
||
path = unquote(parsed_url.path)
|
||
if not self._authorize(path):
|
||
return
|
||
if not self._require_same_origin():
|
||
return
|
||
slot_match = re.fullmatch(r"/api/extension/gscu/slots/(?P<slot>\d+)", path)
|
||
if slot_match is not None:
|
||
try:
|
||
self._json(
|
||
self.extension.delete_infrared_slot(slot_match.group("slot")),
|
||
HTTPStatus.OK,
|
||
)
|
||
except GatewayError as exc:
|
||
self._json(
|
||
{"success": False, "error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
return
|
||
pet_match = re.fullmatch(
|
||
r"/api/extension/pets/(?P<pet_id>pet-[0-9a-f]{32})",
|
||
path,
|
||
)
|
||
if pet_match is not None:
|
||
try:
|
||
deleted = self.pets.delete_card(pet_match.group("pet_id"))
|
||
self._json(
|
||
{"success": True, "deleted": deleted, **self.pets.snapshot()},
|
||
HTTPStatus.OK,
|
||
)
|
||
except KeyError:
|
||
self._json(
|
||
{"success": False, "error": "not_found", "message": "未找到宠物卡片。"},
|
||
HTTPStatus.NOT_FOUND,
|
||
)
|
||
except ValueError as exc:
|
||
self._json(
|
||
{"success": False, "error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
return
|
||
prefix = "/api/mode-profiles/"
|
||
if not path.startswith(prefix):
|
||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||
return
|
||
|
||
mode_id = path.removeprefix(prefix).strip()
|
||
vehicle_id = str(parse_qs(parsed_url.query).get("vehicle_id", [""])[0]) or None
|
||
try:
|
||
self._json(
|
||
self.service.delete_user_mode_profile(mode_id, vehicle_id),
|
||
HTTPStatus.OK,
|
||
)
|
||
except KeyError:
|
||
self._json(
|
||
{"error": "not_found", "message": "未找到可删除的用户自定义模式。"},
|
||
HTTPStatus.NOT_FOUND,
|
||
)
|
||
except PermissionError as exc:
|
||
self._json(
|
||
{"error": "preset_mode_protected", "message": str(exc)},
|
||
HTTPStatus.FORBIDDEN,
|
||
)
|
||
except ValueError as exc:
|
||
self._json(
|
||
{"error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
|
||
def _handle_mode_profile_post(self, path: str) -> None:
|
||
execute_match = re.fullmatch(
|
||
r"/api/mode-profiles/(?P<mode_id>[a-z0-9][a-z0-9_-]{0,63})/execute",
|
||
path,
|
||
)
|
||
if execute_match is not None:
|
||
self._handle_mode_profile_execute(execute_match.group("mode_id"))
|
||
return
|
||
|
||
create_mode = path == "/api/mode-profiles"
|
||
mode_id = path.removeprefix("/api/mode-profiles/").strip()
|
||
if not create_mode and not mode_id:
|
||
self._json(
|
||
{"error": "invalid_request", "message": "模式编号不能为空。"},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
return
|
||
try:
|
||
payload = self._read_json_body(max_bytes=16 * 1024)
|
||
arguments = (
|
||
str(payload.get("name", "")),
|
||
str(payload.get("instruction", "")),
|
||
str(payload.get("source_request_id", "")),
|
||
str(payload.get("vehicle_id", "")) or None,
|
||
)
|
||
result = (
|
||
self.service.create_mode_profile_card(*arguments)
|
||
if create_mode
|
||
else self.service.save_mode_profile_card(mode_id, *arguments)
|
||
)
|
||
saved_profile = result.get("mode_profile") or {}
|
||
saved_mode_id = str(saved_profile.get("id") or "").strip()
|
||
try:
|
||
result["mode_scene_mqtt"] = self.service.publish_saved_mode_scene(
|
||
saved_mode_id,
|
||
str(result.get("vehicle_id") or "") or None,
|
||
)
|
||
except Exception as exc: # noqa: BLE001 - card persistence must survive MQTT loss
|
||
result["mode_scene_mqtt"] = {
|
||
"state": "queue_failed",
|
||
"topic": MQTT_MODE_SCENE_TOPIC,
|
||
"scene_id": saved_mode_id or None,
|
||
"burst_count": MODE_SCENE_MQTT_BURST_COUNT,
|
||
"burst_window_ms": round(
|
||
MODE_SCENE_MQTT_BURST_WINDOW_S * 1_000
|
||
),
|
||
"last_error": str(exc)[:240],
|
||
}
|
||
self._json(result, HTTPStatus.CREATED if create_mode else HTTPStatus.OK)
|
||
except KeyError:
|
||
self._json(
|
||
{"error": "not_found", "message": "未找到需要编辑的模式卡片。"},
|
||
HTTPStatus.NOT_FOUND,
|
||
)
|
||
except (ValueError, json.JSONDecodeError) as exc:
|
||
self._json(
|
||
{"error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
|
||
def _handle_mode_profile_draft_trial(self) -> None:
|
||
try:
|
||
payload = self._read_json_body(max_bytes=16 * 1024)
|
||
result = self.service.trial_mode_profile_draft(
|
||
str(payload.get("name", "")),
|
||
str(payload.get("instruction", "")),
|
||
str(payload.get("source_request_id", "")),
|
||
str(payload.get("vehicle_id", "")) or None,
|
||
)
|
||
self._json(result, HTTPStatus.OK)
|
||
except (ValueError, json.JSONDecodeError) as exc:
|
||
self._json(
|
||
{"error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
|
||
def _saved_mode_infrared_entries(
|
||
self,
|
||
profile: object,
|
||
) -> list[dict[str, object]]:
|
||
command_flow = getattr(profile, "command_flow", None)
|
||
if not isinstance(command_flow, dict):
|
||
return []
|
||
configurations = command_flow.get("function_configs") or {}
|
||
extension_config = None
|
||
if isinstance(configurations, dict):
|
||
extension_config = configurations.get("self_extension")
|
||
if not isinstance(extension_config, dict):
|
||
extension_config = next(
|
||
(
|
||
config
|
||
for config in configurations.values()
|
||
if isinstance(config, dict)
|
||
and str((config.get("spec") or {}).get("domain"))
|
||
== "self_extension"
|
||
),
|
||
None,
|
||
)
|
||
operations = (
|
||
(extension_config.get("spec") or {}).get("operations") or []
|
||
if isinstance(extension_config, dict)
|
||
else []
|
||
)
|
||
if not operations:
|
||
return []
|
||
|
||
catalog = self.extension.list_peripheral_capabilities()
|
||
entries: list[dict[str, object]] = []
|
||
for index, operation in enumerate(operations):
|
||
if not isinstance(operation, dict) or operation.get("capability") != "infrared.emit":
|
||
continue
|
||
parameters = operation.get("parameters") or {}
|
||
target = operation.get("target") or {}
|
||
try:
|
||
slot = int(parameters.get("slot"))
|
||
except (TypeError, ValueError) as exc:
|
||
raise ExtensionIntegrationError(
|
||
"已保存模式包含无效的红外槽位。"
|
||
) from exc
|
||
device_id = str(target.get("device") or "").strip()
|
||
bound_uid = re.sub(
|
||
r"[^0-9A-Fa-f]",
|
||
"",
|
||
str(target.get("card_uid") or ""),
|
||
).upper()
|
||
if not bound_uid:
|
||
raise ExtensionIntegrationError(
|
||
f"已保存模式中的红外命令缺少持久 NFC 绑定:{device_id} / IR {slot}。"
|
||
)
|
||
capability = next(
|
||
(
|
||
item
|
||
for item in catalog
|
||
if item.get("status") == "learned"
|
||
and item.get("enabled", True) is True
|
||
and int(item.get("slot") or 0) == slot
|
||
and str(item.get("device_id") or "") == device_id
|
||
and re.sub(
|
||
r"[^0-9A-Fa-f]",
|
||
"",
|
||
str(item.get("card_uid") or ""),
|
||
).upper()
|
||
== bound_uid
|
||
),
|
||
None,
|
||
)
|
||
if capability is None:
|
||
raise ExtensionIntegrationError(
|
||
f"持久绑定目录未授权模式中的红外命令:{device_id} / IR {slot}。"
|
||
)
|
||
entries.append(
|
||
{
|
||
"index": index,
|
||
"clause": str(parameters.get("action") or f"IR {slot}"),
|
||
"capability": {
|
||
**capability,
|
||
"parse_source": "saved_mode_command_flow",
|
||
},
|
||
}
|
||
)
|
||
return entries
|
||
|
||
def _handle_mode_profile_execute(self, mode_id: str) -> None:
|
||
utterance = ""
|
||
source = "web_console_mode_card"
|
||
try:
|
||
payload = self._read_json_body(max_bytes=16 * 1024)
|
||
vehicle_id = str(payload.get("vehicle_id", "")) or None
|
||
profile = self.service.get_mode_profile_card(mode_id, vehicle_id)
|
||
utterance = profile.name
|
||
source = str(payload.get("client_type") or source)
|
||
execution_mode = str(payload.get("execution_mode", "dry_run"))
|
||
control_transport = str(
|
||
payload.get("control_transport", "wired_can")
|
||
)
|
||
conversation_id = (
|
||
str(payload["conversation_id"])
|
||
if payload.get("conversation_id")
|
||
else None
|
||
)
|
||
hardware_client_id = (
|
||
str(payload["hardware_client_id"])
|
||
if payload.get("hardware_client_id")
|
||
else None
|
||
)
|
||
self.vehicle_display_sync.update_task(
|
||
status="running",
|
||
utterance=utterance,
|
||
source=source,
|
||
)
|
||
# Validate the complete saved flow against the durable NFC binding
|
||
# before any transport is allowed to execute.
|
||
entries = self._saved_mode_infrared_entries(profile)
|
||
can_result = self.service.execute_mode_profile_card(
|
||
mode_id,
|
||
conversation_id=conversation_id,
|
||
execution_mode=execution_mode,
|
||
approval_token=os.getenv(
|
||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
|
||
),
|
||
vehicle_id=vehicle_id,
|
||
simulation_fault=str(payload.get("simulation_fault", "normal")),
|
||
hardware_client_id=hardware_client_id,
|
||
hardware_access_bypass=self._hardware_access_bypass_allowed(),
|
||
control_transport=control_transport,
|
||
)
|
||
if entries:
|
||
infrared_result = self._execute_local_peripheral_actions(
|
||
utterance,
|
||
entries,
|
||
provider="saved_mode_command_flow",
|
||
conversation_id=conversation_id,
|
||
execution_mode=execution_mode,
|
||
)
|
||
result = self._merge_can_and_peripheral_results(
|
||
utterance,
|
||
can_result,
|
||
infrared_result,
|
||
entries,
|
||
)
|
||
result["provider_used"] = "saved_mode_command_flow"
|
||
result["orchestration_route"] = "saved_mode_command_flow"
|
||
result["mode_profile"] = profile.public_dict()
|
||
self.service.persist_composed_result(result)
|
||
else:
|
||
result = can_result
|
||
self.vehicle_display_sync.update_task(
|
||
status=str(result.get("status") or "succeeded"),
|
||
utterance=utterance,
|
||
source=source,
|
||
result=result,
|
||
)
|
||
self._json(result, HTTPStatus.OK)
|
||
except KeyError:
|
||
self._json(
|
||
{"error": "not_found", "message": "未找到需要执行的模式卡片。"},
|
||
HTTPStatus.NOT_FOUND,
|
||
)
|
||
except HardwareAccessRequiredError as exc:
|
||
self._json(
|
||
{
|
||
"error": "hardware_access_required",
|
||
"message": str(exc),
|
||
"access_state": exc.state,
|
||
},
|
||
HTTPStatus.FORBIDDEN,
|
||
)
|
||
except HardwareBusyError as exc:
|
||
self._json(
|
||
{
|
||
"error": "hardware_busy",
|
||
"message": str(exc),
|
||
"retry_after_seconds": exc.retry_after_seconds,
|
||
},
|
||
HTTPStatus.CONFLICT,
|
||
)
|
||
except (HardwareSafetyError, CanGatewayError, GatewayError) as exc:
|
||
if utterance:
|
||
self.vehicle_display_sync.update_task(
|
||
status="failed",
|
||
utterance=utterance,
|
||
source=source,
|
||
error={"code": "mode_execution_failed", "message": str(exc)},
|
||
)
|
||
self._json(
|
||
{"error": "mode_execution_failed", "message": str(exc)},
|
||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||
)
|
||
except (ExtensionIntegrationError, ValueError, json.JSONDecodeError) as exc:
|
||
if utterance:
|
||
self.vehicle_display_sync.update_task(
|
||
status="failed",
|
||
utterance=utterance,
|
||
source=source,
|
||
error={"code": "invalid_mode_command_flow", "message": str(exc)},
|
||
)
|
||
self._json(
|
||
{"error": "invalid_mode_command_flow", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
|
||
def _handle_extension_post(self, path: str) -> None:
|
||
action_map = {
|
||
"/api/extension/gscu/info": "info",
|
||
"/api/extension/gscu/learn": "learn",
|
||
"/api/extension/gscu/cancel": "cancel",
|
||
"/api/extension/gscu/emit": "emit",
|
||
"/api/extension/gscu/read": "read",
|
||
"/api/extension/gscu/write": "write",
|
||
"/api/extension/gscu/restart": "restart",
|
||
"/api/extension/gscu/reset": "reset",
|
||
"/api/extension/gscu/wifi-lock": "wifi_lock",
|
||
"/api/extension/gscu/raw": "raw",
|
||
}
|
||
try:
|
||
payload = self._read_json_body(max_bytes=256 * 1024)
|
||
if path == "/api/extension/pets":
|
||
if "nfc_id" in payload:
|
||
raise ValueError(
|
||
"nfc_id 只能通过当前已识别的 NFC 卡片完成绑定。"
|
||
)
|
||
card = self.pets.create_card(
|
||
payload.get("pet_name"),
|
||
payload.get("action"),
|
||
)
|
||
self._json(
|
||
{"success": True, "card": card, **self.pets.snapshot()},
|
||
HTTPStatus.CREATED,
|
||
)
|
||
return
|
||
pet_binding_match = re.fullmatch(
|
||
r"/api/extension/pets/(?P<pet_id>pet-[0-9a-f]{32})/"
|
||
r"(?P<action>bind-current-nfc|unbind)",
|
||
path,
|
||
)
|
||
if pet_binding_match is not None:
|
||
pet_id = pet_binding_match.group("pet_id")
|
||
try:
|
||
if pet_binding_match.group("action") == "bind-current-nfc":
|
||
card = self.pets.bind_current_nfc(pet_id)
|
||
else:
|
||
card = self.pets.unbind_card(pet_id)
|
||
except KeyError:
|
||
self._json(
|
||
{
|
||
"success": False,
|
||
"error": "not_found",
|
||
"message": "未找到宠物卡片。",
|
||
},
|
||
HTTPStatus.NOT_FOUND,
|
||
)
|
||
return
|
||
self._json(
|
||
{"success": True, "card": card, **self.pets.snapshot()},
|
||
HTTPStatus.OK,
|
||
)
|
||
return
|
||
if path in {
|
||
"/api/extension/gscu/erase",
|
||
"/api/extension/gscu/erase-all",
|
||
}:
|
||
result = self.extension.erase_all_infrared(dict(payload))
|
||
self.vehicle_display_sync.update_extension(
|
||
action={"type": "erase_all"},
|
||
result=result,
|
||
)
|
||
self._json(result)
|
||
return
|
||
action = action_map.get(path)
|
||
if action:
|
||
self._handle_extension_gateway(action, payload)
|
||
return
|
||
if path == "/api/extension/gscu/config":
|
||
result = self.extension.configure_tcp_gateway(dict(payload))
|
||
self.vehicle_display_sync.update_extension(
|
||
action={
|
||
"type": "gateway_config",
|
||
"listen_host": result.get("listen_host"),
|
||
"port": result.get("port"),
|
||
},
|
||
result=result,
|
||
)
|
||
self._json(result)
|
||
return
|
||
if path == "/api/extension/display-sync":
|
||
catalog = payload.get("peripherals", payload.get("catalog", []))
|
||
if not isinstance(catalog, list) or len(catalog) > 248:
|
||
raise ValueError("外设能力目录必须是至多 248 项的数组。")
|
||
durable_sync = self.extension.sync_peripheral_catalog(catalog)
|
||
sync_result = {
|
||
"status": "ready",
|
||
"count": len(catalog),
|
||
"learned_count": durable_sync["count"],
|
||
}
|
||
self.vehicle_display_sync.update_extension(
|
||
catalog=catalog,
|
||
action={
|
||
"type": "hardware_detected",
|
||
"device": payload.get("device"),
|
||
},
|
||
result=sync_result,
|
||
)
|
||
self._json({"success": True, **sync_result})
|
||
return
|
||
if path == "/api/extension/assistant/plan":
|
||
self.vehicle_display_sync.update_extension(
|
||
catalog=payload.get("peripherals", payload.get("catalog", [])),
|
||
action={"type": "plan", "command": payload.get("utterance", "")},
|
||
)
|
||
result = self.extension.plan(payload)
|
||
self.vehicle_display_sync.update_extension(
|
||
result=result,
|
||
)
|
||
self._json(result)
|
||
return
|
||
if path == "/api/extension/vehicle/execute":
|
||
result = self.extension.execute_vehicle(
|
||
payload,
|
||
self.service,
|
||
hardware_access_bypass=self._hardware_access_bypass_allowed(),
|
||
)
|
||
self.vehicle_display_sync.update_task(
|
||
status=str(result.get("status", "succeeded")),
|
||
utterance=str(payload.get("command", payload.get("utterance", ""))),
|
||
source=str(payload.get("client_type", "head_unit")),
|
||
result=result,
|
||
)
|
||
self.vehicle_display_sync.update_extension(
|
||
catalog=payload.get("catalog") if "catalog" in payload else None,
|
||
action={"type": "vehicle_execute"},
|
||
result=result,
|
||
)
|
||
self._json(result)
|
||
return
|
||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||
except GatewayError as exc:
|
||
self._json(
|
||
{"success": False, "error": "gscu_unavailable", "message": str(exc)},
|
||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||
)
|
||
except ExtensionIntegrationError as exc:
|
||
self._json(
|
||
{"success": False, "error": "extension_unavailable", "message": str(exc)},
|
||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||
)
|
||
except HardwareAccessRequiredError as exc:
|
||
self._json(
|
||
{
|
||
"success": False,
|
||
"error": "hardware_access_required",
|
||
"message": str(exc),
|
||
"access_state": exc.state,
|
||
},
|
||
HTTPStatus.FORBIDDEN,
|
||
)
|
||
except HardwareBusyError as exc:
|
||
self._json(
|
||
{
|
||
"success": False,
|
||
"error": "hardware_busy",
|
||
"message": str(exc),
|
||
"retry_after_seconds": exc.retry_after_seconds,
|
||
},
|
||
HTTPStatus.CONFLICT,
|
||
{"Retry-After": str(exc.retry_after_seconds)},
|
||
)
|
||
except HardwareSafetyError as exc:
|
||
self._json(
|
||
{"success": False, "error": "hardware_safety_gate", "message": str(exc)},
|
||
HTTPStatus.FORBIDDEN,
|
||
)
|
||
except OpenAIIntentError as exc:
|
||
self.log_error("extension vehicle intent request failed: %s", exc)
|
||
self._json(
|
||
{
|
||
"success": False,
|
||
"error": "openai_request_failed",
|
||
"message": "车端 GPT 意图解析失败,请检查模型配置。",
|
||
},
|
||
HTTPStatus.BAD_GATEWAY,
|
||
)
|
||
except (ValueError, json.JSONDecodeError) as exc:
|
||
self._json(
|
||
{"success": False, "error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
except Exception as exc: # noqa: BLE001 - keep extension APIs responsive
|
||
self.log_error("self-extension request failed: %s", exc)
|
||
self._json(
|
||
{
|
||
"success": False,
|
||
"error": "internal_error",
|
||
"message": "自扩展服务处理失败,请查看服务端日志。",
|
||
},
|
||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||
)
|
||
|
||
def _handle_calibration_post(self, path: str) -> None:
|
||
if not self._require_calibration_admin():
|
||
return
|
||
try:
|
||
payload = self._read_json_body(max_bytes=256 * 1024)
|
||
action_map = {
|
||
"/api/calibration/can/connect": lambda: self.calibration.connect_can(
|
||
dict(payload.get("can", payload))
|
||
),
|
||
"/api/calibration/start": lambda: self.calibration.calibrate(
|
||
None if payload.get("axis", "all") == "all" else int(payload["axis"])
|
||
),
|
||
"/api/calibration/move": lambda: self.calibration.move(
|
||
int(payload["axis"]), int(payload["permille"])
|
||
),
|
||
"/api/calibration/seek": lambda: self.calibration.seek(
|
||
int(payload["axis"]), bool(payload.get("far"))
|
||
),
|
||
"/api/calibration/stop": self.calibration.stop,
|
||
"/api/calibration/session/reset": self.calibration.reset_virtual_session,
|
||
"/api/calibration/settings": lambda: self.calibration.update_settings(
|
||
dict(payload)
|
||
),
|
||
"/api/calibration/mqtt/connect": self.calibration.connect_mqtt,
|
||
"/api/calibration/mqtt/disconnect": self.calibration.disconnect_mqtt,
|
||
"/api/calibration/mqtt/test": self.calibration.mqtt_test,
|
||
"/api/calibration/video/start": self.calibration.start_video,
|
||
"/api/calibration/video/stop": self.calibration.stop_video,
|
||
}
|
||
action = action_map.get(path)
|
||
if action is None:
|
||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||
return
|
||
|
||
requested_mode = str(
|
||
dict(payload.get("can", {})).get(
|
||
"mode", self.calibration.snapshot()["transport"]["mode"]
|
||
)
|
||
)
|
||
motion_paths = {
|
||
"/api/calibration/can/connect",
|
||
"/api/calibration/start",
|
||
"/api/calibration/move",
|
||
"/api/calibration/seek",
|
||
"/api/calibration/stop",
|
||
}
|
||
if path in motion_paths and requested_mode == "hardware":
|
||
if not self._require_local_decision_point():
|
||
return
|
||
|
||
self._json({"ok": True, "state": action()})
|
||
except CalibrationApiError as exc:
|
||
self._json(
|
||
{"ok": False, "error": "calibration_error", "message": str(exc)},
|
||
HTTPStatus(exc.status),
|
||
)
|
||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||
self._json(
|
||
{"ok": False, "error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
except Exception as exc: # noqa: BLE001 - keep calibration APIs responsive
|
||
self.log_error("self-calibration request failed: %s", exc)
|
||
self._json(
|
||
{
|
||
"ok": False,
|
||
"error": "internal_error",
|
||
"message": "自标定服务处理失败,请查看服务端日志。",
|
||
},
|
||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||
)
|
||
|
||
def _handle_self_repair_post(self, path: str) -> None:
|
||
if not self._require_self_repair_admin():
|
||
return
|
||
try:
|
||
payload = self._read_json_body(max_bytes=256 * 1024)
|
||
physical_paths = {
|
||
"/api/self-repair/hardware/start",
|
||
"/api/self-repair/hardware/stop",
|
||
"/api/self-repair/emergency-stop",
|
||
"/api/self-repair/firmware/build-flash",
|
||
"/api/self-repair/open-log",
|
||
}
|
||
if (
|
||
path in physical_paths
|
||
or (
|
||
path in {
|
||
"/api/self-repair/runtime",
|
||
"/api/self-repair/healing/start",
|
||
"/api/self-repair/healing/reset",
|
||
}
|
||
and str(payload.get("execution_mode", "shadow")) == "bench"
|
||
)
|
||
) and not self._require_local_decision_point():
|
||
return
|
||
|
||
if path == "/api/self-repair/healing/start":
|
||
self._json({"ok": True, "healing": self.repair.start_healing(payload)})
|
||
return
|
||
if path == "/api/self-repair/runtime":
|
||
self._json({"ok": True, "runtime": self.repair.set_runtime(payload)})
|
||
return
|
||
if path == "/api/self-repair/runtime/reset-statistics":
|
||
self._json({
|
||
"ok": True,
|
||
"runtime": self.repair.reset_runtime_statistics(),
|
||
})
|
||
return
|
||
if path == "/api/self-repair/healing/run-all":
|
||
self._json({"ok": True, "batch": self.repair.start_batch(payload)})
|
||
return
|
||
if path == "/api/self-repair/healing/reset":
|
||
self._json({"ok": True, **self.repair.reset_healing(payload)})
|
||
return
|
||
if path == "/api/self-repair/hardware/start":
|
||
self._json({"ok": True, "session": self.repair.start_hardware()})
|
||
return
|
||
if path == "/api/self-repair/hardware/stop":
|
||
self._json({"ok": True, "session": self.repair.stop_hardware()})
|
||
return
|
||
if path == "/api/self-repair/hardware/self-test":
|
||
self._json(
|
||
{"ok": True, "self_test": self.repair.run_hardware_self_test()}
|
||
)
|
||
return
|
||
if path == "/api/self-repair/emergency-stop":
|
||
self._json({"ok": True, "request": self.repair.emergency_stop()})
|
||
return
|
||
if path == "/api/self-repair/snapshot":
|
||
self._json({"ok": True, "path": str(self.repair.save_snapshot())})
|
||
return
|
||
if path == "/api/self-repair/firmware/build-flash":
|
||
self._json(
|
||
{
|
||
"ok": True,
|
||
"firmware": self.repair.build_and_flash_firmware(payload),
|
||
}
|
||
)
|
||
return
|
||
if path == "/api/self-repair/open-log":
|
||
opened = self.repair.open_log_dir()
|
||
self._json({"ok": opened})
|
||
return
|
||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||
self._json(
|
||
{"ok": False, "error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
except Exception as exc: # noqa: BLE001 - keep self-repair API responsive
|
||
self.log_error("self-repair request failed: %s", exc)
|
||
self._json(
|
||
{
|
||
"ok": False,
|
||
"error": "internal_error",
|
||
"message": "自修复服务处理失败,请查看服务端日志。",
|
||
},
|
||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||
)
|
||
|
||
def _require_self_repair_admin(self, parsed_url: ParseResult | None = None) -> bool:
|
||
role = self.headers.get("X-Cognitive-Agent-Role", "").strip().lower()
|
||
if not role and parsed_url is not None:
|
||
role = str(parse_qs(parsed_url.query).get("role", [""])[0]).lower()
|
||
if hmac.compare_digest(role, "admin"):
|
||
return True
|
||
self._json(
|
||
{"error": "admin_required", "message": "自修复仅对 admin 开放。"},
|
||
HTTPStatus.FORBIDDEN,
|
||
)
|
||
return False
|
||
|
||
def _require_calibration_admin(self, parsed_url: ParseResult | None = None) -> bool:
|
||
role = self.headers.get("X-Cognitive-Agent-Role", "").strip().lower()
|
||
if not role and parsed_url is not None:
|
||
role = str(parse_qs(parsed_url.query).get("role", [""])[0]).lower()
|
||
if hmac.compare_digest(role, "admin"):
|
||
return True
|
||
self._json(
|
||
{
|
||
"error": "admin_required",
|
||
"message": "自标定仅对 admin 开放。",
|
||
},
|
||
HTTPStatus.FORBIDDEN,
|
||
)
|
||
return False
|
||
|
||
def _serve_calibration_events(self) -> None:
|
||
self.send_response(HTTPStatus.OK)
|
||
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||
self.send_header("Cache-Control", "no-cache")
|
||
self.send_header("Connection", "keep-alive")
|
||
self.send_header("X-Accel-Buffering", "no")
|
||
self._send_security_headers()
|
||
self.end_headers()
|
||
version = -1
|
||
try:
|
||
while True:
|
||
state = self.calibration.wait_for_version(version, 12.0)
|
||
version = state["version"]
|
||
data = json.dumps(state, ensure_ascii=False, separators=(",", ":"))
|
||
self.wfile.write(f"data: {data}\n\n".encode("utf-8"))
|
||
self.wfile.flush()
|
||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||
pass
|
||
|
||
def _serve_calibration_video(self) -> None:
|
||
self._json(
|
||
{
|
||
"error": "legacy_video_transport_removed",
|
||
"message": "ZED 画面已改为浏览器直连 WebRTC/WHEP。",
|
||
},
|
||
HTTPStatus.GONE,
|
||
)
|
||
|
||
def _handle_extension_gateway(self, action: str, payload: dict[str, object]) -> None:
|
||
try:
|
||
result = self.extension.execute_gateway(action, dict(payload))
|
||
if action != "info":
|
||
self.vehicle_display_sync.update_extension(
|
||
action={"type": action, **dict(payload)},
|
||
result=result,
|
||
)
|
||
self._json(result)
|
||
except GatewayError as exc:
|
||
self._json(
|
||
{"success": False, "error": "gscu_unavailable", "message": str(exc)},
|
||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||
)
|
||
|
||
def _extension_health(self) -> dict[str, object]:
|
||
return {
|
||
**self.extension.health(),
|
||
"nfc": self.nfc.snapshot(),
|
||
"pet_cards": self.pets.snapshot(),
|
||
"extension_toy": self.extension_toys.snapshot(),
|
||
"extension_control": self.extension_control.snapshot(),
|
||
}
|
||
|
||
def _handle_hardware_access_post(self, path: str) -> None:
|
||
local_path = path.startswith("/api/local/")
|
||
if local_path and not self._require_local_decision_point():
|
||
return
|
||
try:
|
||
payload = self._read_json_body(max_bytes=4096)
|
||
client_id = str(payload.get("client_id", ""))
|
||
if path == "/api/hardware/access/request":
|
||
status = self.service.hardware_access.request_access(
|
||
client_id,
|
||
str(payload.get("label", "")),
|
||
self._client_key(),
|
||
)
|
||
self._json(
|
||
status,
|
||
HTTPStatus.OK if status["state"] == "approved" else HTTPStatus.ACCEPTED,
|
||
)
|
||
return
|
||
if path == "/api/hardware/access/release":
|
||
self._json(self.service.hardware_access.release(client_id))
|
||
return
|
||
if path == "/api/local/hardware-access/approve":
|
||
self._json(self.service.hardware_access.approve(client_id))
|
||
return
|
||
if path == "/api/local/hardware-access/deny":
|
||
self._json(self.service.hardware_access.deny(client_id))
|
||
return
|
||
if path == "/api/local/hardware-access/revoke":
|
||
self._json(
|
||
self.service.hardware_access.revoke(client_id or None)
|
||
)
|
||
return
|
||
except (ValueError, json.JSONDecodeError) as exc:
|
||
self._json(
|
||
{"error": "invalid_request", "message": str(exc)},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
|
||
def _read_json_body(self, max_bytes: int = 32768) -> dict[str, object]:
|
||
content_length = int(self.headers.get("Content-Length", "0"))
|
||
if content_length <= 0 or content_length > max_bytes:
|
||
raise ValueError(f"request body must be between 1 and {max_bytes} bytes")
|
||
payload = json.loads(self.rfile.read(content_length).decode("utf-8"))
|
||
if not isinstance(payload, dict):
|
||
raise ValueError("request body must be a JSON object")
|
||
return payload
|
||
|
||
def _require_json_content_type(self) -> bool:
|
||
if is_json_content_type(self.headers.get("Content-Type", "")):
|
||
return True
|
||
self._json(
|
||
{
|
||
"error": "unsupported_media_type",
|
||
"message": "请求 Content-Type 必须为 application/json。",
|
||
},
|
||
HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
|
||
)
|
||
return False
|
||
|
||
def _require_same_origin(self) -> bool:
|
||
"""Reject browser cross-origin mutations while retaining local CLI calls."""
|
||
|
||
origin = self.headers.get("Origin", "").strip()
|
||
if not origin:
|
||
return True
|
||
scheme = "https" if self._is_https() else "http"
|
||
if origin_matches_host(origin, self.headers.get("Host", ""), scheme):
|
||
return True
|
||
self._json(
|
||
{
|
||
"error": "cross_origin_request",
|
||
"message": "拒绝跨来源的状态变更请求。",
|
||
},
|
||
HTTPStatus.FORBIDDEN,
|
||
)
|
||
return False
|
||
|
||
def _handle_speech_transcription(self) -> None:
|
||
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
|
||
if content_type not in {"audio/wav", "audio/wave", "audio/x-wav"}:
|
||
self._json(
|
||
{
|
||
"error": "unsupported_media_type",
|
||
"message": "语音听写接口仅接受 16 位 PCM WAV 音频。",
|
||
},
|
||
HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
|
||
)
|
||
return
|
||
try:
|
||
content_length = int(self.headers.get("Content-Length", "0"))
|
||
if content_length <= 0 or content_length > 2 * 1024 * 1024:
|
||
raise SpeechRecognitionError(
|
||
"invalid_audio_size",
|
||
"录音数据为空或超过大小限制。",
|
||
status=413,
|
||
)
|
||
transcription_arguments: dict[str, object] = {
|
||
"utterance_id": self.headers.get("X-Speech-Utterance-Id")
|
||
}
|
||
requested_mode = (
|
||
self.headers.get("X-Speech-Input-Mode", "").strip().lower()
|
||
)
|
||
legacy_wake_required = (
|
||
self.headers.get("X-Speech-Wake-Word-Required", "").strip() == "1"
|
||
)
|
||
input_mode = requested_mode or (
|
||
"wake" if legacy_wake_required else "manual"
|
||
)
|
||
if input_mode not in {"manual", "wake"}:
|
||
raise SpeechRecognitionError(
|
||
"invalid_speech_input_mode",
|
||
"语音输入模式仅支持 manual 或 wake。",
|
||
status=400,
|
||
)
|
||
if requested_mode == "manual" and legacy_wake_required:
|
||
raise SpeechRecognitionError(
|
||
"speech_input_mode_conflict",
|
||
"手动语音模式不能同时要求唤醒词。",
|
||
status=400,
|
||
)
|
||
transcription_arguments["require_wake_word"] = input_mode == "wake"
|
||
if bool(getattr(self.speech, "supports_input_modes", False)):
|
||
transcription_arguments["input_mode"] = input_mode
|
||
result = self.speech.transcribe_wav(
|
||
self.rfile.read(content_length),
|
||
**transcription_arguments,
|
||
)
|
||
result = {**result, "input_mode": input_mode}
|
||
raw_text = str(result.get("raw_text") or "").strip()
|
||
peripheral_routing = self._speech_peripheral_routing(raw_text)
|
||
if peripheral_routing.get("routing_eligible"):
|
||
result = {
|
||
**result,
|
||
"routing_text": raw_text,
|
||
"peripheral_routing": peripheral_routing,
|
||
}
|
||
self._json(result)
|
||
except SpeechRecognitionError as exc:
|
||
self._json(
|
||
{"error": exc.code, "message": str(exc)},
|
||
HTTPStatus(exc.status),
|
||
)
|
||
except (TypeError, ValueError):
|
||
self._json(
|
||
{"error": "invalid_audio", "message": "录音请求格式无效。"},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
|
||
def _handle_speech_preview(self) -> None:
|
||
"""Return semantic-free partial text for an in-progress microphone session."""
|
||
|
||
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
|
||
if content_type not in {"audio/wav", "audio/wave", "audio/x-wav"}:
|
||
self._json(
|
||
{
|
||
"error": "unsupported_media_type",
|
||
"message": "实时语音接口仅接受 16 位 PCM WAV 音频。",
|
||
},
|
||
HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
|
||
)
|
||
return
|
||
slot_acquired = False
|
||
content_length = 0
|
||
request_body_consumed = False
|
||
try:
|
||
content_length = int(self.headers.get("Content-Length", "0"))
|
||
if content_length <= 0 or content_length > 2 * 1024 * 1024:
|
||
raise SpeechRecognitionError(
|
||
"invalid_audio_size",
|
||
"实时录音数据为空或超过大小限制。",
|
||
status=413,
|
||
)
|
||
sequence = int(self.headers.get("X-Speech-Preview-Sequence", "-1"))
|
||
if sequence < 0 or sequence > 100_000:
|
||
raise SpeechRecognitionError(
|
||
"invalid_preview_sequence",
|
||
"实时转写序号无效。",
|
||
status=400,
|
||
)
|
||
if not self.speech_preview_http_slots.acquire(blocking=False):
|
||
raise SpeechRecognitionError(
|
||
"preview_overloaded",
|
||
"实时语音请求过多,请稍后重试。",
|
||
status=429,
|
||
retry_after_seconds=1,
|
||
)
|
||
slot_acquired = True
|
||
preview_arguments: dict[str, object] = {
|
||
"utterance_id": self.headers.get("X-Speech-Utterance-Id"),
|
||
"sequence": sequence,
|
||
}
|
||
input_mode = (
|
||
self.headers.get("X-Speech-Input-Mode", "wake").strip().lower()
|
||
or "wake"
|
||
)
|
||
if input_mode not in {"manual", "wake"}:
|
||
raise SpeechRecognitionError(
|
||
"invalid_speech_input_mode",
|
||
"语音输入模式仅支持 manual 或 wake。",
|
||
status=400,
|
||
)
|
||
supports_input_modes = bool(
|
||
getattr(self.speech, "supports_input_modes", False)
|
||
)
|
||
if supports_input_modes:
|
||
preview_arguments["input_mode"] = input_mode
|
||
content = self.rfile.read(content_length)
|
||
request_body_consumed = True
|
||
result = self.speech.transcribe_preview_wav(
|
||
content,
|
||
**preview_arguments,
|
||
)
|
||
if input_mode == "manual" and not supports_input_modes:
|
||
raw_text = str(result.get("raw_text") or result.get("text") or "")
|
||
wake_word = dict(result.get("wake_word") or {})
|
||
wake_word.update({"required": False, "remainder": raw_text})
|
||
result = {
|
||
**result,
|
||
"raw_text": raw_text,
|
||
"text": raw_text,
|
||
"live_text": raw_text,
|
||
"wake_word": wake_word,
|
||
}
|
||
result = {**result, "input_mode": input_mode}
|
||
self._json(result)
|
||
except SpeechRecognitionError as exc:
|
||
error_payload: dict[str, object] = {
|
||
"error": exc.code,
|
||
"message": str(exc),
|
||
}
|
||
error_headers: dict[str, str] = {}
|
||
if exc.retry_after_seconds is not None:
|
||
error_payload["retry_after_seconds"] = exc.retry_after_seconds
|
||
error_headers["Retry-After"] = str(exc.retry_after_seconds)
|
||
self._json(
|
||
error_payload,
|
||
HTTPStatus(exc.status),
|
||
error_headers,
|
||
)
|
||
if not request_body_consumed:
|
||
self._drain_rejected_request_body(content_length)
|
||
except (TypeError, ValueError):
|
||
self._json(
|
||
{"error": "invalid_preview", "message": "实时语音请求格式无效。"},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
if not request_body_consumed:
|
||
self._drain_rejected_request_body(content_length)
|
||
finally:
|
||
if slot_acquired:
|
||
self.speech_preview_http_slots.release()
|
||
|
||
def _drain_rejected_request_body(
|
||
self,
|
||
content_length: int,
|
||
*,
|
||
max_bytes: int = 2 * 1024 * 1024,
|
||
) -> None:
|
||
"""Gracefully finish an early-rejected POST without processing its payload.
|
||
|
||
Closing a Windows socket while its peer's request body is still unread can
|
||
replace an already-written HTTP error with TCP RST. Flush the rejection
|
||
first, then briefly discard a valid-sized body so clients reliably receive
|
||
the 4xx response. Callers choose a strict size ceiling, and authorization or
|
||
concurrency gates still run before any payload is read or decoded.
|
||
"""
|
||
|
||
if content_length <= 0 or content_length > max_bytes:
|
||
return
|
||
self.close_connection = True
|
||
try:
|
||
self.wfile.flush()
|
||
except OSError:
|
||
return
|
||
|
||
previous_timeout = self.connection.gettimeout()
|
||
try:
|
||
self.connection.settimeout(0.25)
|
||
remaining = content_length
|
||
while remaining > 0:
|
||
chunk = self.rfile.read(min(remaining, 64 * 1024))
|
||
if not chunk:
|
||
break
|
||
remaining -= len(chunk)
|
||
except (OSError, ValueError):
|
||
pass
|
||
finally:
|
||
try:
|
||
self.connection.settimeout(previous_timeout)
|
||
except OSError:
|
||
pass
|
||
|
||
def _authorize(self, path: str) -> bool:
|
||
if not self.auth.config.enabled or path in PUBLIC_AUTH_PATHS:
|
||
return True
|
||
if self._session_username() is not None:
|
||
return True
|
||
if path.startswith("/api/"):
|
||
self._json(
|
||
{
|
||
"error": "authentication_required",
|
||
"message": "请登录后访问。",
|
||
},
|
||
HTTPStatus.UNAUTHORIZED,
|
||
)
|
||
else:
|
||
requested = path if path.startswith("/") else "/"
|
||
self._redirect(f"/login?next={quote(requested, safe='/')}")
|
||
return False
|
||
|
||
def _login(self) -> None:
|
||
if not self.auth.config.enabled:
|
||
self._json({"enabled": False, "authenticated": True})
|
||
return
|
||
client_key = self._client_key()
|
||
retry_after = self.auth.login_failures.retry_after(client_key)
|
||
if retry_after:
|
||
self._json(
|
||
{
|
||
"error": "login_rate_limited",
|
||
"message": "登录尝试过多,请稍后再试。",
|
||
},
|
||
HTTPStatus.TOO_MANY_REQUESTS,
|
||
{"Retry-After": str(retry_after)},
|
||
)
|
||
return
|
||
try:
|
||
content_length = int(self.headers.get("Content-Length", "0"))
|
||
if content_length <= 0 or content_length > 4096:
|
||
raise ValueError("invalid login body")
|
||
payload = json.loads(self.rfile.read(content_length).decode("utf-8"))
|
||
if not isinstance(payload, dict):
|
||
raise ValueError("invalid login body")
|
||
username = str(payload.get("username", ""))[:128]
|
||
password = str(payload.get("password", ""))[:1024]
|
||
except (ValueError, json.JSONDecodeError):
|
||
self._json(
|
||
{"error": "invalid_request", "message": "登录请求格式无效。"},
|
||
HTTPStatus.BAD_REQUEST,
|
||
)
|
||
return
|
||
|
||
password_valid = verify_password(password, self.auth.config.password_hash)
|
||
username_valid = hmac.compare_digest(
|
||
username.encode("utf-8"),
|
||
self.auth.config.username.encode("utf-8"),
|
||
)
|
||
if not (username_valid and password_valid):
|
||
self.auth.login_failures.record(client_key)
|
||
self._json(
|
||
{"error": "invalid_credentials", "message": "用户名或密码错误。"},
|
||
HTTPStatus.UNAUTHORIZED,
|
||
)
|
||
return
|
||
|
||
self.auth.login_failures.clear(client_key)
|
||
token = self.auth.sessions.issue(self.auth.config.username)
|
||
attributes = [
|
||
f"{self.auth.config.cookie_name}={token}",
|
||
"Path=/",
|
||
"HttpOnly",
|
||
"SameSite=Strict",
|
||
f"Max-Age={self.auth.config.session_ttl_seconds}",
|
||
]
|
||
if self._is_https():
|
||
attributes.append("Secure")
|
||
self._json(
|
||
{
|
||
"authenticated": True,
|
||
"username": self.auth.config.username,
|
||
},
|
||
headers={"Set-Cookie": "; ".join(attributes)},
|
||
)
|
||
|
||
def _logout(self) -> None:
|
||
attributes = [
|
||
f"{self.auth.config.cookie_name}=",
|
||
"Path=/",
|
||
"HttpOnly",
|
||
"SameSite=Strict",
|
||
"Max-Age=0",
|
||
]
|
||
if self._is_https():
|
||
attributes.append("Secure")
|
||
self._json(
|
||
{"authenticated": False},
|
||
headers={"Set-Cookie": "; ".join(attributes)},
|
||
)
|
||
|
||
def _session_username(self) -> str | None:
|
||
if not self.auth.config.enabled:
|
||
return None
|
||
cookie_header = self.headers.get("Cookie", "")
|
||
if not cookie_header or len(cookie_header) > 4096:
|
||
return None
|
||
try:
|
||
cookies = SimpleCookie()
|
||
cookies.load(cookie_header)
|
||
morsel = cookies.get(self.auth.config.cookie_name)
|
||
return self.auth.sessions.verify(morsel.value if morsel else "")
|
||
except (CookieError, ValueError):
|
||
return None
|
||
|
||
def _client_key(self) -> str:
|
||
address = self.client_address[0]
|
||
direct_address = ipaddress.ip_address(address)
|
||
if self.auth.config.trust_proxy_headers and direct_address.is_loopback:
|
||
forwarded = (
|
||
self.headers.get("CF-Connecting-IP")
|
||
or self.headers.get("X-Forwarded-For", "").split(",", 1)[0]
|
||
).strip()
|
||
try:
|
||
address = str(ipaddress.ip_address(forwarded))
|
||
except ValueError:
|
||
pass
|
||
return address
|
||
|
||
def _is_unproxied_loopback_request(self) -> bool:
|
||
"""Distinguish the local decision console from traffic via the tunnel."""
|
||
|
||
try:
|
||
direct_address = ipaddress.ip_address(self.client_address[0])
|
||
except ValueError:
|
||
return False
|
||
proxy_headers = (
|
||
"Forwarded",
|
||
"X-Forwarded-For",
|
||
"X-Real-IP",
|
||
"CF-Connecting-IP",
|
||
)
|
||
return direct_address.is_loopback and not any(
|
||
self.headers.get(name, "").strip() for name in proxy_headers
|
||
)
|
||
|
||
def _hardware_access_bypass_allowed(self) -> bool:
|
||
"""Allow implicit hardware access only for authenticated local sessions."""
|
||
|
||
return (
|
||
self.auth.config.enabled
|
||
and self._is_unproxied_loopback_request()
|
||
and self._session_username() is not None
|
||
)
|
||
|
||
def _passive_state_access_bypass_allowed(
|
||
self,
|
||
control_transport: str,
|
||
) -> bool:
|
||
"""Keep cached MQTT telemetry readable without widening control access.
|
||
|
||
The wireless state endpoints only consume the process-local snapshot
|
||
populated by the persistent MQTT subscription; they never publish a
|
||
frame or reserve the local CAN bench. Mutating control handlers must
|
||
continue to call ``_hardware_access_bypass_allowed`` directly.
|
||
"""
|
||
|
||
return (
|
||
control_transport == WIRELESS_MQTT_TRANSPORT
|
||
or self._hardware_access_bypass_allowed()
|
||
)
|
||
|
||
def _require_local_decision_point(self) -> bool:
|
||
if self._is_unproxied_loopback_request():
|
||
return True
|
||
self._json(
|
||
{
|
||
"error": "local_access_required",
|
||
"message": "真实台架审批与停止权限仅在台架本机开放。",
|
||
},
|
||
HTTPStatus.FORBIDDEN,
|
||
)
|
||
return False
|
||
|
||
def _is_https(self) -> bool:
|
||
if not self.auth.config.trust_proxy_headers:
|
||
return False
|
||
if self.headers.get("X-Forwarded-Proto", "").lower() == "https":
|
||
return True
|
||
return '"scheme":"https"' in self.headers.get("CF-Visitor", "").replace(" ", "")
|
||
|
||
def _serve_request(self, request_id: str) -> None:
|
||
if not REQUEST_ID_PATTERN.fullmatch(request_id):
|
||
self._json({"error": "invalid_request_id"}, HTTPStatus.BAD_REQUEST)
|
||
return
|
||
result_path = self.service.artifact_root / request_id / "result.json"
|
||
if not result_path.is_file():
|
||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||
return
|
||
self._json(json.loads(result_path.read_text(encoding="utf-8")))
|
||
|
||
def _serve_static(self, path: str, query: str = "") -> None:
|
||
relative_path = "index.html" if path in {"", "/"} else path.lstrip("/")
|
||
versioned = bool(parse_qs(query).get("v"))
|
||
self._serve_static_file(relative_path, versioned=versioned)
|
||
|
||
def _serve_static_file(self, relative_path: str, *, versioned: bool = False) -> None:
|
||
candidate = (STATIC_ROOT / relative_path).resolve()
|
||
try:
|
||
candidate.relative_to(STATIC_ROOT.resolve())
|
||
except ValueError:
|
||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||
return
|
||
if not candidate.is_file() and (
|
||
Path(relative_path).suffix
|
||
or relative_path.startswith(("assets/", "vendor/", "icons/"))
|
||
):
|
||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||
return
|
||
if not candidate.is_file():
|
||
candidate = STATIC_ROOT / "index.html"
|
||
content_type, _ = mimetypes.guess_type(candidate.name)
|
||
content = candidate.read_bytes()
|
||
self.send_response(HTTPStatus.OK)
|
||
resolved_content_type = content_type or "application/octet-stream"
|
||
if resolved_content_type.startswith("text/") or resolved_content_type in {
|
||
"application/javascript",
|
||
"application/json",
|
||
"application/manifest+json",
|
||
"image/svg+xml",
|
||
}:
|
||
resolved_content_type = f"{resolved_content_type}; charset=utf-8"
|
||
self.send_header(
|
||
"Content-Type",
|
||
resolved_content_type,
|
||
)
|
||
self.send_header("Content-Length", str(len(content)))
|
||
cache_control = "no-cache"
|
||
if relative_path.startswith(("vendor/", "icons/")) or (
|
||
relative_path.startswith("assets/") and versioned
|
||
):
|
||
cache_control = "public, max-age=31536000, immutable"
|
||
elif relative_path.startswith("assets/"):
|
||
cache_control = "no-cache"
|
||
elif candidate.name == "service-worker.js":
|
||
cache_control = "no-store"
|
||
elif candidate.name in {"index.html", "login.html"}:
|
||
cache_control = "no-cache"
|
||
self.send_header("Cache-Control", cache_control)
|
||
if candidate.name == "service-worker.js":
|
||
self.send_header("Service-Worker-Allowed", "/")
|
||
self._send_security_headers()
|
||
self.end_headers()
|
||
self.wfile.write(content)
|
||
|
||
def _serve_gesture_model(self) -> None:
|
||
path = self.gesture.model_path
|
||
if not path.is_file():
|
||
self._json(
|
||
{"error": "gesture_model_unavailable", "message": "手势模型文件不可用。"},
|
||
HTTPStatus.NOT_FOUND,
|
||
)
|
||
return
|
||
content = path.read_bytes()
|
||
self.send_response(HTTPStatus.OK)
|
||
self.send_header("Content-Type", "application/octet-stream")
|
||
self.send_header("Content-Length", str(len(content)))
|
||
self.send_header("Cache-Control", "private, max-age=3600")
|
||
self._send_security_headers()
|
||
self.end_headers()
|
||
self.wfile.write(content)
|
||
|
||
def _json(
|
||
self,
|
||
payload: object,
|
||
status: HTTPStatus = HTTPStatus.OK,
|
||
headers: dict[str, str] | None = None,
|
||
) -> None:
|
||
content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(content)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
for name, value in (headers or {}).items():
|
||
self.send_header(name, value)
|
||
self._send_security_headers()
|
||
self.end_headers()
|
||
self.wfile.write(content)
|
||
|
||
def _redirect(self, location: str) -> None:
|
||
self.send_response(HTTPStatus.SEE_OTHER)
|
||
self.send_header("Location", location)
|
||
self.send_header("Cache-Control", "no-store")
|
||
self._send_security_headers()
|
||
self.end_headers()
|
||
|
||
def _send_security_headers(self) -> None:
|
||
self.send_header("X-Content-Type-Options", "nosniff")
|
||
self.send_header("X-Frame-Options", "DENY")
|
||
self.send_header("Referrer-Policy", "no-referrer")
|
||
self.send_header(
|
||
"Permissions-Policy",
|
||
"camera=(self), microphone=(self), geolocation=(), payment=(), usb=()",
|
||
)
|
||
self.send_header(
|
||
"Content-Security-Policy",
|
||
"default-src 'self'; "
|
||
"script-src 'self' 'wasm-unsafe-eval'; "
|
||
"style-src 'self'; "
|
||
"img-src 'self' data: blob:; "
|
||
"media-src 'self' blob:; "
|
||
"frame-src 'self' http://*:3001; "
|
||
"connect-src 'self' blob: http://172.20.10.6:8889; "
|
||
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||
)
|
||
if self._is_https():
|
||
self.send_header(
|
||
"Strict-Transport-Security",
|
||
"max-age=31536000; includeSubDomains",
|
||
)
|
||
|
||
|
||
class N7WebHttpServer(ThreadingHTTPServer):
|
||
"""Prevent multiple Windows processes from silently sharing one port."""
|
||
|
||
daemon_threads = True
|
||
allow_reuse_address = os.name != "nt"
|
||
|
||
def server_bind(self) -> None:
|
||
if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||
self.socket.setsockopt(
|
||
socket.SOL_SOCKET,
|
||
socket.SO_EXCLUSIVEADDRUSE,
|
||
1,
|
||
)
|
||
super().server_bind()
|
||
|
||
|
||
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 = N7WebHttpServer((args.host, args.port), SeatDemoHandler)
|
||
SeatDemoHandler.speech.start()
|
||
SeatDemoHandler.assistant.start(
|
||
f"http://127.0.0.1:{server.server_port}/api/internal/codex/vehicle-control"
|
||
)
|
||
SeatDemoHandler.service.start()
|
||
SeatDemoHandler.extension.start()
|
||
SeatDemoHandler.extension_control.start()
|
||
SeatDemoHandler.nfc.start()
|
||
SeatDemoHandler.pets.start()
|
||
SeatDemoHandler.extension_toys.start()
|
||
SeatDemoHandler.calibration.start()
|
||
SeatDemoHandler.repair.start()
|
||
if args.host in {"0.0.0.0", "::"}:
|
||
lan_address = _lan_ipv4_address()
|
||
print(f"Cabin orchestration demo (local): http://127.0.0.1:{args.port}", flush=True)
|
||
if lan_address:
|
||
print(
|
||
f"Cabin orchestration demo (LAN): http://{lan_address}:{args.port}",
|
||
flush=True,
|
||
)
|
||
print(
|
||
"LAN mode: devices on reachable networks can use the GPT-backed API.",
|
||
flush=True,
|
||
)
|
||
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,
|
||
)
|
||
print(
|
||
"N7 new-mode MQTT sync: "
|
||
f"{MQTT_MODE_SCENE_TOPIC} "
|
||
f"({MODE_SCENE_MQTT_BURST_COUNT} frames within "
|
||
f"{round(MODE_SCENE_MQTT_BURST_WINDOW_S * 1_000)} ms)",
|
||
flush=True,
|
||
)
|
||
print(
|
||
"N7 extension capability MQTT: "
|
||
f"{MQTT_EXTENSION_TOY_TOPIC} "
|
||
f"({EXTENSION_TOY_BURST_COUNT} frames within "
|
||
f"{round(EXTENSION_TOY_BURST_WINDOW_S * 1_000)} ms per NFC detection)",
|
||
flush=True,
|
||
)
|
||
print(
|
||
"N7 extension control MQTT: "
|
||
f"subscribe {MQTT_EXTENSION_CONTROL_TOPIC} -> local infrared gateway",
|
||
flush=True,
|
||
)
|
||
hardware_execution_ready = any(
|
||
(
|
||
SeatDemoHandler.service.hardware_settings.configured,
|
||
SeatDemoHandler.service.vehicle_can_settings.configured,
|
||
SeatDemoHandler.service.vehicle_mqtt_settings.configured,
|
||
)
|
||
)
|
||
print(
|
||
"Execution modes: dry_run + simulated_hardware "
|
||
f"({'enabled' if SeatDemoHandler.service.simulated_hardware_enabled else 'locked'}) "
|
||
"+ hardware "
|
||
f"({'configured' if hardware_execution_ready else 'locked'})",
|
||
flush=True,
|
||
)
|
||
try:
|
||
server.serve_forever()
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
server.server_close()
|
||
SeatDemoHandler.speech.close()
|
||
SeatDemoHandler.assistant.close()
|
||
SeatDemoHandler.service.set_extension_control_handler(None)
|
||
SeatDemoHandler.service.set_extension_control_rejection_handler(None)
|
||
SeatDemoHandler.extension_control.close()
|
||
SeatDemoHandler.extension_toys.close()
|
||
SeatDemoHandler.pets.close()
|
||
SeatDemoHandler.nfc.close()
|
||
SeatDemoHandler.service.close()
|
||
SeatDemoHandler.extension.close()
|
||
SeatDemoHandler.calibration.close()
|
||
SeatDemoHandler.repair.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|