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
+140
View File
@@ -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,
)
+194
View File
@@ -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
+78
View File
@@ -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)
+394
View File
@@ -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,
)
+84
View File
@@ -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