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)
|
||||
- [Mission Core monorepo and plugin boundary](docs/07_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)
|
||||
- [Session manifest schema](schemas/session-manifest.schema.json)
|
||||
- [Reference input provenance](docs/reference/README.md)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ device adapter, но структура интерфейса от него не
|
|||
| Контур | Состояние | Что это означает |
|
||||
| --- | --- | --- |
|
||||
| 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. |
|
||||
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
|
||||
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
|
||||
|
|
@ -42,7 +43,8 @@ K1 MQTT :1883, read-only
|
|||
└── встроенный @rerun-io/web-viewer
|
||||
└── пространственная сцена 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
|
||||
CoreBluetooth + live/replay runtime
|
||||
```
|
||||
|
|
@ -129,7 +131,7 @@ cd apps/control-station
|
|||
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 можно указать переменной
|
||||
`VITE_API_TARGET`. Preview production-сборки запускается командой
|
||||
`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.
|
||||
2. Выбрать **Парк → Локальное устройство**.
|
||||
3. Включить K1, дождаться стабильного индикатора и подтвердить это в форме.
|
||||
4. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
|
||||
3. В каталоге моделей выбрать **XGRIDS LixelKity K1**. Только после этого
|
||||
активируется runtime и монтируется custom UI XGRIDS-плагина.
|
||||
4. Включить K1, дождаться стабильного индикатора и подтвердить это в форме.
|
||||
5. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
|
||||
шестисекундного поиска; метка совместимости является подсказкой, выбор делает
|
||||
оператор.
|
||||
5. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
|
||||
6. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
|
||||
reviewed provisioning-запись без автоматических повторов.
|
||||
6. Запустить live-приём по определённому адресу K1 либо replay локального
|
||||
7. Запустить live-приём по определённому адресу K1 либо replay локального
|
||||
`.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и
|
||||
останавливается подтверждённым двойным нажатием кнопки устройства.
|
||||
7. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
|
||||
8. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
|
||||
state. Ручной source вводить не требуется.
|
||||
8. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
|
||||
9. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
|
||||
частота, число точек, задержка и пропуски preview появятся после прихода
|
||||
сообщений K1.
|
||||
|
||||
|
|
@ -197,16 +201,26 @@ Custom timeline, переключатель 2D/3D/карты, семантиче
|
|||
| Метод | Route | Тело / назначение |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/health` | Проверка локального сервиса. |
|
||||
| `GET` | `/api/state` | Авторитетный snapshot состояния. |
|
||||
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }`. |
|
||||
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }`. |
|
||||
| `POST` | `/api/session/live` | Опционально `{ "host", "duration_seconds" }`. |
|
||||
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }`. |
|
||||
| `POST` | `/api/session/stop` | Остановка активного источника. |
|
||||
| `POST` | `/api/viewer/settings` | Размер/цвет точек, накопление, видимость облака, траектории и сетки. |
|
||||
| `WS` | `/api/events` | Периодические snapshots для live UI. |
|
||||
| `GET` | `/api/v1/device-plugins` | Валидированные manifests установленных device plugins. |
|
||||
| `GET` | `/api/v1/device-models` | Backend-каталог моделей из валидированных manifests; UI-каталог текущей сборки формируется отдельным static composition root. |
|
||||
| `POST` | `/api/v1/device-plugins/{pluginId}/actions/{actionId}` | Namespaced действие через host allowlist; тело `{ "input": { ... } }`. |
|
||||
| `WS` | `/api/v1/device-plugins/{pluginId}/events` | Plugin-scoped snapshots с `pluginId` и монотонным `sequence`. |
|
||||
|
||||
`/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`,
|
||||
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_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/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/components/RerunViewport.tsx` | Жизненный цикл встроенного Rerun Web Viewer и selection events. |
|
||||
| `src/api.ts` | REST/WebSocket контракт с FastAPI. |
|
||||
| `src/useK1Console.ts` | Состояние backend, polling, события и действия оператора. |
|
||||
| `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. |
|
||||
| `src/presentation.ts` | Русские подписи фаз и форматирование реальных метрик. |
|
||||
| `src/messages.ts` | Обезличивание и локализация технических сообщений в пользовательском интерфейсе. |
|
||||
| `src/presentation.ts` | Vendor-neutral подписи lifecycle и форматирование нормализованных метрик. |
|
||||
| `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
|
||||
|
||||
## Safety и чувствительные данные
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ import {
|
|||
} from "@nodedc/ui-react";
|
||||
|
||||
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 {
|
||||
rootById,
|
||||
roots,
|
||||
|
|
@ -35,14 +37,12 @@ import {
|
|||
type RootId,
|
||||
} from "./productModel";
|
||||
import { backendLabel, phaseLabel, phaseTone } from "./presentation";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import {
|
||||
defaultSceneSettings,
|
||||
type PointColorMode,
|
||||
type PointPalette,
|
||||
type SceneSettings,
|
||||
} from "./sceneSettings";
|
||||
import { useK1Console } from "./useK1Console";
|
||||
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
||||
import { WorkspaceRenderer } from "./workspaces/Workspaces";
|
||||
import "./styles/scene-windows.css";
|
||||
|
|
@ -106,7 +106,8 @@ function mergeViewerSettings(
|
|||
}
|
||||
|
||||
export default function App() {
|
||||
const console = useK1Console();
|
||||
const runtime = useMissionRuntime();
|
||||
const { selection } = useDevicePluginHost();
|
||||
const workspace = useApplicationWorkspace<string>({
|
||||
navigationOpen: false,
|
||||
contentExpanded: true,
|
||||
|
|
@ -129,17 +130,17 @@ export default function App() {
|
|||
const activeDefinition = workspaceById(workspace.activeView);
|
||||
const rootWorkspaces = workspacesForRoot(activeRoot);
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
const remote = console.state?.viewer_settings;
|
||||
const remote = runtime.state?.viewerSettings;
|
||||
if (!remote) return;
|
||||
setSceneSettings((current) => mergeViewerSettings(current, remote));
|
||||
if (!displayWindowOpen) {
|
||||
setDisplayDraft((current) => mergeViewerSettings(current, remote));
|
||||
}
|
||||
}, [console.state?.viewer_settings, displayWindowOpen]);
|
||||
}, [runtime.state?.viewerSettings, displayWindowOpen]);
|
||||
|
||||
const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||
setSceneWindowOrder((current) => [
|
||||
|
|
@ -208,7 +209,7 @@ export default function App() {
|
|||
};
|
||||
|
||||
const applyDisplaySettings = async () => {
|
||||
const applied = await console.updateViewerSettings(toViewerSettings(displayDraft));
|
||||
const applied = await runtime.updateViewerSettings(toViewerSettings(displayDraft));
|
||||
if (applied) setSceneSettings(displayDraft);
|
||||
};
|
||||
|
||||
|
|
@ -217,7 +218,7 @@ export default function App() {
|
|||
const next = { ...sceneSettings, ...patch };
|
||||
setSceneSettings(next);
|
||||
setDisplayDraft((current) => (displayWindowOpen ? { ...current, ...patch } : next));
|
||||
const applied = await console.updateViewerSettings(toViewerSettings(next));
|
||||
const applied = await runtime.updateViewerSettings(toViewerSettings(next));
|
||||
if (!applied) {
|
||||
setSceneSettings(previous);
|
||||
if (!displayWindowOpen) setDisplayDraft(previous);
|
||||
|
|
@ -231,7 +232,7 @@ export default function App() {
|
|||
actions.push({
|
||||
label: "Обновить состояние локального контура",
|
||||
icon: "refresh",
|
||||
onClick: () => void console.refresh(),
|
||||
onClick: () => void runtime.refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -249,7 +250,7 @@ export default function App() {
|
|||
onClick: openLayout,
|
||||
});
|
||||
return actions;
|
||||
}, [activeDefinition?.kind, console, sceneSettings, sourceUrl]);
|
||||
}, [activeDefinition?.kind, runtime, sceneSettings, sourceUrl]);
|
||||
|
||||
const header = (
|
||||
<AppHeader
|
||||
|
|
@ -269,9 +270,9 @@ export default function App() {
|
|||
}
|
||||
right={
|
||||
<HeaderProfile>
|
||||
<HeaderProfileButton onClick={() => void console.refresh()} title="Обновить локальный контур">
|
||||
<span className="api-dot" data-status={console.backendStatus} aria-hidden="true" />
|
||||
{backendLabel(console.backendStatus)}
|
||||
<HeaderProfileButton onClick={() => void runtime.refresh()} title="Обновить локальный контур">
|
||||
<span className="api-dot" data-status={runtime.backendStatus} aria-hidden="true" />
|
||||
{backendLabel(runtime.backendStatus)}
|
||||
</HeaderProfileButton>
|
||||
<HeaderAvatar label="DC" />
|
||||
</HeaderProfile>
|
||||
|
|
@ -291,9 +292,9 @@ export default function App() {
|
|||
stage={
|
||||
<LandingStage
|
||||
root={currentRoot}
|
||||
backendStatus={console.backendStatus}
|
||||
phase={console.state?.phase}
|
||||
message={localizeRuntimeMessage(console.state?.message)}
|
||||
backendStatus={runtime.backendStatus}
|
||||
phase={runtime.state?.phase}
|
||||
message={runtime.state?.message}
|
||||
onOpenObservation={() => openView("spatial-scene")}
|
||||
onOpenDevice={() => openView("local-device")}
|
||||
/>
|
||||
|
|
@ -309,9 +310,12 @@ export default function App() {
|
|||
{
|
||||
id: "local-contour",
|
||||
label: "Локальный контур",
|
||||
description: console.state?.k1_ip || "Устройство не назначено",
|
||||
description:
|
||||
runtime.state?.activeDevice?.endpointLabel ||
|
||||
selection?.model.displayName ||
|
||||
"Модель не выбрана",
|
||||
icon: <Icon name="network" />,
|
||||
active: console.backendStatus !== "offline",
|
||||
active: runtime.backendStatus !== "offline" && runtime.backendStatus !== "unconfigured",
|
||||
},
|
||||
]}
|
||||
items={rootWorkspaces.map((item) => ({
|
||||
|
|
@ -341,8 +345,8 @@ export default function App() {
|
|||
onExpandedChange={workspace.setContentExpanded}
|
||||
headerTools={
|
||||
activeDefinition.kind === "device" ? (
|
||||
<StatusBadge tone={phaseTone(console.state?.phase)}>
|
||||
{phaseLabel(console.state?.phase)}
|
||||
<StatusBadge tone={phaseTone(runtime.state?.phase)}>
|
||||
{phaseLabel(runtime.state?.phase)}
|
||||
</StatusBadge>
|
||||
) : activeDefinition.kind === "spatial" ? (
|
||||
<StatusBadge tone={effectiveSourceUrl ? "accent" : "warning"}>
|
||||
|
|
@ -357,14 +361,13 @@ export default function App() {
|
|||
>
|
||||
{activeDefinition.kind === "device" ? (
|
||||
<DeviceWorkspace
|
||||
console={console}
|
||||
onOpenSpatialScene={() => openView("spatial-scene")}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceRenderer
|
||||
definition={activeDefinition}
|
||||
state={console.state}
|
||||
backendStatus={console.backendStatus}
|
||||
state={runtime.state}
|
||||
backendStatus={runtime.backendStatus}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
sceneSettings={sceneSettings}
|
||||
navigation={{
|
||||
|
|
@ -482,8 +485,8 @@ export default function App() {
|
|||
<div className="inspector-control-stack">
|
||||
<ControlRow label="Контур">
|
||||
<span className="scene-window-state">
|
||||
<i className="api-dot" data-status={console.backendStatus} aria-hidden="true" />
|
||||
{backendLabel(console.backendStatus)}
|
||||
<i className="api-dot" data-status={runtime.backendStatus} aria-hidden="true" />
|
||||
{backendLabel(runtime.backendStatus)}
|
||||
</span>
|
||||
</ControlRow>
|
||||
<p className="scene-window-note">
|
||||
|
|
@ -520,10 +523,10 @@ export default function App() {
|
|||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
disabled={console.pendingAction === "viewer"}
|
||||
disabled={runtime.pendingAction === "viewer"}
|
||||
onClick={() => void applyDisplaySettings()}
|
||||
>
|
||||
{console.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"}
|
||||
{runtime.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
|
|
@ -660,11 +663,11 @@ export default function App() {
|
|||
<div className="inspector-control-stack">
|
||||
<div className="nodedc-field">
|
||||
<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 className="nodedc-field">
|
||||
<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>
|
||||
),
|
||||
|
|
@ -675,7 +678,7 @@ export default function App() {
|
|||
description: "Преобразования и области обзора",
|
||||
content: (
|
||||
<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">
|
||||
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
|
||||
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
|
||||
import type { RootDefinition } from "../productModel";
|
||||
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
||||
import type { BackendStatus } from "../useK1Console";
|
||||
|
||||
export interface LandingStageProps {
|
||||
root: RootDefinition | null;
|
||||
backendStatus: BackendStatus;
|
||||
phase?: string | null;
|
||||
phase?: RuntimePhase | null;
|
||||
message?: string | null;
|
||||
onOpenObservation: () => 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 {
|
||||
device_id: string;
|
||||
name?: string | null;
|
||||
|
|
@ -9,7 +14,7 @@ export interface BleDevice {
|
|||
|
||||
export type SourceMode = "idle" | "live" | "replay";
|
||||
|
||||
export interface K1Metrics {
|
||||
export interface XgridsK1Metrics {
|
||||
mqtt_to_decode_ms?: number | null;
|
||||
decode_ms?: number | null;
|
||||
publish_ms?: number | null;
|
||||
|
|
@ -22,7 +27,7 @@ export interface K1Metrics {
|
|||
[key: string]: number | null | undefined;
|
||||
}
|
||||
|
||||
export interface ConsoleState {
|
||||
export interface XgridsK1State {
|
||||
phase?: string | null;
|
||||
message?: string | null;
|
||||
devices?: BleDevice[];
|
||||
|
|
@ -33,18 +38,7 @@ export interface ConsoleState {
|
|||
rerun_grpc_url?: string | null;
|
||||
viewer_settings?: ViewerSettings | null;
|
||||
source_mode?: SourceMode | null;
|
||||
metrics?: K1Metrics;
|
||||
}
|
||||
|
||||
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;
|
||||
metrics?: XgridsK1Metrics;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function unwrapState(payload: unknown): ConsoleState {
|
||||
function unwrapState(payload: unknown): XgridsK1State {
|
||||
const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload;
|
||||
|
||||
if (!isRecord(value)) {
|
||||
throw new ApiError("Локальный сервер вернул некорректное состояние.");
|
||||
}
|
||||
|
||||
return value as ConsoleState;
|
||||
return value as XgridsK1State;
|
||||
}
|
||||
|
||||
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
|
|
@ -142,20 +136,27 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
|||
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, {
|
||||
method: "POST",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (payload === undefined) {
|
||||
return api.getState();
|
||||
return xgridsK1Api.getState();
|
||||
}
|
||||
|
||||
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> {
|
||||
const payload = await requestJson("/api/health");
|
||||
if (!isRecord(payload)) {
|
||||
|
|
@ -164,45 +165,48 @@ export const api = {
|
|||
return payload as HealthResponse;
|
||||
},
|
||||
|
||||
async getState(): Promise<ConsoleState> {
|
||||
return unwrapState(await requestJson("/api/state"));
|
||||
async getState(): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.stateRead);
|
||||
},
|
||||
|
||||
scanBle(body: ScanRequest = {}): Promise<ConsoleState> {
|
||||
return postState("/api/ble/scan", body);
|
||||
scanBle(body: ScanRequest = {}): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.discoveryScan, body);
|
||||
},
|
||||
|
||||
connect(body: ConnectRequest): Promise<ConsoleState> {
|
||||
return postState("/api/connect", body);
|
||||
connect(body: ConnectRequest): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.networkProvision, body);
|
||||
},
|
||||
|
||||
startLive(body: LiveRequest = {}): Promise<ConsoleState> {
|
||||
return postState("/api/session/live", body);
|
||||
startLive(body: LiveRequest = {}): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.streamStartLive, body);
|
||||
},
|
||||
|
||||
startReplay(body: ReplayRequest): Promise<ConsoleState> {
|
||||
return postState("/api/session/replay", body);
|
||||
startReplay(body: ReplayRequest): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.streamStartReplay, body);
|
||||
},
|
||||
|
||||
stopSession(): Promise<ConsoleState> {
|
||||
return postState("/api/session/stop");
|
||||
stopSession(): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.streamStop);
|
||||
},
|
||||
|
||||
updateViewerSettings(body: ViewerSettings): Promise<ConsoleState> {
|
||||
return postState("/api/viewer/settings", body);
|
||||
updateViewerSettings(body: ViewerSettings): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.viewerSettingsUpdate, body);
|
||||
},
|
||||
};
|
||||
|
||||
export type EventSocketStatus = "connecting" | "open" | "closed" | "error";
|
||||
|
||||
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:";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function openEventSocket(
|
||||
onState: (state: ConsoleState) => void,
|
||||
onState: (state: XgridsK1State) => void,
|
||||
onStatus: (status: EventSocketStatus) => void,
|
||||
): () => void {
|
||||
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 type { BackendStatus, ViewerSettings } from "../../core/runtime/contracts";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
api,
|
||||
xgridsK1Api,
|
||||
openEventSocket,
|
||||
type ConnectRequest,
|
||||
type ConsoleState,
|
||||
type XgridsK1State,
|
||||
type EventSocketStatus,
|
||||
type LiveRequest,
|
||||
type ReplayRequest,
|
||||
type ViewerSettings,
|
||||
} from "./api";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
|
||||
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
|
||||
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer";
|
||||
|
||||
function messageFor(error: unknown): string {
|
||||
|
|
@ -26,7 +26,7 @@ function messageFor(error: unknown): string {
|
|||
return "Запрос к локальному сервису устройства завершился ошибкой.";
|
||||
}
|
||||
|
||||
function measuredLatency(state: ConsoleState | null): number | null {
|
||||
function measuredLatency(state: XgridsK1State | null): number | null {
|
||||
if (state?.source_mode !== "live") return null;
|
||||
const metrics = state?.metrics;
|
||||
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;
|
||||
}
|
||||
|
||||
export function useK1Console() {
|
||||
const [state, setState] = useState<ConsoleState | null>(null);
|
||||
export function useXgridsK1Runtime(enabled: boolean) {
|
||||
const [state, setState] = useState<XgridsK1State | null>(null);
|
||||
const [backendStatus, setBackendStatus] = useState<BackendStatus>("checking");
|
||||
const [eventStatus, setEventStatus] = useState<EventSocketStatus>("connecting");
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
|
|
@ -51,15 +51,16 @@ export function useK1Console() {
|
|||
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
|
||||
const mounted = useRef(true);
|
||||
|
||||
const acceptState = useCallback((nextState: ConsoleState) => {
|
||||
const acceptState = useCallback((nextState: XgridsK1State) => {
|
||||
setState(nextState);
|
||||
setBackendStatus("online");
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async (reportErrors = true) => {
|
||||
if (!enabled) return;
|
||||
const [healthResult, stateResult] = await Promise.allSettled([
|
||||
api.getHealth(),
|
||||
api.getState(),
|
||||
xgridsK1Api.getHealth(),
|
||||
xgridsK1Api.getState(),
|
||||
]);
|
||||
|
||||
if (!mounted.current) return;
|
||||
|
|
@ -80,10 +81,11 @@ export function useK1Console() {
|
|||
if (stateResult.status === "rejected" && reportErrors) {
|
||||
setError(messageFor(stateResult.reason));
|
||||
}
|
||||
}, [acceptState]);
|
||||
}, [acceptState, enabled]);
|
||||
|
||||
const run = useCallback(
|
||||
async (action: PendingAction, operation: () => Promise<ConsoleState>) => {
|
||||
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
|
||||
if (!enabled) return false;
|
||||
setPendingAction(action);
|
||||
setError(null);
|
||||
|
||||
|
|
@ -103,40 +105,51 @@ export function useK1Console() {
|
|||
if (mounted.current) setPendingAction(null);
|
||||
}
|
||||
},
|
||||
[acceptState],
|
||||
[acceptState, enabled],
|
||||
);
|
||||
|
||||
const scan = useCallback(
|
||||
() => run("scan", () => api.scanBle({ duration_seconds: 6 })),
|
||||
() => run("scan", () => xgridsK1Api.scanBle({ duration_seconds: 6 })),
|
||||
[run],
|
||||
);
|
||||
|
||||
const connect = useCallback(
|
||||
(request: ConnectRequest) => run("connect", () => api.connect(request)),
|
||||
(request: ConnectRequest) => run("connect", () => xgridsK1Api.connect(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
const startLive = useCallback(
|
||||
(request: LiveRequest = {}) => run("live", () => api.startLive(request)),
|
||||
(request: LiveRequest = {}) => run("live", () => xgridsK1Api.startLive(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
const startReplay = useCallback(
|
||||
(request: ReplayRequest) => run("replay", () => api.startReplay(request)),
|
||||
(request: ReplayRequest) => run("replay", () => xgridsK1Api.startReplay(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
const stop = useCallback(
|
||||
() => run("stop", () => api.stopSession()),
|
||||
() => run("stop", () => xgridsK1Api.stopSession()),
|
||||
[run],
|
||||
);
|
||||
|
||||
const updateViewerSettings = useCallback(
|
||||
(request: ViewerSettings) => run("viewer", () => api.updateViewerSettings(request)),
|
||||
(request: ViewerSettings) => run("viewer", () => xgridsK1Api.updateViewerSettings(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
mounted.current = true;
|
||||
setState(null);
|
||||
setBackendStatus("checking");
|
||||
setEventStatus("closed");
|
||||
setPendingAction(null);
|
||||
setError(null);
|
||||
setLatencyHistory([]);
|
||||
return;
|
||||
}
|
||||
|
||||
mounted.current = true;
|
||||
void refresh(true);
|
||||
const poll = window.setInterval(() => void refresh(false), 4_000);
|
||||
|
|
@ -145,9 +158,11 @@ export function useK1Console() {
|
|||
mounted.current = false;
|
||||
window.clearInterval(poll);
|
||||
};
|
||||
}, [refresh]);
|
||||
}, [enabled, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let dispose: (() => void) | undefined;
|
||||
let retry: number | undefined;
|
||||
let cancelled = false;
|
||||
|
|
@ -173,7 +188,7 @@ export function useK1Console() {
|
|||
if (retry !== undefined) window.clearTimeout(retry);
|
||||
dispose?.();
|
||||
};
|
||||
}, [acceptState]);
|
||||
}, [acceptState, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const latency = measuredLatency(state);
|
||||
|
|
@ -6,6 +6,8 @@ import "@nodedc/tokens/themes.css";
|
|||
import "@nodedc/ui-core/styles.css";
|
||||
|
||||
import App from "./App";
|
||||
import { installedDevicePlugins } from "./composition/devicePlugins";
|
||||
import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost";
|
||||
import "./styles.css";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
|
@ -20,6 +22,8 @@ applyNodedcTheme(rootElement, { theme: "dark" });
|
|||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<DevicePluginHostProvider plugins={installedDevicePlugins}>
|
||||
<App />
|
||||
</DevicePluginHostProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,39 +1,38 @@
|
|||
import type { StatusTone } from "@nodedc/ui-react";
|
||||
|
||||
import type { K1Metrics } from "./api";
|
||||
import type { BackendStatus } from "./useK1Console";
|
||||
import type {
|
||||
BackendStatus,
|
||||
RuntimePhase,
|
||||
SourceMode,
|
||||
StreamMetrics,
|
||||
} from "./core/runtime/contracts";
|
||||
|
||||
const phaseLabels: Record<string, string> = {
|
||||
const phaseLabels: Record<RuntimePhase, string> = {
|
||||
unconfigured: "Модель не выбрана",
|
||||
idle: "Ожидание",
|
||||
scanning: "Поиск Bluetooth",
|
||||
device_selected: "Устройство выбрано",
|
||||
provisioning: "Передача настроек Wi‑Fi",
|
||||
connecting: "Подключение",
|
||||
configuring: "Настройка устройства",
|
||||
connected: "Устройство подключено",
|
||||
starting_live: "Запуск потока",
|
||||
live: "Поток в реальном времени",
|
||||
replay: "Повтор записи",
|
||||
starting: "Запуск потока",
|
||||
streaming: "Поток в реальном времени",
|
||||
replaying: "Повтор записи",
|
||||
stopping: "Остановка",
|
||||
error: "Ошибка",
|
||||
};
|
||||
|
||||
export function phaseLabel(phase: string | null | undefined): string {
|
||||
if (!phase) return "Нет состояния";
|
||||
return phaseLabels[phase] ?? "Неизвестное состояние";
|
||||
export function phaseLabel(phase: RuntimePhase | null | undefined): string {
|
||||
return phase ? phaseLabels[phase] : "Нет состояния";
|
||||
}
|
||||
|
||||
export function phaseTone(phase: string | null | undefined): StatusTone {
|
||||
if (!phase) return "neutral";
|
||||
export function phaseTone(phase: RuntimePhase | null | undefined): StatusTone {
|
||||
if (!phase || phase === "unconfigured" || phase === "idle") 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";
|
||||
if (["connected", "streaming", "replaying"].includes(phase)) return "success";
|
||||
return "accent";
|
||||
}
|
||||
|
||||
export function backendLabel(status: BackendStatus): string {
|
||||
return {
|
||||
unconfigured: "Модель не выбрана",
|
||||
checking: "Проверка контура",
|
||||
online: "Контур доступен",
|
||||
degraded: "Контур ограничен",
|
||||
|
|
@ -44,33 +43,16 @@ export function backendLabel(status: BackendStatus): string {
|
|||
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: K1Metrics | 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 pipelineLatency(metrics: StreamMetrics | undefined): number | null {
|
||||
return finiteMetric(metrics?.latencyMs);
|
||||
}
|
||||
|
||||
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 === "replay") return "Повтор записи";
|
||||
if (mode === "idle") return "Ожидание";
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||
description: "От физического устройства до операторского интерфейса.",
|
||||
capabilities: [
|
||||
active("Локальный API", "Проверка состояния, резервный REST-опрос и канал событий WebSocket."),
|
||||
active("Приём данных", "MQTT, декодирование облака точек и позы."),
|
||||
active("Приём данных", "Плагинский транспорт, нормализация облака точек и позы."),
|
||||
ready("Визуальный движок", "Встроенный веб-визуализатор Rerun ожидает совместимый источник."),
|
||||
contract("Бортовой шлюз", "Будущий транспортный адаптер между ROS 2/Zenoh и пунктом управления."),
|
||||
],
|
||||
|
|
@ -231,7 +231,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||
label: "Локальное устройство",
|
||||
title: "Локальное устройство",
|
||||
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
|
||||
description: "Рабочий путь BLE → Wi‑Fi → поток для первого подключённого устройства.",
|
||||
description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
|
||||
icon: "network",
|
||||
kind: "device",
|
||||
groups: [],
|
||||
|
|
@ -526,8 +526,8 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||
title: "Артефакты",
|
||||
description: "Сохраняется политика «сначала исходные данные».",
|
||||
capabilities: [
|
||||
active("Нативные захваты", "Сырые данные MQTT и обезличенный манифест."),
|
||||
active("Воспроизведение", "Воспроизведение исходной записи MQTT и проверенного TSV."),
|
||||
active("Нативные захваты", "Сырые конверты устройства и обезличенный манифест."),
|
||||
active("Воспроизведение", "Повтор исходной записи через адаптер выбранной модели."),
|
||||
ready("Запись RRD", "Совместимая запись Rerun после появления потокового адаптера."),
|
||||
ready("Компоновка RBL", "Версионируемая компоновка визуализатора рядом с кодом."),
|
||||
],
|
||||
|
|
@ -548,7 +548,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||
title: "Транспорт",
|
||||
description: "Источник отделён от визуального представления.",
|
||||
capabilities: [
|
||||
active("MQTT вход", "Доказанный локальный поток текущего устройства."),
|
||||
active("Плагинский вход", "Локальный поток активной модели устройства."),
|
||||
active("Управление REST / WebSocket", "Состояние и управляющие операции локального адаптера."),
|
||||
ready("Rerun gRPC", "Нативный источник реального времени для встроенного веб-визуализатора."),
|
||||
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 {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
|
|
@ -414,3 +557,4 @@
|
|||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
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);
|
||||
}
|
||||
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.device-workspace__grid,
|
||||
.xgrids-k1-plugin .device-workspace__grid,
|
||||
.overview-grid,
|
||||
.mission-layout {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.diagnostics-grid {
|
||||
.xgrids-k1-plugin .diagnostics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
|
@ -210,32 +210,32 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
.session-form--replay {
|
||||
.xgrids-k1-plugin .session-form--replay {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.session-form--replay > :first-child {
|
||||
.xgrids-k1-plugin .session-form--replay > :first-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.session-footer,
|
||||
.error-banner,
|
||||
.xgrids-k1-plugin .session-footer,
|
||||
.xgrids-k1-plugin .error-banner,
|
||||
.source-format-list > div {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
.xgrids-k1-plugin .error-banner {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.error-banner__actions {
|
||||
.xgrids-k1-plugin .error-banner__actions {
|
||||
grid-column: 2;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.device-row__action {
|
||||
.xgrids-k1-plugin .device-row__action {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,556 +1,122 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
type StatusTone,
|
||||
} from "@nodedc/ui-react";
|
||||
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { BleDevice } from "../api";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { localizeRuntimeMessage } from "../messages";
|
||||
import {
|
||||
backendLabel,
|
||||
backendTone,
|
||||
eventStatusLabel,
|
||||
finiteMetric,
|
||||
formatNumber,
|
||||
phaseLabel,
|
||||
phaseTone,
|
||||
pipelineLatency,
|
||||
sourceModeLabel,
|
||||
} from "../presentation";
|
||||
import type { useK1Console } from "../useK1Console";
|
||||
import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost";
|
||||
import type { RegisteredDeviceModel } from "../core/device-plugins/contracts";
|
||||
|
||||
type ConsoleController = ReturnType<typeof useK1Console>;
|
||||
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,
|
||||
function ModelCard({
|
||||
registered,
|
||||
onSelect,
|
||||
}: {
|
||||
device: BleDevice;
|
||||
selected: boolean;
|
||||
registered: RegisteredDeviceModel;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { model, plugin } = registered;
|
||||
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>
|
||||
<article className="device-model-card">
|
||||
<header>
|
||||
<div className="device-model-card__mark" aria-hidden="true">
|
||||
<Icon name="network" />
|
||||
</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 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>
|
||||
<Button width="full" variant="primary" onClick={onSelect}>
|
||||
Выбрать модель
|
||||
</Button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
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 DeviceWorkspace({
|
||||
console,
|
||||
onOpenSpatialScene,
|
||||
}: {
|
||||
console: ConsoleController;
|
||||
onOpenSpatialScene: () => void;
|
||||
}) {
|
||||
export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: () => void }) {
|
||||
const {
|
||||
state,
|
||||
backendStatus,
|
||||
eventStatus,
|
||||
pendingAction,
|
||||
error,
|
||||
latencyHistory,
|
||||
refresh,
|
||||
clearError,
|
||||
scan,
|
||||
connect,
|
||||
startLive,
|
||||
startReplay,
|
||||
stop,
|
||||
} = console;
|
||||
registry,
|
||||
selection,
|
||||
selectionTransitionPending,
|
||||
selectionTransitionError,
|
||||
selectModel,
|
||||
clearSelection,
|
||||
} = useDevicePluginHost();
|
||||
|
||||
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 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" />
|
||||
if (!selection) {
|
||||
return (
|
||||
<div className="device-workspace device-workspace--model-picker">
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<div>
|
||||
<strong>Локальная операция завершилась ошибкой</strong>
|
||||
<p>{localizeRuntimeMessage(error)}</p>
|
||||
<span className="section-eyebrow">ЛОКАЛЬНОЕ УСТРОЙСТВО · ШАГ 01</span>
|
||||
<h2>Выберите модель устройства</h2>
|
||||
<p>
|
||||
Mission Core покажет только сценарий выбранного плагина. До выбора модели нет
|
||||
поиска оборудования, сетевых реквизитов, управления потоком или фиктивных метрик.
|
||||
</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 className="workspace-lead__status">
|
||||
<StatusBadge tone="neutral">Модель не выбрана</StatusBadge>
|
||||
<span>{registry.models.length} моделей доступно в локальной сборке</span>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<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">
|
||||
<GlassSurface className="device-model-catalog" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span>
|
||||
<h2>Подключите устройство к сети</h2>
|
||||
<span className="section-eyebrow">УСТАНОВЛЕННЫЕ ПЛАГИНЫ</span>
|
||||
<h2>Парк поддерживаемых моделей</h2>
|
||||
</div>
|
||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
||||
<StatusBadge tone="neutral">{registry.plugins.length} плагин</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 className="device-model-grid">
|
||||
{registry.models.map((registered) => (
|
||||
<ModelCard
|
||||
key={registered.model.id}
|
||||
registered={registered}
|
||||
onSelect={() => void selectModel(registered.model.id)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ import {
|
|||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { ConsoleState } from "../api";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { localizeRuntimeMessage } from "../messages";
|
||||
import type { BackendStatus, MissionRuntimeState } from "../core/runtime/contracts";
|
||||
import {
|
||||
RerunViewport,
|
||||
type RerunSelection,
|
||||
|
|
@ -21,7 +20,6 @@ import {
|
|||
} from "../productModel";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import type { BackendStatus } from "../useK1Console";
|
||||
|
||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||
if (status === "active") return "success";
|
||||
|
|
@ -82,7 +80,7 @@ export interface WorkspaceNavigation {
|
|||
|
||||
export interface WorkspaceRendererProps {
|
||||
definition: WorkspaceDefinition;
|
||||
state: ConsoleState | null;
|
||||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
sourceUrl: string;
|
||||
sceneSettings: SceneSettings;
|
||||
|
|
@ -95,13 +93,13 @@ function OverviewWorkspace({
|
|||
backendStatus,
|
||||
navigation,
|
||||
}: 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 latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
const frameRate = finiteMetric(metrics?.frameRateHz);
|
||||
const points = finiteMetric(metrics?.pointCount);
|
||||
const adapterOnline = backendStatus === "online";
|
||||
const rerunReady = Boolean(state?.rerun_grpc_url);
|
||||
const rerunReady = Boolean(state?.spatialSource?.url);
|
||||
|
||||
return (
|
||||
<div className="standard-workspace overview-workspace">
|
||||
|
|
@ -115,7 +113,7 @@ function OverviewWorkspace({
|
|||
eyebrow="ДО ПУБЛИКАЦИИ"
|
||||
value={formatNumber(latency)}
|
||||
unit="мс"
|
||||
detail="MQTT callback → Rerun SDK; без экрана"
|
||||
detail="Вход адаптера → Scene Sink; без экрана"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ЧАСТОТА"
|
||||
|
|
@ -130,7 +128,7 @@ function OverviewWorkspace({
|
|||
/>
|
||||
<MetricCard
|
||||
eyebrow="РЕЖИМ"
|
||||
value={sourceModeLabel(state?.source_mode)}
|
||||
value={sourceModeLabel(state?.sourceMode)}
|
||||
detail="Реальное время, повтор или ожидание"
|
||||
/>
|
||||
</section>
|
||||
|
|
@ -147,16 +145,16 @@ function OverviewWorkspace({
|
|||
</StatusBadge>
|
||||
</header>
|
||||
<div className="pipeline-strip" aria-label="Путь данных">
|
||||
<div data-state={state?.k1_ip ? "ready" : "idle"}>
|
||||
<div data-state={state?.activeDevice ? "ready" : "idle"}>
|
||||
<span>01</span>
|
||||
<strong>Устройство</strong>
|
||||
<small>{state?.k1_ip || "не назначено"}</small>
|
||||
<small>{state?.activeDevice?.endpointLabel || state?.activeDevice?.displayName || "не назначено"}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state={streamActive ? "ready" : "idle"}>
|
||||
<span>02</span>
|
||||
<strong>Адаптер</strong>
|
||||
<small>{sourceModeLabel(state?.source_mode)}</small>
|
||||
<small>{sourceModeLabel(state?.sourceMode)}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state={rerunReady ? "ready" : "idle"}>
|
||||
|
|
@ -185,7 +183,7 @@ function OverviewWorkspace({
|
|||
<span><Icon name="network" /></span>
|
||||
<div>
|
||||
<strong>Подключить устройство</strong>
|
||||
<small>Bluetooth, Wi‑Fi и запуск потока</small>
|
||||
<small>Сценарий установленного плагина и запуск потока</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
|
|
@ -243,11 +241,11 @@ function SpatialWorkspace({
|
|||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
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 latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
const frameRate = finiteMetric(metrics?.frameRateHz);
|
||||
const points = finiteMetric(metrics?.pointCount);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
|
|
@ -315,11 +313,11 @@ function SpatialWorkspace({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{state?.source_mode && state.source_mode !== "idle" && !sourceUrl.trim() ? (
|
||||
{state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
||||
<div className="scene-adapter-note">
|
||||
<Icon name="alert" />
|
||||
<span>
|
||||
Локальный поток <strong>{sourceModeLabel(state.source_mode).toLocaleLowerCase("ru-RU")}</strong> активен,
|
||||
Локальный поток <strong>{sourceModeLabel(state.sourceMode).toLocaleLowerCase("ru-RU")}</strong> активен,
|
||||
Rerun-мост запускается и опубликует адрес автоматически.
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -440,11 +438,11 @@ function MapWorkspace({ definition }: WorkspaceRendererProps) {
|
|||
|
||||
function TimelineWorkspace({ definition, state }: WorkspaceRendererProps) {
|
||||
const rows = useMemo(() => [
|
||||
{ label: "Устройство", value: state?.phase ? sourceModeLabel(state.source_mode) : "нет событий", 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?.metrics?.point_count ? "синхронно" : "нет данных", active: Boolean(state?.metrics?.point_count) },
|
||||
{ label: "Устройство", value: state?.phase ? sourceModeLabel(state.sourceMode) : "нет событий", active: Boolean(state?.phase) },
|
||||
{ label: "Поток точек", value: state?.sourceMode && state.sourceMode !== "idle" ? "активен" : "ожидание", active: state?.sourceMode !== "idle" && Boolean(state?.sourceMode) },
|
||||
{ label: "Поза", value: state?.metrics?.pointCount ? "синхронно" : "нет данных", active: Boolean(state?.metrics?.pointCount) },
|
||||
{ label: "Камеры", value: "каналы не назначены", active: false },
|
||||
{ label: "События", value: localizeRuntimeMessage(state?.message) || "нет событий", active: Boolean(state?.message) },
|
||||
{ label: "События", value: state?.message || "нет событий", active: Boolean(state?.message) },
|
||||
], [state]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -32,8 +32,11 @@ exist.
|
|||
## Invariants
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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;
|
||||
- provisioning profile `xgrids-k1-fw3-wifi-v1`;
|
||||
- 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.
|
||||
|
||||
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
|
||||
extracted incrementally.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
This directory reserves the versioned host/plugin contract. It is not yet a
|
||||
published SDK and must not be treated as an implemented runtime.
|
||||
This directory owns the versioned host/plugin contract. The v1alpha frontend
|
||||
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;
|
||||
- provisioning requests using secret references;
|
||||
- device-session lifecycle and health;
|
||||
|
|
@ -14,5 +19,25 @@ The first contract will define:
|
|||
- SceneSink and event interfaces;
|
||||
- capability-driven UI contribution data without plugin-owned layout.
|
||||
|
||||
The XGRIDS K1 extraction is the first acceptance test. A synthetic reference
|
||||
plugin will verify that no K1 names or protocol assumptions remain in core.
|
||||
The XGRIDS K1 extraction is the first real-device acceptance path. Synthetic
|
||||
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
|
||||
|
||||
This directory is the target package boundary for the first Mission Core device
|
||||
plugin. The verified implementation remains temporarily in `src/k1link` so the
|
||||
This directory is the package boundary for the first Mission Core device
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
- 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
|
||||
truth and every move into this package must preserve replay and real-device
|
||||
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]
|
||||
name = "nodedc-mission-core"
|
||||
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"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
license = { text = "Proprietary" }
|
||||
|
|
|
|||
|
|
@ -146,6 +146,8 @@ class VisualizationRuntime:
|
|||
return
|
||||
assert thread is not None
|
||||
thread.join(timeout=wait_seconds)
|
||||
if thread.is_alive():
|
||||
raise RuntimeError("поток не завершился за отведённое время; повторите остановку")
|
||||
|
||||
def close(self, *, wait_seconds: float = 5.0) -> None:
|
||||
"""Stop the active source and release the process-wide visual bridge."""
|
||||
|
|
@ -324,9 +326,7 @@ class VisualizationRuntime:
|
|||
self._rerun_grpc_url = bridge.grpc_url
|
||||
if not self._closed and self._phase != "stopping":
|
||||
self._phase = running_phase
|
||||
self._message = (
|
||||
"Локальный Rerun-мост готов; источник данных запущен."
|
||||
)
|
||||
self._message = "Локальный Rerun-мост готов; источник данных запущен."
|
||||
publisher_ready.set()
|
||||
self._notify()
|
||||
if publisher_aborted.is_set():
|
||||
|
|
|
|||
|
|
@ -1,255 +1,33 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from bleak.exc import BleakError
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import ValidationError
|
||||
|
||||
from k1link import __version__
|
||||
from k1link.artifacts import write_json_atomic
|
||||
from k1link.ble.scanner import scan
|
||||
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
|
||||
from k1link.mqtt import validate_private_ipv4
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
|
||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||
from k1link.web.plugin_runtime import (
|
||||
STATE_READ_ACTION_ID,
|
||||
DevicePluginActionRequest,
|
||||
DevicePluginDispatcher,
|
||||
PluginActionNotFoundError,
|
||||
PluginExecutionError,
|
||||
PluginNotFoundError,
|
||||
)
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class BleScanRequest(BaseModel):
|
||||
duration_seconds: float = Field(default=6.0, ge=1.0, le=60.0)
|
||||
|
||||
|
||||
class ConnectRequest(BaseModel):
|
||||
device_id: str = Field(min_length=1, max_length=128)
|
||||
ssid: str = Field(min_length=1, max_length=128)
|
||||
password: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class LiveRequest(BaseModel):
|
||||
host: str | None = Field(default=None, max_length=15)
|
||||
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
|
||||
|
||||
|
||||
class ReplayRequest(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=4096)
|
||||
speed: float = Field(default=1.0, ge=0.0, le=100.0)
|
||||
loop: bool = False
|
||||
|
||||
|
||||
class ViewerSettingsRequest(BaseModel):
|
||||
point_size: float = Field(default=2.5, ge=0.5, le=12.0)
|
||||
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
|
||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
|
||||
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
accumulation_seconds: float = Field(default=12.0, ge=0.0, le=120.0)
|
||||
show_points: bool = True
|
||||
show_trajectory: bool = True
|
||||
show_grid: bool = True
|
||||
|
||||
|
||||
class ConsoleService:
|
||||
def __init__(self, repository_root: Path) -> None:
|
||||
self.repository_root = repository_root.resolve()
|
||||
self._lock = threading.Lock()
|
||||
self._devices: list[dict[str, Any]] = []
|
||||
self._selected_device_id: str | None = None
|
||||
self._k1_ip: str | None = None
|
||||
self._operation_phase: str | None = None
|
||||
self._operation_message: str | None = None
|
||||
self.runtime = VisualizationRuntime()
|
||||
|
||||
def state(self) -> dict[str, Any]:
|
||||
runtime = self.runtime.snapshot()
|
||||
metrics = runtime["metrics"]
|
||||
with self._lock:
|
||||
operation_phase = self._operation_phase
|
||||
operation_message = self._operation_message
|
||||
devices = list(self._devices)
|
||||
selected_device_id = self._selected_device_id
|
||||
k1_ip = self._k1_ip
|
||||
|
||||
runtime_active = runtime["source_mode"] != "idle" or runtime["phase"] in {
|
||||
"starting_live",
|
||||
"stopping",
|
||||
"error",
|
||||
}
|
||||
if operation_phase is not None:
|
||||
phase = operation_phase
|
||||
message = operation_message
|
||||
elif runtime_active:
|
||||
phase = runtime["phase"]
|
||||
message = runtime["message"]
|
||||
elif k1_ip is not None:
|
||||
phase = "connected"
|
||||
message = runtime["message"]
|
||||
elif selected_device_id is not None:
|
||||
phase = "device_selected"
|
||||
message = "Устройство выбрано. Теперь введите название и пароль Wi-Fi."
|
||||
elif devices:
|
||||
likely_count = sum(bool(item.get("likely_k1")) for item in devices)
|
||||
phase = "idle"
|
||||
message = (
|
||||
f"Найдено BLE-устройств: {len(devices)}. "
|
||||
f"Совместимых профилей: {likely_count}. Выберите нужное устройство."
|
||||
)
|
||||
else:
|
||||
phase = "idle"
|
||||
message = runtime["message"]
|
||||
|
||||
return {
|
||||
"phase": phase,
|
||||
"message": message,
|
||||
"devices": devices,
|
||||
"selected_device_id": selected_device_id,
|
||||
"k1_ip": k1_ip,
|
||||
"foxglove_ws_url": runtime["foxglove_ws_url"],
|
||||
"foxglove_viewer_url": runtime["foxglove_viewer_url"],
|
||||
"rerun_grpc_url": runtime["rerun_grpc_url"],
|
||||
"viewer_settings": runtime["viewer_settings"],
|
||||
"source_mode": runtime["source_mode"],
|
||||
"metrics": {
|
||||
"pipeline_ms": metrics["mqtt_to_publish_ms"],
|
||||
"end_to_end_ms": metrics["mqtt_to_publish_ms"],
|
||||
"decode_ms": metrics["decode_publish_ms"],
|
||||
"frame_rate": metrics["pcl_fps"],
|
||||
"frame_rate_hz": metrics["pcl_fps"],
|
||||
"point_count": metrics["last_point_count"],
|
||||
"dropped_preview_frames": metrics["preview_dropped"],
|
||||
**metrics,
|
||||
},
|
||||
}
|
||||
|
||||
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
|
||||
self._set_operation("scanning", "Сканируем все устройства Bluetooth (BLE)…")
|
||||
try:
|
||||
result = await scan(duration_seconds)
|
||||
devices = [
|
||||
{
|
||||
"device_id": item["macos_uuid"],
|
||||
"name": item["local_name"] or item["name"],
|
||||
"rssi": item["rssi"],
|
||||
"address": None,
|
||||
"connectable": True,
|
||||
"likely_k1": item["k1_name_candidate"],
|
||||
}
|
||||
for item in result["devices"]
|
||||
]
|
||||
with self._lock:
|
||||
self._devices = devices
|
||||
self._operation_message = (
|
||||
f"Поиск завершён. Найдено BLE-устройств: {len(devices)}."
|
||||
)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._operation_phase = None
|
||||
return self.state()
|
||||
|
||||
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
|
||||
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
|
||||
if request.device_id not in known_ids:
|
||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
||||
self._set_operation(
|
||||
"provisioning",
|
||||
"Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
|
||||
)
|
||||
session_dir = _new_operation_session_dir(
|
||||
self.repository_root,
|
||||
"viewer_wifi_provisioning",
|
||||
)
|
||||
session_dir.mkdir(parents=True, exist_ok=False)
|
||||
password = request.password
|
||||
try:
|
||||
result = await provision_wifi_once(
|
||||
request.device_id,
|
||||
request.ssid,
|
||||
password,
|
||||
timeout_seconds=45.0,
|
||||
write_mode="auto",
|
||||
)
|
||||
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
|
||||
ipv4 = _provisioned_ipv4(result)
|
||||
write_json_atomic(
|
||||
session_dir / "manifest.redacted.json",
|
||||
{
|
||||
"schema_version": 1,
|
||||
"started_at_utc": result["started_at_utc"],
|
||||
"completed_at_utc": result["completed_at_utc"],
|
||||
"operation": "single_reviewed_wifi_provisioning_write",
|
||||
"profile_id": result["profile_id"],
|
||||
"outcome": result["outcome"],
|
||||
"k1_lan_address_observed": ipv4 is not None,
|
||||
"credentials_persisted_by_connector": False,
|
||||
},
|
||||
)
|
||||
if ipv4 is None:
|
||||
raise RuntimeError(
|
||||
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
|
||||
)
|
||||
with self._lock:
|
||||
self._selected_device_id = request.device_id
|
||||
self._k1_ip = ipv4
|
||||
self._operation_message = (
|
||||
"Устройство подключено к Wi-Fi и сообщило локальный адрес."
|
||||
)
|
||||
finally:
|
||||
password = ""
|
||||
with self._lock:
|
||||
self._operation_phase = None
|
||||
return self.state()
|
||||
|
||||
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]:
|
||||
target = host or self.state()["k1_ip"]
|
||||
if not isinstance(target, str) or not target:
|
||||
raise ValueError(
|
||||
"сначала подключите устройство к Wi-Fi или укажите его локальный адрес"
|
||||
)
|
||||
target = validate_private_ipv4(target)
|
||||
out_dir = new_live_session_dir(self.repository_root)
|
||||
self.runtime.start_live(target, out_dir, duration_seconds=duration_seconds)
|
||||
return self.state()
|
||||
|
||||
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
|
||||
replay_path = Path(path).expanduser().resolve()
|
||||
if not replay_path.is_relative_to(self.repository_root):
|
||||
raise ValueError("файл записи должен находиться внутри репозитория")
|
||||
self.runtime.start_replay(replay_path, speed=speed, loop=loop)
|
||||
return self.state()
|
||||
|
||||
def stop(self) -> dict[str, Any]:
|
||||
self.runtime.stop()
|
||||
return self.state()
|
||||
|
||||
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
|
||||
self.runtime.update_scene_settings(
|
||||
RerunSceneSettings(
|
||||
point_size=request.point_size,
|
||||
color_mode=request.color_mode,
|
||||
palette=request.palette,
|
||||
custom_color=request.custom_color,
|
||||
accumulation_seconds=request.accumulation_seconds,
|
||||
show_points=request.show_points,
|
||||
show_trajectory=request.show_trajectory,
|
||||
show_grid=request.show_grid,
|
||||
)
|
||||
)
|
||||
return self.state()
|
||||
|
||||
def _set_operation(self, phase: str, message: str) -> None:
|
||||
with self._lock:
|
||||
self._operation_phase = phase
|
||||
self._operation_message = message
|
||||
|
||||
|
||||
service = ConsoleService(REPOSITORY_ROOT)
|
||||
plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
|
||||
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
|
||||
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -257,11 +35,11 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|||
try:
|
||||
yield
|
||||
finally:
|
||||
service.runtime.close()
|
||||
plugin_environment.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="NODEDC Mission Core API",
|
||||
title="NODEDC MISSION CORE API",
|
||||
version=__version__,
|
||||
docs_url="/api/docs",
|
||||
redoc_url=None,
|
||||
|
|
@ -280,97 +58,63 @@ def health() -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
@app.get("/api/state")
|
||||
def get_state() -> dict[str, Any]:
|
||||
return service.state()
|
||||
|
||||
|
||||
@app.post("/api/ble/scan")
|
||||
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
|
||||
@app.get("/api/v1/device-plugins")
|
||||
def get_device_plugins() -> dict[str, Any]:
|
||||
try:
|
||||
return await service.scan_ble(request.duration_seconds)
|
||||
except (BleakError, OSError, RuntimeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
|
||||
return {"items": plugin_catalog.plugin_documents()}
|
||||
except PluginCatalogError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/connect")
|
||||
async def connect(request: ConnectRequest) -> dict[str, Any]:
|
||||
@app.get("/api/v1/device-models")
|
||||
def get_device_models() -> dict[str, Any]:
|
||||
try:
|
||||
return await service.connect(request)
|
||||
except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Ошибка подключения устройства к Wi-Fi: {exc}",
|
||||
) from exc
|
||||
return {"items": plugin_catalog.model_documents()}
|
||||
except PluginCatalogError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/session/live")
|
||||
def start_live(request: LiveRequest) -> dict[str, Any]:
|
||||
@app.post("/api/v1/device-plugins/{plugin_id}/actions/{action_id}")
|
||||
async def invoke_device_plugin_action(
|
||||
plugin_id: str,
|
||||
action_id: str,
|
||||
request: DevicePluginActionRequest,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return service.start_live(request.host, request.duration_seconds)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
state = await plugin_dispatcher.invoke(plugin_id, action_id, request.input)
|
||||
return {"state": state}
|
||||
except (PluginNotFoundError, PluginActionNotFoundError) as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except PluginExecutionError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/session/replay")
|
||||
def start_replay(request: ReplayRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return service.start_replay(request.path, request.speed, request.loop)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/api/session/stop")
|
||||
def stop_session() -> dict[str, Any]:
|
||||
return service.stop()
|
||||
|
||||
|
||||
@app.post("/api/viewer/settings")
|
||||
def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
|
||||
return service.update_viewer_settings(request)
|
||||
|
||||
|
||||
@app.websocket("/api/events")
|
||||
async def events(websocket: WebSocket) -> None:
|
||||
@app.websocket("/api/v1/device-plugins/{plugin_id}/events")
|
||||
async def device_plugin_events(websocket: WebSocket, plugin_id: str) -> None:
|
||||
await websocket.accept()
|
||||
sequence = 0
|
||||
try:
|
||||
while True:
|
||||
await websocket.send_json({"state": service.state()})
|
||||
state = await plugin_dispatcher.invoke(plugin_id, STATE_READ_ACTION_ID, {})
|
||||
sequence += 1
|
||||
await websocket.send_json({"pluginId": plugin_id, "sequence": sequence, "state": state})
|
||||
await asyncio.sleep(0.5)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except (PluginNotFoundError, PluginActionNotFoundError):
|
||||
await websocket.close(code=1008, reason="Device plugin is not available")
|
||||
except PluginExecutionError:
|
||||
await websocket.close(code=1011, reason="Device plugin state stream failed")
|
||||
|
||||
|
||||
for legacy_router in plugin_environment.legacy_routers:
|
||||
app.include_router(legacy_router)
|
||||
|
||||
|
||||
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
||||
if frontend_dist.is_dir():
|
||||
app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="frontend")
|
||||
|
||||
|
||||
def _new_operation_session_dir(repository_root: Path, suffix: str) -> Path:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
base = repository_root / "sessions" / f"{stamp}_{suffix}"
|
||||
candidate = base
|
||||
serial = 1
|
||||
while candidate.exists():
|
||||
serial += 1
|
||||
candidate = base.with_name(f"{base.name}_{serial:02d}")
|
||||
return candidate
|
||||
|
||||
|
||||
def _provisioned_ipv4(result: Mapping[str, Any]) -> str | None:
|
||||
observations = result.get("observations")
|
||||
if not isinstance(observations, list):
|
||||
return None
|
||||
for observation in reversed(observations):
|
||||
if not isinstance(observation, dict):
|
||||
continue
|
||||
status = observation.get("status")
|
||||
if not isinstance(status, dict):
|
||||
continue
|
||||
address = status.get("ipv4")
|
||||
if isinstance(address, str) and address != AP_FALLBACK_IPV4:
|
||||
try:
|
||||
return validate_private_ipv4(address)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest
|
||||
from k1link.web.plugin_runtime import (
|
||||
DevicePluginDispatcher,
|
||||
DevicePluginRuntimeContribution,
|
||||
)
|
||||
|
||||
|
||||
class DevicePluginCompositionError(RuntimeError):
|
||||
"""The reviewed plugin manifests and executable runtime do not match."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstalledDevicePluginEnvironment:
|
||||
catalog: DevicePluginCatalog
|
||||
dispatcher: DevicePluginDispatcher
|
||||
legacy_routers: tuple[APIRouter, ...]
|
||||
_contributions: tuple[DevicePluginRuntimeContribution, ...]
|
||||
|
||||
def close(self) -> None:
|
||||
cleanup_errors = _close_contributions(self._contributions)
|
||||
if cleanup_errors:
|
||||
error = DevicePluginCompositionError(
|
||||
"One or more device plugins failed during shutdown"
|
||||
)
|
||||
_add_cleanup_notes(error, cleanup_errors)
|
||||
raise error from cleanup_errors[0]
|
||||
|
||||
|
||||
def _close_contributions(
|
||||
contributions: tuple[DevicePluginRuntimeContribution, ...]
|
||||
| list[DevicePluginRuntimeContribution],
|
||||
) -> list[Exception]:
|
||||
errors: list[Exception] = []
|
||||
for contribution in reversed(contributions):
|
||||
try:
|
||||
contribution.close()
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
return errors
|
||||
|
||||
|
||||
def _add_cleanup_notes(error: BaseException, cleanup_errors: list[Exception]) -> None:
|
||||
for index, cleanup_error in enumerate(cleanup_errors, start=1):
|
||||
error.add_note(
|
||||
f"device-plugin cleanup failure {index}: "
|
||||
f"{type(cleanup_error).__name__}: {cleanup_error}"
|
||||
)
|
||||
|
||||
|
||||
def _load_factory(entrypoint: str) -> Any:
|
||||
module_name, separator, attribute_name = entrypoint.partition(":")
|
||||
if not separator or not module_name or not attribute_name:
|
||||
raise DevicePluginCompositionError(f"Invalid device-plugin backendEntrypoint: {entrypoint}")
|
||||
try:
|
||||
module = import_module(module_name)
|
||||
factory = getattr(module, attribute_name)
|
||||
except (ImportError, AttributeError) as exc:
|
||||
raise DevicePluginCompositionError(
|
||||
f"Cannot load device-plugin backendEntrypoint {entrypoint}: {exc}"
|
||||
) from exc
|
||||
if not callable(factory):
|
||||
raise DevicePluginCompositionError(
|
||||
f"Device-plugin backendEntrypoint is not callable: {entrypoint}"
|
||||
)
|
||||
return factory
|
||||
|
||||
|
||||
def _load_contribution(
|
||||
repository_root: Path,
|
||||
manifest: DevicePluginManifest,
|
||||
) -> DevicePluginRuntimeContribution:
|
||||
entrypoint = manifest.spec.runtime.backendEntrypoint
|
||||
if manifest.spec.runtime.isolation != "transitional-in-process":
|
||||
raise DevicePluginCompositionError(
|
||||
"This Mission Core build only supports reviewed transitional-in-process "
|
||||
f"plugins; {manifest.metadata.id} requests {manifest.spec.runtime.isolation}"
|
||||
)
|
||||
factory = _load_factory(entrypoint)
|
||||
contribution = factory(repository_root)
|
||||
if not isinstance(contribution, DevicePluginRuntimeContribution):
|
||||
raise DevicePluginCompositionError(
|
||||
f"Device-plugin factory {entrypoint} returned an invalid contribution"
|
||||
)
|
||||
|
||||
try:
|
||||
adapter = contribution.adapter
|
||||
if adapter.plugin_id != manifest.metadata.id:
|
||||
raise DevicePluginCompositionError(
|
||||
"Device-plugin manifest/runtime id mismatch: "
|
||||
f"{manifest.metadata.id} != {adapter.plugin_id}"
|
||||
)
|
||||
manifest_actions = frozenset(action.id for action in manifest.spec.actions)
|
||||
if adapter.action_ids != manifest_actions:
|
||||
raise DevicePluginCompositionError(
|
||||
f"Device-plugin manifest/runtime actions mismatch for {adapter.plugin_id}"
|
||||
)
|
||||
except Exception as exc:
|
||||
_add_cleanup_notes(exc, _close_contributions((contribution,)))
|
||||
raise
|
||||
return contribution
|
||||
|
||||
|
||||
def load_installed_device_plugins(
|
||||
repository_root: Path,
|
||||
) -> InstalledDevicePluginEnvironment:
|
||||
"""Load only local, validated manifest entrypoints and cross-check every adapter."""
|
||||
|
||||
catalog = DevicePluginCatalog(repository_root)
|
||||
manifests = catalog.manifests()
|
||||
loaded: list[DevicePluginRuntimeContribution] = []
|
||||
try:
|
||||
for manifest in manifests:
|
||||
loaded.append(_load_contribution(repository_root, manifest))
|
||||
except Exception as exc:
|
||||
_add_cleanup_notes(exc, _close_contributions(loaded))
|
||||
raise
|
||||
contributions = tuple(loaded)
|
||||
dispatcher = DevicePluginDispatcher([contribution.adapter for contribution in contributions])
|
||||
catalog_ids = {manifest.metadata.id for manifest in manifests}
|
||||
if set(dispatcher.action_declarations) != catalog_ids:
|
||||
raise DevicePluginCompositionError(
|
||||
"Device-plugin catalog/runtime composition is incomplete"
|
||||
)
|
||||
return InstalledDevicePluginEnvironment(
|
||||
catalog=catalog,
|
||||
dispatcher=dispatcher,
|
||||
legacy_routers=tuple(
|
||||
router for contribution in contributions for router in contribution.legacy_routers
|
||||
),
|
||||
_contributions=contributions,
|
||||
)
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from k1link.web.plugin_runtime import STATE_READ_ACTION_ID
|
||||
|
||||
PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha1"
|
||||
|
||||
|
||||
def _reject_blank(value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("must contain a non-whitespace character")
|
||||
return value
|
||||
|
||||
|
||||
ShortText = Annotated[
|
||||
str,
|
||||
Field(min_length=1, max_length=160),
|
||||
AfterValidator(_reject_blank),
|
||||
]
|
||||
DescriptionText = Annotated[
|
||||
str,
|
||||
Field(min_length=1, max_length=1024),
|
||||
AfterValidator(_reject_blank),
|
||||
]
|
||||
EntrypointText = Annotated[
|
||||
str,
|
||||
Field(min_length=1, max_length=256),
|
||||
AfterValidator(_reject_blank),
|
||||
]
|
||||
|
||||
|
||||
class CapabilityManifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: ShortText
|
||||
label: ShortText
|
||||
|
||||
|
||||
class UiContributionManifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slot: Literal["device.connection"]
|
||||
componentKey: ShortText
|
||||
|
||||
|
||||
class DeviceModelManifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: ShortText
|
||||
vendor: ShortText
|
||||
displayName: ShortText
|
||||
category: ShortText
|
||||
description: DescriptionText
|
||||
verified: bool
|
||||
capabilities: list[CapabilityManifest]
|
||||
ui: UiContributionManifest
|
||||
|
||||
|
||||
class PluginRuntimeManifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
backendEntrypoint: EntrypointText
|
||||
isolation: Literal["transitional-in-process"]
|
||||
|
||||
|
||||
class PluginActionManifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: ShortText
|
||||
mutating: bool
|
||||
secretFields: list[ShortText]
|
||||
|
||||
|
||||
class PluginMetadata(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: ShortText
|
||||
version: Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
max_length=160,
|
||||
pattern=r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$",
|
||||
),
|
||||
AfterValidator(_reject_blank),
|
||||
]
|
||||
displayName: ShortText
|
||||
|
||||
|
||||
class PluginSpec(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
hostApiRange: Literal["v1alpha1"]
|
||||
runtime: PluginRuntimeManifest
|
||||
permissions: list[ShortText]
|
||||
actions: list[PluginActionManifest]
|
||||
models: list[DeviceModelManifest] = Field(min_length=1, max_length=1)
|
||||
|
||||
|
||||
class DevicePluginManifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
apiVersion: Literal["missioncore.nodedc/v1alpha1"]
|
||||
kind: Literal["DevicePlugin"]
|
||||
metadata: PluginMetadata
|
||||
spec: PluginSpec
|
||||
|
||||
|
||||
class PluginCatalogError(RuntimeError):
|
||||
"""An installed manifest is invalid or conflicts with another manifest."""
|
||||
|
||||
|
||||
class DevicePluginCatalog:
|
||||
"""Read-only catalog of statically reviewed device-plugin manifests."""
|
||||
|
||||
def __init__(self, repository_root: Path) -> None:
|
||||
self.repository_root = repository_root.resolve()
|
||||
self._validated_manifests: tuple[DevicePluginManifest, ...] | None = None
|
||||
|
||||
def manifests(self) -> list[DevicePluginManifest]:
|
||||
if self._validated_manifests is not None:
|
||||
return list(self._validated_manifests)
|
||||
|
||||
plugin_root = self.repository_root / "plugins"
|
||||
manifests: list[DevicePluginManifest] = []
|
||||
plugin_ids: set[str] = set()
|
||||
model_ids: set[str] = set()
|
||||
|
||||
for path in sorted(plugin_root.glob("*/plugin.manifest.json")):
|
||||
try:
|
||||
manifest = DevicePluginManifest.model_validate_json(path.read_text("utf-8"))
|
||||
except (OSError, ValidationError) as exc:
|
||||
raise PluginCatalogError(f"Invalid device-plugin manifest {path}: {exc}") from exc
|
||||
|
||||
plugin_id = manifest.metadata.id
|
||||
if plugin_id in plugin_ids:
|
||||
raise PluginCatalogError(f"Duplicate device-plugin id: {plugin_id}")
|
||||
plugin_ids.add(plugin_id)
|
||||
|
||||
action_ids: set[str] = set()
|
||||
for action in manifest.spec.actions:
|
||||
if action.id in action_ids:
|
||||
raise PluginCatalogError(
|
||||
f"Duplicate device-plugin action id in {plugin_id}: {action.id}"
|
||||
)
|
||||
action_ids.add(action.id)
|
||||
if len(action.secretFields) != len(set(action.secretFields)):
|
||||
raise PluginCatalogError(
|
||||
f"Duplicate secret field in action {plugin_id}/{action.id}"
|
||||
)
|
||||
|
||||
state_read = next(
|
||||
(action for action in manifest.spec.actions if action.id == STATE_READ_ACTION_ID),
|
||||
None,
|
||||
)
|
||||
if state_read is None or state_read.mutating or bool(state_read.secretFields):
|
||||
raise PluginCatalogError(
|
||||
f"Device plugin {plugin_id} must declare safe {STATE_READ_ACTION_ID}"
|
||||
)
|
||||
|
||||
if len(manifest.spec.permissions) != len(set(manifest.spec.permissions)):
|
||||
raise PluginCatalogError(f"Duplicate device-plugin permission in {plugin_id}")
|
||||
|
||||
for model in manifest.spec.models:
|
||||
if model.id in model_ids:
|
||||
raise PluginCatalogError(f"Duplicate device-model id: {model.id}")
|
||||
model_ids.add(model.id)
|
||||
capability_ids = [capability.id for capability in model.capabilities]
|
||||
if len(capability_ids) != len(set(capability_ids)):
|
||||
raise PluginCatalogError(f"Duplicate capability id in device model {model.id}")
|
||||
manifests.append(manifest)
|
||||
|
||||
self._validated_manifests = tuple(manifests)
|
||||
return list(self._validated_manifests)
|
||||
|
||||
def plugin_documents(self) -> list[dict[str, Any]]:
|
||||
return [manifest.model_dump(mode="json") for manifest in self.manifests()]
|
||||
|
||||
def model_documents(self) -> list[dict[str, Any]]:
|
||||
models: list[dict[str, Any]] = []
|
||||
for manifest in self.manifests():
|
||||
for model in manifest.spec.models:
|
||||
models.append(
|
||||
{
|
||||
"pluginId": manifest.metadata.id,
|
||||
"pluginVersion": manifest.metadata.version,
|
||||
**model.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
return models
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
STATE_READ_ACTION_ID = "state.read"
|
||||
|
||||
|
||||
class DevicePluginActionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
input: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DevicePluginActionAdapter(Protocol):
|
||||
plugin_id: str
|
||||
action_ids: frozenset[str]
|
||||
|
||||
async def invoke(self, action_id: str, payload: Mapping[str, Any]) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
def _noop() -> None:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DevicePluginRuntimeContribution:
|
||||
"""One reviewed backend plugin contribution loaded from its manifest factory."""
|
||||
|
||||
adapter: DevicePluginActionAdapter
|
||||
legacy_routers: tuple[APIRouter, ...] = ()
|
||||
close: Callable[[], None] = _noop
|
||||
|
||||
|
||||
class PluginNotFoundError(LookupError):
|
||||
"""The requested plugin is not installed in the runtime composition."""
|
||||
|
||||
|
||||
class PluginActionNotFoundError(LookupError):
|
||||
"""The requested action is not declared by the selected plugin."""
|
||||
|
||||
|
||||
class PluginExecutionError(RuntimeError):
|
||||
"""A validated plugin action failed while talking to its device/runtime."""
|
||||
|
||||
|
||||
class DevicePluginDispatcher:
|
||||
"""Host-owned dispatcher for allowlisted, namespaced plugin actions."""
|
||||
|
||||
def __init__(self, adapters: list[DevicePluginActionAdapter]) -> None:
|
||||
self._adapters: dict[str, DevicePluginActionAdapter] = {}
|
||||
for adapter in adapters:
|
||||
if adapter.plugin_id in self._adapters:
|
||||
raise ValueError(f"Duplicate runtime device-plugin id: {adapter.plugin_id}")
|
||||
self._adapters[adapter.plugin_id] = adapter
|
||||
|
||||
@property
|
||||
def action_declarations(self) -> dict[str, frozenset[str]]:
|
||||
return {plugin_id: adapter.action_ids for plugin_id, adapter in self._adapters.items()}
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
plugin_id: str,
|
||||
action_id: str,
|
||||
payload: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
adapter = self._adapters.get(plugin_id)
|
||||
if adapter is None:
|
||||
raise PluginNotFoundError(f"Device plugin is not installed: {plugin_id}")
|
||||
if action_id not in adapter.action_ids:
|
||||
raise PluginActionNotFoundError(
|
||||
f"Device plugin {plugin_id} does not declare action {action_id}"
|
||||
)
|
||||
return await adapter.invoke(action_id, payload)
|
||||
|
|
@ -0,0 +1,394 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Protocol
|
||||
|
||||
from bleak.exc import BleakError
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from k1link.artifacts import write_json_atomic
|
||||
from k1link.ble.scanner import scan
|
||||
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
|
||||
from k1link.mqtt import validate_private_ipv4
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
|
||||
from k1link.web.plugin_runtime import (
|
||||
DevicePluginRuntimeContribution,
|
||||
PluginActionNotFoundError,
|
||||
PluginExecutionError,
|
||||
)
|
||||
|
||||
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
||||
|
||||
ACTION_STATE_READ = "state.read"
|
||||
ACTION_DISCOVERY_SCAN = "discovery.scan"
|
||||
ACTION_NETWORK_PROVISION = "network.provision"
|
||||
ACTION_STREAM_START_LIVE = "stream.start-live"
|
||||
ACTION_STREAM_START_REPLAY = "stream.start-replay"
|
||||
ACTION_STREAM_STOP = "stream.stop"
|
||||
ACTION_VIEWER_SETTINGS_UPDATE = "viewer.settings.update"
|
||||
|
||||
|
||||
class StrictRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class EmptyRequest(StrictRequest):
|
||||
pass
|
||||
|
||||
|
||||
class BleScanRequest(StrictRequest):
|
||||
duration_seconds: float = Field(default=6.0, ge=1.0, le=60.0)
|
||||
|
||||
|
||||
class ConnectRequest(StrictRequest):
|
||||
device_id: str = Field(min_length=1, max_length=128)
|
||||
ssid: str = Field(min_length=1, max_length=128)
|
||||
password: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class LiveRequest(StrictRequest):
|
||||
host: str | None = Field(default=None, max_length=15)
|
||||
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
|
||||
|
||||
|
||||
class ReplayRequest(StrictRequest):
|
||||
path: str = Field(min_length=1, max_length=4096)
|
||||
speed: float = Field(default=1.0, ge=0.0, le=100.0)
|
||||
loop: bool = False
|
||||
|
||||
|
||||
class ViewerSettingsRequest(StrictRequest):
|
||||
point_size: float = Field(default=2.5, ge=0.5, le=12.0)
|
||||
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
|
||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
|
||||
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
accumulation_seconds: float = Field(default=12.0, ge=0.0, le=120.0)
|
||||
show_points: bool = True
|
||||
show_trajectory: bool = True
|
||||
show_grid: bool = True
|
||||
|
||||
|
||||
class XgridsK1CompatibilityService:
|
||||
"""The proven K1 runtime kept intact behind the plugin facade."""
|
||||
|
||||
def __init__(self, repository_root: Path) -> None:
|
||||
self.repository_root = repository_root.resolve()
|
||||
self._lock = threading.Lock()
|
||||
self._devices: list[dict[str, Any]] = []
|
||||
self._selected_device_id: str | None = None
|
||||
self._k1_ip: str | None = None
|
||||
self._operation_phase: str | None = None
|
||||
self._operation_message: str | None = None
|
||||
self.runtime = VisualizationRuntime()
|
||||
|
||||
def state(self) -> dict[str, Any]:
|
||||
runtime = self.runtime.snapshot()
|
||||
metrics = runtime["metrics"]
|
||||
with self._lock:
|
||||
operation_phase = self._operation_phase
|
||||
operation_message = self._operation_message
|
||||
devices = list(self._devices)
|
||||
selected_device_id = self._selected_device_id
|
||||
k1_ip = self._k1_ip
|
||||
|
||||
runtime_active = runtime["source_mode"] != "idle" or runtime["phase"] in {
|
||||
"starting_live",
|
||||
"stopping",
|
||||
"error",
|
||||
}
|
||||
if operation_phase is not None:
|
||||
phase = operation_phase
|
||||
message = operation_message
|
||||
elif runtime_active:
|
||||
phase = runtime["phase"]
|
||||
message = runtime["message"]
|
||||
elif k1_ip is not None:
|
||||
phase = "connected"
|
||||
message = runtime["message"]
|
||||
elif selected_device_id is not None:
|
||||
phase = "device_selected"
|
||||
message = "Устройство выбрано. Теперь введите название и пароль Wi-Fi."
|
||||
elif devices:
|
||||
likely_count = sum(bool(item.get("likely_k1")) for item in devices)
|
||||
phase = "idle"
|
||||
message = (
|
||||
f"Найдено BLE-устройств: {len(devices)}. "
|
||||
f"Совместимых профилей: {likely_count}. Выберите нужное устройство."
|
||||
)
|
||||
else:
|
||||
phase = "idle"
|
||||
message = runtime["message"]
|
||||
|
||||
return {
|
||||
"phase": phase,
|
||||
"message": message,
|
||||
"devices": devices,
|
||||
"selected_device_id": selected_device_id,
|
||||
"k1_ip": k1_ip,
|
||||
"foxglove_ws_url": runtime["foxglove_ws_url"],
|
||||
"foxglove_viewer_url": runtime["foxglove_viewer_url"],
|
||||
"rerun_grpc_url": runtime["rerun_grpc_url"],
|
||||
"viewer_settings": runtime["viewer_settings"],
|
||||
"source_mode": runtime["source_mode"],
|
||||
"metrics": {
|
||||
"pipeline_ms": metrics["mqtt_to_publish_ms"],
|
||||
"end_to_end_ms": metrics["mqtt_to_publish_ms"],
|
||||
"decode_ms": metrics["decode_publish_ms"],
|
||||
"frame_rate": metrics["pcl_fps"],
|
||||
"frame_rate_hz": metrics["pcl_fps"],
|
||||
"point_count": metrics["last_point_count"],
|
||||
"dropped_preview_frames": metrics["preview_dropped"],
|
||||
**metrics,
|
||||
},
|
||||
}
|
||||
|
||||
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
|
||||
self._set_operation("scanning", "Сканируем все устройства Bluetooth (BLE)…")
|
||||
try:
|
||||
result = await scan(duration_seconds)
|
||||
devices = [
|
||||
{
|
||||
"device_id": item["macos_uuid"],
|
||||
"name": item["local_name"] or item["name"],
|
||||
"rssi": item["rssi"],
|
||||
"address": None,
|
||||
"connectable": True,
|
||||
"likely_k1": item["k1_name_candidate"],
|
||||
}
|
||||
for item in result["devices"]
|
||||
]
|
||||
with self._lock:
|
||||
self._devices = devices
|
||||
self._operation_message = f"Поиск завершён. Найдено BLE-устройств: {len(devices)}."
|
||||
finally:
|
||||
with self._lock:
|
||||
self._operation_phase = None
|
||||
return self.state()
|
||||
|
||||
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
|
||||
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
|
||||
if request.device_id not in known_ids:
|
||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
||||
self._set_operation(
|
||||
"provisioning",
|
||||
"Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
|
||||
)
|
||||
session_dir = _new_operation_session_dir(
|
||||
self.repository_root,
|
||||
"viewer_wifi_provisioning",
|
||||
)
|
||||
session_dir.mkdir(parents=True, exist_ok=False)
|
||||
password = request.password
|
||||
try:
|
||||
result = await provision_wifi_once(
|
||||
request.device_id,
|
||||
request.ssid,
|
||||
password,
|
||||
timeout_seconds=45.0,
|
||||
write_mode="auto",
|
||||
)
|
||||
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
|
||||
ipv4 = _provisioned_ipv4(result)
|
||||
write_json_atomic(
|
||||
session_dir / "manifest.redacted.json",
|
||||
{
|
||||
"schema_version": 1,
|
||||
"started_at_utc": result["started_at_utc"],
|
||||
"completed_at_utc": result["completed_at_utc"],
|
||||
"operation": "single_reviewed_wifi_provisioning_write",
|
||||
"profile_id": result["profile_id"],
|
||||
"outcome": result["outcome"],
|
||||
"k1_lan_address_observed": ipv4 is not None,
|
||||
"credentials_persisted_by_connector": False,
|
||||
},
|
||||
)
|
||||
if ipv4 is None:
|
||||
raise RuntimeError(
|
||||
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
|
||||
)
|
||||
with self._lock:
|
||||
self._selected_device_id = request.device_id
|
||||
self._k1_ip = ipv4
|
||||
self._operation_message = (
|
||||
"Устройство подключено к Wi-Fi и сообщило локальный адрес."
|
||||
)
|
||||
finally:
|
||||
password = ""
|
||||
with self._lock:
|
||||
self._operation_phase = None
|
||||
return self.state()
|
||||
|
||||
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]:
|
||||
target = host or self.state()["k1_ip"]
|
||||
if not isinstance(target, str) or not target:
|
||||
raise ValueError(
|
||||
"сначала подключите устройство к Wi-Fi или укажите его локальный адрес"
|
||||
)
|
||||
target = validate_private_ipv4(target)
|
||||
out_dir = new_live_session_dir(self.repository_root)
|
||||
self.runtime.start_live(target, out_dir, duration_seconds=duration_seconds)
|
||||
return self.state()
|
||||
|
||||
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
|
||||
replay_path = Path(path).expanduser().resolve()
|
||||
if not replay_path.is_relative_to(self.repository_root):
|
||||
raise ValueError("файл записи должен находиться внутри репозитория")
|
||||
self.runtime.start_replay(replay_path, speed=speed, loop=loop)
|
||||
return self.state()
|
||||
|
||||
def stop(self) -> dict[str, Any]:
|
||||
self.runtime.stop()
|
||||
return self.state()
|
||||
|
||||
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
|
||||
self.runtime.update_scene_settings(
|
||||
RerunSceneSettings(
|
||||
point_size=request.point_size,
|
||||
color_mode=request.color_mode,
|
||||
palette=request.palette,
|
||||
custom_color=request.custom_color,
|
||||
accumulation_seconds=request.accumulation_seconds,
|
||||
show_points=request.show_points,
|
||||
show_trajectory=request.show_trajectory,
|
||||
show_grid=request.show_grid,
|
||||
)
|
||||
)
|
||||
return self.state()
|
||||
|
||||
def _set_operation(self, phase: str, message: str) -> None:
|
||||
with self._lock:
|
||||
self._operation_phase = phase
|
||||
self._operation_message = message
|
||||
|
||||
|
||||
class XgridsK1ServicePort(Protocol):
|
||||
def state(self) -> dict[str, Any]: ...
|
||||
|
||||
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]: ...
|
||||
|
||||
async def connect(self, request: ConnectRequest) -> dict[str, Any]: ...
|
||||
|
||||
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]: ...
|
||||
|
||||
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]: ...
|
||||
|
||||
def stop(self) -> dict[str, Any]: ...
|
||||
|
||||
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class XgridsK1PluginFacade:
|
||||
"""Plugin action facade delegating to the proven compatibility service."""
|
||||
|
||||
plugin_id = XGRIDS_K1_PLUGIN_ID
|
||||
action_ids = frozenset(
|
||||
{
|
||||
ACTION_STATE_READ,
|
||||
ACTION_DISCOVERY_SCAN,
|
||||
ACTION_NETWORK_PROVISION,
|
||||
ACTION_STREAM_START_LIVE,
|
||||
ACTION_STREAM_START_REPLAY,
|
||||
ACTION_STREAM_STOP,
|
||||
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, service: XgridsK1ServicePort) -> None:
|
||||
self.service = service
|
||||
|
||||
async def invoke(self, action_id: str, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
return await self._invoke_validated(action_id, payload)
|
||||
except (ValidationError, ValueError, PluginActionNotFoundError):
|
||||
raise
|
||||
except (BleakError, OSError, TimeoutError, RuntimeError) as exc:
|
||||
raise PluginExecutionError(str(exc)) from exc
|
||||
|
||||
async def _invoke_validated(
|
||||
self,
|
||||
action_id: str,
|
||||
payload: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if action_id == ACTION_STATE_READ:
|
||||
EmptyRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(self.service.state)
|
||||
if action_id == ACTION_DISCOVERY_SCAN:
|
||||
scan_request = BleScanRequest.model_validate(payload)
|
||||
return await self.service.scan_ble(scan_request.duration_seconds)
|
||||
if action_id == ACTION_NETWORK_PROVISION:
|
||||
connect_request = ConnectRequest.model_validate(payload)
|
||||
return await self.service.connect(connect_request)
|
||||
if action_id == ACTION_STREAM_START_LIVE:
|
||||
live_request = LiveRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(
|
||||
self.service.start_live,
|
||||
live_request.host,
|
||||
live_request.duration_seconds,
|
||||
)
|
||||
if action_id == ACTION_STREAM_START_REPLAY:
|
||||
replay_request = ReplayRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(
|
||||
self.service.start_replay,
|
||||
replay_request.path,
|
||||
replay_request.speed,
|
||||
replay_request.loop,
|
||||
)
|
||||
if action_id == ACTION_STREAM_STOP:
|
||||
EmptyRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(self.service.stop)
|
||||
if action_id == ACTION_VIEWER_SETTINGS_UPDATE:
|
||||
settings_request = ViewerSettingsRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(
|
||||
self.service.update_viewer_settings,
|
||||
settings_request,
|
||||
)
|
||||
raise PluginActionNotFoundError(f"Unsupported XGRIDS K1 action: {action_id}")
|
||||
|
||||
|
||||
def _new_operation_session_dir(repository_root: Path, suffix: str) -> Path:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
base = repository_root / "sessions" / f"{stamp}_{suffix}"
|
||||
candidate = base
|
||||
serial = 1
|
||||
while candidate.exists():
|
||||
serial += 1
|
||||
candidate = base.with_name(f"{base.name}_{serial:02d}")
|
||||
return candidate
|
||||
|
||||
|
||||
def _provisioned_ipv4(result: Mapping[str, Any]) -> str | None:
|
||||
observations = result.get("observations")
|
||||
if not isinstance(observations, list):
|
||||
return None
|
||||
for observation in reversed(observations):
|
||||
if not isinstance(observation, dict):
|
||||
continue
|
||||
status = observation.get("status")
|
||||
if not isinstance(status, dict):
|
||||
continue
|
||||
address = status.get("ipv4")
|
||||
if isinstance(address, str) and address != AP_FALLBACK_IPV4:
|
||||
try:
|
||||
return validate_private_ipv4(address)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribution:
|
||||
"""Manifest entrypoint for the reviewed XGRIDS compatibility adapter."""
|
||||
|
||||
from k1link.web.xgrids_k1_legacy_api import build_xgrids_k1_legacy_router
|
||||
|
||||
service = XgridsK1CompatibilityService(repository_root)
|
||||
adapter = XgridsK1PluginFacade(service)
|
||||
return DevicePluginRuntimeContribution(
|
||||
adapter=adapter,
|
||||
legacy_routers=(build_xgrids_k1_legacy_router(adapter),),
|
||||
close=service.runtime.close,
|
||||
)
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
||||
|
||||
from k1link.web.plugin_runtime import PluginExecutionError
|
||||
from k1link.web.xgrids_k1_facade import (
|
||||
ACTION_DISCOVERY_SCAN,
|
||||
ACTION_NETWORK_PROVISION,
|
||||
ACTION_STATE_READ,
|
||||
ACTION_STREAM_START_LIVE,
|
||||
ACTION_STREAM_START_REPLAY,
|
||||
ACTION_STREAM_STOP,
|
||||
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||
BleScanRequest,
|
||||
ConnectRequest,
|
||||
LiveRequest,
|
||||
ReplayRequest,
|
||||
ViewerSettingsRequest,
|
||||
XgridsK1PluginFacade,
|
||||
)
|
||||
|
||||
|
||||
def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
||||
"""Temporary flat API kept for scripts created before the plugin boundary."""
|
||||
|
||||
router = APIRouter(include_in_schema=True)
|
||||
|
||||
@router.get("/api/state", deprecated=True)
|
||||
async def get_state() -> dict[str, Any]:
|
||||
return await adapter.invoke(ACTION_STATE_READ, {})
|
||||
|
||||
@router.post("/api/ble/scan", deprecated=True)
|
||||
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_DISCOVERY_SCAN, request.model_dump())
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
|
||||
|
||||
@router.post("/api/connect", deprecated=True)
|
||||
async def connect(request: ConnectRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_NETWORK_PROVISION, request.model_dump())
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Ошибка подключения устройства к Wi-Fi: {exc}",
|
||||
) from exc
|
||||
|
||||
@router.post("/api/session/live", deprecated=True)
|
||||
async def start_live(request: LiveRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_STREAM_START_LIVE, request.model_dump())
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.post("/api/session/replay", deprecated=True)
|
||||
async def start_replay(request: ReplayRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_STREAM_START_REPLAY, request.model_dump())
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.post("/api/session/stop", deprecated=True)
|
||||
async def stop_session() -> dict[str, Any]:
|
||||
return await adapter.invoke(ACTION_STREAM_STOP, {})
|
||||
|
||||
@router.post("/api/viewer/settings", deprecated=True)
|
||||
async def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
|
||||
return await adapter.invoke(ACTION_VIEWER_SETTINGS_UPDATE, request.model_dump())
|
||||
|
||||
@router.websocket("/api/events")
|
||||
async def events(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
try:
|
||||
while True:
|
||||
await websocket.send_json({"state": await adapter.invoke(ACTION_STATE_READ, {})})
|
||||
await asyncio.sleep(0.5)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
return router
|
||||
|
|
@ -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(
|
||||
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
|
||||
)
|
||||
pose_payload = struct.pack(
|
||||
"<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3
|
||||
)
|
||||
pose_payload = struct.pack("<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("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(
|
||||
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
|
||||
)
|
||||
pose_payload = struct.pack(
|
||||
"<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3
|
||||
)
|
||||
pose_payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3)
|
||||
frames = bytearray(RAW_MAGIC)
|
||||
for topic, payload in ((point_topic, point_payload), (pose_topic, pose_payload)):
|
||||
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(
|
||||
"<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
|
||||
)
|
||||
capture.write_bytes(RAW_MAGIC + FRAME_HEADER.pack(len(topic), len(payload)) + topic + payload)
|
||||
(tmp_path / "mqtt.metadata.jsonl").write_text(
|
||||
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
|
||||
with pytest.raises(RuntimeError, match="runtime завершён"):
|
||||
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
|
||||
|
||||
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(
|
||||
|
|
@ -34,8 +34,8 @@ def test_ble_scan_exposes_every_device_and_only_labels_likely_k1(
|
|||
]
|
||||
}
|
||||
|
||||
monkeypatch.setattr(web_app, "scan", fake_scan)
|
||||
service = web_app.ConsoleService(tmp_path)
|
||||
monkeypatch.setattr(xgrids_backend, "scan", fake_scan)
|
||||
service = xgrids_backend.XgridsK1CompatibilityService(tmp_path)
|
||||
|
||||
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:
|
||||
service = web_app.ConsoleService(tmp_path)
|
||||
request = web_app.ViewerSettingsRequest(
|
||||
service = xgrids_backend.XgridsK1CompatibilityService(tmp_path)
|
||||
request = xgrids_backend.ViewerSettingsRequest(
|
||||
point_size=4.0,
|
||||
color_mode="height",
|
||||
palette="viridis",
|
||||
|
|
|
|||
Loading…
Reference in New Issue