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

View File

@ -178,6 +178,7 @@ present.
- [Live console and embedded Rerun runbook](docs/06_K1_LIVE_VIEWER.md) - [Live console and embedded Rerun runbook](docs/06_K1_LIVE_VIEWER.md)
- [Mission Core monorepo and plugin boundary](docs/07_MISSION_CORE_MONOREPO.md) - [Mission Core monorepo and plugin boundary](docs/07_MISSION_CORE_MONOREPO.md)
- [Monorepo architecture decision](docs/adr/0002-mission-core-monorepo.md) - [Monorepo architecture decision](docs/adr/0002-mission-core-monorepo.md)
- [Device plugin UI and runtime boundary](docs/adr/0003-device-plugin-ui-and-runtime-boundary.md)
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md) - [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
- [Session manifest schema](schemas/session-manifest.schema.json) - [Session manifest schema](schemas/session-manifest.schema.json)
- [Reference input provenance](docs/reference/README.md) - [Reference input provenance](docs/reference/README.md)

View File

@ -16,6 +16,7 @@ device adapter, но структура интерфейса от него не
| Контур | Состояние | Что это означает | | Контур | Состояние | Что это означает |
| --- | --- | --- | | --- | --- | --- |
| Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. | | Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
| Device plugin registry | Реализован, v1alpha1 | До выбора модели provider остаётся inert и не делает I/O; каждый manifest объявляет ровно одну модель, custom `device.connection` UI key и backend factory. Frontend component подключается одним reviewed import в composition root. |
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. | | Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. | | K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. | | K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
@ -42,7 +43,8 @@ K1 MQTT :1883, read-only
└── встроенный @rerun-io/web-viewer └── встроенный @rerun-io/web-viewer
└── пространственная сцена Mission Core └── пространственная сцена Mission Core
Mission Core Control Station ←→ REST /api/* + WebSocket /api/events Mission Core Control Station ←→ REST /api/v1/device-plugins/*
+ plugin-scoped WebSocket events
FastAPI на 127.0.0.1:8000 FastAPI на 127.0.0.1:8000
CoreBluetooth + live/replay runtime CoreBluetooth + live/replay runtime
``` ```
@ -129,7 +131,7 @@ cd apps/control-station
npm run dev npm run dev
``` ```
Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/api/events` на Vite слушает `http://127.0.0.1:5173` и проксирует весь `/api` (включая WebSocket) на
`http://127.0.0.1:8000`. Другой локальный backend можно указать переменной `http://127.0.0.1:8000`. Другой локальный backend можно указать переменной
`VITE_API_TARGET`. Preview production-сборки запускается командой `VITE_API_TARGET`. Preview production-сборки запускается командой
`npm run preview` на `http://127.0.0.1:4173`. `npm run preview` на `http://127.0.0.1:4173`.
@ -138,18 +140,20 @@ Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/
1. Запустить `uv run k1link serve` и открыть Mission Core Control Station. 1. Запустить `uv run k1link serve` и открыть Mission Core Control Station.
2. Выбрать **Парк → Локальное устройство**. 2. Выбрать **Парк → Локальное устройство**.
3. Включить K1, дождаться стабильного индикатора и подтвердить это в форме. 3. В каталоге моделей выбрать **XGRIDS LixelKity K1**. Только после этого
4. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат активируется runtime и монтируется custom UI XGRIDS-плагина.
4. Включить K1, дождаться стабильного индикатора и подтвердить это в форме.
5. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
шестисекундного поиска; метка совместимости является подсказкой, выбор делает шестисекундного поиска; метка совместимости является подсказкой, выбор делает
оператор. оператор.
5. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна 6. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
reviewed provisioning-запись без автоматических повторов. reviewed provisioning-запись без автоматических повторов.
6. Запустить live-приём по определённому адресу K1 либо replay локального 7. Запустить live-приём по определённому адресу K1 либо replay локального
`.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и `.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и
останавливается подтверждённым двойным нажатием кнопки устройства. останавливается подтверждённым двойным нажатием кнопки устройства.
7. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в 8. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
state. Ручной source вводить не требуется. state. Ручной source вводить не требуется.
8. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория, 9. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
частота, число точек, задержка и пропуски preview появятся после прихода частота, число точек, задержка и пропуски preview появятся после прихода
сообщений K1. сообщений K1.
@ -197,16 +201,26 @@ Custom timeline, переключатель 2D/3D/карты, семантиче
| Метод | Route | Тело / назначение | | Метод | Route | Тело / назначение |
| --- | --- | --- | | --- | --- | --- |
| `GET` | `/api/health` | Проверка локального сервиса. | | `GET` | `/api/health` | Проверка локального сервиса. |
| `GET` | `/api/state` | Авторитетный snapshot состояния. | | `GET` | `/api/v1/device-plugins` | Валидированные manifests установленных device plugins. |
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }`. | | `GET` | `/api/v1/device-models` | Backend-каталог моделей из валидированных manifests; UI-каталог текущей сборки формируется отдельным static composition root. |
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }`. | | `POST` | `/api/v1/device-plugins/{pluginId}/actions/{actionId}` | Namespaced действие через host allowlist; тело `{ "input": { ... } }`. |
| `POST` | `/api/session/live` | Опционально `{ "host", "duration_seconds" }`. | | `WS` | `/api/v1/device-plugins/{pluginId}/events` | Plugin-scoped snapshots с `pluginId` и монотонным `sequence`. |
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }`. |
| `POST` | `/api/session/stop` | Остановка активного источника. |
| `POST` | `/api/viewer/settings` | Размер/цвет точек, накопление, видимость облака, траектории и сетки. |
| `WS` | `/api/events` | Периодические snapshots для live UI. |
`/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или Frontend XGRIDS-плагина использует только v1alpha namespaced routes. Старые
`/api/state`, `/api/events`, `/api/ble/scan`, `/api/connect`, `/api/session/*` и
`/api/viewer/settings` сохранены как deprecated compatibility shims внутри
XGRIDS backend contribution.
В v1alpha1 backend composition загружается из manifests, а frontend plugins
статически включаются в сборку через `src/composition/devicePlugins.ts`.
Автоматической runtime-сверки двух installed sets пока нет: их соответствие —
проверяемое требование сборки до появления подписанных plugin bundles и startup
compatibility handshake. Поля manifest `permissions`, `mutating` и
`secretFields` пока являются декларативными метаданными; generic host валидирует
их форму и action allowlist, но ещё не реализует на их основе RBAC, подтверждения
оператора или secret-vault substitution.
Legacy `/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`, как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`,
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и `selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются `viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
@ -218,14 +232,15 @@ Custom timeline, переключатель 2D/3D/карты, семантиче
| --- | --- | | --- | --- |
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. | | `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. | | `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
| `src/workspaces/DeviceWorkspace.tsx` | Реальный K1 BLE/Wi-Fi/live/replay adapter UI. | | `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle и plugin host. |
| `src/core/runtime/` | Нормализованное состояние активного устройства и spatial source. |
| `src/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. |
| `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. |
| `src/device-plugins/xgrids-k1/` | Реальный K1 BLE/Wi-Fi/live/replay UI, client и compatibility mapper. |
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. | | `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
| `src/components/RerunViewport.tsx` | Жизненный цикл встроенного Rerun Web Viewer и selection events. | | `src/components/RerunViewport.tsx` | Жизненный цикл встроенного Rerun Web Viewer и selection events. |
| `src/api.ts` | REST/WebSocket контракт с FastAPI. |
| `src/useK1Console.ts` | Состояние backend, polling, события и действия оператора. |
| `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. | | `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. |
| `src/presentation.ts` | Русские подписи фаз и форматирование реальных метрик. | | `src/presentation.ts` | Vendor-neutral подписи lifecycle и форматирование нормализованных метрик. |
| `src/messages.ts` | Обезличивание и локализация технических сообщений в пользовательском интерфейсе. |
| `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. | | `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
## Safety и чувствительные данные ## Safety и чувствительные данные

View File

@ -26,7 +26,9 @@ import {
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
import { LandingStage } from "./components/LandingStage"; import { LandingStage } from "./components/LandingStage";
import type { ViewerSettings } from "./api"; import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
import type { ViewerSettings } from "./core/runtime/contracts";
import { import {
rootById, rootById,
roots, roots,
@ -35,14 +37,12 @@ import {
type RootId, type RootId,
} from "./productModel"; } from "./productModel";
import { backendLabel, phaseLabel, phaseTone } from "./presentation"; import { backendLabel, phaseLabel, phaseTone } from "./presentation";
import { localizeRuntimeMessage } from "./messages";
import { import {
defaultSceneSettings, defaultSceneSettings,
type PointColorMode, type PointColorMode,
type PointPalette, type PointPalette,
type SceneSettings, type SceneSettings,
} from "./sceneSettings"; } from "./sceneSettings";
import { useK1Console } from "./useK1Console";
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace"; import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { WorkspaceRenderer } from "./workspaces/Workspaces"; import { WorkspaceRenderer } from "./workspaces/Workspaces";
import "./styles/scene-windows.css"; import "./styles/scene-windows.css";
@ -106,7 +106,8 @@ function mergeViewerSettings(
} }
export default function App() { export default function App() {
const console = useK1Console(); const runtime = useMissionRuntime();
const { selection } = useDevicePluginHost();
const workspace = useApplicationWorkspace<string>({ const workspace = useApplicationWorkspace<string>({
navigationOpen: false, navigationOpen: false,
contentExpanded: true, contentExpanded: true,
@ -129,17 +130,17 @@ export default function App() {
const activeDefinition = workspaceById(workspace.activeView); const activeDefinition = workspaceById(workspace.activeView);
const rootWorkspaces = workspacesForRoot(activeRoot); const rootWorkspaces = workspacesForRoot(activeRoot);
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null; const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
const automaticSourceUrl = console.state?.rerun_grpc_url?.trim() ?? ""; const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
const effectiveSourceUrl = sourceUrl || automaticSourceUrl; const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
useEffect(() => { useEffect(() => {
const remote = console.state?.viewer_settings; const remote = runtime.state?.viewerSettings;
if (!remote) return; if (!remote) return;
setSceneSettings((current) => mergeViewerSettings(current, remote)); setSceneSettings((current) => mergeViewerSettings(current, remote));
if (!displayWindowOpen) { if (!displayWindowOpen) {
setDisplayDraft((current) => mergeViewerSettings(current, remote)); setDisplayDraft((current) => mergeViewerSettings(current, remote));
} }
}, [console.state?.viewer_settings, displayWindowOpen]); }, [runtime.state?.viewerSettings, displayWindowOpen]);
const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => { const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => {
setSceneWindowOrder((current) => [ setSceneWindowOrder((current) => [
@ -208,7 +209,7 @@ export default function App() {
}; };
const applyDisplaySettings = async () => { const applyDisplaySettings = async () => {
const applied = await console.updateViewerSettings(toViewerSettings(displayDraft)); const applied = await runtime.updateViewerSettings(toViewerSettings(displayDraft));
if (applied) setSceneSettings(displayDraft); if (applied) setSceneSettings(displayDraft);
}; };
@ -217,7 +218,7 @@ export default function App() {
const next = { ...sceneSettings, ...patch }; const next = { ...sceneSettings, ...patch };
setSceneSettings(next); setSceneSettings(next);
setDisplayDraft((current) => (displayWindowOpen ? { ...current, ...patch } : next)); setDisplayDraft((current) => (displayWindowOpen ? { ...current, ...patch } : next));
const applied = await console.updateViewerSettings(toViewerSettings(next)); const applied = await runtime.updateViewerSettings(toViewerSettings(next));
if (!applied) { if (!applied) {
setSceneSettings(previous); setSceneSettings(previous);
if (!displayWindowOpen) setDisplayDraft(previous); if (!displayWindowOpen) setDisplayDraft(previous);
@ -231,7 +232,7 @@ export default function App() {
actions.push({ actions.push({
label: "Обновить состояние локального контура", label: "Обновить состояние локального контура",
icon: "refresh", icon: "refresh",
onClick: () => void console.refresh(), onClick: () => void runtime.refresh(),
}); });
} }
@ -249,7 +250,7 @@ export default function App() {
onClick: openLayout, onClick: openLayout,
}); });
return actions; return actions;
}, [activeDefinition?.kind, console, sceneSettings, sourceUrl]); }, [activeDefinition?.kind, runtime, sceneSettings, sourceUrl]);
const header = ( const header = (
<AppHeader <AppHeader
@ -269,9 +270,9 @@ export default function App() {
} }
right={ right={
<HeaderProfile> <HeaderProfile>
<HeaderProfileButton onClick={() => void console.refresh()} title="Обновить локальный контур"> <HeaderProfileButton onClick={() => void runtime.refresh()} title="Обновить локальный контур">
<span className="api-dot" data-status={console.backendStatus} aria-hidden="true" /> <span className="api-dot" data-status={runtime.backendStatus} aria-hidden="true" />
{backendLabel(console.backendStatus)} {backendLabel(runtime.backendStatus)}
</HeaderProfileButton> </HeaderProfileButton>
<HeaderAvatar label="DC" /> <HeaderAvatar label="DC" />
</HeaderProfile> </HeaderProfile>
@ -291,9 +292,9 @@ export default function App() {
stage={ stage={
<LandingStage <LandingStage
root={currentRoot} root={currentRoot}
backendStatus={console.backendStatus} backendStatus={runtime.backendStatus}
phase={console.state?.phase} phase={runtime.state?.phase}
message={localizeRuntimeMessage(console.state?.message)} message={runtime.state?.message}
onOpenObservation={() => openView("spatial-scene")} onOpenObservation={() => openView("spatial-scene")}
onOpenDevice={() => openView("local-device")} onOpenDevice={() => openView("local-device")}
/> />
@ -309,9 +310,12 @@ export default function App() {
{ {
id: "local-contour", id: "local-contour",
label: "Локальный контур", label: "Локальный контур",
description: console.state?.k1_ip || "Устройство не назначено", description:
runtime.state?.activeDevice?.endpointLabel ||
selection?.model.displayName ||
"Модель не выбрана",
icon: <Icon name="network" />, icon: <Icon name="network" />,
active: console.backendStatus !== "offline", active: runtime.backendStatus !== "offline" && runtime.backendStatus !== "unconfigured",
}, },
]} ]}
items={rootWorkspaces.map((item) => ({ items={rootWorkspaces.map((item) => ({
@ -341,8 +345,8 @@ export default function App() {
onExpandedChange={workspace.setContentExpanded} onExpandedChange={workspace.setContentExpanded}
headerTools={ headerTools={
activeDefinition.kind === "device" ? ( activeDefinition.kind === "device" ? (
<StatusBadge tone={phaseTone(console.state?.phase)}> <StatusBadge tone={phaseTone(runtime.state?.phase)}>
{phaseLabel(console.state?.phase)} {phaseLabel(runtime.state?.phase)}
</StatusBadge> </StatusBadge>
) : activeDefinition.kind === "spatial" ? ( ) : activeDefinition.kind === "spatial" ? (
<StatusBadge tone={effectiveSourceUrl ? "accent" : "warning"}> <StatusBadge tone={effectiveSourceUrl ? "accent" : "warning"}>
@ -357,14 +361,13 @@ export default function App() {
> >
{activeDefinition.kind === "device" ? ( {activeDefinition.kind === "device" ? (
<DeviceWorkspace <DeviceWorkspace
console={console}
onOpenSpatialScene={() => openView("spatial-scene")} onOpenSpatialScene={() => openView("spatial-scene")}
/> />
) : ( ) : (
<WorkspaceRenderer <WorkspaceRenderer
definition={activeDefinition} definition={activeDefinition}
state={console.state} state={runtime.state}
backendStatus={console.backendStatus} backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl} sourceUrl={effectiveSourceUrl}
sceneSettings={sceneSettings} sceneSettings={sceneSettings}
navigation={{ navigation={{
@ -482,8 +485,8 @@ export default function App() {
<div className="inspector-control-stack"> <div className="inspector-control-stack">
<ControlRow label="Контур"> <ControlRow label="Контур">
<span className="scene-window-state"> <span className="scene-window-state">
<i className="api-dot" data-status={console.backendStatus} aria-hidden="true" /> <i className="api-dot" data-status={runtime.backendStatus} aria-hidden="true" />
{backendLabel(console.backendStatus)} {backendLabel(runtime.backendStatus)}
</span> </span>
</ControlRow> </ControlRow>
<p className="scene-window-note"> <p className="scene-window-note">
@ -520,10 +523,10 @@ export default function App() {
<Button <Button
variant="primary" variant="primary"
shape="pill" shape="pill"
disabled={console.pendingAction === "viewer"} disabled={runtime.pendingAction === "viewer"}
onClick={() => void applyDisplaySettings()} onClick={() => void applyDisplaySettings()}
> >
{console.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"} {runtime.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"}
</Button> </Button>
</WindowFooterActions> </WindowFooterActions>
} }
@ -660,11 +663,11 @@ export default function App() {
<div className="inspector-control-stack"> <div className="inspector-control-stack">
<div className="nodedc-field"> <div className="nodedc-field">
<span className="nodedc-field__description">Основной поток геометрии лидара</span> <span className="nodedc-field__description">Основной поток геометрии лидара</span>
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} /> <Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
</div> </div>
<div className="nodedc-field"> <div className="nodedc-field">
<span className="nodedc-field__description">Положение и ориентация устройства во времени</span> <span className="nodedc-field__description">Положение и ориентация устройства во времени</span>
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} /> <Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
</div> </div>
</div> </div>
), ),
@ -675,7 +678,7 @@ export default function App() {
description: "Преобразования и области обзора", description: "Преобразования и области обзора",
content: ( content: (
<div className="inspector-control-stack"> <div className="inspector-control-stack">
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} /> <Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
<div className="nodedc-field"> <div className="nodedc-field">
<span className="nodedc-field__description">Камерный канал пока не подключён</span> <span className="nodedc-field__description">Камерный канал пока не подключён</span>
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} /> <Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />

View File

@ -1,13 +1,13 @@
import { Button, Icon, StatusBadge } from "@nodedc/ui-react"; import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
import type { RootDefinition } from "../productModel"; import type { RootDefinition } from "../productModel";
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation"; import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
import type { BackendStatus } from "../useK1Console";
export interface LandingStageProps { export interface LandingStageProps {
root: RootDefinition | null; root: RootDefinition | null;
backendStatus: BackendStatus; backendStatus: BackendStatus;
phase?: string | null; phase?: RuntimePhase | null;
message?: string | null; message?: string | null;
onOpenObservation: () => void; onOpenObservation: () => void;
onOpenDevice: () => void; onOpenDevice: () => void;

View File

@ -0,0 +1,8 @@
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
import { xgridsK1Plugin } from "../device-plugins/xgrids-k1/plugin";
// Composition root: this is the only place where Mission Core chooses which
// statically reviewed device plugins are shipped in the current build.
export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
xgridsK1Plugin,
]);

View File

@ -0,0 +1,160 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry";
interface DevicePluginHostValue {
registry: DevicePluginRegistry;
selection: RegisteredDeviceModel | null;
selectionTransitionPending: boolean;
selectionTransitionError: string | null;
selectModel: (modelId: string) => Promise<boolean>;
clearSelection: () => Promise<boolean>;
}
const DevicePluginHostContext = createContext<DevicePluginHostValue | null>(null);
export function DevicePluginHostProvider({
plugins,
children,
}: {
plugins: readonly DeviceUiPlugin[];
children: ReactNode;
}) {
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [selectionTransitionPending, setSelectionTransitionPending] = useState(false);
const [selectionTransitionError, setSelectionTransitionError] = useState<string | null>(null);
const transitionInFlight = useRef(false);
const deactivationHandlers = useRef(new Map<string, () => Promise<boolean>>());
const selection = selectedModelId ? registry.resolveModel(selectedModelId) : null;
const registerDeactivation = useCallback(
(pluginId: string, handler: () => Promise<boolean>) => {
deactivationHandlers.current.set(pluginId, handler);
return () => {
if (deactivationHandlers.current.get(pluginId) === handler) {
deactivationHandlers.current.delete(pluginId);
}
};
},
[],
);
const transitionTo = useCallback(
async (nextModelId: string | null): Promise<boolean> => {
if (transitionInFlight.current) return false;
if (nextModelId !== null && !registry.resolveModel(nextModelId)) {
throw new Error(`Модель устройства не зарегистрирована: ${nextModelId}.`);
}
if (nextModelId === selectedModelId) return true;
transitionInFlight.current = true;
setSelectionTransitionPending(true);
setSelectionTransitionError(null);
try {
const current = selectedModelId ? registry.resolveModel(selectedModelId) : null;
const deactivate = current
? deactivationHandlers.current.get(current.plugin.manifest.metadata.id)
: undefined;
if (current && !deactivate) {
setSelectionTransitionError(
"Активный плагин не зарегистрировал безопасное завершение сессии.",
);
return false;
}
if (deactivate) {
try {
if (!(await deactivate())) {
setSelectionTransitionError(
"Плагин не подтвердил завершение текущей сессии.",
);
return false;
}
} catch {
setSelectionTransitionError(
"Не удалось безопасно завершить текущую сессию плагина.",
);
return false;
}
}
setSelectedModelId(nextModelId);
return true;
} finally {
transitionInFlight.current = false;
setSelectionTransitionPending(false);
}
},
[registry, selectedModelId],
);
const deactivationRegistrars = useMemo(
() =>
new Map(
registry.plugins.map((plugin) => [
plugin.manifest.metadata.id,
(handler: () => Promise<boolean>) =>
registerDeactivation(plugin.manifest.metadata.id, handler),
]),
),
[registerDeactivation, registry.plugins],
);
const value = useMemo<DevicePluginHostValue>(
() => ({
registry,
selection,
selectionTransitionPending,
selectionTransitionError,
selectModel: (modelId) => transitionTo(modelId),
clearSelection: () => transitionTo(null),
}),
[
registry,
selection,
selectionTransitionError,
selectionTransitionPending,
transitionTo,
],
);
const runtimeTree = [...registry.plugins].reverse().reduce<ReactNode>((child, plugin) => {
const RuntimeProvider = plugin.RuntimeProvider;
return (
<RuntimeProvider
key={plugin.manifest.metadata.id}
activeModel={
selection?.plugin.manifest.metadata.id === plugin.manifest.metadata.id
? selection.model
: null
}
registerDeactivation={deactivationRegistrars.get(plugin.manifest.metadata.id)!}
>
{child}
</RuntimeProvider>
);
}, children);
return (
<DevicePluginHostContext.Provider value={value}>
<IdleMissionRuntimeProvider>{runtimeTree}</IdleMissionRuntimeProvider>
</DevicePluginHostContext.Provider>
);
}
export function useDevicePluginHost(): DevicePluginHostValue {
const value = useContext(DevicePluginHostContext);
if (!value) {
throw new Error("DevicePluginHostProvider не подключён в composition root.");
}
return value;
}

View File

@ -0,0 +1,74 @@
import type { ComponentType, ReactNode } from "react";
export const DEVICE_PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha1" as const;
export const DEVICE_STATE_READ_ACTION_ID = "state.read" as const;
export interface DeviceCapability {
id: string;
label: string;
}
export interface DevicePluginActionDefinition {
id: string;
mutating: boolean;
secretFields: readonly string[];
}
export interface DeviceModelDefinition {
id: string;
vendor: string;
displayName: string;
category: string;
description: string;
verified: boolean;
capabilities: readonly DeviceCapability[];
ui: {
slot: "device.connection";
componentKey: string;
};
}
export interface DevicePluginManifest {
apiVersion: typeof DEVICE_PLUGIN_API_VERSION;
kind: "DevicePlugin";
metadata: {
id: string;
version: string;
displayName: string;
};
spec: {
hostApiRange: "v1alpha1";
runtime: {
backendEntrypoint: string;
isolation: "transitional-in-process";
};
permissions: readonly string[];
actions: readonly DevicePluginActionDefinition[];
models: readonly DeviceModelDefinition[];
};
}
export interface DevicePluginHostActions {
openSpatialScene: () => void;
}
export interface DevicePluginConnectionProps {
model: DeviceModelDefinition;
host: DevicePluginHostActions;
}
export interface DeviceUiPlugin {
manifest: DevicePluginManifest;
RuntimeProvider: ComponentType<{
activeModel: DeviceModelDefinition | null;
registerDeactivation: (handler: () => Promise<boolean>) => () => void;
children: ReactNode;
}>;
connectionViews: Readonly<Record<string, ComponentType<DevicePluginConnectionProps>>>;
}
export interface RegisteredDeviceModel {
plugin: DeviceUiPlugin;
model: DeviceModelDefinition;
ConnectionView: ComponentType<DevicePluginConnectionProps>;
}

View File

@ -0,0 +1,182 @@
import {
DEVICE_PLUGIN_API_VERSION,
type DeviceCapability,
type DeviceModelDefinition,
type DevicePluginActionDefinition,
type DevicePluginManifest,
} from "./contracts";
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`Некорректный manifest: ${path} должен быть объектом.`);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: Record<string, unknown>,
path: string,
allowed: readonly string[],
): void {
const extras = Object.keys(value).filter((key) => !allowed.includes(key));
if (extras.length) {
throw new Error(`Некорректный manifest: ${path} содержит неизвестные поля ${extras.join(", ")}.`);
}
}
function text(value: unknown, path: string, maxLength = 160): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`Некорректный manifest: ${path} должен быть непустой строкой.`);
}
if (value.length > maxLength) {
throw new Error(
`Некорректный manifest: ${path} длиннее ${maxLength} символов.`,
);
}
return value;
}
function flag(value: unknown, path: string): boolean {
if (typeof value !== "boolean") {
throw new Error(`Некорректный manifest: ${path} должен быть boolean.`);
}
return value;
}
function list(value: unknown, path: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`Некорректный manifest: ${path} должен быть массивом.`);
}
return value;
}
function capability(value: unknown, path: string): DeviceCapability {
const item = record(value, path);
exactKeys(item, path, ["id", "label"]);
return { id: text(item.id, `${path}.id`), label: text(item.label, `${path}.label`) };
}
function action(value: unknown, path: string): DevicePluginActionDefinition {
const item = record(value, path);
exactKeys(item, path, ["id", "mutating", "secretFields"]);
return {
id: text(item.id, `${path}.id`),
mutating: flag(item.mutating, `${path}.mutating`),
secretFields: list(item.secretFields, `${path}.secretFields`).map((field, index) =>
text(field, `${path}.secretFields[${index}]`),
),
};
}
function model(value: unknown, path: string): DeviceModelDefinition {
const item = record(value, path);
exactKeys(item, path, [
"id",
"vendor",
"displayName",
"category",
"description",
"verified",
"capabilities",
"ui",
]);
const ui = record(item.ui, `${path}.ui`);
exactKeys(ui, `${path}.ui`, ["slot", "componentKey"]);
const slot = text(ui.slot, `${path}.ui.slot`);
if (slot !== "device.connection") {
throw new Error(`Некорректный manifest: слот ${slot} пока не поддерживается.`);
}
return {
id: text(item.id, `${path}.id`),
vendor: text(item.vendor, `${path}.vendor`),
displayName: text(item.displayName, `${path}.displayName`),
category: text(item.category, `${path}.category`),
description: text(item.description, `${path}.description`, 1024),
verified: flag(item.verified, `${path}.verified`),
capabilities: list(item.capabilities, `${path}.capabilities`).map((entry, index) =>
capability(entry, `${path}.capabilities[${index}]`),
),
ui: {
slot,
componentKey: text(ui.componentKey, `${path}.ui.componentKey`),
},
};
}
export function parseDevicePluginManifest(document: unknown): DevicePluginManifest {
const root = record(document, "root");
exactKeys(root, "root", ["apiVersion", "kind", "metadata", "spec"]);
if (root.apiVersion !== DEVICE_PLUGIN_API_VERSION || root.kind !== "DevicePlugin") {
throw new Error("Manifest использует несовместимую версию или kind.");
}
const metadata = record(root.metadata, "metadata");
exactKeys(metadata, "metadata", ["id", "version", "displayName"]);
const spec = record(root.spec, "spec");
exactKeys(spec, "spec", [
"hostApiRange",
"runtime",
"permissions",
"actions",
"models",
]);
if (spec.hostApiRange !== "v1alpha1") {
throw new Error(`Manifest требует несовместимый host API: ${String(spec.hostApiRange)}.`);
}
const runtime = record(spec.runtime, "spec.runtime");
exactKeys(runtime, "spec.runtime", ["backendEntrypoint", "isolation"]);
const isolation = text(runtime.isolation, "spec.runtime.isolation");
if (isolation !== "transitional-in-process") {
throw new Error(`Некорректный manifest: неизвестная изоляция ${isolation}.`);
}
const models = list(spec.models, "spec.models").map((entry, index) =>
model(entry, `spec.models[${index}]`),
);
if (models.length !== 1) {
throw new Error("Manifest v1alpha1 должен объявлять ровно одну модель.");
}
const version = text(metadata.version, "metadata.version");
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) {
throw new Error(`Некорректный manifest: версия ${version} не является semver.`);
}
return {
apiVersion: DEVICE_PLUGIN_API_VERSION,
kind: "DevicePlugin",
metadata: {
id: text(metadata.id, "metadata.id"),
version,
displayName: text(metadata.displayName, "metadata.displayName"),
},
spec: {
hostApiRange: "v1alpha1",
runtime: {
backendEntrypoint: text(
runtime.backendEntrypoint,
"spec.runtime.backendEntrypoint",
256,
),
isolation,
},
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
text(entry, `spec.permissions[${index}]`),
),
actions: list(spec.actions, "spec.actions").map((entry, index) =>
action(entry, `spec.actions[${index}]`),
),
models,
},
};
}
export function requirePluginAction(
manifest: DevicePluginManifest,
actionId: string,
): string {
const action = manifest.spec.actions.find((candidate) => candidate.id === actionId);
if (!action) {
throw new Error(`Плагин ${manifest.metadata.id} не объявляет действие ${actionId}.`);
}
return action.id;
}

View File

@ -0,0 +1,95 @@
import {
DEVICE_PLUGIN_API_VERSION,
DEVICE_STATE_READ_ACTION_ID,
type DeviceUiPlugin,
type RegisteredDeviceModel,
} from "./contracts";
export interface DevicePluginRegistry {
readonly plugins: readonly DeviceUiPlugin[];
readonly models: readonly RegisteredDeviceModel[];
resolveModel: (modelId: string) => RegisteredDeviceModel | null;
}
export function createDevicePluginRegistry(
installedPlugins: readonly DeviceUiPlugin[],
): DevicePluginRegistry {
const pluginIds = new Set<string>();
const modelIds = new Set<string>();
const models: RegisteredDeviceModel[] = [];
for (const plugin of installedPlugins) {
const { manifest } = plugin;
if (manifest.apiVersion !== DEVICE_PLUGIN_API_VERSION) {
throw new Error(
`Плагин ${manifest.metadata.id} использует несовместимый контракт ${manifest.apiVersion}.`,
);
}
if (manifest.kind !== "DevicePlugin") {
throw new Error(`Неподдерживаемый kind плагина: ${manifest.kind}.`);
}
if (!manifest.metadata.id.trim() || pluginIds.has(manifest.metadata.id)) {
throw new Error(
`Идентификатор плагина пуст или повторяется: ${manifest.metadata.id || "<empty>"}.`,
);
}
pluginIds.add(manifest.metadata.id);
if (manifest.spec.models.length !== 1) {
throw new Error(
`Плагин ${manifest.metadata.id} должен объявлять ровно одну модель в v1alpha1.`,
);
}
const permissions = new Set(manifest.spec.permissions);
if (permissions.size !== manifest.spec.permissions.length) {
throw new Error(`Плагин ${manifest.metadata.id} повторяет permission.`);
}
const actionIds = new Set<string>();
for (const action of manifest.spec.actions) {
if (!action.id.trim() || actionIds.has(action.id)) {
throw new Error(
`Идентификатор действия пуст или повторяется: ${action.id || "<empty>"}.`,
);
}
actionIds.add(action.id);
if (new Set(action.secretFields).size !== action.secretFields.length) {
throw new Error(`Действие ${action.id} повторяет secret field.`);
}
}
const stateRead = manifest.spec.actions.find(
(action) => action.id === DEVICE_STATE_READ_ACTION_ID,
);
if (!stateRead || stateRead.mutating || stateRead.secretFields.length) {
throw new Error(
`Плагин ${manifest.metadata.id} должен объявлять безопасное действие state.read.`,
);
}
for (const model of manifest.spec.models) {
if (!model.id.trim() || modelIds.has(model.id)) {
throw new Error(`Идентификатор модели пуст или повторяется: ${model.id || "<empty>"}.`);
}
modelIds.add(model.id);
const capabilityIds = new Set(model.capabilities.map((capability) => capability.id));
if (capabilityIds.size !== model.capabilities.length) {
throw new Error(`Модель ${model.id} повторяет capability.`);
}
const ConnectionView = plugin.connectionViews[model.ui.componentKey];
if (!ConnectionView) {
throw new Error(
`Плагин ${manifest.metadata.id} не реализует UI ${model.ui.componentKey}.`,
);
}
models.push({ plugin, model, ConnectionView });
}
}
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
return {
plugins: Object.freeze([...installedPlugins]),
models: Object.freeze(models),
resolveModel: (modelId) => modelById.get(modelId) ?? null,
};
}

View File

@ -0,0 +1,35 @@
import { createContext, useContext, type ReactNode } from "react";
import type { MissionRuntimeController } from "./contracts";
const idleRuntime: MissionRuntimeController = {
state: {
phase: "unconfigured",
message: "Сначала выберите модель локального устройства.",
sourceMode: "idle",
},
backendStatus: "unconfigured",
pendingAction: null,
refresh: () => undefined,
updateViewerSettings: async () => false,
};
const MissionRuntimeContext = createContext<MissionRuntimeController>(idleRuntime);
export function MissionRuntimeProvider({
value,
children,
}: {
value: MissionRuntimeController;
children: ReactNode;
}) {
return <MissionRuntimeContext.Provider value={value}>{children}</MissionRuntimeContext.Provider>;
}
export function IdleMissionRuntimeProvider({ children }: { children: ReactNode }) {
return <MissionRuntimeProvider value={idleRuntime}>{children}</MissionRuntimeProvider>;
}
export function useMissionRuntime(): MissionRuntimeController {
return useContext(MissionRuntimeContext);
}

View File

@ -0,0 +1,65 @@
export type BackendStatus = "unconfigured" | "checking" | "online" | "degraded" | "offline";
export type RuntimePhase =
| "unconfigured"
| "idle"
| "configuring"
| "connected"
| "starting"
| "streaming"
| "replaying"
| "stopping"
| "error";
export type SourceMode = "idle" | "live" | "replay";
export interface ViewerSettings {
point_size: number;
color_mode: "intensity" | "height" | "distance" | "rgb" | "class";
palette: "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
custom_color: string;
accumulation_seconds: number;
show_points: boolean;
show_trajectory: boolean;
show_grid: boolean;
}
export interface StreamMetrics {
latencyMs?: number | null;
frameRateHz?: number | null;
pointCount?: number | null;
droppedPreviewFrames?: number | null;
}
export interface ActiveDeviceSnapshot {
pluginId: string;
modelId: string;
displayName: string;
instanceId?: string | null;
endpointLabel?: string | null;
}
export interface SpatialSourceDescriptor {
id: string;
url: string;
label: string;
kind: "rerun-grpc" | "rrd" | "other";
}
export interface MissionRuntimeState {
phase: RuntimePhase;
message?: string | null;
activeDevice?: ActiveDeviceSnapshot | null;
spatialSource?: SpatialSourceDescriptor | null;
viewerSettings?: ViewerSettings | null;
sourceMode: SourceMode;
metrics?: StreamMetrics;
}
export interface MissionRuntimeController {
state: MissionRuntimeState | null;
backendStatus: BackendStatus;
pendingAction: string | null;
refresh: () => void | Promise<void>;
updateViewerSettings: (settings: ViewerSettings) => Promise<boolean>;
}

View File

@ -0,0 +1,551 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Button,
Checker,
GlassSurface,
Icon,
SegmentedControl,
StatusBadge,
TextField,
type StatusTone,
} from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "../../core/device-plugins/contracts";
import { MetricCard } from "../../components/MetricCard";
import type { BleDevice } from "./api";
import { localizeRuntimeMessage } from "./messages";
import {
backendLabel,
backendTone,
eventStatusLabel,
finiteMetric,
formatNumber,
phaseLabel,
phaseTone,
pipelineLatency,
sourceModeLabel,
} from "./presentation";
import { useXgridsK1Controller } from "./runtimeContext";
type SessionIntent = "live" | "replay";
const sessionItems = [
{ value: "live", label: "Реальное устройство" },
{ value: "replay", label: "Повтор записи" },
] satisfies Array<{ value: SessionIntent; label: string }>;
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="detail-row">
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
}
function WizardStep({
number,
title,
status,
tone = "neutral",
children,
}: {
number: string;
title: string;
status: string;
tone?: StatusTone;
children: ReactNode;
}) {
return (
<section className="wizard-step">
<div className="wizard-step__rail" aria-hidden="true">
<span>{number}</span>
</div>
<div className="wizard-step__content">
<header>
<h3>{title}</h3>
<StatusBadge tone={tone}>{status}</StatusBadge>
</header>
{children}
</div>
</section>
);
}
function DeviceRow({
device,
selected,
onSelect,
}: {
device: BleDevice;
selected: boolean;
onSelect: () => void;
}) {
return (
<div
className="device-row"
data-compatible={device.likely_k1 ? "true" : undefined}
data-selected={selected ? "true" : undefined}
>
<div className="device-row__identity">
<span className="device-row__signal" aria-hidden="true" />
<div>
<span className="device-row__name">
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
{device.likely_k1 ? <small>Совместимый профиль</small> : null}
</span>
<code>{device.device_id}</code>
</div>
</div>
<div className="device-row__action">
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
<Button
size="compact"
variant={selected ? "primary" : "secondary"}
disabled={device.connectable === false}
onClick={onSelect}
>
{selected ? "Выбрано" : "Выбрать"}
</Button>
</div>
</div>
);
}
function LatencyTrace({ values }: { values: number[] }) {
const ceiling = Math.max(16, ...values);
return (
<div className="latency-trace" aria-label="Последние измерения времени до публикации">
{values.length ? (
values.map((value, index) => (
<span
key={`${index}-${value}`}
style={{ height: `${Math.max(8, Math.min(100, (value / ceiling) * 100))}%` }}
title={`${value.toFixed(1)} мс`}
/>
))
) : (
<p>Измерений пока нет. График появится после получения реальных данных.</p>
)}
</div>
);
}
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
const console = useXgridsK1Controller();
const {
state,
backendStatus,
eventStatus,
pendingAction,
error,
latencyHistory,
refresh,
clearError,
scan,
connect,
startLive,
startReplay,
stop,
} = console;
const [powerConfirmed, setPowerConfirmed] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
useEffect(() => {
if (state?.selected_device_id) {
setSelectedDeviceId(state.selected_device_id);
return;
}
if (
selectedDeviceId &&
state?.devices &&
!state.devices.some((device) => device.device_id === selectedDeviceId)
) {
setSelectedDeviceId("");
}
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const droppedFrames = finiteMetric(metrics?.dropped_preview_frames);
const devices = state?.devices ?? [];
const isBusy = pendingAction !== null;
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
const sourceLabel = sourceModeLabel(state?.source_mode);
const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId],
);
const submitConnect = async () => {
if (!canConnect) return;
const succeeded = await connect({
device_id: selectedDeviceId,
ssid: ssid.trim(),
password,
});
if (succeeded) setPassword("");
};
const submitLive = async () => {
const targetHost = liveHost.trim();
const started = await startLive(targetHost ? { host: targetHost } : {});
if (started) host.openSpatialScene();
};
const submitReplay = async () => {
const speed = Number(replaySpeed);
const started = await startReplay({
path: replayPath.trim(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
loop: replayLoop,
});
if (started) host.openSpatialScene();
};
return (
<div className="device-workspace xgrids-k1-plugin">
{error ? (
<aside className="error-banner" role="alert">
<span className="error-banner__dot" aria-hidden="true" />
<div>
<strong>Локальная операция завершилась ошибкой</strong>
<p>{localizeRuntimeMessage(error)}</p>
</div>
<div className="error-banner__actions">
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
Повторить
</Button>
<Button size="compact" variant="ghost" onClick={clearError}>
Закрыть
</Button>
</div>
</aside>
) : null}
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">РАБОЧИЙ АДАПТЕР УСТРОЙСТВА</span>
<h2>Подключение {model.displayName}</h2>
<p>
Этот путь уже работает физически, но остаётся изолированным адаптером. Парковая и
операторская модель от конкретного устройства не зависят.
</p>
</div>
<div className="workspace-lead__status">
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
</div>
</section>
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
<MetricCard
featured
eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)}
unit="мс"
detail="MQTT callback → Rerun SDK; без экрана"
/>
<MetricCard
eyebrow="ЧАСТОТА КАДРОВ"
value={formatNumber(frameRate)}
unit="кадр/с"
detail="Последнее измерение адаптера"
/>
<MetricCard
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Реальное число декодированных точек"
/>
<MetricCard
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Исходные данные при этом сохраняются"
/>
</section>
<div className="device-workspace__grid">
<GlassSurface className="connection-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</span>
<h2>Подключите устройство к сети</h2>
</div>
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
</header>
<div className="wizard-list">
<WizardStep
number="01"
title="Включите устройство"
status={powerConfirmed ? "Подтверждено" : "Ожидает"}
tone={powerConfirmed ? "success" : "warning"}
>
<div className="nodedc-field">
<span className="nodedc-field__description">
Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия.
</span>
<Checker
checked={powerConfirmed}
label="Устройство включено, индикатор стабилен"
onChange={setPowerConfirmed}
/>
</div>
</WizardStep>
<WizardStep
number="02"
title="Выберите Bluetooth-устройство"
status={
pendingAction === "scan"
? "Поиск…"
: selectedDeviceId
? "Устройство выбрано"
: `Найдено: ${devices.length}`
}
tone={
pendingAction === "scan"
? "accent"
: selectedDeviceId
? "success"
: "neutral"
}
>
<p className="step-copy">
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Совместимый
профиль только подсказка; окончательный выбор всегда делает оператор.
</p>
<Button
width="full"
variant="secondary"
icon={<Icon name="search" />}
disabled={!powerConfirmed || isBusy}
onClick={() => {
setSelectedDeviceId("");
void scan();
}}
>
{pendingAction === "scan"
? "Сканируем Bluetooth — 6 секунд…"
: "Показать все BLE-устройства"}
</Button>
<div className="device-list">
{devices.length ? (
devices.map((device) => (
<DeviceRow
key={device.device_id}
device={device}
selected={device.device_id === selectedDeviceId}
onSelect={() => setSelectedDeviceId(device.device_id)}
/>
))
) : (
<div className="empty-device-list">
Устройства пока не найдены. Проверьте питание и состояние индикатора, затем
повторите поиск.
</div>
)}
</div>
</WizardStep>
<WizardStep
number="03"
title="Передайте настройки WiFi"
status={state?.k1_ip ? "Подключено" : "Не подключено"}
tone={state?.k1_ip ? "success" : "neutral"}
>
<div className="field-stack">
<TextField
label="Название сети WiFi"
hint="SSID"
value={ssid}
onChange={(event) => setSsid(event.target.value)}
autoComplete="off"
spellCheck={false}
placeholder="Сеть локального контура"
/>
<TextField
label="Пароль WiFi"
hint="Только в оперативной памяти"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="off"
placeholder="Введите пароль"
/>
</div>
<div className="connection-summary">
<span>Устройство</span>
<strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong>
</div>
<Button
width="full"
variant="primary"
icon={<Icon name="network" />}
disabled={!canConnect}
onClick={() => void submitConnect()}
>
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к WiFi"}
</Button>
<p className="safety-note">
Пароль передаётся только локальному сервису на этом компьютере, не сохраняется в браузере и
удаляется из формы после успешного подключения.
</p>
</WizardStep>
</div>
</GlassSurface>
<div className="device-workspace__side">
<GlassSurface className="session-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">
{sessionIntent === "live" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
</span>
<h2>{sessionIntent === "live" ? "Запустите поток" : "Повторите запись"}</h2>
</div>
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
{sourceLabel}
</StatusBadge>
</header>
<SegmentedControl
label="Источник данных"
value={sessionIntent}
items={sessionItems}
onChange={setSessionIntent}
/>
{sessionIntent === "live" ? (
<div className="session-form">
<TextField
label="Адрес устройства"
hint="Обычно определяется автоматически"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
spellCheck={false}
placeholder={state?.k1_ip || "Сначала подключите устройство к WiFi"}
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !liveTargetReady}
onClick={() => void submitLive()}
>
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
</Button>
<p className="live-instruction">
{liveTargetReady
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
: "Сначала подключите устройство к WiFi или укажите его локальный адрес."}
</p>
</div>
) : (
<div className="session-form session-form--replay">
<TextField
label="Путь к записи"
hint="Локальный файл исходных данных"
value={replayPath}
onChange={(event) => setReplayPath(event.target.value)}
spellCheck={false}
placeholder="sessions/.../capture.tsv"
/>
<TextField
label="Скорость повтора"
hint="Множитель"
type="number"
min="0.1"
step="0.1"
value={replaySpeed}
onChange={(event) => setReplaySpeed(event.target.value)}
/>
<div className="nodedc-field">
<span className="nodedc-field__description">После последнего кадра начать запись заново.</span>
<Checker
checked={replayLoop}
label="Повторять по кругу"
onChange={setReplayLoop}
/>
</div>
<Button
variant="primary"
icon={<Icon name="video" />}
disabled={isBusy || replayPath.trim().length === 0}
onClick={() => void submitReplay()}
>
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
</Button>
</div>
)}
<div className="session-footer">
<p>Статус изменится только после ответа локального сервиса.</p>
<Button
variant="secondary"
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
onClick={() => void stop()}
>
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
</Button>
</div>
</GlassSurface>
<div className="diagnostics-grid">
<GlassSurface className="status-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">ТЕКУЩЕЕ СОСТОЯНИЕ</span>
<h2>Локальный контур</h2>
</div>
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
</header>
<dl className="detail-list">
<DetailRow label="Канал событий">
<span className="inline-state" data-state={eventStatus}>
{eventStatusLabel(eventStatus)}
</span>
</DetailRow>
<DetailRow label="Источник">{sourceLabel}</DetailRow>
<DetailRow label="Адрес устройства">
<code>{state?.k1_ip || "Не получен"}</code>
</DetailRow>
</dl>
</GlassSurface>
<GlassSurface className="latency-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
<h2>Время до публикации</h2>
</div>
<strong className="latency-now">
{formatNumber(latency)} <span>мс</span>
</strong>
</header>
<LatencyTrace values={latencyHistory} />
<div className="latency-legend">
<span>Старые</span>
<span>Последние</span>
</div>
</GlassSurface>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,3 +1,8 @@
import type { ViewerSettings } from "../../core/runtime/contracts";
import { xgridsK1Actions, xgridsK1Manifest } from "./manifest";
const PLUGIN_ID = xgridsK1Manifest.metadata.id;
export interface BleDevice { export interface BleDevice {
device_id: string; device_id: string;
name?: string | null; name?: string | null;
@ -9,7 +14,7 @@ export interface BleDevice {
export type SourceMode = "idle" | "live" | "replay"; export type SourceMode = "idle" | "live" | "replay";
export interface K1Metrics { export interface XgridsK1Metrics {
mqtt_to_decode_ms?: number | null; mqtt_to_decode_ms?: number | null;
decode_ms?: number | null; decode_ms?: number | null;
publish_ms?: number | null; publish_ms?: number | null;
@ -22,7 +27,7 @@ export interface K1Metrics {
[key: string]: number | null | undefined; [key: string]: number | null | undefined;
} }
export interface ConsoleState { export interface XgridsK1State {
phase?: string | null; phase?: string | null;
message?: string | null; message?: string | null;
devices?: BleDevice[]; devices?: BleDevice[];
@ -33,18 +38,7 @@ export interface ConsoleState {
rerun_grpc_url?: string | null; rerun_grpc_url?: string | null;
viewer_settings?: ViewerSettings | null; viewer_settings?: ViewerSettings | null;
source_mode?: SourceMode | null; source_mode?: SourceMode | null;
metrics?: K1Metrics; metrics?: XgridsK1Metrics;
}
export interface ViewerSettings {
point_size: number;
color_mode: "intensity" | "height" | "distance" | "rgb" | "class";
palette: "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
custom_color: string;
accumulation_seconds: number;
show_points: boolean;
show_trajectory: boolean;
show_grid: boolean;
} }
export interface HealthResponse { export interface HealthResponse {
@ -89,14 +83,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
function unwrapState(payload: unknown): ConsoleState { function unwrapState(payload: unknown): XgridsK1State {
const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload; const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload;
if (!isRecord(value)) { if (!isRecord(value)) {
throw new ApiError("Локальный сервер вернул некорректное состояние."); throw new ApiError("Локальный сервер вернул некорректное состояние.");
} }
return value as ConsoleState; return value as XgridsK1State;
} }
async function requestJson(path: string, init?: RequestInit): Promise<unknown> { async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
@ -142,20 +136,27 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
return body; return body;
} }
async function postState(path: string, body?: object): Promise<ConsoleState> { async function postState(path: string, body?: object): Promise<XgridsK1State> {
const payload = await requestJson(path, { const payload = await requestJson(path, {
method: "POST", method: "POST",
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
if (payload === undefined) { if (payload === undefined) {
return api.getState(); return xgridsK1Api.getState();
} }
return unwrapState(payload); return unwrapState(payload);
} }
export const api = { function invokeState(actionId: string, input: object = {}): Promise<XgridsK1State> {
return postState(
`/api/v1/device-plugins/${encodeURIComponent(PLUGIN_ID)}/actions/${encodeURIComponent(actionId)}`,
{ input },
);
}
export const xgridsK1Api = {
async getHealth(): Promise<HealthResponse> { async getHealth(): Promise<HealthResponse> {
const payload = await requestJson("/api/health"); const payload = await requestJson("/api/health");
if (!isRecord(payload)) { if (!isRecord(payload)) {
@ -164,45 +165,48 @@ export const api = {
return payload as HealthResponse; return payload as HealthResponse;
}, },
async getState(): Promise<ConsoleState> { async getState(): Promise<XgridsK1State> {
return unwrapState(await requestJson("/api/state")); return invokeState(xgridsK1Actions.stateRead);
}, },
scanBle(body: ScanRequest = {}): Promise<ConsoleState> { scanBle(body: ScanRequest = {}): Promise<XgridsK1State> {
return postState("/api/ble/scan", body); return invokeState(xgridsK1Actions.discoveryScan, body);
}, },
connect(body: ConnectRequest): Promise<ConsoleState> { connect(body: ConnectRequest): Promise<XgridsK1State> {
return postState("/api/connect", body); return invokeState(xgridsK1Actions.networkProvision, body);
}, },
startLive(body: LiveRequest = {}): Promise<ConsoleState> { startLive(body: LiveRequest = {}): Promise<XgridsK1State> {
return postState("/api/session/live", body); return invokeState(xgridsK1Actions.streamStartLive, body);
}, },
startReplay(body: ReplayRequest): Promise<ConsoleState> { startReplay(body: ReplayRequest): Promise<XgridsK1State> {
return postState("/api/session/replay", body); return invokeState(xgridsK1Actions.streamStartReplay, body);
}, },
stopSession(): Promise<ConsoleState> { stopSession(): Promise<XgridsK1State> {
return postState("/api/session/stop"); return invokeState(xgridsK1Actions.streamStop);
}, },
updateViewerSettings(body: ViewerSettings): Promise<ConsoleState> { updateViewerSettings(body: ViewerSettings): Promise<XgridsK1State> {
return postState("/api/viewer/settings", body); return invokeState(xgridsK1Actions.viewerSettingsUpdate, body);
}, },
}; };
export type EventSocketStatus = "connecting" | "open" | "closed" | "error"; export type EventSocketStatus = "connecting" | "open" | "closed" | "error";
function eventSocketUrl(): string { function eventSocketUrl(): string {
const url = new URL("/api/events", window.location.href); const url = new URL(
`/api/v1/device-plugins/${encodeURIComponent(PLUGIN_ID)}/events`,
window.location.href,
);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.toString(); return url.toString();
} }
export function openEventSocket( export function openEventSocket(
onState: (state: ConsoleState) => void, onState: (state: XgridsK1State) => void,
onStatus: (status: EventSocketStatus) => void, onStatus: (status: EventSocketStatus) => void,
): () => void { ): () => void {
onStatus("connecting"); onStatus("connecting");

View File

@ -0,0 +1,19 @@
import manifestDocument from "../../../../../plugins/xgrids-k1/plugin.manifest.json";
import { DEVICE_STATE_READ_ACTION_ID } from "../../core/device-plugins/contracts";
import {
parseDevicePluginManifest,
requirePluginAction,
} from "../../core/device-plugins/manifestParser";
export const xgridsK1Manifest = parseDevicePluginManifest(manifestDocument);
export const xgridsK1Actions = Object.freeze({
stateRead: requirePluginAction(xgridsK1Manifest, DEVICE_STATE_READ_ACTION_ID),
discoveryScan: requirePluginAction(xgridsK1Manifest, "discovery.scan"),
networkProvision: requirePluginAction(xgridsK1Manifest, "network.provision"),
streamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"),
streamStartReplay: requirePluginAction(xgridsK1Manifest, "stream.start-replay"),
streamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"),
viewerSettingsUpdate: requirePluginAction(xgridsK1Manifest, "viewer.settings.update"),
});

View File

@ -0,0 +1,12 @@
import type { DeviceUiPlugin } from "../../core/device-plugins/contracts";
import { XgridsK1Connection } from "./XgridsK1Connection";
import { xgridsK1Manifest } from "./manifest";
import { XgridsK1RuntimeProvider } from "./runtimeContext";
export const xgridsK1Plugin: DeviceUiPlugin = {
manifest: xgridsK1Manifest,
RuntimeProvider: XgridsK1RuntimeProvider,
connectionViews: Object.freeze({
"xgrids-k1.connection": XgridsK1Connection,
}),
};

View File

@ -0,0 +1,85 @@
import type { StatusTone } from "@nodedc/ui-react";
import type { BackendStatus } from "../../core/runtime/contracts";
import type { XgridsK1Metrics } from "./api";
const phaseLabels: Record<string, string> = {
idle: "Ожидание",
scanning: "Поиск Bluetooth",
device_selected: "Устройство выбрано",
provisioning: "Передача настроек WiFi",
connecting: "Подключение",
connected: "Устройство подключено",
starting_live: "Запуск потока",
live: "Поток в реальном времени",
replay: "Повтор записи",
stopping: "Остановка",
error: "Ошибка",
};
export function phaseLabel(phase: string | null | undefined): string {
if (!phase) return "Нет состояния";
return phaseLabels[phase] ?? "Неизвестное состояние";
}
export function phaseTone(phase: string | null | undefined): StatusTone {
if (!phase) return "neutral";
if (phase === "error") return "danger";
if (["connected", "live", "replay"].includes(phase)) return "success";
if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) return "accent";
return "neutral";
}
export function backendLabel(status: BackendStatus): string {
return {
unconfigured: "Модель не выбрана",
checking: "Проверка контура",
online: "Контур доступен",
degraded: "Контур ограничен",
offline: "Контур недоступен",
}[status];
}
export function backendTone(status: BackendStatus): StatusTone {
if (status === "online") return "success";
if (status === "degraded" || status === "checking") return "warning";
if (status === "unconfigured") return "neutral";
return "danger";
}
export function eventStatusLabel(status: string): string {
return {
connecting: "подключение",
open: "подключён",
closed: "закрыт",
error: "ошибка",
}[status] ?? "неизвестно";
}
export function finiteMetric(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export function pipelineLatency(metrics: XgridsK1Metrics | undefined): number | null {
if (!metrics) return null;
const direct = finiteMetric(metrics.pipeline_ms ?? metrics.end_to_end_ms);
if (direct !== null) return direct;
const segments = [finiteMetric(metrics.mqtt_to_decode_ms), finiteMetric(metrics.publish_ms)]
.filter((value): value is number => value !== null);
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
}
export function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—";
return value.toLocaleString("ru-RU", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
}
export function sourceModeLabel(mode: string | null | undefined): string {
if (mode === "live") return "Реальное время";
if (mode === "replay") return "Повтор записи";
if (mode === "idle") return "Ожидание";
return "Неизвестно";
}

View File

@ -0,0 +1,126 @@
import { createContext, useContext, useEffect, type ReactNode } from "react";
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
import {
MissionRuntimeProvider,
useMissionRuntime,
} from "../../core/runtime/MissionRuntimeContext";
import type {
MissionRuntimeController,
MissionRuntimeState,
RuntimePhase,
} from "../../core/runtime/contracts";
import { localizeRuntimeMessage } from "./messages";
import { xgridsK1Manifest } from "./manifest";
import { finiteMetric, pipelineLatency } from "./presentation";
import { useXgridsK1Runtime } from "./useXgridsK1Runtime";
export type XgridsK1Controller = ReturnType<typeof useXgridsK1Runtime>;
const XgridsK1RuntimeContext = createContext<XgridsK1Controller | null>(null);
function normalizePhase(phase: string | null | undefined): RuntimePhase {
if (phase === "error") return "error";
if (phase === "connected") return "connected";
if (phase === "starting_live") return "starting";
if (phase === "live") return "streaming";
if (phase === "replay") return "replaying";
if (phase === "stopping") return "stopping";
if (["scanning", "device_selected", "provisioning", "connecting"].includes(phase ?? "")) {
return "configuring";
}
return "idle";
}
function normalizeState(
controller: XgridsK1Controller,
activeModel: DeviceModelDefinition,
): MissionRuntimeState | null {
const state = controller.state;
if (!state) return null;
const metrics = state.metrics;
const hasDevice = Boolean(state.selected_device_id || state.k1_ip);
const sourceUrl = state.rerun_grpc_url?.trim() ?? "";
return {
phase: normalizePhase(state.phase),
message: localizeRuntimeMessage(state.message),
activeDevice: hasDevice
? {
pluginId: xgridsK1Manifest.metadata.id,
modelId: activeModel.id,
displayName: activeModel.displayName,
instanceId: state.selected_device_id,
endpointLabel: state.k1_ip,
}
: null,
spatialSource: sourceUrl
? {
id: "xgrids-k1-rerun-live",
url: sourceUrl,
label: "Локальный пространственный поток",
kind: "rerun-grpc",
}
: null,
viewerSettings: state.viewer_settings,
sourceMode: state.source_mode ?? "idle",
metrics: {
latencyMs: pipelineLatency(metrics),
frameRateHz: finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz),
pointCount: finiteMetric(metrics?.point_count),
droppedPreviewFrames: finiteMetric(metrics?.dropped_preview_frames),
},
};
}
export function XgridsK1RuntimeProvider({
activeModel,
registerDeactivation,
children,
}: {
activeModel: DeviceModelDefinition | null;
registerDeactivation: (handler: () => Promise<boolean>) => () => void;
children: ReactNode;
}) {
const active = activeModel !== null;
const inheritedRuntime = useMissionRuntime();
const controller = useXgridsK1Runtime(active);
const missionRuntime: MissionRuntimeController = {
state: activeModel ? normalizeState(controller, activeModel) : null,
backendStatus: controller.backendStatus,
pendingAction: controller.pendingAction,
refresh: controller.refresh,
updateViewerSettings: controller.updateViewerSettings,
};
useEffect(() => {
if (!activeModel) return;
return registerDeactivation(async () => {
if (controller.pendingAction !== null) return false;
// Always ask the backend to stop. The browser snapshot may be stale or not
// loaded yet, while a previous local capture is still alive.
return controller.stop();
});
}, [
activeModel,
controller.pendingAction,
controller.stop,
registerDeactivation,
]);
return (
<XgridsK1RuntimeContext.Provider value={active ? controller : null}>
<MissionRuntimeProvider value={active ? missionRuntime : inheritedRuntime}>
{children}
</MissionRuntimeProvider>
</XgridsK1RuntimeContext.Provider>
);
}
export function useXgridsK1Controller(): XgridsK1Controller {
const controller = useContext(XgridsK1RuntimeContext);
if (!controller) {
throw new Error("XGRIDS K1 runtime используется вне собственного plugin provider.");
}
return controller;
}

View File

@ -1,19 +1,19 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { BackendStatus, ViewerSettings } from "../../core/runtime/contracts";
import { import {
ApiError, ApiError,
api, xgridsK1Api,
openEventSocket, openEventSocket,
type ConnectRequest, type ConnectRequest,
type ConsoleState, type XgridsK1State,
type EventSocketStatus, type EventSocketStatus,
type LiveRequest, type LiveRequest,
type ReplayRequest, type ReplayRequest,
type ViewerSettings,
} from "./api"; } from "./api";
import { localizeRuntimeMessage } from "./messages"; import { localizeRuntimeMessage } from "./messages";
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer"; export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer";
function messageFor(error: unknown): string { function messageFor(error: unknown): string {
@ -26,7 +26,7 @@ function messageFor(error: unknown): string {
return "Запрос к локальному сервису устройства завершился ошибкой."; return "Запрос к локальному сервису устройства завершился ошибкой.";
} }
function measuredLatency(state: ConsoleState | null): number | null { function measuredLatency(state: XgridsK1State | null): number | null {
if (state?.source_mode !== "live") return null; if (state?.source_mode !== "live") return null;
const metrics = state?.metrics; const metrics = state?.metrics;
if (!metrics) return null; if (!metrics) return null;
@ -42,8 +42,8 @@ function measuredLatency(state: ConsoleState | null): number | null {
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null; return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
} }
export function useK1Console() { export function useXgridsK1Runtime(enabled: boolean) {
const [state, setState] = useState<ConsoleState | null>(null); const [state, setState] = useState<XgridsK1State | null>(null);
const [backendStatus, setBackendStatus] = useState<BackendStatus>("checking"); const [backendStatus, setBackendStatus] = useState<BackendStatus>("checking");
const [eventStatus, setEventStatus] = useState<EventSocketStatus>("connecting"); const [eventStatus, setEventStatus] = useState<EventSocketStatus>("connecting");
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null); const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
@ -51,15 +51,16 @@ export function useK1Console() {
const [latencyHistory, setLatencyHistory] = useState<number[]>([]); const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
const mounted = useRef(true); const mounted = useRef(true);
const acceptState = useCallback((nextState: ConsoleState) => { const acceptState = useCallback((nextState: XgridsK1State) => {
setState(nextState); setState(nextState);
setBackendStatus("online"); setBackendStatus("online");
}, []); }, []);
const refresh = useCallback(async (reportErrors = true) => { const refresh = useCallback(async (reportErrors = true) => {
if (!enabled) return;
const [healthResult, stateResult] = await Promise.allSettled([ const [healthResult, stateResult] = await Promise.allSettled([
api.getHealth(), xgridsK1Api.getHealth(),
api.getState(), xgridsK1Api.getState(),
]); ]);
if (!mounted.current) return; if (!mounted.current) return;
@ -80,10 +81,11 @@ export function useK1Console() {
if (stateResult.status === "rejected" && reportErrors) { if (stateResult.status === "rejected" && reportErrors) {
setError(messageFor(stateResult.reason)); setError(messageFor(stateResult.reason));
} }
}, [acceptState]); }, [acceptState, enabled]);
const run = useCallback( const run = useCallback(
async (action: PendingAction, operation: () => Promise<ConsoleState>) => { async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
if (!enabled) return false;
setPendingAction(action); setPendingAction(action);
setError(null); setError(null);
@ -103,40 +105,51 @@ export function useK1Console() {
if (mounted.current) setPendingAction(null); if (mounted.current) setPendingAction(null);
} }
}, },
[acceptState], [acceptState, enabled],
); );
const scan = useCallback( const scan = useCallback(
() => run("scan", () => api.scanBle({ duration_seconds: 6 })), () => run("scan", () => xgridsK1Api.scanBle({ duration_seconds: 6 })),
[run], [run],
); );
const connect = useCallback( const connect = useCallback(
(request: ConnectRequest) => run("connect", () => api.connect(request)), (request: ConnectRequest) => run("connect", () => xgridsK1Api.connect(request)),
[run], [run],
); );
const startLive = useCallback( const startLive = useCallback(
(request: LiveRequest = {}) => run("live", () => api.startLive(request)), (request: LiveRequest = {}) => run("live", () => xgridsK1Api.startLive(request)),
[run], [run],
); );
const startReplay = useCallback( const startReplay = useCallback(
(request: ReplayRequest) => run("replay", () => api.startReplay(request)), (request: ReplayRequest) => run("replay", () => xgridsK1Api.startReplay(request)),
[run], [run],
); );
const stop = useCallback( const stop = useCallback(
() => run("stop", () => api.stopSession()), () => run("stop", () => xgridsK1Api.stopSession()),
[run], [run],
); );
const updateViewerSettings = useCallback( const updateViewerSettings = useCallback(
(request: ViewerSettings) => run("viewer", () => api.updateViewerSettings(request)), (request: ViewerSettings) => run("viewer", () => xgridsK1Api.updateViewerSettings(request)),
[run], [run],
); );
useEffect(() => { useEffect(() => {
if (!enabled) {
mounted.current = true;
setState(null);
setBackendStatus("checking");
setEventStatus("closed");
setPendingAction(null);
setError(null);
setLatencyHistory([]);
return;
}
mounted.current = true; mounted.current = true;
void refresh(true); void refresh(true);
const poll = window.setInterval(() => void refresh(false), 4_000); const poll = window.setInterval(() => void refresh(false), 4_000);
@ -145,9 +158,11 @@ export function useK1Console() {
mounted.current = false; mounted.current = false;
window.clearInterval(poll); window.clearInterval(poll);
}; };
}, [refresh]); }, [enabled, refresh]);
useEffect(() => { useEffect(() => {
if (!enabled) return;
let dispose: (() => void) | undefined; let dispose: (() => void) | undefined;
let retry: number | undefined; let retry: number | undefined;
let cancelled = false; let cancelled = false;
@ -173,7 +188,7 @@ export function useK1Console() {
if (retry !== undefined) window.clearTimeout(retry); if (retry !== undefined) window.clearTimeout(retry);
dispose?.(); dispose?.();
}; };
}, [acceptState]); }, [acceptState, enabled]);
useEffect(() => { useEffect(() => {
const latency = measuredLatency(state); const latency = measuredLatency(state);

View File

@ -6,6 +6,8 @@ import "@nodedc/tokens/themes.css";
import "@nodedc/ui-core/styles.css"; import "@nodedc/ui-core/styles.css";
import App from "./App"; import App from "./App";
import { installedDevicePlugins } from "./composition/devicePlugins";
import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost";
import "./styles.css"; import "./styles.css";
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
@ -20,6 +22,8 @@ applyNodedcTheme(rootElement, { theme: "dark" });
createRoot(rootElement).render( createRoot(rootElement).render(
<StrictMode> <StrictMode>
<App /> <DevicePluginHostProvider plugins={installedDevicePlugins}>
<App />
</DevicePluginHostProvider>
</StrictMode>, </StrictMode>,
); );

View File

@ -1,39 +1,38 @@
import type { StatusTone } from "@nodedc/ui-react"; import type { StatusTone } from "@nodedc/ui-react";
import type { K1Metrics } from "./api"; import type {
import type { BackendStatus } from "./useK1Console"; BackendStatus,
RuntimePhase,
SourceMode,
StreamMetrics,
} from "./core/runtime/contracts";
const phaseLabels: Record<string, string> = { const phaseLabels: Record<RuntimePhase, string> = {
unconfigured: "Модель не выбрана",
idle: "Ожидание", idle: "Ожидание",
scanning: "Поиск Bluetooth", configuring: "Настройка устройства",
device_selected: "Устройство выбрано",
provisioning: "Передача настроек WiFi",
connecting: "Подключение",
connected: "Устройство подключено", connected: "Устройство подключено",
starting_live: "Запуск потока", starting: "Запуск потока",
live: "Поток в реальном времени", streaming: "Поток в реальном времени",
replay: "Повтор записи", replaying: "Повтор записи",
stopping: "Остановка", stopping: "Остановка",
error: "Ошибка", error: "Ошибка",
}; };
export function phaseLabel(phase: string | null | undefined): string { export function phaseLabel(phase: RuntimePhase | null | undefined): string {
if (!phase) return "Нет состояния"; return phase ? phaseLabels[phase] : "Нет состояния";
return phaseLabels[phase] ?? "Неизвестное состояние";
} }
export function phaseTone(phase: string | null | undefined): StatusTone { export function phaseTone(phase: RuntimePhase | null | undefined): StatusTone {
if (!phase) return "neutral"; if (!phase || phase === "unconfigured" || phase === "idle") return "neutral";
if (phase === "error") return "danger"; if (phase === "error") return "danger";
if (["connected", "live", "replay"].includes(phase)) return "success"; if (["connected", "streaming", "replaying"].includes(phase)) return "success";
if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) { return "accent";
return "accent";
}
return "neutral";
} }
export function backendLabel(status: BackendStatus): string { export function backendLabel(status: BackendStatus): string {
return { return {
unconfigured: "Модель не выбрана",
checking: "Проверка контура", checking: "Проверка контура",
online: "Контур доступен", online: "Контур доступен",
degraded: "Контур ограничен", degraded: "Контур ограничен",
@ -44,33 +43,16 @@ export function backendLabel(status: BackendStatus): string {
export function backendTone(status: BackendStatus): StatusTone { export function backendTone(status: BackendStatus): StatusTone {
if (status === "online") return "success"; if (status === "online") return "success";
if (status === "degraded" || status === "checking") return "warning"; if (status === "degraded" || status === "checking") return "warning";
if (status === "unconfigured") return "neutral";
return "danger"; return "danger";
} }
export function eventStatusLabel(status: string): string {
return {
connecting: "подключение",
open: "подключён",
closed: "закрыт",
error: "ошибка",
}[status] ?? "неизвестно";
}
export function finiteMetric(value: number | null | undefined): number | null { export function finiteMetric(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null; return typeof value === "number" && Number.isFinite(value) ? value : null;
} }
export function pipelineLatency(metrics: K1Metrics | undefined): number | null { export function pipelineLatency(metrics: StreamMetrics | undefined): number | null {
if (!metrics) return null; return finiteMetric(metrics?.latencyMs);
const direct = finiteMetric(metrics.pipeline_ms ?? metrics.end_to_end_ms);
if (direct !== null) return direct;
const segments = [
finiteMetric(metrics.mqtt_to_decode_ms),
finiteMetric(metrics.publish_ms),
].filter((value): value is number => value !== null);
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
} }
export function formatNumber(value: number | null, digits = 1): string { export function formatNumber(value: number | null, digits = 1): string {
@ -81,7 +63,7 @@ export function formatNumber(value: number | null, digits = 1): string {
}); });
} }
export function sourceModeLabel(mode: string | null | undefined): string { export function sourceModeLabel(mode: SourceMode | null | undefined): string {
if (mode === "live") return "Реальное время"; if (mode === "live") return "Реальное время";
if (mode === "replay") return "Повтор записи"; if (mode === "replay") return "Повтор записи";
if (mode === "idle") return "Ожидание"; if (mode === "idle") return "Ожидание";

View File

@ -175,7 +175,7 @@ export const workspaces: WorkspaceDefinition[] = [
description: "От физического устройства до операторского интерфейса.", description: "От физического устройства до операторского интерфейса.",
capabilities: [ capabilities: [
active("Локальный API", "Проверка состояния, резервный REST-опрос и канал событий WebSocket."), active("Локальный API", "Проверка состояния, резервный REST-опрос и канал событий WebSocket."),
active("Приём данных", "MQTT, декодирование облака точек и позы."), active("Приём данных", "Плагинский транспорт, нормализация облака точек и позы."),
ready("Визуальный движок", "Встроенный веб-визуализатор Rerun ожидает совместимый источник."), ready("Визуальный движок", "Встроенный веб-визуализатор Rerun ожидает совместимый источник."),
contract("Бортовой шлюз", "Будущий транспортный адаптер между ROS 2/Zenoh и пунктом управления."), contract("Бортовой шлюз", "Будущий транспортный адаптер между ROS 2/Zenoh и пунктом управления."),
], ],
@ -231,7 +231,7 @@ export const workspaces: WorkspaceDefinition[] = [
label: "Локальное устройство", label: "Локальное устройство",
title: "Локальное устройство", title: "Локальное устройство",
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР", eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
description: "Рабочий путь BLE → WiFi → поток для первого подключённого устройства.", description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
icon: "network", icon: "network",
kind: "device", kind: "device",
groups: [], groups: [],
@ -526,8 +526,8 @@ export const workspaces: WorkspaceDefinition[] = [
title: "Артефакты", title: "Артефакты",
description: "Сохраняется политика «сначала исходные данные».", description: "Сохраняется политика «сначала исходные данные».",
capabilities: [ capabilities: [
active("Нативные захваты", "Сырые данные MQTT и обезличенный манифест."), active("Нативные захваты", "Сырые конверты устройства и обезличенный манифест."),
active("Воспроизведение", "Воспроизведение исходной записи MQTT и проверенного TSV."), active("Воспроизведение", "Повтор исходной записи через адаптер выбранной модели."),
ready("Запись RRD", "Совместимая запись Rerun после появления потокового адаптера."), ready("Запись RRD", "Совместимая запись Rerun после появления потокового адаптера."),
ready("Компоновка RBL", "Версионируемая компоновка визуализатора рядом с кодом."), ready("Компоновка RBL", "Версионируемая компоновка визуализатора рядом с кодом."),
], ],
@ -548,7 +548,7 @@ export const workspaces: WorkspaceDefinition[] = [
title: "Транспорт", title: "Транспорт",
description: "Источник отделён от визуального представления.", description: "Источник отделён от визуального представления.",
capabilities: [ capabilities: [
active("MQTT вход", "Доказанный локальный поток текущего устройства."), active("Плагинский вход", "Локальный поток активной модели устройства."),
active("Управление REST / WebSocket", "Состояние и управляющие операции локального адаптера."), active("Управление REST / WebSocket", "Состояние и управляющие операции локального адаптера."),
ready("Rerun gRPC", "Нативный источник реального времени для встроенного веб-визуализатора."), ready("Rerun gRPC", "Нативный источник реального времени для встроенного веб-визуализатора."),
ready("RRD по HTTP", "Открытие одной или нескольких записей."), ready("RRD по HTTP", "Открытие одной или нескольких записей."),

View File

@ -1,3 +1,146 @@
.device-workspace--model-picker,
.device-plugin-slot {
display: grid;
min-width: 0;
gap: 0.85rem;
}
.device-model-catalog {
background: var(--station-panel);
}
.device-model-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
gap: 0.85rem;
margin-top: 1rem;
}
.device-model-card {
display: grid;
min-width: 0;
gap: 0.9rem;
border: 1px solid var(--station-hairline);
border-radius: 1rem;
background: rgb(255 255 255 / 0.025);
padding: 1rem;
}
.device-model-card > header {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
}
.device-model-card__mark {
display: grid;
width: 2.25rem;
height: 2.25rem;
place-items: center;
border-radius: 0.7rem;
background: rgb(255 255 255 / 0.06);
color: var(--nodedc-text-primary);
}
.device-model-card header span,
.device-model-card header h3,
.device-model-card > p,
.device-model-card dl {
margin: 0;
}
.device-model-card header span {
color: var(--nodedc-text-muted);
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.device-model-card header h3 {
margin-top: 0.12rem;
color: var(--nodedc-text-primary);
font-size: 0.82rem;
}
.device-model-card > p {
color: var(--nodedc-text-secondary);
font-size: 0.67rem;
line-height: 1.5;
}
.device-model-card dl {
display: grid;
gap: 0.42rem;
border-top: 1px solid var(--station-hairline);
border-bottom: 1px solid var(--station-hairline);
padding: 0.7rem 0;
}
.device-model-card dl > div {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.61rem;
}
.device-model-card dt {
color: var(--nodedc-text-muted);
}
.device-model-card dd {
margin: 0;
color: var(--nodedc-text-secondary);
text-align: right;
}
.device-model-card__capabilities {
display: flex;
flex-wrap: wrap;
gap: 0.38rem;
}
.device-model-card__capabilities span {
border-radius: 999px;
background: rgb(255 255 255 / 0.055);
color: var(--nodedc-text-secondary);
padding: 0.28rem 0.5rem;
font-size: 0.54rem;
}
.device-plugin-slot__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-radius: 0.9rem;
background: rgb(255 255 255 / 0.035);
padding: 0.72rem 0.85rem;
}
.device-plugin-slot__bar > div {
display: grid;
min-width: 0;
gap: 0.12rem;
}
.device-plugin-slot__bar strong {
color: var(--nodedc-text-primary);
font-size: 0.74rem;
}
.device-plugin-slot__bar small {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
}
.device-plugin-slot__bar .device-plugin-slot__error {
color: rgb(var(--nodedc-danger-rgb));
}
.xgrids-k1-plugin {
.device-workspace__grid { .device-workspace__grid {
display: grid; display: grid;
min-width: 0; min-width: 0;
@ -414,3 +557,4 @@
color: var(--nodedc-text-muted); color: var(--nodedc-text-muted);
font-size: 0.54rem; font-size: 0.54rem;
} }
}

View File

@ -3,7 +3,7 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.device-workspace__grid { .xgrids-k1-plugin .device-workspace__grid {
grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr); grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr);
} }
@ -13,7 +13,7 @@
} }
@media (max-width: 1280px) { @media (max-width: 1280px) {
.device-workspace__grid, .xgrids-k1-plugin .device-workspace__grid,
.overview-grid, .overview-grid,
.mission-layout { .mission-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -72,7 +72,7 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.diagnostics-grid { .xgrids-k1-plugin .diagnostics-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -210,32 +210,32 @@
display: none; display: none;
} }
.session-form--replay { .xgrids-k1-plugin .session-form--replay {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.session-form--replay > :first-child { .xgrids-k1-plugin .session-form--replay > :first-child {
grid-column: auto; grid-column: auto;
} }
.session-footer, .xgrids-k1-plugin .session-footer,
.error-banner, .xgrids-k1-plugin .error-banner,
.source-format-list > div { .source-format-list > div {
align-items: stretch; align-items: stretch;
grid-template-columns: 1fr; grid-template-columns: 1fr;
flex-direction: column; flex-direction: column;
} }
.error-banner { .xgrids-k1-plugin .error-banner {
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
} }
.error-banner__actions { .xgrids-k1-plugin .error-banner__actions {
grid-column: 2; grid-column: 2;
justify-content: flex-end; justify-content: flex-end;
} }
.device-row__action { .xgrids-k1-plugin .device-row__action {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
} }

View File

@ -1,556 +1,122 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import {
Button,
Checker,
GlassSurface,
Icon,
SegmentedControl,
StatusBadge,
TextField,
type StatusTone,
} from "@nodedc/ui-react";
import type { BleDevice } from "../api"; import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost";
import { MetricCard } from "../components/MetricCard"; import type { RegisteredDeviceModel } from "../core/device-plugins/contracts";
import { localizeRuntimeMessage } from "../messages";
import {
backendLabel,
backendTone,
eventStatusLabel,
finiteMetric,
formatNumber,
phaseLabel,
phaseTone,
pipelineLatency,
sourceModeLabel,
} from "../presentation";
import type { useK1Console } from "../useK1Console";
type ConsoleController = ReturnType<typeof useK1Console>; function ModelCard({
type SessionIntent = "live" | "replay"; registered,
const sessionItems = [
{ value: "live", label: "Реальное устройство" },
{ value: "replay", label: "Повтор записи" },
] satisfies Array<{ value: SessionIntent; label: string }>;
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="detail-row">
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
}
function WizardStep({
number,
title,
status,
tone = "neutral",
children,
}: {
number: string;
title: string;
status: string;
tone?: StatusTone;
children: ReactNode;
}) {
return (
<section className="wizard-step">
<div className="wizard-step__rail" aria-hidden="true">
<span>{number}</span>
</div>
<div className="wizard-step__content">
<header>
<h3>{title}</h3>
<StatusBadge tone={tone}>{status}</StatusBadge>
</header>
{children}
</div>
</section>
);
}
function DeviceRow({
device,
selected,
onSelect, onSelect,
}: { }: {
device: BleDevice; registered: RegisteredDeviceModel;
selected: boolean;
onSelect: () => void; onSelect: () => void;
}) { }) {
const { model, plugin } = registered;
return ( return (
<div <article className="device-model-card">
className="device-row" <header>
data-compatible={device.likely_k1 ? "true" : undefined} <div className="device-model-card__mark" aria-hidden="true">
data-selected={selected ? "true" : undefined} <Icon name="network" />
>
<div className="device-row__identity">
<span className="device-row__signal" aria-hidden="true" />
<div>
<span className="device-row__name">
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
{device.likely_k1 ? <small>Совместимый профиль</small> : null}
</span>
<code>{device.device_id}</code>
</div> </div>
<div>
<span>{model.vendor}</span>
<h3>{model.displayName}</h3>
</div>
<StatusBadge tone={model.verified ? "success" : "warning"}>
{model.verified ? "Проверено" : "Экспериментально"}
</StatusBadge>
</header>
<p>{model.description}</p>
<dl>
<div><dt>Категория</dt><dd>{model.category}</dd></div>
<div><dt>Плагин</dt><dd>{plugin.manifest.metadata.displayName} · v{plugin.manifest.metadata.version}</dd></div>
</dl>
<div className="device-model-card__capabilities" aria-label="Возможности модели">
{model.capabilities.map((capability) => <span key={capability.id}>{capability.label}</span>)}
</div> </div>
<div className="device-row__action"> <Button width="full" variant="primary" onClick={onSelect}>
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span> Выбрать модель
<Button </Button>
size="compact" </article>
variant={selected ? "primary" : "secondary"}
disabled={device.connectable === false}
onClick={onSelect}
>
{selected ? "Выбрано" : "Выбрать"}
</Button>
</div>
</div>
); );
} }
function LatencyTrace({ values }: { values: number[] }) { export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: () => void }) {
const ceiling = Math.max(16, ...values);
return (
<div className="latency-trace" aria-label="Последние измерения времени до публикации">
{values.length ? (
values.map((value, index) => (
<span
key={`${index}-${value}`}
style={{ height: `${Math.max(8, Math.min(100, (value / ceiling) * 100))}%` }}
title={`${value.toFixed(1)} мс`}
/>
))
) : (
<p>Измерений пока нет. График появится после получения реальных данных.</p>
)}
</div>
);
}
export function DeviceWorkspace({
console,
onOpenSpatialScene,
}: {
console: ConsoleController;
onOpenSpatialScene: () => void;
}) {
const { const {
state, registry,
backendStatus, selection,
eventStatus, selectionTransitionPending,
pendingAction, selectionTransitionError,
error, selectModel,
latencyHistory, clearSelection,
refresh, } = useDevicePluginHost();
clearError,
scan,
connect,
startLive,
startReplay,
stop,
} = console;
const [powerConfirmed, setPowerConfirmed] = useState(false); if (!selection) {
const [selectedDeviceId, setSelectedDeviceId] = useState(""); return (
const [ssid, setSsid] = useState(""); <div className="device-workspace device-workspace--model-picker">
const [password, setPassword] = useState(""); <section className="workspace-lead workspace-lead--compact">
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
useEffect(() => {
if (state?.selected_device_id) {
setSelectedDeviceId(state.selected_device_id);
return;
}
if (
selectedDeviceId &&
state?.devices &&
!state.devices.some((device) => device.device_id === selectedDeviceId)
) {
setSelectedDeviceId("");
}
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const droppedFrames = finiteMetric(metrics?.dropped_preview_frames);
const devices = state?.devices ?? [];
const isBusy = pendingAction !== null;
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
const sourceLabel = sourceModeLabel(state?.source_mode);
const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId],
);
const submitConnect = async () => {
if (!canConnect) return;
const succeeded = await connect({
device_id: selectedDeviceId,
ssid: ssid.trim(),
password,
});
if (succeeded) setPassword("");
};
const submitLive = async () => {
const host = liveHost.trim();
const started = await startLive(host ? { host } : {});
if (started) onOpenSpatialScene();
};
const submitReplay = async () => {
const speed = Number(replaySpeed);
const started = await startReplay({
path: replayPath.trim(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
loop: replayLoop,
});
if (started) onOpenSpatialScene();
};
return (
<div className="device-workspace">
{error ? (
<aside className="error-banner" role="alert">
<span className="error-banner__dot" aria-hidden="true" />
<div> <div>
<strong>Локальная операция завершилась ошибкой</strong> <span className="section-eyebrow">ЛОКАЛЬНОЕ УСТРОЙСТВО · ШАГ 01</span>
<p>{localizeRuntimeMessage(error)}</p> <h2>Выберите модель устройства</h2>
<p>
Mission Core покажет только сценарий выбранного плагина. До выбора модели нет
поиска оборудования, сетевых реквизитов, управления потоком или фиктивных метрик.
</p>
</div> </div>
<div className="error-banner__actions"> <div className="workspace-lead__status">
<Button size="compact" variant="secondary" onClick={() => void refresh()}> <StatusBadge tone="neutral">Модель не выбрана</StatusBadge>
Повторить <span>{registry.models.length} моделей доступно в локальной сборке</span>
</Button>
<Button size="compact" variant="ghost" onClick={clearError}>
Закрыть
</Button>
</div> </div>
</aside> </section>
) : null}
<section className="workspace-lead workspace-lead--compact"> <GlassSurface className="device-model-catalog" padding="lg">
<div>
<span className="section-eyebrow">РАБОЧИЙ АДАПТЕР УСТРОЙСТВА</span>
<h2>Подключение первого устройства</h2>
<p>
Этот путь уже работает физически, но остаётся изолированным адаптером. Парковая и
операторская модель от конкретного устройства не зависят.
</p>
</div>
<div className="workspace-lead__status">
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
</div>
</section>
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
<MetricCard
featured
eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)}
unit="мс"
detail="MQTT callback → Rerun SDK; без экрана"
/>
<MetricCard
eyebrow="ЧАСТОТА КАДРОВ"
value={formatNumber(frameRate)}
unit="кадр/с"
detail="Последнее измерение адаптера"
/>
<MetricCard
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Реальное число декодированных точек"
/>
<MetricCard
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Исходные данные при этом сохраняются"
/>
</section>
<div className="device-workspace__grid">
<GlassSurface className="connection-panel" padding="lg">
<header className="panel-heading"> <header className="panel-heading">
<div> <div>
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</span> <span className="section-eyebrow">УСТАНОВЛЕННЫЕ ПЛАГИНЫ</span>
<h2>Подключите устройство к сети</h2> <h2>Парк поддерживаемых моделей</h2>
</div> </div>
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge> <StatusBadge tone="neutral">{registry.plugins.length} плагин</StatusBadge>
</header> </header>
<div className="device-model-grid">
<div className="wizard-list"> {registry.models.map((registered) => (
<WizardStep <ModelCard
number="01" key={registered.model.id}
title="Включите устройство" registered={registered}
status={powerConfirmed ? "Подтверждено" : "Ожидает"} onSelect={() => void selectModel(registered.model.id)}
tone={powerConfirmed ? "success" : "warning"} />
> ))}
<div className="nodedc-field">
<span className="nodedc-field__description">
Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия.
</span>
<Checker
checked={powerConfirmed}
label="Устройство включено, индикатор стабилен"
onChange={setPowerConfirmed}
/>
</div>
</WizardStep>
<WizardStep
number="02"
title="Выберите Bluetooth-устройство"
status={
pendingAction === "scan"
? "Поиск…"
: selectedDeviceId
? "Устройство выбрано"
: `Найдено: ${devices.length}`
}
tone={
pendingAction === "scan"
? "accent"
: selectedDeviceId
? "success"
: "neutral"
}
>
<p className="step-copy">
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Совместимый
профиль только подсказка; окончательный выбор всегда делает оператор.
</p>
<Button
width="full"
variant="secondary"
icon={<Icon name="search" />}
disabled={!powerConfirmed || isBusy}
onClick={() => {
setSelectedDeviceId("");
void scan();
}}
>
{pendingAction === "scan"
? "Сканируем Bluetooth — 6 секунд…"
: "Показать все BLE-устройства"}
</Button>
<div className="device-list">
{devices.length ? (
devices.map((device) => (
<DeviceRow
key={device.device_id}
device={device}
selected={device.device_id === selectedDeviceId}
onSelect={() => setSelectedDeviceId(device.device_id)}
/>
))
) : (
<div className="empty-device-list">
Устройства пока не найдены. Проверьте питание и состояние индикатора, затем
повторите поиск.
</div>
)}
</div>
</WizardStep>
<WizardStep
number="03"
title="Передайте настройки WiFi"
status={state?.k1_ip ? "Подключено" : "Не подключено"}
tone={state?.k1_ip ? "success" : "neutral"}
>
<div className="field-stack">
<TextField
label="Название сети WiFi"
hint="SSID"
value={ssid}
onChange={(event) => setSsid(event.target.value)}
autoComplete="off"
spellCheck={false}
placeholder="Сеть локального контура"
/>
<TextField
label="Пароль WiFi"
hint="Только в оперативной памяти"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="off"
placeholder="Введите пароль"
/>
</div>
<div className="connection-summary">
<span>Устройство</span>
<strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong>
</div>
<Button
width="full"
variant="primary"
icon={<Icon name="network" />}
disabled={!canConnect}
onClick={() => void submitConnect()}
>
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к WiFi"}
</Button>
<p className="safety-note">
Пароль передаётся только локальному сервису на этом компьютере, не сохраняется в браузере и
удаляется из формы после успешного подключения.
</p>
</WizardStep>
</div> </div>
</GlassSurface> </GlassSurface>
<div className="device-workspace__side">
<GlassSurface className="session-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">
{sessionIntent === "live" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
</span>
<h2>{sessionIntent === "live" ? "Запустите поток" : "Повторите запись"}</h2>
</div>
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
{sourceLabel}
</StatusBadge>
</header>
<SegmentedControl
label="Источник данных"
value={sessionIntent}
items={sessionItems}
onChange={setSessionIntent}
/>
{sessionIntent === "live" ? (
<div className="session-form">
<TextField
label="Адрес устройства"
hint="Обычно определяется автоматически"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
spellCheck={false}
placeholder={state?.k1_ip || "Сначала подключите устройство к WiFi"}
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !liveTargetReady}
onClick={() => void submitLive()}
>
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
</Button>
<p className="live-instruction">
{liveTargetReady
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
: "Сначала подключите устройство к WiFi или укажите его локальный адрес."}
</p>
</div>
) : (
<div className="session-form session-form--replay">
<TextField
label="Путь к записи"
hint="Локальный файл исходных данных"
value={replayPath}
onChange={(event) => setReplayPath(event.target.value)}
spellCheck={false}
placeholder="sessions/.../capture.tsv"
/>
<TextField
label="Скорость повтора"
hint="Множитель"
type="number"
min="0.1"
step="0.1"
value={replaySpeed}
onChange={(event) => setReplaySpeed(event.target.value)}
/>
<div className="nodedc-field">
<span className="nodedc-field__description">После последнего кадра начать запись заново.</span>
<Checker
checked={replayLoop}
label="Повторять по кругу"
onChange={setReplayLoop}
/>
</div>
<Button
variant="primary"
icon={<Icon name="video" />}
disabled={isBusy || replayPath.trim().length === 0}
onClick={() => void submitReplay()}
>
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
</Button>
</div>
)}
<div className="session-footer">
<p>Статус изменится только после ответа локального сервиса.</p>
<Button
variant="secondary"
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
onClick={() => void stop()}
>
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
</Button>
</div>
</GlassSurface>
<div className="diagnostics-grid">
<GlassSurface className="status-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">ТЕКУЩЕЕ СОСТОЯНИЕ</span>
<h2>Локальный контур</h2>
</div>
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
</header>
<dl className="detail-list">
<DetailRow label="Канал событий">
<span className="inline-state" data-state={eventStatus}>
{eventStatusLabel(eventStatus)}
</span>
</DetailRow>
<DetailRow label="Источник">{sourceLabel}</DetailRow>
<DetailRow label="Адрес устройства">
<code>{state?.k1_ip || "Не получен"}</code>
</DetailRow>
</dl>
</GlassSurface>
<GlassSurface className="latency-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
<h2>Время до публикации</h2>
</div>
<strong className="latency-now">
{formatNumber(latency)} <span>мс</span>
</strong>
</header>
<LatencyTrace values={latencyHistory} />
<div className="latency-legend">
<span>Старые</span>
<span>Последние</span>
</div>
</GlassSurface>
</div>
</div>
</div> </div>
);
}
const ConnectionView = selection.ConnectionView;
return (
<div className="device-plugin-slot">
<div className="device-plugin-slot__bar">
<div>
<span className="section-eyebrow">АКТИВНАЯ МОДЕЛЬ</span>
<strong>{selection.model.displayName}</strong>
<small>{selection.plugin.manifest.metadata.displayName} · v{selection.plugin.manifest.metadata.version}</small>
{selectionTransitionError ? (
<small className="device-plugin-slot__error" role="alert">
{selectionTransitionError}
</small>
) : null}
</div>
<Button
variant="secondary"
size="compact"
disabled={selectionTransitionPending}
onClick={() => void clearSelection()}
>
{selectionTransitionPending ? "Завершаем текущую сессию…" : "Выбрать другую модель"}
</Button>
</div>
<ConnectionView
model={selection.model}
host={{ openSpatialScene: onOpenSpatialScene }}
/>
</div> </div>
); );
} }

View File

@ -6,9 +6,8 @@ import {
StatusBadge, StatusBadge,
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
import type { ConsoleState } from "../api";
import { MetricCard } from "../components/MetricCard"; import { MetricCard } from "../components/MetricCard";
import { localizeRuntimeMessage } from "../messages"; import type { BackendStatus, MissionRuntimeState } from "../core/runtime/contracts";
import { import {
RerunViewport, RerunViewport,
type RerunSelection, type RerunSelection,
@ -21,7 +20,6 @@ import {
} from "../productModel"; } from "../productModel";
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation"; import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
import type { SceneSettings } from "../sceneSettings"; import type { SceneSettings } from "../sceneSettings";
import type { BackendStatus } from "../useK1Console";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" { function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success"; if (status === "active") return "success";
@ -82,7 +80,7 @@ export interface WorkspaceNavigation {
export interface WorkspaceRendererProps { export interface WorkspaceRendererProps {
definition: WorkspaceDefinition; definition: WorkspaceDefinition;
state: ConsoleState | null; state: MissionRuntimeState | null;
backendStatus: BackendStatus; backendStatus: BackendStatus;
sourceUrl: string; sourceUrl: string;
sceneSettings: SceneSettings; sceneSettings: SceneSettings;
@ -95,13 +93,13 @@ function OverviewWorkspace({
backendStatus, backendStatus,
navigation, navigation,
}: WorkspaceRendererProps) { }: WorkspaceRendererProps) {
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay"; const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
const metrics = streamActive ? state?.metrics : undefined; const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics); const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz); const frameRate = finiteMetric(metrics?.frameRateHz);
const points = finiteMetric(metrics?.point_count); const points = finiteMetric(metrics?.pointCount);
const adapterOnline = backendStatus === "online"; const adapterOnline = backendStatus === "online";
const rerunReady = Boolean(state?.rerun_grpc_url); const rerunReady = Boolean(state?.spatialSource?.url);
return ( return (
<div className="standard-workspace overview-workspace"> <div className="standard-workspace overview-workspace">
@ -115,7 +113,7 @@ function OverviewWorkspace({
eyebrow="ДО ПУБЛИКАЦИИ" eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)} value={formatNumber(latency)}
unit="мс" unit="мс"
detail="MQTT callback → Rerun SDK; без экрана" detail="Вход адаптера → Scene Sink; без экрана"
/> />
<MetricCard <MetricCard
eyebrow="ЧАСТОТА" eyebrow="ЧАСТОТА"
@ -130,7 +128,7 @@ function OverviewWorkspace({
/> />
<MetricCard <MetricCard
eyebrow="РЕЖИМ" eyebrow="РЕЖИМ"
value={sourceModeLabel(state?.source_mode)} value={sourceModeLabel(state?.sourceMode)}
detail="Реальное время, повтор или ожидание" detail="Реальное время, повтор или ожидание"
/> />
</section> </section>
@ -147,16 +145,16 @@ function OverviewWorkspace({
</StatusBadge> </StatusBadge>
</header> </header>
<div className="pipeline-strip" aria-label="Путь данных"> <div className="pipeline-strip" aria-label="Путь данных">
<div data-state={state?.k1_ip ? "ready" : "idle"}> <div data-state={state?.activeDevice ? "ready" : "idle"}>
<span>01</span> <span>01</span>
<strong>Устройство</strong> <strong>Устройство</strong>
<small>{state?.k1_ip || "не назначено"}</small> <small>{state?.activeDevice?.endpointLabel || state?.activeDevice?.displayName || "не назначено"}</small>
</div> </div>
<Icon name="chevron-right" /> <Icon name="chevron-right" />
<div data-state={streamActive ? "ready" : "idle"}> <div data-state={streamActive ? "ready" : "idle"}>
<span>02</span> <span>02</span>
<strong>Адаптер</strong> <strong>Адаптер</strong>
<small>{sourceModeLabel(state?.source_mode)}</small> <small>{sourceModeLabel(state?.sourceMode)}</small>
</div> </div>
<Icon name="chevron-right" /> <Icon name="chevron-right" />
<div data-state={rerunReady ? "ready" : "idle"}> <div data-state={rerunReady ? "ready" : "idle"}>
@ -185,7 +183,7 @@ function OverviewWorkspace({
<span><Icon name="network" /></span> <span><Icon name="network" /></span>
<div> <div>
<strong>Подключить устройство</strong> <strong>Подключить устройство</strong>
<small>Bluetooth, WiFi и запуск потока</small> <small>Сценарий установленного плагина и запуск потока</small>
</div> </div>
<Icon name="chevron-right" /> <Icon name="chevron-right" />
</button> </button>
@ -243,11 +241,11 @@ function SpatialWorkspace({
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle"); const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState(""); const [viewerMessage, setViewerMessage] = useState("");
const [selection, setSelection] = useState<RerunSelection | null>(null); const [selection, setSelection] = useState<RerunSelection | null>(null);
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay"; const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
const metrics = streamActive ? state?.metrics : undefined; const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics); const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz); const frameRate = finiteMetric(metrics?.frameRateHz);
const points = finiteMetric(metrics?.point_count); const points = finiteMetric(metrics?.pointCount);
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => { const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
setViewerStatus(status); setViewerStatus(status);
@ -315,11 +313,11 @@ function SpatialWorkspace({
</div> </div>
</div> </div>
{state?.source_mode && state.source_mode !== "idle" && !sourceUrl.trim() ? ( {state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
<div className="scene-adapter-note"> <div className="scene-adapter-note">
<Icon name="alert" /> <Icon name="alert" />
<span> <span>
Локальный поток <strong>{sourceModeLabel(state.source_mode).toLocaleLowerCase("ru-RU")}</strong> активен, Локальный поток <strong>{sourceModeLabel(state.sourceMode).toLocaleLowerCase("ru-RU")}</strong> активен,
Rerun-мост запускается и опубликует адрес автоматически. Rerun-мост запускается и опубликует адрес автоматически.
</span> </span>
</div> </div>
@ -440,11 +438,11 @@ function MapWorkspace({ definition }: WorkspaceRendererProps) {
function TimelineWorkspace({ definition, state }: WorkspaceRendererProps) { function TimelineWorkspace({ definition, state }: WorkspaceRendererProps) {
const rows = useMemo(() => [ const rows = useMemo(() => [
{ label: "Устройство", value: state?.phase ? sourceModeLabel(state.source_mode) : "нет событий", active: Boolean(state?.phase) }, { label: "Устройство", value: state?.phase ? sourceModeLabel(state.sourceMode) : "нет событий", active: Boolean(state?.phase) },
{ label: "Поток точек", value: state?.source_mode && state.source_mode !== "idle" ? "активен" : "ожидание", active: state?.source_mode !== "idle" && Boolean(state?.source_mode) }, { label: "Поток точек", value: state?.sourceMode && state.sourceMode !== "idle" ? "активен" : "ожидание", active: state?.sourceMode !== "idle" && Boolean(state?.sourceMode) },
{ label: "Поза", value: state?.metrics?.point_count ? "синхронно" : "нет данных", active: Boolean(state?.metrics?.point_count) }, { label: "Поза", value: state?.metrics?.pointCount ? "синхронно" : "нет данных", active: Boolean(state?.metrics?.pointCount) },
{ label: "Камеры", value: "каналы не назначены", active: false }, { label: "Камеры", value: "каналы не назначены", active: false },
{ label: "События", value: localizeRuntimeMessage(state?.message) || "нет событий", active: Boolean(state?.message) }, { label: "События", value: state?.message || "нет событий", active: Boolean(state?.message) },
], [state]); ], [state]);
return ( return (

View File

@ -32,8 +32,11 @@ exist.
## Invariants ## Invariants
- Raw evidence is durable before preview work. - Raw evidence is durable before preview work.
- Mission Core starts when the XGRIDS plugin is absent or disabled. - Mission Core starts when the XGRIDS manifest is absent. A formal
disabled/quarantine state for an installed plugin is a future supervisor feature.
- The plugin cannot add global navigation or arbitrary CSS. - The plugin cannot add global navigation or arbitrary CSS.
- Vendor writes and commands require declared capabilities and operator gates. - Vendor writes and commands must ultimately require declared capabilities and
operator gates. In v1alpha1 `permissions`, `mutating`, and `secretFields` are
validated metadata only; concrete adapters still enforce request safety.
- The visualization engine can be replaced without changing device plugins. - The visualization engine can be replaced without changing device plugins.
- Real captures and credentials remain outside Git. - Real captures and credentials remain outside Git.

View File

@ -42,7 +42,8 @@ The rename does not alter these proven interfaces:
- native `.k1mqtt` framing and `K1MQTT` magic; - native `.k1mqtt` framing and `K1MQTT` magic;
- provisioning profile `xgrids-k1-fw3-wifi-v1`; - provisioning profile `xgrids-k1-fw3-wifi-v1`;
- K1 MQTT topics and firmware-scoped protobuf/LZ4 codecs; - K1 MQTT topics and firmware-scoped protobuf/LZ4 codecs;
- current `k1_ip`, `K1Metrics`, and `useK1Console` compatibility fields; - wire-level `k1_ip` and metric values (their frontend type/hook names may move
behind the plugin boundary; see ADR 0003);
- schema v1 `$id`, which remains an immutable legacy identifier. - schema v1 `$id`, which remains an immutable legacy identifier.
Those names move or change only during the plugin-extraction milestone with Those names move or change only during the plugin-extraction milestone with
@ -55,5 +56,8 @@ explicit migration and regression tests.
- The existing K1 implementation remains operational while contracts are - The existing K1 implementation remains operational while contracts are
extracted incrementally. extracted incrementally.
- Core must eventually boot without the XGRIDS plugin. - Core must eventually boot without the XGRIDS plugin.
- Plugin failure must not terminate the Mission Core host or raw evidence plane. - An invalid installed manifest, missing factory, or manifest/runtime drift fails
startup before the host accepts traffic. After successful startup, an action
failure is isolated to that namespaced plugin request and must not terminate
the Mission Core host or raw evidence plane.
- A future repository split remains possible after Plugin SDK v1 is stable. - A future repository split remains possible after Plugin SDK v1 is stable.

View File

@ -0,0 +1,154 @@
# ADR 0003: device plugin UI and runtime boundary
- Status: accepted
- Date: 2026-07-16
## Context
The verified XGRIDS K1 workflow originally lived directly in the Mission Core
React shell and local FastAPI service. That preserved experimental speed, but it
made BLE, Wi-Fi, K1 state fields, and stream actions look like platform concepts.
Adding a second device that way would spread vendor branches through navigation,
runtime state, and visualization.
## Decision
Mission Core uses an allowlisted device-plugin registry. Each installed plugin
provides a versioned manifest, exactly one device model in v1alpha1, a runtime
provider, and UI contributions for declared host slots. The one-model limit
matches the current one-runtime-adapter lifecycle; multi-model plugins require
explicit device-session and model routing in the next SDK version.
The only import of a concrete device plugin from platform code is the composition
root `apps/control-station/src/composition/devicePlugins.ts`. Shipping another
frontend plugin therefore requires one reviewed composition-root import; `App`,
navigation, workspaces, and generic runtime code do not change. Runtime discovery
of arbitrary JavaScript is deliberately not supported.
Backend factories are loaded only from validated, repository-local manifests.
Startup fails unless every catalog manifest produces exactly one adapter with
the same plugin ID and action set. The catalog is then frozen for the life of
the process, so a backend-catalog model cannot appear without an executable
runtime. Frontend availability remains the separate reviewed static-composition
step described below.
The local-device UX is:
```text
no model selected
-> model catalog from registered manifests
-> operator selects a model
-> host mounts that plugin's device.connection contribution
-> plugin performs its own setup and stream workflow
-> plugin normalizes status, source, and metrics into MissionRuntimeState
```
Provider shells remain structurally mounted to keep the Mission Core React tree
stable, but their contract requires them to be inert while `activeModel` is
`null`. Before a model is selected, no device API is polled and no discovery,
credentials, stream controls, or metrics are shown. The selected custom view is
mounted only for its manifest `componentKey`.
Changing the model is an asynchronous lifecycle transition. The active plugin
must confirm deactivation; the XGRIDS implementation always issues `stream.stop`
before its UI is removed. A failed or still-running teardown prevents the switch.
The shared runtime envelope contains only:
- generic lifecycle phase and message;
- active device identity and an opaque endpoint label;
- selected spatial source;
- generic stream mode and metrics;
- viewer settings.
Vendor fields such as `k1_ip`, `likely_k1`, BLE candidates, MQTT topics, and
firmware-specific phases remain inside the XGRIDS plugin adapter.
## Current implementation
- Canonical manifest: `plugins/xgrids-k1/plugin.manifest.json`.
- Host contracts and registry: `apps/control-station/src/core/device-plugins/`.
- Generic runtime envelope: `apps/control-station/src/core/runtime/`.
- XGRIDS UI/runtime adapter: `apps/control-station/src/device-plugins/xgrids-k1/`.
- Read-only backend catalog: `GET /api/v1/device-plugins` and
`GET /api/v1/device-models`.
- Host-owned action dispatcher:
`POST /api/v1/device-plugins/{pluginId}/actions/{actionId}`.
- Plugin-scoped event stream:
`WS /api/v1/device-plugins/{pluginId}/events`, with plugin ID and sequence.
- Manifest factory composition:
`src/k1link/web/device_plugin_composition.py`.
- Existing K1 action endpoints are compatibility shims over that dispatcher, so
the proven physical implementation is unchanged.
The XGRIDS frontend code is statically linked into the Control Station during
this v1alpha milestone. It is isolated by imports and contracts but is not yet a
separately built npm workspace package. Backend composition is manifest-driven;
frontend/backend installed-set parity is a reviewed build-time responsibility
until the host gains signed plugin bundles and a startup compatibility handshake.
## Required dependency rules
- Core cannot import a concrete plugin outside the composition root.
- Core cannot inspect opaque plugin state or branch on a model ID.
- Plugins render only inside declared slots and cannot own global navigation or
global CSS.
- Plugins publish canonical spatial sources; they do not control the scene
engine or route names.
- Passwords and other secrets cannot enter manifests, runtime snapshots, event
payloads, or browser persistence.
- Invalid or duplicate plugin/model IDs fail registry construction.
- Manifest/runtime ID or action drift fails backend startup.
- Every v1alpha1 plugin declares exactly one model and a non-mutating,
secret-free `state.read` action.
- A plugin must stop or cancel active work before model selection can change.
Manifest `permissions`, action `mutating`, and `secretFields` are validated and
exposed as declarative contract metadata in v1alpha1. They do not yet implement
operator authorization, policy enforcement, or secret-vault substitution; the
dispatcher enforces only installed plugin IDs and declared action IDs. Until the
next SDK milestone, plugin adapters remain responsible for request validation
and for keeping secret values out of state, events, logs, and browser storage.
## Lifecycle model
Enrollment, connectivity, and streaming are the target independent state
machines. The v1alpha shared runtime currently exposes an aggregate `phase`
plus `sourceMode`; plugins must keep their detailed state private until the
three fields are added to the stable SDK:
```text
Enrollment: empty -> selecting_model -> setup_in_progress -> enrolled
Connectivity: unknown/offline -> connecting -> connected -> degraded/offline
Stream: idle -> starting -> streaming -> stopping -> idle
\-> failed -> idle
```
The current XGRIDS contribution maps its proven internal workflow into those
platform states without changing the wire protocol:
```text
confirm power -> scan BLE -> select candidate -> enter Wi-Fi
-> provision once -> receive LAN address -> start source
-> wait for first point frame -> streaming
```
## Consequences and next extraction
The shell can now boot with no selected device and contains no K1 wire fields.
Adding another statically reviewed UI plugin does not require changing `App` or
the generic local-device workspace.
Backend execution supports only reviewed `transitional-in-process` plugins in
v1alpha1, but already enters through the manifest
factory, allowlisted XGRIDS facade and host-owned dispatcher. Synchronous
capture/runtime work is moved off the API event loop. The next milestone adds
independent device-session IDs, operation IDs, cancellation, timeouts, audited
secret references, and process isolation. Only after replay parity and a
physical regression may BLE, MQTT, codecs, and raw evidence code move out of
the compatibility package. Rerun must ultimately consume canonical
PointCloud/Pose envelopes instead of K1 topics.
This ADR supersedes the compatibility names listed in ADR 0002: `K1Metrics`,
`useK1Console`, and `ConsoleService` were renamed and isolated inside the
XGRIDS adapter without changing the proven transport algorithms.

View File

@ -1,11 +1,16 @@
# Mission Core Plugin SDK # Mission Core Plugin SDK
This directory reserves the versioned host/plugin contract. It is not yet a This directory owns the versioned host/plugin contract. The v1alpha frontend
published SDK and must not be treated as an implemented runtime. contract, validated registry, generic runtime envelope, and backend read-only
catalog are implemented in-tree, but this is not yet a separately published
SDK package.
The first contract will define: The v1alpha1 contract currently validates one model per plugin, one reviewed
`transitional-in-process` backend entrypoint, a required safe `state.read`
action, and declared UI/action metadata. It establishes:
- plugin manifest, version compatibility, firmware profiles, and permissions; - plugin manifest, version compatibility, firmware profiles, and declarative
permissions;
- discovery candidates and opaque device references; - discovery candidates and opaque device references;
- provisioning requests using secret references; - provisioning requests using secret references;
- device-session lifecycle and health; - device-session lifecycle and health;
@ -14,5 +19,25 @@ The first contract will define:
- SceneSink and event interfaces; - SceneSink and event interfaces;
- capability-driven UI contribution data without plugin-owned layout. - capability-driven UI contribution data without plugin-owned layout.
The XGRIDS K1 extraction is the first acceptance test. A synthetic reference The XGRIDS K1 extraction is the first real-device acceptance path. Synthetic
plugin will verify that no K1 names or protocol assumptions remain in core. multi-plugin composition tests verify that backend routing has no K1 identity or
protocol assumption; static boundary tests enforce the same rule in the frontend.
Current implementation references:
- `apps/control-station/src/core/device-plugins/` — TypeScript manifest and UI
contribution contracts;
- `apps/control-station/src/core/runtime/` — normalized runtime envelope;
- `src/k1link/web/plugin_catalog.py` — strict backend manifest validation;
- `src/k1link/web/plugin_runtime.py` — host-owned allowlisted action dispatcher;
- `src/k1link/web/device_plugin_composition.py` — manifest factory loader and
startup parity check between catalog and executable adapters;
- `docs/adr/0003-device-plugin-ui-and-runtime-boundary.md` — accepted boundary
and extraction sequence.
The v1alpha lifecycle is fail-closed: inactive provider shells must perform no
I/O, events are scoped by plugin ID, and selection cannot change until the
active plugin confirms teardown. Process isolation and independent device
session IDs remain the next SDK milestone. `permissions`, `mutating`, and
`secretFields` are contract metadata only in v1alpha1; host authorization and
secret-vault enforcement are not implemented yet.

View File

@ -1,9 +1,16 @@
# XGRIDS / LixelKity K1 plugin # XGRIDS / LixelKity K1 plugin
This directory is the target package boundary for the first Mission Core device This directory is the package boundary for the first Mission Core device
plugin. The verified implementation remains temporarily in `src/k1link` so the plugin. It now owns the canonical v1alpha manifest
[`plugin.manifest.json`](plugin.manifest.json). The statically linked frontend
contribution lives in `apps/control-station/src/device-plugins/xgrids-k1/`; the
verified backend implementation remains temporarily in `src/k1link` so the
real-device path stays operational during extraction. real-device path stays operational during extraction.
The v1alpha1 host deliberately assigns one model to one plugin runtime, so this
manifest exposes only `xgrids.lixelkity-k1`. Adding another device model means a
new plugin contribution until session/model routing lands in the next SDK.
The plugin owns: The plugin owns:
- BLE discovery hints and K1 GATT metadata; - BLE discovery hints and K1 GATT metadata;
@ -24,3 +31,21 @@ The plugin does not own:
Until extraction is complete, `src/k1link` is the compatibility source of Until extraction is complete, `src/k1link` is the compatibility source of
truth and every move into this package must preserve replay and real-device truth and every move into this package must preserve replay and real-device
acceptance. acceptance.
Mission Core imports this plugin only from
`apps/control-station/src/composition/devicePlugins.ts`. The generic shell never
branches on this plugin ID or reads `k1_ip`, BLE candidates, MQTT topics, or
other vendor state.
Backend startup loads the reviewed factory declared by `backendEntrypoint`, then
cross-checks its plugin ID and complete action set against this manifest.
Actions enter through the host dispatcher and the transitional facade
`src/k1link/web/xgrids_k1_facade.py`. The old flat routes live in the plugin's
legacy router; both paths delegate to the same proven
`XgridsK1CompatibilityService` methods. Synchronous capture/runtime operations
run outside the FastAPI event loop.
Model switching calls the plugin deactivation hook and must receive a successful
`stream.stop` before Mission Core removes this custom UI. BLE, MQTT, codec and
evidence modules remain in `src/k1link` until replay parity and another physical
K1 regression are complete.

View File

@ -0,0 +1,53 @@
{
"apiVersion": "missioncore.nodedc/v1alpha1",
"kind": "DevicePlugin",
"metadata": {
"id": "nodedc.device.xgrids-lixelkity-k1",
"version": "0.1.0",
"displayName": "XGRIDS K1 Integration"
},
"spec": {
"hostApiRange": "v1alpha1",
"runtime": {
"backendEntrypoint": "k1link.web.xgrids_k1_facade:build_xgrids_k1_plugin",
"isolation": "transitional-in-process"
},
"permissions": [
"device.discovery.ble",
"device.provisioning.wifi-over-ble",
"network.mqtt.subscribe-private-lan",
"evidence.write-session-artifacts"
],
"actions": [
{ "id": "state.read", "mutating": false, "secretFields": [] },
{ "id": "discovery.scan", "mutating": false, "secretFields": [] },
{ "id": "network.provision", "mutating": true, "secretFields": ["password"] },
{ "id": "stream.start-live", "mutating": true, "secretFields": [] },
{ "id": "stream.start-replay", "mutating": true, "secretFields": [] },
{ "id": "stream.stop", "mutating": true, "secretFields": [] },
{ "id": "viewer.settings.update", "mutating": true, "secretFields": [] }
],
"models": [
{
"id": "xgrids.lixelkity-k1",
"vendor": "XGRIDS",
"displayName": "XGRIDS LixelKity K1",
"category": "Мобильный лидарный сканер",
"description": "Проверенный локальный профиль: BLE-настройка Wi-Fi, MQTT-приём, облако точек, поза и raw-first запись.",
"verified": true,
"capabilities": [
{ "id": "device.discovery.ble", "label": "Поиск BLE" },
{ "id": "device.provisioning.wifi-over-ble", "label": "Wi-Fi через BLE" },
{ "id": "spatial.point-cloud.live", "label": "Облако точек" },
{ "id": "spatial.pose.live", "label": "Траектория" },
{ "id": "evidence.raw-capture", "label": "Исходная запись" },
{ "id": "evidence.replay", "label": "Повтор записи" }
],
"ui": {
"slot": "device.connection",
"componentKey": "xgrids-k1.connection"
}
}
]
}
}

View File

@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "nodedc-mission-core" name = "nodedc-mission-core"
version = "0.1.0" version = "0.1.0"
description = "NODEDC Mission Core monorepo for mission control, spatial observation, and device plugins" description = "NODEDC MISSION CORE monorepo for mission control, spatial observation, and device plugins"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12,<3.13" requires-python = ">=3.12,<3.13"
license = { text = "Proprietary" } license = { text = "Proprietary" }

View File

@ -146,6 +146,8 @@ class VisualizationRuntime:
return return
assert thread is not None assert thread is not None
thread.join(timeout=wait_seconds) thread.join(timeout=wait_seconds)
if thread.is_alive():
raise RuntimeError("поток не завершился за отведённое время; повторите остановку")
def close(self, *, wait_seconds: float = 5.0) -> None: def close(self, *, wait_seconds: float = 5.0) -> None:
"""Stop the active source and release the process-wide visual bridge.""" """Stop the active source and release the process-wide visual bridge."""
@ -324,9 +326,7 @@ class VisualizationRuntime:
self._rerun_grpc_url = bridge.grpc_url self._rerun_grpc_url = bridge.grpc_url
if not self._closed and self._phase != "stopping": if not self._closed and self._phase != "stopping":
self._phase = running_phase self._phase = running_phase
self._message = ( self._message = "Локальный Rerun-мост готов; источник данных запущен."
"Локальный Rerun-мост готов; источник данных запущен."
)
publisher_ready.set() publisher_ready.set()
self._notify() self._notify()
if publisher_aborted.is_set(): if publisher_aborted.is_set():

View File

@ -1,255 +1,33 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import threading from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime
from pathlib import Path 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 import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field from pydantic import ValidationError
from k1link import __version__ from k1link import __version__
from k1link.artifacts import write_json_atomic from k1link.web.device_plugin_composition import load_installed_device_plugins
from k1link.ble.scanner import scan from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once from k1link.web.plugin_runtime import (
from k1link.mqtt import validate_private_ipv4 STATE_READ_ACTION_ID,
from k1link.viewer.rerun_bridge import RerunSceneSettings DevicePluginActionRequest,
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir DevicePluginDispatcher,
PluginActionNotFoundError,
PluginExecutionError,
PluginNotFoundError,
)
REPOSITORY_ROOT = Path(__file__).resolve().parents[3] REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
class BleScanRequest(BaseModel): plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
duration_seconds: float = Field(default=6.0, ge=1.0, le=60.0) plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
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)
@asynccontextmanager @asynccontextmanager
@ -257,11 +35,11 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
try: try:
yield yield
finally: finally:
service.runtime.close() plugin_environment.close()
app = FastAPI( app = FastAPI(
title="NODEDC Mission Core API", title="NODEDC MISSION CORE API",
version=__version__, version=__version__,
docs_url="/api/docs", docs_url="/api/docs",
redoc_url=None, redoc_url=None,
@ -280,97 +58,63 @@ def health() -> dict[str, Any]:
} }
@app.get("/api/state") @app.get("/api/v1/device-plugins")
def get_state() -> dict[str, Any]: def get_device_plugins() -> dict[str, Any]:
return service.state()
@app.post("/api/ble/scan")
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
try: try:
return await service.scan_ble(request.duration_seconds) return {"items": plugin_catalog.plugin_documents()}
except (BleakError, OSError, RuntimeError, ValueError) as exc: except PluginCatalogError as exc:
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.post("/api/connect") @app.get("/api/v1/device-models")
async def connect(request: ConnectRequest) -> dict[str, Any]: def get_device_models() -> dict[str, Any]:
try: try:
return await service.connect(request) return {"items": plugin_catalog.model_documents()}
except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc: except PluginCatalogError as exc:
raise HTTPException( raise HTTPException(status_code=500, detail=str(exc)) from exc
status_code=502,
detail=f"Ошибка подключения устройства к Wi-Fi: {exc}",
) from exc
@app.post("/api/session/live") @app.post("/api/v1/device-plugins/{plugin_id}/actions/{action_id}")
def start_live(request: LiveRequest) -> dict[str, Any]: async def invoke_device_plugin_action(
plugin_id: str,
action_id: str,
request: DevicePluginActionRequest,
) -> dict[str, Any]:
try: try:
return service.start_live(request.host, request.duration_seconds) state = await plugin_dispatcher.invoke(plugin_id, action_id, request.input)
except (OSError, RuntimeError, ValueError) as exc: 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 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") @app.websocket("/api/v1/device-plugins/{plugin_id}/events")
def start_replay(request: ReplayRequest) -> dict[str, Any]: async def device_plugin_events(websocket: WebSocket, plugin_id: str) -> None:
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:
await websocket.accept() await websocket.accept()
sequence = 0
try: try:
while True: 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) await asyncio.sleep(0.5)
except WebSocketDisconnect: except WebSocketDisconnect:
return 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" frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
if frontend_dist.is_dir(): if frontend_dist.is_dir():
app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="frontend") 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

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,
)

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

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)

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,
)

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

View File

@ -0,0 +1,92 @@
from __future__ import annotations
from pathlib import Path
def test_mission_core_frontend_has_single_plugin_composition_root() -> None:
repository_root = Path(__file__).resolve().parents[1]
source_root = repository_root / "apps" / "control-station" / "src"
composition = source_root / "composition" / "devicePlugins.ts"
forbidden_import = "device-plugins/xgrids-k1"
imports = [
path.relative_to(source_root).as_posix()
for path in source_root.rglob("*.ts*")
if forbidden_import in path.read_text("utf-8")
]
assert imports == [composition.relative_to(source_root).as_posix()]
def test_mission_core_shell_does_not_know_xgrids_wire_fields() -> None:
repository_root = Path(__file__).resolve().parents[1]
source_root = repository_root / "apps" / "control-station" / "src"
core_paths = [
source_root / "App.tsx",
source_root / "presentation.ts",
source_root / "productModel.ts",
source_root / "components",
source_root / "core",
source_root / "workspaces",
]
forbidden_tokens = ("k1_ip", "likely_k1", "useK1Console", "rerun_grpc_url")
checked_files: list[Path] = []
for path in core_paths:
if path.is_dir():
checked_files.extend(path.rglob("*.ts*"))
else:
checked_files.append(path)
violations = {
path.relative_to(source_root).as_posix(): token
for path in checked_files
for token in forbidden_tokens
if token in path.read_text("utf-8")
}
assert violations == {}
def test_xgrids_client_uses_manifest_identity_and_plugin_scoped_events() -> None:
repository_root = Path(__file__).resolve().parents[1]
plugin_root = (
repository_root / "apps" / "control-station" / "src" / "device-plugins" / "xgrids-k1"
)
api_source = (plugin_root / "api.ts").read_text("utf-8")
manifest_source = (plugin_root / "manifest.ts").read_text("utf-8")
assert "nodedc.device.xgrids-lixelkity-k1" not in api_source
assert 'new URL("/api/events"' not in api_source
assert "/api/v1/device-plugins/" in api_source
assert "parseDevicePluginManifest" in manifest_source
def test_backend_host_has_no_concrete_xgrids_imports() -> None:
repository_root = Path(__file__).resolve().parents[1]
app_source = (repository_root / "src" / "k1link" / "web" / "app.py").read_text("utf-8")
assert "xgrids" not in app_source.lower()
assert "k1_ip" not in app_source
assert "Bleak" not in app_source
def test_model_switch_is_guarded_by_plugin_deactivation() -> None:
repository_root = Path(__file__).resolve().parents[1]
frontend_root = repository_root / "apps" / "control-station" / "src"
host_source = (frontend_root / "core" / "device-plugins" / "DevicePluginHost.tsx").read_text(
"utf-8"
)
xgrids_runtime = (
frontend_root / "device-plugins" / "xgrids-k1" / "runtimeContext.tsx"
).read_text("utf-8")
deactivation_guard = "if (!(await deactivate()))"
assert "if (current && !deactivate)" in host_source
assert deactivation_guard in host_source
assert host_source.index(deactivation_guard) < host_source.index(
"setSelectedModelId(nextModelId)"
)
assert "catch" in host_source
assert "selectionTransitionError" in host_source
assert "return controller.stop();" in xgrids_runtime

View File

@ -0,0 +1,178 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
def _manifest(plugin_id: str, model_id: str) -> dict[str, Any]:
return {
"apiVersion": "missioncore.nodedc/v1alpha1",
"kind": "DevicePlugin",
"metadata": {
"id": plugin_id,
"version": "0.1.0",
"displayName": plugin_id,
},
"spec": {
"hostApiRange": "v1alpha1",
"runtime": {
"backendEntrypoint": "example.plugin:adapter",
"isolation": "transitional-in-process",
},
"permissions": [],
"actions": [{"id": "state.read", "mutating": False, "secretFields": []}],
"models": [
{
"id": model_id,
"vendor": "Example",
"displayName": model_id,
"category": "Synthetic",
"description": "Synthetic contract fixture.",
"verified": False,
"capabilities": [],
"ui": {
"slot": "device.connection",
"componentKey": "synthetic.connection",
},
}
],
},
}
def _write_manifest(root: Path, directory: str, document: dict[str, Any]) -> None:
target = root / "plugins" / directory / "plugin.manifest.json"
target.parent.mkdir(parents=True)
target.write_text(json.dumps(document), encoding="utf-8")
def test_repository_catalog_exposes_xgrids_model() -> None:
repository_root = Path(__file__).resolve().parents[1]
catalog = DevicePluginCatalog(repository_root)
plugins = catalog.plugin_documents()
models = catalog.model_documents()
assert any(item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1" for item in plugins)
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
"pluginVersion": "0.1.0",
"id": "xgrids.lixelkity-k1",
"vendor": "XGRIDS",
"displayName": "XGRIDS LixelKity K1",
"category": "Мобильный лидарный сканер",
"description": (
"Проверенный локальный профиль: BLE-настройка Wi-Fi, MQTT-приём, "
"облако точек, поза и raw-first запись."
),
"verified": True,
"capabilities": [
{"id": "device.discovery.ble", "label": "Поиск BLE"},
{
"id": "device.provisioning.wifi-over-ble",
"label": "Wi-Fi через BLE",
},
{"id": "spatial.point-cloud.live", "label": "Облако точек"},
{"id": "spatial.pose.live", "label": "Траектория"},
{"id": "evidence.raw-capture", "label": "Исходная запись"},
{"id": "evidence.replay", "label": "Повтор записи"},
],
"ui": {
"slot": "device.connection",
"componentKey": "xgrids-k1.connection",
},
}
def test_catalog_accepts_multiple_distinct_plugins_and_models(tmp_path: Path) -> None:
_write_manifest(tmp_path, "first", _manifest("example.first", "example.first-model"))
_write_manifest(tmp_path, "second", _manifest("example.second", "example.second-model"))
catalog = DevicePluginCatalog(tmp_path)
assert {item["metadata"]["id"] for item in catalog.plugin_documents()} == {
"example.first",
"example.second",
}
assert {item["id"] for item in catalog.model_documents()} == {
"example.first-model",
"example.second-model",
}
def test_catalog_rejects_duplicate_model_ids(tmp_path: Path) -> None:
_write_manifest(tmp_path, "first", _manifest("example.first", "example.model"))
_write_manifest(tmp_path, "second", _manifest("example.second", "example.model"))
with pytest.raises(PluginCatalogError, match="Duplicate device-model id"):
DevicePluginCatalog(tmp_path).manifests()
def test_catalog_rejects_unknown_contract_version(tmp_path: Path) -> None:
document = _manifest("example.invalid", "example.invalid-model")
document["apiVersion"] = "missioncore.nodedc/v999"
_write_manifest(tmp_path, "invalid", document)
with pytest.raises(PluginCatalogError, match="Invalid device-plugin manifest"):
DevicePluginCatalog(tmp_path).manifests()
def test_v1alpha_catalog_rejects_multiple_models_and_process_isolation(
tmp_path: Path,
) -> None:
multiple_models = _manifest("example.multi", "example.model-a")
model_a = multiple_models["spec"]["models"][0]
multiple_models["spec"]["models"].append({**model_a, "id": "example.model-b"})
_write_manifest(tmp_path, "multi", multiple_models)
with pytest.raises(PluginCatalogError, match="Invalid device-plugin manifest"):
DevicePluginCatalog(tmp_path).manifests()
process_root = tmp_path / "process"
process_manifest = _manifest("example.process", "example.process-model")
process_manifest["spec"]["runtime"]["isolation"] = "process"
_write_manifest(process_root, "process", process_manifest)
with pytest.raises(PluginCatalogError, match="Invalid device-plugin manifest"):
DevicePluginCatalog(process_root).manifests()
def test_catalog_requires_safe_state_read_action(tmp_path: Path) -> None:
document = _manifest("example.unsafe", "example.unsafe-model")
document["spec"]["actions"] = [{"id": "state.read", "mutating": True, "secretFields": []}]
_write_manifest(tmp_path, "unsafe", document)
with pytest.raises(PluginCatalogError, match="must declare safe state.read"):
DevicePluginCatalog(tmp_path).manifests()
@pytest.mark.parametrize(
("mutate", "invalid_value"),
[
(lambda document, value: document["spec"]["permissions"].append(value), " "),
(
lambda document, value: document["spec"]["actions"][0]["secretFields"].append(value),
"",
),
(
lambda document, value: document["spec"]["models"][0].update({"description": value}),
"x" * 1025,
),
],
)
def test_catalog_rejects_strings_that_frontend_manifest_parser_rejects(
tmp_path: Path,
mutate: Any,
invalid_value: str,
) -> None:
document = _manifest("example.invalid", "example.invalid-model")
mutate(document, invalid_value)
_write_manifest(tmp_path, "invalid", document)
with pytest.raises(PluginCatalogError, match="Invalid device-plugin manifest"):
DevicePluginCatalog(tmp_path).manifests()

View File

@ -0,0 +1,335 @@
from __future__ import annotations
import asyncio
import json
import threading
from collections.abc import Mapping
from pathlib import Path
from typing import Any
import pytest
from pydantic import ValidationError
import k1link.web.device_plugin_composition as plugin_composition
from k1link.web.device_plugin_composition import (
DevicePluginCompositionError,
InstalledDevicePluginEnvironment,
load_installed_device_plugins,
)
from k1link.web.plugin_catalog import DevicePluginCatalog
from k1link.web.plugin_runtime import (
DevicePluginDispatcher,
DevicePluginRuntimeContribution,
PluginActionNotFoundError,
PluginNotFoundError,
)
from k1link.web.xgrids_k1_facade import (
ACTION_DISCOVERY_SCAN,
ACTION_NETWORK_PROVISION,
ACTION_STREAM_START_LIVE,
ACTION_STREAM_START_REPLAY,
ACTION_STREAM_STOP,
ACTION_VIEWER_SETTINGS_UPDATE,
XGRIDS_K1_PLUGIN_ID,
ConnectRequest,
ViewerSettingsRequest,
XgridsK1PluginFacade,
)
class FakeXgridsService:
def __init__(self) -> None:
self.calls: list[tuple[str, object]] = []
def state(self) -> dict[str, Any]:
self.calls.append(("state", None))
return {"phase": "idle"}
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
self.calls.append(("scan", duration_seconds))
return {"phase": "idle", "devices": []}
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
self.calls.append(("connect", request))
return {"phase": "connected", "k1_ip": "192.168.1.20"}
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]:
self.calls.append(("live", (host, duration_seconds)))
return {"phase": "live"}
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
self.calls.append(("replay", (path, speed, loop)))
return {"phase": "replay"}
def stop(self) -> dict[str, Any]:
self.calls.append(("stop", None))
return {"phase": "idle"}
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
self.calls.append(("viewer", request))
return {"phase": "idle", "viewer_settings": request.model_dump()}
def test_manifest_and_runtime_facade_declare_identical_actions() -> None:
repository_root = Path(__file__).resolve().parents[1]
manifest = next(
candidate
for candidate in DevicePluginCatalog(repository_root).manifests()
if candidate.metadata.id == XGRIDS_K1_PLUGIN_ID
)
assert {action.id for action in manifest.spec.actions} == XgridsK1PluginFacade.action_ids
def test_repository_runtime_composition_exactly_matches_catalog() -> None:
repository_root = Path(__file__).resolve().parents[1]
environment = load_installed_device_plugins(repository_root)
try:
assert set(environment.dispatcher.action_declarations) == {
manifest.metadata.id for manifest in environment.catalog.manifests()
}
finally:
environment.close()
def test_composition_closes_a_runtime_that_does_not_match_its_manifest(
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository_root = Path(__file__).resolve().parents[1]
manifest = next(
candidate
for candidate in DevicePluginCatalog(repository_root).manifests()
if candidate.metadata.id == XGRIDS_K1_PLUGIN_ID
)
mismatched_manifest = manifest.model_copy(
update={"metadata": manifest.metadata.model_copy(update={"id": "example.other-plugin"})}
)
closed: list[bool] = []
contribution = DevicePluginRuntimeContribution(
adapter=XgridsK1PluginFacade(FakeXgridsService()),
close=lambda: closed.append(True),
)
monkeypatch.setattr(
plugin_composition,
"_load_factory",
lambda _: lambda __: contribution,
)
with pytest.raises(DevicePluginCompositionError, match="id mismatch"):
plugin_composition._load_contribution(repository_root, mismatched_manifest)
assert closed == [True]
def test_composition_loads_and_dispatches_two_synthetic_plugins(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
class SyntheticAdapter:
action_ids = frozenset({"state.read"})
def __init__(self, plugin_id: str) -> None:
self.plugin_id = plugin_id
async def invoke(
self,
action_id: str,
payload: Mapping[str, Any],
) -> dict[str, Any]:
assert action_id == "state.read"
assert payload == {}
return {"plugin_id": self.plugin_id}
closed: list[str] = []
contributions = {
f"synthetic:{suffix}": DevicePluginRuntimeContribution(
adapter=SyntheticAdapter(f"example.{suffix}"),
close=lambda suffix=suffix: closed.append(suffix),
)
for suffix in ("first", "second")
}
for suffix in ("first", "second"):
document = {
"apiVersion": "missioncore.nodedc/v1alpha1",
"kind": "DevicePlugin",
"metadata": {
"id": f"example.{suffix}",
"version": "0.1.0",
"displayName": f"Example {suffix}",
},
"spec": {
"hostApiRange": "v1alpha1",
"runtime": {
"backendEntrypoint": f"synthetic:{suffix}",
"isolation": "transitional-in-process",
},
"permissions": [],
"actions": [{"id": "state.read", "mutating": False, "secretFields": []}],
"models": [
{
"id": f"example.{suffix}-model",
"vendor": "Example",
"displayName": f"Example {suffix}",
"category": "Synthetic",
"description": "Synthetic runtime composition fixture.",
"verified": False,
"capabilities": [],
"ui": {
"slot": "device.connection",
"componentKey": f"example.{suffix}.connection",
},
}
],
},
}
manifest_path = tmp_path / "plugins" / suffix / "plugin.manifest.json"
manifest_path.parent.mkdir(parents=True)
manifest_path.write_text(json.dumps(document), encoding="utf-8")
monkeypatch.setattr(
plugin_composition,
"_load_factory",
lambda entrypoint: lambda _: contributions[entrypoint],
)
environment = load_installed_device_plugins(tmp_path)
try:
for suffix in ("first", "second"):
state = asyncio.run(
environment.dispatcher.invoke(f"example.{suffix}", "state.read", {})
)
assert state == {"plugin_id": f"example.{suffix}"}
finally:
environment.close()
assert closed == ["second", "first"]
def test_environment_shutdown_attempts_every_plugin_after_close_failure(
tmp_path: Path,
) -> None:
closed: list[str] = []
def failing_close() -> None:
closed.append("failing")
raise RuntimeError("synthetic close failure")
environment = InstalledDevicePluginEnvironment(
catalog=DevicePluginCatalog(tmp_path),
dispatcher=DevicePluginDispatcher([]),
legacy_routers=(),
_contributions=(
DevicePluginRuntimeContribution(
adapter=XgridsK1PluginFacade(FakeXgridsService()),
close=lambda: closed.append("healthy"),
),
DevicePluginRuntimeContribution(
adapter=XgridsK1PluginFacade(FakeXgridsService()),
close=failing_close,
),
),
)
with pytest.raises(DevicePluginCompositionError, match="failed during shutdown") as raised:
environment.close()
assert closed == ["failing", "healthy"]
assert "synthetic close failure" in " ".join(raised.value.__notes__)
def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
service = FakeXgridsService()
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
state = asyncio.run(
dispatcher.invoke(
XGRIDS_K1_PLUGIN_ID,
ACTION_DISCOVERY_SCAN,
{"duration_seconds": 6},
)
)
assert state == {"phase": "idle", "devices": []}
assert service.calls == [("scan", 6.0)]
def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(FakeXgridsService())])
with pytest.raises(PluginNotFoundError):
asyncio.run(dispatcher.invoke("missing.plugin", ACTION_STREAM_STOP, {}))
with pytest.raises(PluginActionNotFoundError):
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, "unknown.action", {}))
def test_facade_validates_payload_before_calling_service() -> None:
service = FakeXgridsService()
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
with pytest.raises(ValidationError):
asyncio.run(
dispatcher.invoke(
XGRIDS_K1_PLUGIN_ID,
ACTION_NETWORK_PROVISION,
{"device_id": "id", "ssid": "network", "password": "secret", "extra": True},
)
)
assert service.calls == []
@pytest.mark.parametrize(
("action_id", "payload", "expected_call"),
[
(ACTION_STREAM_START_LIVE, {"host": "192.168.1.20"}, "live"),
(
ACTION_STREAM_START_REPLAY,
{"path": "sessions/capture.k1mqtt", "speed": 1, "loop": False},
"replay",
),
(ACTION_STREAM_STOP, {}, "stop"),
(
ACTION_VIEWER_SETTINGS_UPDATE,
{
"point_size": 3,
"color_mode": "height",
"palette": "viridis",
"custom_color": "#102030",
"accumulation_seconds": 12,
"show_points": True,
"show_trajectory": True,
"show_grid": False,
},
"viewer",
),
],
)
def test_facade_preserves_existing_runtime_operations(
action_id: str,
payload: dict[str, object],
expected_call: str,
) -> None:
service = FakeXgridsService()
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, action_id, payload))
assert service.calls[0][0] == expected_call
def test_sync_runtime_actions_run_outside_the_api_event_loop() -> None:
class ThreadAwareService(FakeXgridsService):
thread_id: int | None = None
def stop(self) -> dict[str, Any]:
self.thread_id = threading.get_ident()
return super().stop()
service = ThreadAwareService()
dispatcher = DevicePluginDispatcher([XgridsK1PluginFacade(service)])
event_loop_thread = threading.get_ident()
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {}))
assert service.thread_id is not None
assert service.thread_id != event_loop_thread

View File

@ -60,9 +60,7 @@ def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
point_payload = struct.pack("<III", 16, 0, 0) + struct.pack( point_payload = struct.pack("<III", 16, 0, 0) + struct.pack(
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40 "<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
) )
pose_payload = struct.pack( pose_payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3)
"<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3
)
bridge.process(_message("RealtimePointcloud", point_payload)) bridge.process(_message("RealtimePointcloud", point_payload))
bridge.process(_message("RealtimePath", pose_payload, sequence=8)) bridge.process(_message("RealtimePath", pose_payload, sequence=8))
@ -155,9 +153,7 @@ def test_runtime_exposes_rerun_url_and_stops_cleanly(tmp_path: Path) -> None:
point_payload = struct.pack("<III", 16, 0, 0) + struct.pack( point_payload = struct.pack("<III", 16, 0, 0) + struct.pack(
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40 "<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
) )
pose_payload = struct.pack( pose_payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3)
"<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3
)
frames = bytearray(RAW_MAGIC) frames = bytearray(RAW_MAGIC)
for topic, payload in ((point_topic, point_payload), (pose_topic, pose_payload)): for topic, payload in ((point_topic, point_payload), (pose_topic, pose_payload)):
topic_raw = topic.encode() topic_raw = topic.encode()
@ -236,9 +232,7 @@ def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) ->
payload = struct.pack("<III", 16, 0, 0) + struct.pack( payload = struct.pack("<III", 16, 0, 0) + struct.pack(
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40 "<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
) )
capture.write_bytes( capture.write_bytes(RAW_MAGIC + FRAME_HEADER.pack(len(topic), len(payload)) + topic + payload)
RAW_MAGIC + FRAME_HEADER.pack(len(topic), len(payload)) + topic + payload
)
(tmp_path / "mqtt.metadata.jsonl").write_text( (tmp_path / "mqtt.metadata.jsonl").write_text(
json.dumps( json.dumps(
{ {
@ -279,3 +273,48 @@ def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) ->
assert runtime.snapshot()["rerun_grpc_url"] is None assert runtime.snapshot()["rerun_grpc_url"] is None
with pytest.raises(RuntimeError, match="runtime завершён"): with pytest.raises(RuntimeError, match="runtime завершён"):
runtime.start_replay(capture, speed=0.0) runtime.start_replay(capture, speed=0.0)
def test_stop_fails_closed_when_runtime_thread_misses_deadline(tmp_path: Path) -> None:
capture = tmp_path / "mqtt.raw.k1mqtt"
topic = b"RealtimePointcloud"
payload = struct.pack("<III", 16, 0, 0) + struct.pack(
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
)
capture.write_bytes(RAW_MAGIC + FRAME_HEADER.pack(len(topic), len(payload)) + topic + payload)
(tmp_path / "mqtt.metadata.jsonl").write_text(
json.dumps(
{
"record_type": "message",
"sequence": 1,
"received_at_epoch_ns": 1_000_000_000,
"received_monotonic_ns": 1_000_000_000,
}
)
+ "\n",
encoding="utf-8",
)
factory_entered = threading.Event()
release_factory = threading.Event()
def blocked_factory(**kwargs: object) -> RerunBridge:
factory_entered.set()
assert release_factory.wait(timeout=5.0)
return RerunBridge(
recording_factory=lambda _: FakeRecording(), # type: ignore[arg-type]
**kwargs, # type: ignore[arg-type]
)
runtime = VisualizationRuntime(bridge_factory=blocked_factory)
runtime.start_replay(capture, speed=0.0)
assert factory_entered.wait(timeout=2.0)
with pytest.raises(RuntimeError, match="не завершился"):
runtime.stop(wait_seconds=0.01)
assert runtime.snapshot()["phase"] == "stopping"
release_factory.set()
runtime.stop(wait_seconds=2.0)
assert runtime.snapshot()["source_mode"] == "idle"
runtime.close()

View File

@ -6,7 +6,7 @@ from typing import Any
from pytest import MonkeyPatch from pytest import MonkeyPatch
import k1link.web.app as web_app import k1link.web.xgrids_k1_facade as xgrids_backend
def test_ble_scan_exposes_every_device_and_only_labels_likely_k1( def test_ble_scan_exposes_every_device_and_only_labels_likely_k1(
@ -34,8 +34,8 @@ def test_ble_scan_exposes_every_device_and_only_labels_likely_k1(
] ]
} }
monkeypatch.setattr(web_app, "scan", fake_scan) monkeypatch.setattr(xgrids_backend, "scan", fake_scan)
service = web_app.ConsoleService(tmp_path) service = xgrids_backend.XgridsK1CompatibilityService(tmp_path)
state = asyncio.run(service.scan_ble(6.0)) state = asyncio.run(service.scan_ble(6.0))
@ -48,8 +48,8 @@ def test_ble_scan_exposes_every_device_and_only_labels_likely_k1(
def test_viewer_settings_are_validated_and_exposed(tmp_path: Path) -> None: def test_viewer_settings_are_validated_and_exposed(tmp_path: Path) -> None:
service = web_app.ConsoleService(tmp_path) service = xgrids_backend.XgridsK1CompatibilityService(tmp_path)
request = web_app.ViewerSettingsRequest( request = xgrids_backend.ViewerSettingsRequest(
point_size=4.0, point_size=4.0,
color_mode="height", color_mode="height",
palette="viridis", palette="viridis",