feat: introduce device plugin runtime boundary

This commit is contained in:
DCCONSTRUCTIONS
2026-07-16 13:49:10 +03:00
parent 9225227421
commit 27bf7527df
45 changed files with 3748 additions and 1062 deletions
+57 -313
View File
@@ -1,255 +1,33 @@
from __future__ import annotations
import asyncio
import threading
from collections.abc import AsyncIterator, Mapping
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal
from typing import Any
from bleak.exc import BleakError
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from pydantic import ValidationError
from k1link import __version__
from k1link.artifacts import write_json_atomic
from k1link.ble.scanner import scan
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
from k1link.mqtt import validate_private_ipv4
from k1link.viewer.rerun_bridge import RerunSceneSettings
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
from k1link.web.device_plugin_composition import load_installed_device_plugins
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
from k1link.web.plugin_runtime import (
STATE_READ_ACTION_ID,
DevicePluginActionRequest,
DevicePluginDispatcher,
PluginActionNotFoundError,
PluginExecutionError,
PluginNotFoundError,
)
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
class BleScanRequest(BaseModel):
duration_seconds: float = Field(default=6.0, ge=1.0, le=60.0)
class ConnectRequest(BaseModel):
device_id: str = Field(min_length=1, max_length=128)
ssid: str = Field(min_length=1, max_length=128)
password: str = Field(min_length=1, max_length=256)
class LiveRequest(BaseModel):
host: str | None = Field(default=None, max_length=15)
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
class ReplayRequest(BaseModel):
path: str = Field(min_length=1, max_length=4096)
speed: float = Field(default=1.0, ge=0.0, le=100.0)
loop: bool = False
class ViewerSettingsRequest(BaseModel):
point_size: float = Field(default=2.5, ge=0.5, le=12.0)
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
accumulation_seconds: float = Field(default=12.0, ge=0.0, le=120.0)
show_points: bool = True
show_trajectory: bool = True
show_grid: bool = True
class ConsoleService:
def __init__(self, repository_root: Path) -> None:
self.repository_root = repository_root.resolve()
self._lock = threading.Lock()
self._devices: list[dict[str, Any]] = []
self._selected_device_id: str | None = None
self._k1_ip: str | None = None
self._operation_phase: str | None = None
self._operation_message: str | None = None
self.runtime = VisualizationRuntime()
def state(self) -> dict[str, Any]:
runtime = self.runtime.snapshot()
metrics = runtime["metrics"]
with self._lock:
operation_phase = self._operation_phase
operation_message = self._operation_message
devices = list(self._devices)
selected_device_id = self._selected_device_id
k1_ip = self._k1_ip
runtime_active = runtime["source_mode"] != "idle" or runtime["phase"] in {
"starting_live",
"stopping",
"error",
}
if operation_phase is not None:
phase = operation_phase
message = operation_message
elif runtime_active:
phase = runtime["phase"]
message = runtime["message"]
elif k1_ip is not None:
phase = "connected"
message = runtime["message"]
elif selected_device_id is not None:
phase = "device_selected"
message = "Устройство выбрано. Теперь введите название и пароль Wi-Fi."
elif devices:
likely_count = sum(bool(item.get("likely_k1")) for item in devices)
phase = "idle"
message = (
f"Найдено BLE-устройств: {len(devices)}. "
f"Совместимых профилей: {likely_count}. Выберите нужное устройство."
)
else:
phase = "idle"
message = runtime["message"]
return {
"phase": phase,
"message": message,
"devices": devices,
"selected_device_id": selected_device_id,
"k1_ip": k1_ip,
"foxglove_ws_url": runtime["foxglove_ws_url"],
"foxglove_viewer_url": runtime["foxglove_viewer_url"],
"rerun_grpc_url": runtime["rerun_grpc_url"],
"viewer_settings": runtime["viewer_settings"],
"source_mode": runtime["source_mode"],
"metrics": {
"pipeline_ms": metrics["mqtt_to_publish_ms"],
"end_to_end_ms": metrics["mqtt_to_publish_ms"],
"decode_ms": metrics["decode_publish_ms"],
"frame_rate": metrics["pcl_fps"],
"frame_rate_hz": metrics["pcl_fps"],
"point_count": metrics["last_point_count"],
"dropped_preview_frames": metrics["preview_dropped"],
**metrics,
},
}
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
self._set_operation("scanning", "Сканируем все устройства Bluetooth (BLE)…")
try:
result = await scan(duration_seconds)
devices = [
{
"device_id": item["macos_uuid"],
"name": item["local_name"] or item["name"],
"rssi": item["rssi"],
"address": None,
"connectable": True,
"likely_k1": item["k1_name_candidate"],
}
for item in result["devices"]
]
with self._lock:
self._devices = devices
self._operation_message = (
f"Поиск завершён. Найдено BLE-устройств: {len(devices)}."
)
finally:
with self._lock:
self._operation_phase = None
return self.state()
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
if request.device_id not in known_ids:
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
self._set_operation(
"provisioning",
"Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
)
session_dir = _new_operation_session_dir(
self.repository_root,
"viewer_wifi_provisioning",
)
session_dir.mkdir(parents=True, exist_ok=False)
password = request.password
try:
result = await provision_wifi_once(
request.device_id,
request.ssid,
password,
timeout_seconds=45.0,
write_mode="auto",
)
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
ipv4 = _provisioned_ipv4(result)
write_json_atomic(
session_dir / "manifest.redacted.json",
{
"schema_version": 1,
"started_at_utc": result["started_at_utc"],
"completed_at_utc": result["completed_at_utc"],
"operation": "single_reviewed_wifi_provisioning_write",
"profile_id": result["profile_id"],
"outcome": result["outcome"],
"k1_lan_address_observed": ipv4 is not None,
"credentials_persisted_by_connector": False,
},
)
if ipv4 is None:
raise RuntimeError(
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
)
with self._lock:
self._selected_device_id = request.device_id
self._k1_ip = ipv4
self._operation_message = (
"Устройство подключено к Wi-Fi и сообщило локальный адрес."
)
finally:
password = ""
with self._lock:
self._operation_phase = None
return self.state()
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]:
target = host or self.state()["k1_ip"]
if not isinstance(target, str) or not target:
raise ValueError(
"сначала подключите устройство к Wi-Fi или укажите его локальный адрес"
)
target = validate_private_ipv4(target)
out_dir = new_live_session_dir(self.repository_root)
self.runtime.start_live(target, out_dir, duration_seconds=duration_seconds)
return self.state()
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
replay_path = Path(path).expanduser().resolve()
if not replay_path.is_relative_to(self.repository_root):
raise ValueError("файл записи должен находиться внутри репозитория")
self.runtime.start_replay(replay_path, speed=speed, loop=loop)
return self.state()
def stop(self) -> dict[str, Any]:
self.runtime.stop()
return self.state()
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
self.runtime.update_scene_settings(
RerunSceneSettings(
point_size=request.point_size,
color_mode=request.color_mode,
palette=request.palette,
custom_color=request.custom_color,
accumulation_seconds=request.accumulation_seconds,
show_points=request.show_points,
show_trajectory=request.show_trajectory,
show_grid=request.show_grid,
)
)
return self.state()
def _set_operation(self, phase: str, message: str) -> None:
with self._lock:
self._operation_phase = phase
self._operation_message = message
service = ConsoleService(REPOSITORY_ROOT)
plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
@asynccontextmanager
@@ -257,11 +35,11 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
try:
yield
finally:
service.runtime.close()
plugin_environment.close()
app = FastAPI(
title="NODEDC Mission Core API",
title="NODEDC MISSION CORE API",
version=__version__,
docs_url="/api/docs",
redoc_url=None,
@@ -280,97 +58,63 @@ def health() -> dict[str, Any]:
}
@app.get("/api/state")
def get_state() -> dict[str, Any]:
return service.state()
@app.post("/api/ble/scan")
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
@app.get("/api/v1/device-plugins")
def get_device_plugins() -> dict[str, Any]:
try:
return await service.scan_ble(request.duration_seconds)
except (BleakError, OSError, RuntimeError, ValueError) as exc:
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
return {"items": plugin_catalog.plugin_documents()}
except PluginCatalogError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.post("/api/connect")
async def connect(request: ConnectRequest) -> dict[str, Any]:
@app.get("/api/v1/device-models")
def get_device_models() -> dict[str, Any]:
try:
return await service.connect(request)
except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc:
raise HTTPException(
status_code=502,
detail=f"Ошибка подключения устройства к Wi-Fi: {exc}",
) from exc
return {"items": plugin_catalog.model_documents()}
except PluginCatalogError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.post("/api/session/live")
def start_live(request: LiveRequest) -> dict[str, Any]:
@app.post("/api/v1/device-plugins/{plugin_id}/actions/{action_id}")
async def invoke_device_plugin_action(
plugin_id: str,
action_id: str,
request: DevicePluginActionRequest,
) -> dict[str, Any]:
try:
return service.start_live(request.host, request.duration_seconds)
except (OSError, RuntimeError, ValueError) as exc:
state = await plugin_dispatcher.invoke(plugin_id, action_id, request.input)
return {"state": state}
except (PluginNotFoundError, PluginActionNotFoundError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PluginExecutionError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@app.post("/api/session/replay")
def start_replay(request: ReplayRequest) -> dict[str, Any]:
try:
return service.start_replay(request.path, request.speed, request.loop)
except (OSError, RuntimeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/session/stop")
def stop_session() -> dict[str, Any]:
return service.stop()
@app.post("/api/viewer/settings")
def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
return service.update_viewer_settings(request)
@app.websocket("/api/events")
async def events(websocket: WebSocket) -> None:
@app.websocket("/api/v1/device-plugins/{plugin_id}/events")
async def device_plugin_events(websocket: WebSocket, plugin_id: str) -> None:
await websocket.accept()
sequence = 0
try:
while True:
await websocket.send_json({"state": service.state()})
state = await plugin_dispatcher.invoke(plugin_id, STATE_READ_ACTION_ID, {})
sequence += 1
await websocket.send_json({"pluginId": plugin_id, "sequence": sequence, "state": state})
await asyncio.sleep(0.5)
except WebSocketDisconnect:
return
except (PluginNotFoundError, PluginActionNotFoundError):
await websocket.close(code=1008, reason="Device plugin is not available")
except PluginExecutionError:
await websocket.close(code=1011, reason="Device plugin state stream failed")
for legacy_router in plugin_environment.legacy_routers:
app.include_router(legacy_router)
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
if frontend_dist.is_dir():
app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="frontend")
def _new_operation_session_dir(repository_root: Path, suffix: str) -> Path:
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
base = repository_root / "sessions" / f"{stamp}_{suffix}"
candidate = base
serial = 1
while candidate.exists():
serial += 1
candidate = base.with_name(f"{base.name}_{serial:02d}")
return candidate
def _provisioned_ipv4(result: Mapping[str, Any]) -> str | None:
observations = result.get("observations")
if not isinstance(observations, list):
return None
for observation in reversed(observations):
if not isinstance(observation, dict):
continue
status = observation.get("status")
if not isinstance(status, dict):
continue
address = status.get("ipv4")
if isinstance(address, str) and address != AP_FALLBACK_IPV4:
try:
return validate_private_ipv4(address)
except ValueError:
continue
return None