feat: introduce device plugin runtime boundary
This commit is contained in:
+57
-313
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user