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
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest
|
||||
from k1link.web.plugin_runtime import (
|
||||
DevicePluginDispatcher,
|
||||
DevicePluginRuntimeContribution,
|
||||
)
|
||||
|
||||
|
||||
class DevicePluginCompositionError(RuntimeError):
|
||||
"""The reviewed plugin manifests and executable runtime do not match."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstalledDevicePluginEnvironment:
|
||||
catalog: DevicePluginCatalog
|
||||
dispatcher: DevicePluginDispatcher
|
||||
legacy_routers: tuple[APIRouter, ...]
|
||||
_contributions: tuple[DevicePluginRuntimeContribution, ...]
|
||||
|
||||
def close(self) -> None:
|
||||
cleanup_errors = _close_contributions(self._contributions)
|
||||
if cleanup_errors:
|
||||
error = DevicePluginCompositionError(
|
||||
"One or more device plugins failed during shutdown"
|
||||
)
|
||||
_add_cleanup_notes(error, cleanup_errors)
|
||||
raise error from cleanup_errors[0]
|
||||
|
||||
|
||||
def _close_contributions(
|
||||
contributions: tuple[DevicePluginRuntimeContribution, ...]
|
||||
| list[DevicePluginRuntimeContribution],
|
||||
) -> list[Exception]:
|
||||
errors: list[Exception] = []
|
||||
for contribution in reversed(contributions):
|
||||
try:
|
||||
contribution.close()
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
return errors
|
||||
|
||||
|
||||
def _add_cleanup_notes(error: BaseException, cleanup_errors: list[Exception]) -> None:
|
||||
for index, cleanup_error in enumerate(cleanup_errors, start=1):
|
||||
error.add_note(
|
||||
f"device-plugin cleanup failure {index}: "
|
||||
f"{type(cleanup_error).__name__}: {cleanup_error}"
|
||||
)
|
||||
|
||||
|
||||
def _load_factory(entrypoint: str) -> Any:
|
||||
module_name, separator, attribute_name = entrypoint.partition(":")
|
||||
if not separator or not module_name or not attribute_name:
|
||||
raise DevicePluginCompositionError(f"Invalid device-plugin backendEntrypoint: {entrypoint}")
|
||||
try:
|
||||
module = import_module(module_name)
|
||||
factory = getattr(module, attribute_name)
|
||||
except (ImportError, AttributeError) as exc:
|
||||
raise DevicePluginCompositionError(
|
||||
f"Cannot load device-plugin backendEntrypoint {entrypoint}: {exc}"
|
||||
) from exc
|
||||
if not callable(factory):
|
||||
raise DevicePluginCompositionError(
|
||||
f"Device-plugin backendEntrypoint is not callable: {entrypoint}"
|
||||
)
|
||||
return factory
|
||||
|
||||
|
||||
def _load_contribution(
|
||||
repository_root: Path,
|
||||
manifest: DevicePluginManifest,
|
||||
) -> DevicePluginRuntimeContribution:
|
||||
entrypoint = manifest.spec.runtime.backendEntrypoint
|
||||
if manifest.spec.runtime.isolation != "transitional-in-process":
|
||||
raise DevicePluginCompositionError(
|
||||
"This Mission Core build only supports reviewed transitional-in-process "
|
||||
f"plugins; {manifest.metadata.id} requests {manifest.spec.runtime.isolation}"
|
||||
)
|
||||
factory = _load_factory(entrypoint)
|
||||
contribution = factory(repository_root)
|
||||
if not isinstance(contribution, DevicePluginRuntimeContribution):
|
||||
raise DevicePluginCompositionError(
|
||||
f"Device-plugin factory {entrypoint} returned an invalid contribution"
|
||||
)
|
||||
|
||||
try:
|
||||
adapter = contribution.adapter
|
||||
if adapter.plugin_id != manifest.metadata.id:
|
||||
raise DevicePluginCompositionError(
|
||||
"Device-plugin manifest/runtime id mismatch: "
|
||||
f"{manifest.metadata.id} != {adapter.plugin_id}"
|
||||
)
|
||||
manifest_actions = frozenset(action.id for action in manifest.spec.actions)
|
||||
if adapter.action_ids != manifest_actions:
|
||||
raise DevicePluginCompositionError(
|
||||
f"Device-plugin manifest/runtime actions mismatch for {adapter.plugin_id}"
|
||||
)
|
||||
except Exception as exc:
|
||||
_add_cleanup_notes(exc, _close_contributions((contribution,)))
|
||||
raise
|
||||
return contribution
|
||||
|
||||
|
||||
def load_installed_device_plugins(
|
||||
repository_root: Path,
|
||||
) -> InstalledDevicePluginEnvironment:
|
||||
"""Load only local, validated manifest entrypoints and cross-check every adapter."""
|
||||
|
||||
catalog = DevicePluginCatalog(repository_root)
|
||||
manifests = catalog.manifests()
|
||||
loaded: list[DevicePluginRuntimeContribution] = []
|
||||
try:
|
||||
for manifest in manifests:
|
||||
loaded.append(_load_contribution(repository_root, manifest))
|
||||
except Exception as exc:
|
||||
_add_cleanup_notes(exc, _close_contributions(loaded))
|
||||
raise
|
||||
contributions = tuple(loaded)
|
||||
dispatcher = DevicePluginDispatcher([contribution.adapter for contribution in contributions])
|
||||
catalog_ids = {manifest.metadata.id for manifest in manifests}
|
||||
if set(dispatcher.action_declarations) != catalog_ids:
|
||||
raise DevicePluginCompositionError(
|
||||
"Device-plugin catalog/runtime composition is incomplete"
|
||||
)
|
||||
return InstalledDevicePluginEnvironment(
|
||||
catalog=catalog,
|
||||
dispatcher=dispatcher,
|
||||
legacy_routers=tuple(
|
||||
router for contribution in contributions for router in contribution.legacy_routers
|
||||
),
|
||||
_contributions=contributions,
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from k1link.web.plugin_runtime import STATE_READ_ACTION_ID
|
||||
|
||||
PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha1"
|
||||
|
||||
|
||||
def _reject_blank(value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("must contain a non-whitespace character")
|
||||
return value
|
||||
|
||||
|
||||
ShortText = Annotated[
|
||||
str,
|
||||
Field(min_length=1, max_length=160),
|
||||
AfterValidator(_reject_blank),
|
||||
]
|
||||
DescriptionText = Annotated[
|
||||
str,
|
||||
Field(min_length=1, max_length=1024),
|
||||
AfterValidator(_reject_blank),
|
||||
]
|
||||
EntrypointText = Annotated[
|
||||
str,
|
||||
Field(min_length=1, max_length=256),
|
||||
AfterValidator(_reject_blank),
|
||||
]
|
||||
|
||||
|
||||
class CapabilityManifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: ShortText
|
||||
label: ShortText
|
||||
|
||||
|
||||
class UiContributionManifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slot: Literal["device.connection"]
|
||||
componentKey: ShortText
|
||||
|
||||
|
||||
class DeviceModelManifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: ShortText
|
||||
vendor: ShortText
|
||||
displayName: ShortText
|
||||
category: ShortText
|
||||
description: DescriptionText
|
||||
verified: bool
|
||||
capabilities: list[CapabilityManifest]
|
||||
ui: UiContributionManifest
|
||||
|
||||
|
||||
class PluginRuntimeManifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
backendEntrypoint: EntrypointText
|
||||
isolation: Literal["transitional-in-process"]
|
||||
|
||||
|
||||
class PluginActionManifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: ShortText
|
||||
mutating: bool
|
||||
secretFields: list[ShortText]
|
||||
|
||||
|
||||
class PluginMetadata(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: ShortText
|
||||
version: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
max_length=160,
|
||||
pattern=r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$",
|
||||
),
|
||||
AfterValidator(_reject_blank),
|
||||
]
|
||||
displayName: ShortText
|
||||
|
||||
|
||||
class PluginSpec(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
hostApiRange: Literal["v1alpha1"]
|
||||
runtime: PluginRuntimeManifest
|
||||
permissions: list[ShortText]
|
||||
actions: list[PluginActionManifest]
|
||||
models: list[DeviceModelManifest] = Field(min_length=1, max_length=1)
|
||||
|
||||
|
||||
class DevicePluginManifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
apiVersion: Literal["missioncore.nodedc/v1alpha1"]
|
||||
kind: Literal["DevicePlugin"]
|
||||
metadata: PluginMetadata
|
||||
spec: PluginSpec
|
||||
|
||||
|
||||
class PluginCatalogError(RuntimeError):
|
||||
"""An installed manifest is invalid or conflicts with another manifest."""
|
||||
|
||||
|
||||
class DevicePluginCatalog:
|
||||
"""Read-only catalog of statically reviewed device-plugin manifests."""
|
||||
|
||||
def __init__(self, repository_root: Path) -> None:
|
||||
self.repository_root = repository_root.resolve()
|
||||
self._validated_manifests: tuple[DevicePluginManifest, ...] | None = None
|
||||
|
||||
def manifests(self) -> list[DevicePluginManifest]:
|
||||
if self._validated_manifests is not None:
|
||||
return list(self._validated_manifests)
|
||||
|
||||
plugin_root = self.repository_root / "plugins"
|
||||
manifests: list[DevicePluginManifest] = []
|
||||
plugin_ids: set[str] = set()
|
||||
model_ids: set[str] = set()
|
||||
|
||||
for path in sorted(plugin_root.glob("*/plugin.manifest.json")):
|
||||
try:
|
||||
manifest = DevicePluginManifest.model_validate_json(path.read_text("utf-8"))
|
||||
except (OSError, ValidationError) as exc:
|
||||
raise PluginCatalogError(f"Invalid device-plugin manifest {path}: {exc}") from exc
|
||||
|
||||
plugin_id = manifest.metadata.id
|
||||
if plugin_id in plugin_ids:
|
||||
raise PluginCatalogError(f"Duplicate device-plugin id: {plugin_id}")
|
||||
plugin_ids.add(plugin_id)
|
||||
|
||||
action_ids: set[str] = set()
|
||||
for action in manifest.spec.actions:
|
||||
if action.id in action_ids:
|
||||
raise PluginCatalogError(
|
||||
f"Duplicate device-plugin action id in {plugin_id}: {action.id}"
|
||||
)
|
||||
action_ids.add(action.id)
|
||||
if len(action.secretFields) != len(set(action.secretFields)):
|
||||
raise PluginCatalogError(
|
||||
f"Duplicate secret field in action {plugin_id}/{action.id}"
|
||||
)
|
||||
|
||||
state_read = next(
|
||||
(action for action in manifest.spec.actions if action.id == STATE_READ_ACTION_ID),
|
||||
None,
|
||||
)
|
||||
if state_read is None or state_read.mutating or bool(state_read.secretFields):
|
||||
raise PluginCatalogError(
|
||||
f"Device plugin {plugin_id} must declare safe {STATE_READ_ACTION_ID}"
|
||||
)
|
||||
|
||||
if len(manifest.spec.permissions) != len(set(manifest.spec.permissions)):
|
||||
raise PluginCatalogError(f"Duplicate device-plugin permission in {plugin_id}")
|
||||
|
||||
for model in manifest.spec.models:
|
||||
if model.id in model_ids:
|
||||
raise PluginCatalogError(f"Duplicate device-model id: {model.id}")
|
||||
model_ids.add(model.id)
|
||||
capability_ids = [capability.id for capability in model.capabilities]
|
||||
if len(capability_ids) != len(set(capability_ids)):
|
||||
raise PluginCatalogError(f"Duplicate capability id in device model {model.id}")
|
||||
manifests.append(manifest)
|
||||
|
||||
self._validated_manifests = tuple(manifests)
|
||||
return list(self._validated_manifests)
|
||||
|
||||
def plugin_documents(self) -> list[dict[str, Any]]:
|
||||
return [manifest.model_dump(mode="json") for manifest in self.manifests()]
|
||||
|
||||
def model_documents(self) -> list[dict[str, Any]]:
|
||||
models: list[dict[str, Any]] = []
|
||||
for manifest in self.manifests():
|
||||
for model in manifest.spec.models:
|
||||
models.append(
|
||||
{
|
||||
"pluginId": manifest.metadata.id,
|
||||
"pluginVersion": manifest.metadata.version,
|
||||
**model.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
return models
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
STATE_READ_ACTION_ID = "state.read"
|
||||
|
||||
|
||||
class DevicePluginActionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
input: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DevicePluginActionAdapter(Protocol):
|
||||
plugin_id: str
|
||||
action_ids: frozenset[str]
|
||||
|
||||
async def invoke(self, action_id: str, payload: Mapping[str, Any]) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
def _noop() -> None:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DevicePluginRuntimeContribution:
|
||||
"""One reviewed backend plugin contribution loaded from its manifest factory."""
|
||||
|
||||
adapter: DevicePluginActionAdapter
|
||||
legacy_routers: tuple[APIRouter, ...] = ()
|
||||
close: Callable[[], None] = _noop
|
||||
|
||||
|
||||
class PluginNotFoundError(LookupError):
|
||||
"""The requested plugin is not installed in the runtime composition."""
|
||||
|
||||
|
||||
class PluginActionNotFoundError(LookupError):
|
||||
"""The requested action is not declared by the selected plugin."""
|
||||
|
||||
|
||||
class PluginExecutionError(RuntimeError):
|
||||
"""A validated plugin action failed while talking to its device/runtime."""
|
||||
|
||||
|
||||
class DevicePluginDispatcher:
|
||||
"""Host-owned dispatcher for allowlisted, namespaced plugin actions."""
|
||||
|
||||
def __init__(self, adapters: list[DevicePluginActionAdapter]) -> None:
|
||||
self._adapters: dict[str, DevicePluginActionAdapter] = {}
|
||||
for adapter in adapters:
|
||||
if adapter.plugin_id in self._adapters:
|
||||
raise ValueError(f"Duplicate runtime device-plugin id: {adapter.plugin_id}")
|
||||
self._adapters[adapter.plugin_id] = adapter
|
||||
|
||||
@property
|
||||
def action_declarations(self) -> dict[str, frozenset[str]]:
|
||||
return {plugin_id: adapter.action_ids for plugin_id, adapter in self._adapters.items()}
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
plugin_id: str,
|
||||
action_id: str,
|
||||
payload: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
adapter = self._adapters.get(plugin_id)
|
||||
if adapter is None:
|
||||
raise PluginNotFoundError(f"Device plugin is not installed: {plugin_id}")
|
||||
if action_id not in adapter.action_ids:
|
||||
raise PluginActionNotFoundError(
|
||||
f"Device plugin {plugin_id} does not declare action {action_id}"
|
||||
)
|
||||
return await adapter.invoke(action_id, payload)
|
||||
@@ -0,0 +1,394 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Protocol
|
||||
|
||||
from bleak.exc import BleakError
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
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.plugin_runtime import (
|
||||
DevicePluginRuntimeContribution,
|
||||
PluginActionNotFoundError,
|
||||
PluginExecutionError,
|
||||
)
|
||||
|
||||
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
||||
|
||||
ACTION_STATE_READ = "state.read"
|
||||
ACTION_DISCOVERY_SCAN = "discovery.scan"
|
||||
ACTION_NETWORK_PROVISION = "network.provision"
|
||||
ACTION_STREAM_START_LIVE = "stream.start-live"
|
||||
ACTION_STREAM_START_REPLAY = "stream.start-replay"
|
||||
ACTION_STREAM_STOP = "stream.stop"
|
||||
ACTION_VIEWER_SETTINGS_UPDATE = "viewer.settings.update"
|
||||
|
||||
|
||||
class StrictRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class EmptyRequest(StrictRequest):
|
||||
pass
|
||||
|
||||
|
||||
class BleScanRequest(StrictRequest):
|
||||
duration_seconds: float = Field(default=6.0, ge=1.0, le=60.0)
|
||||
|
||||
|
||||
class ConnectRequest(StrictRequest):
|
||||
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(StrictRequest):
|
||||
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(StrictRequest):
|
||||
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(StrictRequest):
|
||||
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 XgridsK1CompatibilityService:
|
||||
"""The proven K1 runtime kept intact behind the plugin facade."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class XgridsK1ServicePort(Protocol):
|
||||
def state(self) -> dict[str, Any]: ...
|
||||
|
||||
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]: ...
|
||||
|
||||
async def connect(self, request: ConnectRequest) -> dict[str, Any]: ...
|
||||
|
||||
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]: ...
|
||||
|
||||
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]: ...
|
||||
|
||||
def stop(self) -> dict[str, Any]: ...
|
||||
|
||||
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class XgridsK1PluginFacade:
|
||||
"""Plugin action facade delegating to the proven compatibility service."""
|
||||
|
||||
plugin_id = XGRIDS_K1_PLUGIN_ID
|
||||
action_ids = frozenset(
|
||||
{
|
||||
ACTION_STATE_READ,
|
||||
ACTION_DISCOVERY_SCAN,
|
||||
ACTION_NETWORK_PROVISION,
|
||||
ACTION_STREAM_START_LIVE,
|
||||
ACTION_STREAM_START_REPLAY,
|
||||
ACTION_STREAM_STOP,
|
||||
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, service: XgridsK1ServicePort) -> None:
|
||||
self.service = service
|
||||
|
||||
async def invoke(self, action_id: str, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
return await self._invoke_validated(action_id, payload)
|
||||
except (ValidationError, ValueError, PluginActionNotFoundError):
|
||||
raise
|
||||
except (BleakError, OSError, TimeoutError, RuntimeError) as exc:
|
||||
raise PluginExecutionError(str(exc)) from exc
|
||||
|
||||
async def _invoke_validated(
|
||||
self,
|
||||
action_id: str,
|
||||
payload: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if action_id == ACTION_STATE_READ:
|
||||
EmptyRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(self.service.state)
|
||||
if action_id == ACTION_DISCOVERY_SCAN:
|
||||
scan_request = BleScanRequest.model_validate(payload)
|
||||
return await self.service.scan_ble(scan_request.duration_seconds)
|
||||
if action_id == ACTION_NETWORK_PROVISION:
|
||||
connect_request = ConnectRequest.model_validate(payload)
|
||||
return await self.service.connect(connect_request)
|
||||
if action_id == ACTION_STREAM_START_LIVE:
|
||||
live_request = LiveRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(
|
||||
self.service.start_live,
|
||||
live_request.host,
|
||||
live_request.duration_seconds,
|
||||
)
|
||||
if action_id == ACTION_STREAM_START_REPLAY:
|
||||
replay_request = ReplayRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(
|
||||
self.service.start_replay,
|
||||
replay_request.path,
|
||||
replay_request.speed,
|
||||
replay_request.loop,
|
||||
)
|
||||
if action_id == ACTION_STREAM_STOP:
|
||||
EmptyRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(self.service.stop)
|
||||
if action_id == ACTION_VIEWER_SETTINGS_UPDATE:
|
||||
settings_request = ViewerSettingsRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(
|
||||
self.service.update_viewer_settings,
|
||||
settings_request,
|
||||
)
|
||||
raise PluginActionNotFoundError(f"Unsupported XGRIDS K1 action: {action_id}")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribution:
|
||||
"""Manifest entrypoint for the reviewed XGRIDS compatibility adapter."""
|
||||
|
||||
from k1link.web.xgrids_k1_legacy_api import build_xgrids_k1_legacy_router
|
||||
|
||||
service = XgridsK1CompatibilityService(repository_root)
|
||||
adapter = XgridsK1PluginFacade(service)
|
||||
return DevicePluginRuntimeContribution(
|
||||
adapter=adapter,
|
||||
legacy_routers=(build_xgrids_k1_legacy_router(adapter),),
|
||||
close=service.runtime.close,
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
||||
|
||||
from k1link.web.plugin_runtime import PluginExecutionError
|
||||
from k1link.web.xgrids_k1_facade import (
|
||||
ACTION_DISCOVERY_SCAN,
|
||||
ACTION_NETWORK_PROVISION,
|
||||
ACTION_STATE_READ,
|
||||
ACTION_STREAM_START_LIVE,
|
||||
ACTION_STREAM_START_REPLAY,
|
||||
ACTION_STREAM_STOP,
|
||||
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||
BleScanRequest,
|
||||
ConnectRequest,
|
||||
LiveRequest,
|
||||
ReplayRequest,
|
||||
ViewerSettingsRequest,
|
||||
XgridsK1PluginFacade,
|
||||
)
|
||||
|
||||
|
||||
def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
||||
"""Temporary flat API kept for scripts created before the plugin boundary."""
|
||||
|
||||
router = APIRouter(include_in_schema=True)
|
||||
|
||||
@router.get("/api/state", deprecated=True)
|
||||
async def get_state() -> dict[str, Any]:
|
||||
return await adapter.invoke(ACTION_STATE_READ, {})
|
||||
|
||||
@router.post("/api/ble/scan", deprecated=True)
|
||||
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_DISCOVERY_SCAN, request.model_dump())
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
|
||||
|
||||
@router.post("/api/connect", deprecated=True)
|
||||
async def connect(request: ConnectRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_NETWORK_PROVISION, request.model_dump())
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Ошибка подключения устройства к Wi-Fi: {exc}",
|
||||
) from exc
|
||||
|
||||
@router.post("/api/session/live", deprecated=True)
|
||||
async def start_live(request: LiveRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_STREAM_START_LIVE, request.model_dump())
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.post("/api/session/replay", deprecated=True)
|
||||
async def start_replay(request: ReplayRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_STREAM_START_REPLAY, request.model_dump())
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.post("/api/session/stop", deprecated=True)
|
||||
async def stop_session() -> dict[str, Any]:
|
||||
return await adapter.invoke(ACTION_STREAM_STOP, {})
|
||||
|
||||
@router.post("/api/viewer/settings", deprecated=True)
|
||||
async def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
|
||||
return await adapter.invoke(ACTION_VIEWER_SETTINGS_UPDATE, request.model_dump())
|
||||
|
||||
@router.websocket("/api/events")
|
||||
async def events(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
try:
|
||||
while True:
|
||||
await websocket.send_json({"state": await adapter.invoke(ACTION_STATE_READ, {})})
|
||||
await asyncio.sleep(0.5)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
return router
|
||||
Reference in New Issue
Block a user