feat: introduce device plugin runtime boundary
This commit is contained in:
parent
9225227421
commit
27bf7527df
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 и чувствительные данные
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
@ -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">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</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="Передайте настройки Wi‑Fi"
|
||||||
|
status={state?.k1_ip ? "Подключено" : "Не подключено"}
|
||||||
|
tone={state?.k1_ip ? "success" : "neutral"}
|
||||||
|
>
|
||||||
|
<div className="field-stack">
|
||||||
|
<TextField
|
||||||
|
label="Название сети Wi‑Fi"
|
||||||
|
hint="SSID"
|
||||||
|
value={ssid}
|
||||||
|
onChange={(event) => setSsid(event.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="Сеть локального контура"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Пароль Wi‑Fi"
|
||||||
|
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" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
|
||||||
|
</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" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
|
||||||
|
</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 || "Сначала подключите устройство к Wi‑Fi"}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={<Icon name="activity" />}
|
||||||
|
disabled={isBusy || !liveTargetReady}
|
||||||
|
onClick={() => void submitLive()}
|
||||||
|
>
|
||||||
|
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
|
||||||
|
</Button>
|
||||||
|
<p className="live-instruction">
|
||||||
|
{liveTargetReady
|
||||||
|
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
|
||||||
|
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
@ -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"),
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
@ -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: "Передача настроек Wi‑Fi",
|
||||||
|
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 "Неизвестно";
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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: "Передача настроек Wi‑Fi",
|
|
||||||
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 "Ожидание";
|
||||||
|
|
|
||||||
|
|
@ -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 → Wi‑Fi → поток для первого подключённого устройства.",
|
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", "Открытие одной или нескольких записей."),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</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="Передайте настройки Wi‑Fi"
|
|
||||||
status={state?.k1_ip ? "Подключено" : "Не подключено"}
|
|
||||||
tone={state?.k1_ip ? "success" : "neutral"}
|
|
||||||
>
|
|
||||||
<div className="field-stack">
|
|
||||||
<TextField
|
|
||||||
label="Название сети Wi‑Fi"
|
|
||||||
hint="SSID"
|
|
||||||
value={ssid}
|
|
||||||
onChange={(event) => setSsid(event.target.value)}
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
placeholder="Сеть локального контура"
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Пароль Wi‑Fi"
|
|
||||||
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" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
|
|
||||||
</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" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
|
|
||||||
</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 || "Сначала подключите устройство к Wi‑Fi"}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
icon={<Icon name="activity" />}
|
|
||||||
disabled={isBusy || !liveTargetReady}
|
|
||||||
onClick={() => void submitLive()}
|
|
||||||
>
|
|
||||||
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
|
|
||||||
</Button>
|
|
||||||
<p className="live-instruction">
|
|
||||||
{liveTargetReady
|
|
||||||
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
|
|
||||||
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, Wi‑Fi и запуск потока</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 (
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from importlib import import_module
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest
|
||||||
|
from k1link.web.plugin_runtime import (
|
||||||
|
DevicePluginDispatcher,
|
||||||
|
DevicePluginRuntimeContribution,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DevicePluginCompositionError(RuntimeError):
|
||||||
|
"""The reviewed plugin manifests and executable runtime do not match."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InstalledDevicePluginEnvironment:
|
||||||
|
catalog: DevicePluginCatalog
|
||||||
|
dispatcher: DevicePluginDispatcher
|
||||||
|
legacy_routers: tuple[APIRouter, ...]
|
||||||
|
_contributions: tuple[DevicePluginRuntimeContribution, ...]
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
cleanup_errors = _close_contributions(self._contributions)
|
||||||
|
if cleanup_errors:
|
||||||
|
error = DevicePluginCompositionError(
|
||||||
|
"One or more device plugins failed during shutdown"
|
||||||
|
)
|
||||||
|
_add_cleanup_notes(error, cleanup_errors)
|
||||||
|
raise error from cleanup_errors[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _close_contributions(
|
||||||
|
contributions: tuple[DevicePluginRuntimeContribution, ...]
|
||||||
|
| list[DevicePluginRuntimeContribution],
|
||||||
|
) -> list[Exception]:
|
||||||
|
errors: list[Exception] = []
|
||||||
|
for contribution in reversed(contributions):
|
||||||
|
try:
|
||||||
|
contribution.close()
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(exc)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def _add_cleanup_notes(error: BaseException, cleanup_errors: list[Exception]) -> None:
|
||||||
|
for index, cleanup_error in enumerate(cleanup_errors, start=1):
|
||||||
|
error.add_note(
|
||||||
|
f"device-plugin cleanup failure {index}: "
|
||||||
|
f"{type(cleanup_error).__name__}: {cleanup_error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_factory(entrypoint: str) -> Any:
|
||||||
|
module_name, separator, attribute_name = entrypoint.partition(":")
|
||||||
|
if not separator or not module_name or not attribute_name:
|
||||||
|
raise DevicePluginCompositionError(f"Invalid device-plugin backendEntrypoint: {entrypoint}")
|
||||||
|
try:
|
||||||
|
module = import_module(module_name)
|
||||||
|
factory = getattr(module, attribute_name)
|
||||||
|
except (ImportError, AttributeError) as exc:
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
f"Cannot load device-plugin backendEntrypoint {entrypoint}: {exc}"
|
||||||
|
) from exc
|
||||||
|
if not callable(factory):
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
f"Device-plugin backendEntrypoint is not callable: {entrypoint}"
|
||||||
|
)
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
def _load_contribution(
|
||||||
|
repository_root: Path,
|
||||||
|
manifest: DevicePluginManifest,
|
||||||
|
) -> DevicePluginRuntimeContribution:
|
||||||
|
entrypoint = manifest.spec.runtime.backendEntrypoint
|
||||||
|
if manifest.spec.runtime.isolation != "transitional-in-process":
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
"This Mission Core build only supports reviewed transitional-in-process "
|
||||||
|
f"plugins; {manifest.metadata.id} requests {manifest.spec.runtime.isolation}"
|
||||||
|
)
|
||||||
|
factory = _load_factory(entrypoint)
|
||||||
|
contribution = factory(repository_root)
|
||||||
|
if not isinstance(contribution, DevicePluginRuntimeContribution):
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
f"Device-plugin factory {entrypoint} returned an invalid contribution"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
adapter = contribution.adapter
|
||||||
|
if adapter.plugin_id != manifest.metadata.id:
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
"Device-plugin manifest/runtime id mismatch: "
|
||||||
|
f"{manifest.metadata.id} != {adapter.plugin_id}"
|
||||||
|
)
|
||||||
|
manifest_actions = frozenset(action.id for action in manifest.spec.actions)
|
||||||
|
if adapter.action_ids != manifest_actions:
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
f"Device-plugin manifest/runtime actions mismatch for {adapter.plugin_id}"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_add_cleanup_notes(exc, _close_contributions((contribution,)))
|
||||||
|
raise
|
||||||
|
return contribution
|
||||||
|
|
||||||
|
|
||||||
|
def load_installed_device_plugins(
|
||||||
|
repository_root: Path,
|
||||||
|
) -> InstalledDevicePluginEnvironment:
|
||||||
|
"""Load only local, validated manifest entrypoints and cross-check every adapter."""
|
||||||
|
|
||||||
|
catalog = DevicePluginCatalog(repository_root)
|
||||||
|
manifests = catalog.manifests()
|
||||||
|
loaded: list[DevicePluginRuntimeContribution] = []
|
||||||
|
try:
|
||||||
|
for manifest in manifests:
|
||||||
|
loaded.append(_load_contribution(repository_root, manifest))
|
||||||
|
except Exception as exc:
|
||||||
|
_add_cleanup_notes(exc, _close_contributions(loaded))
|
||||||
|
raise
|
||||||
|
contributions = tuple(loaded)
|
||||||
|
dispatcher = DevicePluginDispatcher([contribution.adapter for contribution in contributions])
|
||||||
|
catalog_ids = {manifest.metadata.id for manifest in manifests}
|
||||||
|
if set(dispatcher.action_declarations) != catalog_ids:
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
"Device-plugin catalog/runtime composition is incomplete"
|
||||||
|
)
|
||||||
|
return InstalledDevicePluginEnvironment(
|
||||||
|
catalog=catalog,
|
||||||
|
dispatcher=dispatcher,
|
||||||
|
legacy_routers=tuple(
|
||||||
|
router for contribution in contributions for router in contribution.legacy_routers
|
||||||
|
),
|
||||||
|
_contributions=contributions,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
|
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, ValidationError
|
||||||
|
|
||||||
|
from k1link.web.plugin_runtime import STATE_READ_ACTION_ID
|
||||||
|
|
||||||
|
PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha1"
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_blank(value: str) -> str:
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError("must contain a non-whitespace character")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
ShortText = Annotated[
|
||||||
|
str,
|
||||||
|
Field(min_length=1, max_length=160),
|
||||||
|
AfterValidator(_reject_blank),
|
||||||
|
]
|
||||||
|
DescriptionText = Annotated[
|
||||||
|
str,
|
||||||
|
Field(min_length=1, max_length=1024),
|
||||||
|
AfterValidator(_reject_blank),
|
||||||
|
]
|
||||||
|
EntrypointText = Annotated[
|
||||||
|
str,
|
||||||
|
Field(min_length=1, max_length=256),
|
||||||
|
AfterValidator(_reject_blank),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityManifest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
id: ShortText
|
||||||
|
label: ShortText
|
||||||
|
|
||||||
|
|
||||||
|
class UiContributionManifest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
slot: Literal["device.connection"]
|
||||||
|
componentKey: ShortText
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceModelManifest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
id: ShortText
|
||||||
|
vendor: ShortText
|
||||||
|
displayName: ShortText
|
||||||
|
category: ShortText
|
||||||
|
description: DescriptionText
|
||||||
|
verified: bool
|
||||||
|
capabilities: list[CapabilityManifest]
|
||||||
|
ui: UiContributionManifest
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRuntimeManifest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
backendEntrypoint: EntrypointText
|
||||||
|
isolation: Literal["transitional-in-process"]
|
||||||
|
|
||||||
|
|
||||||
|
class PluginActionManifest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
id: ShortText
|
||||||
|
mutating: bool
|
||||||
|
secretFields: list[ShortText]
|
||||||
|
|
||||||
|
|
||||||
|
class PluginMetadata(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
id: ShortText
|
||||||
|
version: Annotated[
|
||||||
|
str,
|
||||||
|
Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=160,
|
||||||
|
pattern=r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$",
|
||||||
|
),
|
||||||
|
AfterValidator(_reject_blank),
|
||||||
|
]
|
||||||
|
displayName: ShortText
|
||||||
|
|
||||||
|
|
||||||
|
class PluginSpec(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
hostApiRange: Literal["v1alpha1"]
|
||||||
|
runtime: PluginRuntimeManifest
|
||||||
|
permissions: list[ShortText]
|
||||||
|
actions: list[PluginActionManifest]
|
||||||
|
models: list[DeviceModelManifest] = Field(min_length=1, max_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class DevicePluginManifest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
apiVersion: Literal["missioncore.nodedc/v1alpha1"]
|
||||||
|
kind: Literal["DevicePlugin"]
|
||||||
|
metadata: PluginMetadata
|
||||||
|
spec: PluginSpec
|
||||||
|
|
||||||
|
|
||||||
|
class PluginCatalogError(RuntimeError):
|
||||||
|
"""An installed manifest is invalid or conflicts with another manifest."""
|
||||||
|
|
||||||
|
|
||||||
|
class DevicePluginCatalog:
|
||||||
|
"""Read-only catalog of statically reviewed device-plugin manifests."""
|
||||||
|
|
||||||
|
def __init__(self, repository_root: Path) -> None:
|
||||||
|
self.repository_root = repository_root.resolve()
|
||||||
|
self._validated_manifests: tuple[DevicePluginManifest, ...] | None = None
|
||||||
|
|
||||||
|
def manifests(self) -> list[DevicePluginManifest]:
|
||||||
|
if self._validated_manifests is not None:
|
||||||
|
return list(self._validated_manifests)
|
||||||
|
|
||||||
|
plugin_root = self.repository_root / "plugins"
|
||||||
|
manifests: list[DevicePluginManifest] = []
|
||||||
|
plugin_ids: set[str] = set()
|
||||||
|
model_ids: set[str] = set()
|
||||||
|
|
||||||
|
for path in sorted(plugin_root.glob("*/plugin.manifest.json")):
|
||||||
|
try:
|
||||||
|
manifest = DevicePluginManifest.model_validate_json(path.read_text("utf-8"))
|
||||||
|
except (OSError, ValidationError) as exc:
|
||||||
|
raise PluginCatalogError(f"Invalid device-plugin manifest {path}: {exc}") from exc
|
||||||
|
|
||||||
|
plugin_id = manifest.metadata.id
|
||||||
|
if plugin_id in plugin_ids:
|
||||||
|
raise PluginCatalogError(f"Duplicate device-plugin id: {plugin_id}")
|
||||||
|
plugin_ids.add(plugin_id)
|
||||||
|
|
||||||
|
action_ids: set[str] = set()
|
||||||
|
for action in manifest.spec.actions:
|
||||||
|
if action.id in action_ids:
|
||||||
|
raise PluginCatalogError(
|
||||||
|
f"Duplicate device-plugin action id in {plugin_id}: {action.id}"
|
||||||
|
)
|
||||||
|
action_ids.add(action.id)
|
||||||
|
if len(action.secretFields) != len(set(action.secretFields)):
|
||||||
|
raise PluginCatalogError(
|
||||||
|
f"Duplicate secret field in action {plugin_id}/{action.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
state_read = next(
|
||||||
|
(action for action in manifest.spec.actions if action.id == STATE_READ_ACTION_ID),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if state_read is None or state_read.mutating or bool(state_read.secretFields):
|
||||||
|
raise PluginCatalogError(
|
||||||
|
f"Device plugin {plugin_id} must declare safe {STATE_READ_ACTION_ID}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(manifest.spec.permissions) != len(set(manifest.spec.permissions)):
|
||||||
|
raise PluginCatalogError(f"Duplicate device-plugin permission in {plugin_id}")
|
||||||
|
|
||||||
|
for model in manifest.spec.models:
|
||||||
|
if model.id in model_ids:
|
||||||
|
raise PluginCatalogError(f"Duplicate device-model id: {model.id}")
|
||||||
|
model_ids.add(model.id)
|
||||||
|
capability_ids = [capability.id for capability in model.capabilities]
|
||||||
|
if len(capability_ids) != len(set(capability_ids)):
|
||||||
|
raise PluginCatalogError(f"Duplicate capability id in device model {model.id}")
|
||||||
|
manifests.append(manifest)
|
||||||
|
|
||||||
|
self._validated_manifests = tuple(manifests)
|
||||||
|
return list(self._validated_manifests)
|
||||||
|
|
||||||
|
def plugin_documents(self) -> list[dict[str, Any]]:
|
||||||
|
return [manifest.model_dump(mode="json") for manifest in self.manifests()]
|
||||||
|
|
||||||
|
def model_documents(self) -> list[dict[str, Any]]:
|
||||||
|
models: list[dict[str, Any]] = []
|
||||||
|
for manifest in self.manifests():
|
||||||
|
for model in manifest.spec.models:
|
||||||
|
models.append(
|
||||||
|
{
|
||||||
|
"pluginId": manifest.metadata.id,
|
||||||
|
"pluginVersion": manifest.metadata.version,
|
||||||
|
**model.model_dump(mode="json"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return models
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
STATE_READ_ACTION_ID = "state.read"
|
||||||
|
|
||||||
|
|
||||||
|
class DevicePluginActionRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
input: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class DevicePluginActionAdapter(Protocol):
|
||||||
|
plugin_id: str
|
||||||
|
action_ids: frozenset[str]
|
||||||
|
|
||||||
|
async def invoke(self, action_id: str, payload: Mapping[str, Any]) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _noop() -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DevicePluginRuntimeContribution:
|
||||||
|
"""One reviewed backend plugin contribution loaded from its manifest factory."""
|
||||||
|
|
||||||
|
adapter: DevicePluginActionAdapter
|
||||||
|
legacy_routers: tuple[APIRouter, ...] = ()
|
||||||
|
close: Callable[[], None] = _noop
|
||||||
|
|
||||||
|
|
||||||
|
class PluginNotFoundError(LookupError):
|
||||||
|
"""The requested plugin is not installed in the runtime composition."""
|
||||||
|
|
||||||
|
|
||||||
|
class PluginActionNotFoundError(LookupError):
|
||||||
|
"""The requested action is not declared by the selected plugin."""
|
||||||
|
|
||||||
|
|
||||||
|
class PluginExecutionError(RuntimeError):
|
||||||
|
"""A validated plugin action failed while talking to its device/runtime."""
|
||||||
|
|
||||||
|
|
||||||
|
class DevicePluginDispatcher:
|
||||||
|
"""Host-owned dispatcher for allowlisted, namespaced plugin actions."""
|
||||||
|
|
||||||
|
def __init__(self, adapters: list[DevicePluginActionAdapter]) -> None:
|
||||||
|
self._adapters: dict[str, DevicePluginActionAdapter] = {}
|
||||||
|
for adapter in adapters:
|
||||||
|
if adapter.plugin_id in self._adapters:
|
||||||
|
raise ValueError(f"Duplicate runtime device-plugin id: {adapter.plugin_id}")
|
||||||
|
self._adapters[adapter.plugin_id] = adapter
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_declarations(self) -> dict[str, frozenset[str]]:
|
||||||
|
return {plugin_id: adapter.action_ids for plugin_id, adapter in self._adapters.items()}
|
||||||
|
|
||||||
|
async def invoke(
|
||||||
|
self,
|
||||||
|
plugin_id: str,
|
||||||
|
action_id: str,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
adapter = self._adapters.get(plugin_id)
|
||||||
|
if adapter is None:
|
||||||
|
raise PluginNotFoundError(f"Device plugin is not installed: {plugin_id}")
|
||||||
|
if action_id not in adapter.action_ids:
|
||||||
|
raise PluginActionNotFoundError(
|
||||||
|
f"Device plugin {plugin_id} does not declare action {action_id}"
|
||||||
|
)
|
||||||
|
return await adapter.invoke(action_id, payload)
|
||||||
|
|
@ -0,0 +1,394 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal, Protocol
|
||||||
|
|
||||||
|
from bleak.exc import BleakError
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||||
|
|
||||||
|
from k1link.artifacts import write_json_atomic
|
||||||
|
from k1link.ble.scanner import scan
|
||||||
|
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
|
||||||
|
from k1link.mqtt import validate_private_ipv4
|
||||||
|
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||||
|
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
|
||||||
|
from k1link.web.plugin_runtime import (
|
||||||
|
DevicePluginRuntimeContribution,
|
||||||
|
PluginActionNotFoundError,
|
||||||
|
PluginExecutionError,
|
||||||
|
)
|
||||||
|
|
||||||
|
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
||||||
|
|
||||||
|
ACTION_STATE_READ = "state.read"
|
||||||
|
ACTION_DISCOVERY_SCAN = "discovery.scan"
|
||||||
|
ACTION_NETWORK_PROVISION = "network.provision"
|
||||||
|
ACTION_STREAM_START_LIVE = "stream.start-live"
|
||||||
|
ACTION_STREAM_START_REPLAY = "stream.start-replay"
|
||||||
|
ACTION_STREAM_STOP = "stream.stop"
|
||||||
|
ACTION_VIEWER_SETTINGS_UPDATE = "viewer.settings.update"
|
||||||
|
|
||||||
|
|
||||||
|
class StrictRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyRequest(StrictRequest):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BleScanRequest(StrictRequest):
|
||||||
|
duration_seconds: float = Field(default=6.0, ge=1.0, le=60.0)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectRequest(StrictRequest):
|
||||||
|
device_id: str = Field(min_length=1, max_length=128)
|
||||||
|
ssid: str = Field(min_length=1, max_length=128)
|
||||||
|
password: str = Field(min_length=1, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class LiveRequest(StrictRequest):
|
||||||
|
host: str | None = Field(default=None, max_length=15)
|
||||||
|
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayRequest(StrictRequest):
|
||||||
|
path: str = Field(min_length=1, max_length=4096)
|
||||||
|
speed: float = Field(default=1.0, ge=0.0, le=100.0)
|
||||||
|
loop: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ViewerSettingsRequest(StrictRequest):
|
||||||
|
point_size: float = Field(default=2.5, ge=0.5, le=12.0)
|
||||||
|
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
|
||||||
|
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
|
||||||
|
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||||
|
accumulation_seconds: float = Field(default=12.0, ge=0.0, le=120.0)
|
||||||
|
show_points: bool = True
|
||||||
|
show_trajectory: bool = True
|
||||||
|
show_grid: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class XgridsK1CompatibilityService:
|
||||||
|
"""The proven K1 runtime kept intact behind the plugin facade."""
|
||||||
|
|
||||||
|
def __init__(self, repository_root: Path) -> None:
|
||||||
|
self.repository_root = repository_root.resolve()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._devices: list[dict[str, Any]] = []
|
||||||
|
self._selected_device_id: str | None = None
|
||||||
|
self._k1_ip: str | None = None
|
||||||
|
self._operation_phase: str | None = None
|
||||||
|
self._operation_message: str | None = None
|
||||||
|
self.runtime = VisualizationRuntime()
|
||||||
|
|
||||||
|
def state(self) -> dict[str, Any]:
|
||||||
|
runtime = self.runtime.snapshot()
|
||||||
|
metrics = runtime["metrics"]
|
||||||
|
with self._lock:
|
||||||
|
operation_phase = self._operation_phase
|
||||||
|
operation_message = self._operation_message
|
||||||
|
devices = list(self._devices)
|
||||||
|
selected_device_id = self._selected_device_id
|
||||||
|
k1_ip = self._k1_ip
|
||||||
|
|
||||||
|
runtime_active = runtime["source_mode"] != "idle" or runtime["phase"] in {
|
||||||
|
"starting_live",
|
||||||
|
"stopping",
|
||||||
|
"error",
|
||||||
|
}
|
||||||
|
if operation_phase is not None:
|
||||||
|
phase = operation_phase
|
||||||
|
message = operation_message
|
||||||
|
elif runtime_active:
|
||||||
|
phase = runtime["phase"]
|
||||||
|
message = runtime["message"]
|
||||||
|
elif k1_ip is not None:
|
||||||
|
phase = "connected"
|
||||||
|
message = runtime["message"]
|
||||||
|
elif selected_device_id is not None:
|
||||||
|
phase = "device_selected"
|
||||||
|
message = "Устройство выбрано. Теперь введите название и пароль Wi-Fi."
|
||||||
|
elif devices:
|
||||||
|
likely_count = sum(bool(item.get("likely_k1")) for item in devices)
|
||||||
|
phase = "idle"
|
||||||
|
message = (
|
||||||
|
f"Найдено BLE-устройств: {len(devices)}. "
|
||||||
|
f"Совместимых профилей: {likely_count}. Выберите нужное устройство."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
phase = "idle"
|
||||||
|
message = runtime["message"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"phase": phase,
|
||||||
|
"message": message,
|
||||||
|
"devices": devices,
|
||||||
|
"selected_device_id": selected_device_id,
|
||||||
|
"k1_ip": k1_ip,
|
||||||
|
"foxglove_ws_url": runtime["foxglove_ws_url"],
|
||||||
|
"foxglove_viewer_url": runtime["foxglove_viewer_url"],
|
||||||
|
"rerun_grpc_url": runtime["rerun_grpc_url"],
|
||||||
|
"viewer_settings": runtime["viewer_settings"],
|
||||||
|
"source_mode": runtime["source_mode"],
|
||||||
|
"metrics": {
|
||||||
|
"pipeline_ms": metrics["mqtt_to_publish_ms"],
|
||||||
|
"end_to_end_ms": metrics["mqtt_to_publish_ms"],
|
||||||
|
"decode_ms": metrics["decode_publish_ms"],
|
||||||
|
"frame_rate": metrics["pcl_fps"],
|
||||||
|
"frame_rate_hz": metrics["pcl_fps"],
|
||||||
|
"point_count": metrics["last_point_count"],
|
||||||
|
"dropped_preview_frames": metrics["preview_dropped"],
|
||||||
|
**metrics,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
|
||||||
|
self._set_operation("scanning", "Сканируем все устройства Bluetooth (BLE)…")
|
||||||
|
try:
|
||||||
|
result = await scan(duration_seconds)
|
||||||
|
devices = [
|
||||||
|
{
|
||||||
|
"device_id": item["macos_uuid"],
|
||||||
|
"name": item["local_name"] or item["name"],
|
||||||
|
"rssi": item["rssi"],
|
||||||
|
"address": None,
|
||||||
|
"connectable": True,
|
||||||
|
"likely_k1": item["k1_name_candidate"],
|
||||||
|
}
|
||||||
|
for item in result["devices"]
|
||||||
|
]
|
||||||
|
with self._lock:
|
||||||
|
self._devices = devices
|
||||||
|
self._operation_message = f"Поиск завершён. Найдено BLE-устройств: {len(devices)}."
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self._operation_phase = None
|
||||||
|
return self.state()
|
||||||
|
|
||||||
|
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
|
||||||
|
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
|
||||||
|
if request.device_id not in known_ids:
|
||||||
|
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
||||||
|
self._set_operation(
|
||||||
|
"provisioning",
|
||||||
|
"Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
|
||||||
|
)
|
||||||
|
session_dir = _new_operation_session_dir(
|
||||||
|
self.repository_root,
|
||||||
|
"viewer_wifi_provisioning",
|
||||||
|
)
|
||||||
|
session_dir.mkdir(parents=True, exist_ok=False)
|
||||||
|
password = request.password
|
||||||
|
try:
|
||||||
|
result = await provision_wifi_once(
|
||||||
|
request.device_id,
|
||||||
|
request.ssid,
|
||||||
|
password,
|
||||||
|
timeout_seconds=45.0,
|
||||||
|
write_mode="auto",
|
||||||
|
)
|
||||||
|
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
|
||||||
|
ipv4 = _provisioned_ipv4(result)
|
||||||
|
write_json_atomic(
|
||||||
|
session_dir / "manifest.redacted.json",
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"started_at_utc": result["started_at_utc"],
|
||||||
|
"completed_at_utc": result["completed_at_utc"],
|
||||||
|
"operation": "single_reviewed_wifi_provisioning_write",
|
||||||
|
"profile_id": result["profile_id"],
|
||||||
|
"outcome": result["outcome"],
|
||||||
|
"k1_lan_address_observed": ipv4 is not None,
|
||||||
|
"credentials_persisted_by_connector": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if ipv4 is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._selected_device_id = request.device_id
|
||||||
|
self._k1_ip = ipv4
|
||||||
|
self._operation_message = (
|
||||||
|
"Устройство подключено к Wi-Fi и сообщило локальный адрес."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
password = ""
|
||||||
|
with self._lock:
|
||||||
|
self._operation_phase = None
|
||||||
|
return self.state()
|
||||||
|
|
||||||
|
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]:
|
||||||
|
target = host or self.state()["k1_ip"]
|
||||||
|
if not isinstance(target, str) or not target:
|
||||||
|
raise ValueError(
|
||||||
|
"сначала подключите устройство к Wi-Fi или укажите его локальный адрес"
|
||||||
|
)
|
||||||
|
target = validate_private_ipv4(target)
|
||||||
|
out_dir = new_live_session_dir(self.repository_root)
|
||||||
|
self.runtime.start_live(target, out_dir, duration_seconds=duration_seconds)
|
||||||
|
return self.state()
|
||||||
|
|
||||||
|
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
|
||||||
|
replay_path = Path(path).expanduser().resolve()
|
||||||
|
if not replay_path.is_relative_to(self.repository_root):
|
||||||
|
raise ValueError("файл записи должен находиться внутри репозитория")
|
||||||
|
self.runtime.start_replay(replay_path, speed=speed, loop=loop)
|
||||||
|
return self.state()
|
||||||
|
|
||||||
|
def stop(self) -> dict[str, Any]:
|
||||||
|
self.runtime.stop()
|
||||||
|
return self.state()
|
||||||
|
|
||||||
|
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
|
||||||
|
self.runtime.update_scene_settings(
|
||||||
|
RerunSceneSettings(
|
||||||
|
point_size=request.point_size,
|
||||||
|
color_mode=request.color_mode,
|
||||||
|
palette=request.palette,
|
||||||
|
custom_color=request.custom_color,
|
||||||
|
accumulation_seconds=request.accumulation_seconds,
|
||||||
|
show_points=request.show_points,
|
||||||
|
show_trajectory=request.show_trajectory,
|
||||||
|
show_grid=request.show_grid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return self.state()
|
||||||
|
|
||||||
|
def _set_operation(self, phase: str, message: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._operation_phase = phase
|
||||||
|
self._operation_message = message
|
||||||
|
|
||||||
|
|
||||||
|
class XgridsK1ServicePort(Protocol):
|
||||||
|
def state(self) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
async def connect(self, request: ConnectRequest) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
def stop(self) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class XgridsK1PluginFacade:
|
||||||
|
"""Plugin action facade delegating to the proven compatibility service."""
|
||||||
|
|
||||||
|
plugin_id = XGRIDS_K1_PLUGIN_ID
|
||||||
|
action_ids = frozenset(
|
||||||
|
{
|
||||||
|
ACTION_STATE_READ,
|
||||||
|
ACTION_DISCOVERY_SCAN,
|
||||||
|
ACTION_NETWORK_PROVISION,
|
||||||
|
ACTION_STREAM_START_LIVE,
|
||||||
|
ACTION_STREAM_START_REPLAY,
|
||||||
|
ACTION_STREAM_STOP,
|
||||||
|
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, service: XgridsK1ServicePort) -> None:
|
||||||
|
self.service = service
|
||||||
|
|
||||||
|
async def invoke(self, action_id: str, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await self._invoke_validated(action_id, payload)
|
||||||
|
except (ValidationError, ValueError, PluginActionNotFoundError):
|
||||||
|
raise
|
||||||
|
except (BleakError, OSError, TimeoutError, RuntimeError) as exc:
|
||||||
|
raise PluginExecutionError(str(exc)) from exc
|
||||||
|
|
||||||
|
async def _invoke_validated(
|
||||||
|
self,
|
||||||
|
action_id: str,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if action_id == ACTION_STATE_READ:
|
||||||
|
EmptyRequest.model_validate(payload)
|
||||||
|
return await asyncio.to_thread(self.service.state)
|
||||||
|
if action_id == ACTION_DISCOVERY_SCAN:
|
||||||
|
scan_request = BleScanRequest.model_validate(payload)
|
||||||
|
return await self.service.scan_ble(scan_request.duration_seconds)
|
||||||
|
if action_id == ACTION_NETWORK_PROVISION:
|
||||||
|
connect_request = ConnectRequest.model_validate(payload)
|
||||||
|
return await self.service.connect(connect_request)
|
||||||
|
if action_id == ACTION_STREAM_START_LIVE:
|
||||||
|
live_request = LiveRequest.model_validate(payload)
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
self.service.start_live,
|
||||||
|
live_request.host,
|
||||||
|
live_request.duration_seconds,
|
||||||
|
)
|
||||||
|
if action_id == ACTION_STREAM_START_REPLAY:
|
||||||
|
replay_request = ReplayRequest.model_validate(payload)
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
self.service.start_replay,
|
||||||
|
replay_request.path,
|
||||||
|
replay_request.speed,
|
||||||
|
replay_request.loop,
|
||||||
|
)
|
||||||
|
if action_id == ACTION_STREAM_STOP:
|
||||||
|
EmptyRequest.model_validate(payload)
|
||||||
|
return await asyncio.to_thread(self.service.stop)
|
||||||
|
if action_id == ACTION_VIEWER_SETTINGS_UPDATE:
|
||||||
|
settings_request = ViewerSettingsRequest.model_validate(payload)
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
self.service.update_viewer_settings,
|
||||||
|
settings_request,
|
||||||
|
)
|
||||||
|
raise PluginActionNotFoundError(f"Unsupported XGRIDS K1 action: {action_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def _new_operation_session_dir(repository_root: Path, suffix: str) -> Path:
|
||||||
|
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
base = repository_root / "sessions" / f"{stamp}_{suffix}"
|
||||||
|
candidate = base
|
||||||
|
serial = 1
|
||||||
|
while candidate.exists():
|
||||||
|
serial += 1
|
||||||
|
candidate = base.with_name(f"{base.name}_{serial:02d}")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _provisioned_ipv4(result: Mapping[str, Any]) -> str | None:
|
||||||
|
observations = result.get("observations")
|
||||||
|
if not isinstance(observations, list):
|
||||||
|
return None
|
||||||
|
for observation in reversed(observations):
|
||||||
|
if not isinstance(observation, dict):
|
||||||
|
continue
|
||||||
|
status = observation.get("status")
|
||||||
|
if not isinstance(status, dict):
|
||||||
|
continue
|
||||||
|
address = status.get("ipv4")
|
||||||
|
if isinstance(address, str) and address != AP_FALLBACK_IPV4:
|
||||||
|
try:
|
||||||
|
return validate_private_ipv4(address)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribution:
|
||||||
|
"""Manifest entrypoint for the reviewed XGRIDS compatibility adapter."""
|
||||||
|
|
||||||
|
from k1link.web.xgrids_k1_legacy_api import build_xgrids_k1_legacy_router
|
||||||
|
|
||||||
|
service = XgridsK1CompatibilityService(repository_root)
|
||||||
|
adapter = XgridsK1PluginFacade(service)
|
||||||
|
return DevicePluginRuntimeContribution(
|
||||||
|
adapter=adapter,
|
||||||
|
legacy_routers=(build_xgrids_k1_legacy_router(adapter),),
|
||||||
|
close=service.runtime.close,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from k1link.web.plugin_runtime import PluginExecutionError
|
||||||
|
from k1link.web.xgrids_k1_facade import (
|
||||||
|
ACTION_DISCOVERY_SCAN,
|
||||||
|
ACTION_NETWORK_PROVISION,
|
||||||
|
ACTION_STATE_READ,
|
||||||
|
ACTION_STREAM_START_LIVE,
|
||||||
|
ACTION_STREAM_START_REPLAY,
|
||||||
|
ACTION_STREAM_STOP,
|
||||||
|
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||||
|
BleScanRequest,
|
||||||
|
ConnectRequest,
|
||||||
|
LiveRequest,
|
||||||
|
ReplayRequest,
|
||||||
|
ViewerSettingsRequest,
|
||||||
|
XgridsK1PluginFacade,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
||||||
|
"""Temporary flat API kept for scripts created before the plugin boundary."""
|
||||||
|
|
||||||
|
router = APIRouter(include_in_schema=True)
|
||||||
|
|
||||||
|
@router.get("/api/state", deprecated=True)
|
||||||
|
async def get_state() -> dict[str, Any]:
|
||||||
|
return await adapter.invoke(ACTION_STATE_READ, {})
|
||||||
|
|
||||||
|
@router.post("/api/ble/scan", deprecated=True)
|
||||||
|
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await adapter.invoke(ACTION_DISCOVERY_SCAN, request.model_dump())
|
||||||
|
except (PluginExecutionError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
|
||||||
|
|
||||||
|
@router.post("/api/connect", deprecated=True)
|
||||||
|
async def connect(request: ConnectRequest) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await adapter.invoke(ACTION_NETWORK_PROVISION, request.model_dump())
|
||||||
|
except (PluginExecutionError, ValueError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"Ошибка подключения устройства к Wi-Fi: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
@router.post("/api/session/live", deprecated=True)
|
||||||
|
async def start_live(request: LiveRequest) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await adapter.invoke(ACTION_STREAM_START_LIVE, request.model_dump())
|
||||||
|
except (PluginExecutionError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
@router.post("/api/session/replay", deprecated=True)
|
||||||
|
async def start_replay(request: ReplayRequest) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await adapter.invoke(ACTION_STREAM_START_REPLAY, request.model_dump())
|
||||||
|
except (PluginExecutionError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
@router.post("/api/session/stop", deprecated=True)
|
||||||
|
async def stop_session() -> dict[str, Any]:
|
||||||
|
return await adapter.invoke(ACTION_STREAM_STOP, {})
|
||||||
|
|
||||||
|
@router.post("/api/viewer/settings", deprecated=True)
|
||||||
|
async def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
|
||||||
|
return await adapter.invoke(ACTION_VIEWER_SETTINGS_UPDATE, request.model_dump())
|
||||||
|
|
||||||
|
@router.websocket("/api/events")
|
||||||
|
async def events(websocket: WebSocket) -> None:
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await websocket.send_json({"state": await adapter.invoke(ACTION_STATE_READ, {})})
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
return
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue