feat(plugins): isolate device integrations

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 19:29:32 +03:00
parent f9ffb7bd1c
commit 24a47318f2
122 changed files with 3304 additions and 1892 deletions
+5 -4
View File
@@ -18,13 +18,14 @@ device adapter, но структура интерфейса от него не
| --- | --- | --- |
| Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
| Device plugin registry | Реализован, v1alpha1 + v1alpha2 | До выбора модели provider остаётся inert и не делает I/O. v1alpha1 сохраняет одну модель; v1alpha2 допускает одну или несколько моделей и требует profile coverage каждой. Custom `device.connection` UI key и backend factory подключаются одним reviewed import в composition root. |
| Plugin-owned connection UI | Реализован | XGRIDS provisioning, acquisition/replay, diagnostics, metrics и scoped styles физически находятся в `plugins/xgrids-k1/frontend`; generic Control Station предоставляет только frontend SDK, model catalog и host slot. |
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
| Автоматический MQTT → Rerun | Реализован | Первый live/adapter file-replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие такие сессии переиспользуют его. Saved observation replay использует отдельный immutable HTTP RRD path. |
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. |
| Сохранённые observation sessions | Реализованы для point/pose | Три последние сессии, background preparation, cache v6, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. |
| Сохранённые observation sessions | Реализованы для point/pose | Три последние сессии, background preparation, cache v7, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. |
| K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но реальная архивная K1 camera-session ещё не прошла physical acceptance. |
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
| Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. |
@@ -54,7 +55,7 @@ Mission Core Control Station ←→ REST /api/v1/device-plugins/*
sealed/recovered observation session
└── SQLite catalog + bounded background preparation
└── atomic RRD cache v6 + recorded-media manifest v2
└── atomic RRD cache v7 + recorded-media manifest v2
└── generation-bound same-origin HTTP
└── aggregate admission
└── Rerun native receiver + recorded fMP4 player
@@ -280,11 +281,11 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
| --- | --- |
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
| `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle и plugin host. |
| `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle, plugin host и public frontend SDK surface. |
| `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. |
| `../../plugins/xgrids-k1/frontend/` | Plugin-owned K1 BLE/Wi-Fi, acquisition/replay UI, API client, runtime mapper и scoped styles. |
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
| `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. |
| `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |
@@ -1,5 +1,5 @@
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
import { xgridsK1Plugin } from "../device-plugins/xgrids-k1/plugin";
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
// Composition root: this is the only place where Mission Core chooses which
// statically reviewed device plugins are shipped in the current build.
@@ -0,0 +1,14 @@
/**
* Public frontend host surface for statically reviewed device plugins.
*
* Plugin source may depend on this module through the
* `@mission-core/plugin-sdk` alias. It must not reach into Control Station
* implementation paths directly.
*/
export * from "./contracts";
export { parseDevicePluginManifest, requirePluginAction } from "./manifestParser";
export {
MissionRuntimeProvider,
useMissionRuntime,
} from "../runtime/MissionRuntimeContext";
export type * from "../runtime/contracts";
@@ -1,703 +0,0 @@
import { useEffect, useMemo, useRef, 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, CompatibilityAttestation } from "./api";
import {
isConfirmedLiveState,
isSourceRuntimeBusy,
provisioningIntentKey,
recoverableAcquisition,
sourceStatusLabel,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import {
backendLabel,
backendTone,
eventStatusLabel,
finiteMetric,
formatNumber,
phaseLabel,
phaseTone,
pipelineLatency,
} from "./presentation";
import { useXgridsK1Controller } from "./runtimeContext";
type SessionIntent = "live" | "replay";
const sessionItems = [
{ value: "live", label: "Реальное устройство" },
{ value: "replay", label: "Повтор записи" },
] satisfies Array<{ value: SessionIntent; label: string }>;
const EXACT_PROFILE_ATTESTATION: CompatibilityAttestation = {
firmware_version: "3.0.2",
topology: "direct-lan",
operator_confirmed: true,
};
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,
prepareAndStartAcquisition,
startReplay,
stop,
abort,
} = console;
const [powerConfirmed, setPowerConfirmed] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [profileConfirmed, setProfileConfirmed] = useState(false);
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
const provisioningIntentRef = useRef<string | null>(null);
useEffect(() => {
if (state?.selected_device_id) {
if (state.selected_device_id !== selectedDeviceId) {
setProfileConfirmed(false);
provisioningIntentRef.current = null;
}
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]);
useEffect(() => {
if (state?.source_mode === "live" || state?.source_mode === "replay") {
setSessionIntent(state.source_mode);
} else if (state?.acquisition?.state === "prepared") {
setSessionIntent("live");
}
}, [state?.acquisition?.state, state?.source_mode]);
const confirmedLive = isConfirmedLiveState(state);
const streamActive = confirmedLive || 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 &&
profileConfirmed &&
selectedDeviceId.length > 0 &&
credentialsReady &&
!isBusy;
const activeAcquisition = recoverableAcquisition(state);
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
const sessionLocked = sourceRuntimeBusy || activeAcquisition !== null;
const effectiveSessionIntent: SessionIntent =
state?.source_mode === "live" || state?.source_mode === "replay"
? state.source_mode
: activeAcquisition
? "live"
: sessionIntent;
const liveTargetReady = Boolean(
state?.k1_ip || liveHost.trim() || preparedAcquisition?.target_host,
);
const sourceLabel = sourceStatusLabel(state);
const relevantAcquisitionFailed =
state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
const sourceTone: StatusTone =
state?.phase === "error" || relevantAcquisitionFailed
? "danger"
: confirmedLive || state?.source_mode === "replay"
? "success"
: sourceRuntimeBusy || preparedAcquisition
? "warning"
: "neutral";
const connectionPhaseLabel =
sourceRuntimeBusy || preparedAcquisition ? sourceLabel : phaseLabel(state?.phase);
const connectionPhaseTone =
sourceRuntimeBusy || preparedAcquisition ? sourceTone : phaseTone(state?.phase);
const selectableSessionItems = useMemo(
() => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })),
[sessionLocked],
);
const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId],
);
const submitConnect = async () => {
if (!canConnect) return;
const idempotencyKey = provisioningIntentKey(provisioningIntentRef.current);
provisioningIntentRef.current = idempotencyKey;
const succeeded = await connect({
device_id: selectedDeviceId,
ssid: ssid.trim(),
password,
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
idempotency_key: idempotencyKey,
});
if (succeeded) {
provisioningIntentRef.current = null;
setPassword("");
}
};
const submitLive = async () => {
if (!profileConfirmed || sourceRuntimeBusy) return;
const targetHost = liveHost.trim();
const started = await prepareAndStartAcquisition({
...(targetHost ? { host: targetHost } : {}),
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
});
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={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
</div>
</section>
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
<MetricCard
featured
eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)}
unit="мс"
detail="MQTT callback → Rerun SDK; без экрана"
/>
<MetricCard
eyebrow="ЧАСТОТА КАДРОВ"
value={formatNumber(frameRate)}
unit="кадр/с"
detail="Последнее измерение адаптера"
/>
<MetricCard
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Реальное число декодированных точек"
/>
<MetricCard
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Исходные данные при этом сохраняются"
/>
</section>
<div className="device-workspace__grid">
<GlassSurface className="connection-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</span>
<h2>Подключите устройство к сети</h2>
</div>
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</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={(checked) => {
setPowerConfirmed(checked);
if (!checked) {
setProfileConfirmed(false);
provisioningIntentRef.current = null;
}
}}
/>
</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("");
setProfileConfirmed(false);
provisioningIntentRef.current = null;
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);
setProfileConfirmed(false);
provisioningIntentRef.current = null;
}}
/>
))
) : (
<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">
<div className="nodedc-field">
<span className="nodedc-field__description">
Mission Core не определяет прошивку автоматически. Сверьте её на устройстве
или в официальном приложении и подтвердите только точное соответствие профилю.
</span>
<Checker
checked={profileConfirmed}
label="Я вручную подтвердил FW 3.0.2 и прямое подключение в локальной сети"
onChange={(checked) => {
setProfileConfirmed(checked);
provisioningIntentRef.current = null;
}}
/>
</div>
<TextField
label="Название сети Wi‑Fi"
hint="SSID"
value={ssid}
onChange={(event) => {
setSsid(event.target.value);
provisioningIntentRef.current = null;
}}
autoComplete="off"
spellCheck={false}
placeholder="Сеть локального контура"
/>
<TextField
label="Пароль WiFi"
hint="Только в оперативной памяти"
type="password"
value={password}
onChange={(event) => {
setPassword(event.target.value);
provisioningIntentRef.current = null;
}}
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">
{effectiveSessionIntent === "live" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
</span>
<h2>{effectiveSessionIntent === "live" ? "Подготовьте приём" : "Повторите запись"}</h2>
</div>
<StatusBadge tone={sourceTone}>
{sourceLabel}
</StatusBadge>
</header>
<SegmentedControl
label="Источник данных"
value={effectiveSessionIntent}
items={selectableSessionItems}
onChange={(intent) => {
if (!sessionLocked) setSessionIntent(intent);
}}
/>
{effectiveSessionIntent === "live" ? (
<div className="session-form">
<TextField
label="Адрес устройства"
hint="Обычно определяется автоматически"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
spellCheck={false}
placeholder={
state?.k1_ip ||
preparedAcquisition?.target_host ||
"Сначала подключите устройство к Wi‑Fi"
}
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={
isBusy ||
!profileConfirmed ||
!liveTargetReady ||
sourceRuntimeBusy ||
(activeAcquisition !== null && preparedAcquisition === null)
}
onClick={() => void submitLive()}
>
{pendingAction === "live"
? "Подготавливаем приём…"
: preparedAcquisition
? "Продолжить подготовленный приём"
: "Подготовить приём данных"}
</Button>
<p className="live-instruction">
{!profileConfirmed
? "Сначала вручную подтвердите точную прошивку 3.0.2 и direct-LAN топологию. Интерфейс не аттестует устройство автоматически."
: liveTargetReady
? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами."
: "Сначала подключите устройство к 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 || sessionLocked || replayPath.trim().length === 0}
onClick={() => void submitReplay()}
>
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
</Button>
</div>
)}
<div className="session-footer">
<p>
{state?.source_mode === "replay"
? "Остановка завершит фактически запущенный повтор записи."
: activeAcquisition || state?.source_mode === "live"
? "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
: "Активного источника сейчас нет."}
</p>
<Button
variant="secondary"
disabled={isBusy || (!sourceRuntimeBusy && activeAcquisition === null)}
onClick={() => void stop()}
>
{pendingAction === "stop"
? state?.source_mode === "replay"
? "Останавливаем повтор…"
: "Останавливаем локальный приём…"
: state?.source_mode === "replay"
? "Остановить повтор"
: preparedAcquisition
? "Завершить подготовленный приём"
: "Остановить локальный приём"}
</Button>
{activeAcquisition ? (
<Button
variant="ghost"
disabled={isBusy}
onClick={() => void abort()}
>
{pendingAction === "abort"
? "Прерываем локальную операцию…"
: preparedAcquisition
? "Отменить подготовку"
: "Аварийно завершить локальный приём"}
</Button>
) : null}
</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,461 +0,0 @@
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;
rssi?: number | null;
address?: string | null;
connectable?: boolean | null;
likely_k1?: boolean | null;
}
export type SourceMode = "idle" | "live" | "replay";
export type AcquisitionState =
| "preparing"
| "prepared"
| "awaiting_external_start"
| "starting"
| "acquiring"
| "awaiting_external_stop"
| "stopping"
| "finalizing"
| "completed"
| "failed"
| "aborted"
| "interrupted";
export type OperationStatus =
| "accepted"
| "running"
| "operator_action_required"
| "succeeded"
| "failed"
| "cancelled"
| "timed_out"
| "interrupted";
export interface XgridsDeviceRef {
device_id: string;
model_id: string;
identity_stability: "stable" | "provisional";
identity_basis:
| "hardware-identifier"
| "plugin-derived"
| "operator-assigned"
| "transport-local";
transport_alias?: string | null;
}
export interface XgridsDeviceSession {
device_session_id: string;
device_id: string;
opened_at?: string | null;
compatibility_profile_id?: string | null;
connectivity?: "unknown" | "offline" | "connecting" | "connected" | "degraded";
}
export interface XgridsCompatibilityState {
profile_id?: string | null;
decision?: "compatible" | "limited" | "unknown" | "incompatible";
permitted_mode?: "blocked" | "evidence-only" | "read-only" | "active-control";
firmware_claim?: string | null;
vendor_writes_enabled?: boolean;
camera_preview?: string | null;
}
export interface XgridsAcquisition {
schema_version?: string;
acquisition_id: string;
device_id: string;
device_session_id: string;
compatibility_profile_id: string;
control_mode: "operator-manual" | "plugin-commanded" | "observe-only";
requested_streams: string[];
target_host: string;
duration_seconds: number;
evidence_policy: "required" | "best-effort" | "disabled";
state: AcquisitionState;
state_revision: number;
created_at?: string | null;
updated_at?: string | null;
message_code?: string | null;
operator_instructions?: string[];
result?: Record<string, unknown> | null;
}
export interface XgridsOperation {
schema_version?: string;
operation_id: string;
action: string;
status: OperationStatus;
accepted_at?: string | null;
completed_at?: string | null;
deadline_at?: string | null;
device_id?: string | null;
device_session_id?: string | null;
idempotency_key?: string | null;
stage_code?: string | null;
message_code?: string | null;
sequence?: number;
state_revision?: number;
cancellable?: boolean;
cancel_requested?: boolean;
result?: Record<string, unknown> | null;
error?: Record<string, unknown> | null;
evidence_refs?: string[];
}
export interface XgridsK1Metrics {
mqtt_to_decode_ms?: number | null;
decode_ms?: number | null;
publish_ms?: number | null;
pipeline_ms?: number | null;
end_to_end_ms?: number | null;
frame_rate?: number | null;
frame_rate_hz?: number | null;
point_count?: number | null;
dropped_preview_frames?: number | null;
[key: string]: number | null | undefined;
}
export interface XgridsCameraPreviewDelivery {
id: string;
kind: "mse-fmp4-websocket";
url: string;
media_type: string;
}
export interface XgridsCameraPreviewActivation {
group_id: string;
max_active: number;
selected: boolean;
controllable: boolean;
}
export interface XgridsCameraPreviewState {
phase?: string | null;
revision?: number | null;
generation?: number | null;
active_source_id?: string | null;
delivery?: XgridsCameraPreviewDelivery | null;
}
export interface XgridsSensorCatalogStream {
stream_id: string;
source_id?: string | null;
semantic_channel_id?: string | null;
label?: string | null;
sensor_kind?: string | null;
modality?: string | null;
availability?: string | null;
endpoint_label?: string | null;
activation?: XgridsCameraPreviewActivation | null;
delivery?: XgridsCameraPreviewDelivery | null;
decode_status?: string | null;
frame_id?: string | null;
coordinate_convention?: string | null;
}
export interface XgridsSensorCatalog {
schema_version?: string | null;
revision?: string | null;
streams?: XgridsSensorCatalogStream[];
}
export interface XgridsK1State {
contract_version?: string | null;
phase?: string | null;
message?: string | null;
devices?: BleDevice[];
selected_device_id?: string | null;
k1_ip?: string | null;
foxglove_ws_url?: string | null;
foxglove_viewer_url?: string | null;
rerun_grpc_url?: string | null;
viewer_settings?: ViewerSettings | null;
source_mode?: SourceMode | null;
metrics?: XgridsK1Metrics;
compatibility?: XgridsCompatibilityState | null;
device_ref?: XgridsDeviceRef | null;
device_session?: XgridsDeviceSession | null;
acquisition?: XgridsAcquisition | null;
operations?: XgridsOperation[];
last_operation?: XgridsOperation | null;
sensor_catalog?: XgridsSensorCatalog | null;
camera_preview?: XgridsCameraPreviewState | null;
}
export interface HealthResponse {
ok?: boolean;
status?: string;
service?: string;
version?: string;
}
export interface ScanRequest {
duration_seconds?: number;
}
export interface CompatibilityAttestation {
firmware_version: "3.0.2";
topology: "direct-lan";
operator_confirmed: true;
}
export interface ConnectRequest {
device_id: string;
ssid: string;
password: string;
compatibility_attestation: CompatibilityAttestation;
operation_id?: string;
idempotency_key?: string;
}
export interface PrepareAcquisitionRequest {
host?: string;
duration_seconds?: number;
requested_streams?: RequestedStreamId[];
evidence_policy?: "required" | "best-effort" | "disabled";
compatibility_attestation: CompatibilityAttestation;
operation_id?: string;
idempotency_key?: string;
deadline_seconds?: number;
}
export type RequestedStreamId =
| "spatial.point-cloud.live"
| "spatial.pose.live"
| "device.status.live"
| "device.heartbeat.live";
export interface StartAcquisitionRequest {
acquisition_id: string;
expected_state_revision?: number;
operation_id?: string;
idempotency_key?: string;
deadline_seconds?: number;
}
export interface StopAcquisitionRequest {
acquisition_id: string;
mode: "capture-only" | "graceful";
operator_confirmed?: boolean;
operation_id?: string;
idempotency_key?: string;
deadline_seconds?: number;
}
export interface AbortAcquisitionRequest {
acquisition_id: string;
operation_id?: string;
idempotency_key?: string;
deadline_seconds?: number;
}
export interface CompatibilityLiveRequest {
host?: string;
duration_seconds?: number;
compatibility_attestation: CompatibilityAttestation;
}
export interface ReplayRequest {
path: string;
speed?: number;
loop?: boolean;
}
export interface SelectCameraPreviewRequest {
source_id: string;
device_session_id: string;
}
export interface StopCameraPreviewRequest {
device_session_id: string;
generation: number;
}
export class ApiError extends Error {
readonly status: number;
constructor(message: string, status = 0) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function unwrapState(payload: unknown): XgridsK1State {
const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload;
if (!isRecord(value)) {
throw new ApiError("Локальный сервер вернул некорректное состояние.");
}
return value as XgridsK1State;
}
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
let response: Response;
try {
response = await fetch(path, {
...init,
headers: {
Accept: "application/json",
...(init?.body ? { "Content-Type": "application/json" } : {}),
...init?.headers,
},
});
} catch {
throw new ApiError("Не удалось подключиться к локальному сервису устройства.");
}
const bodyText = await response.text();
let body: unknown;
if (bodyText) {
try {
body = JSON.parse(bodyText) as unknown;
} catch {
body = bodyText;
}
}
if (!response.ok) {
const detail =
isRecord(body) && typeof body.detail === "string"
? body.detail
: typeof body === "string" && body.trim()
? body.trim()
: `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`;
throw new ApiError(
detail || `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`,
response.status,
);
}
return body;
}
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 xgridsK1Api.getState();
}
return unwrapState(payload);
}
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)) {
throw new ApiError("Локальный сервер вернул некорректный ответ проверки.");
}
return payload as HealthResponse;
},
async getState(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.stateRead);
},
scanBle(body: ScanRequest = {}): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.discoveryScan, body);
},
connect(body: ConnectRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.networkProvision, body);
},
prepareAcquisition(body: PrepareAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionPrepare, body);
},
startAcquisition(body: StartAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionStart, body);
},
stopAcquisition(body: StopAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionStop, body);
},
abortAcquisition(body: AbortAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionAbort, body);
},
startReplay(body: ReplayRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.streamStartReplay, body);
},
startLiveCompatibility(body: CompatibilityLiveRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.compatibilityStreamStartLive, body);
},
stopSessionCompatibility(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.compatibilityStreamStop);
},
selectCameraPreview(body: SelectCameraPreviewRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.cameraPreviewSelect, body);
},
stopCameraPreview(body: StopCameraPreviewRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.cameraPreviewStop, 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/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: XgridsK1State) => void,
onStatus: (status: EventSocketStatus) => void,
): () => void {
onStatus("connecting");
const socket = new WebSocket(eventSocketUrl());
socket.addEventListener("open", () => onStatus("open"));
socket.addEventListener("message", (event) => {
try {
const payload = JSON.parse(String(event.data)) as unknown;
onState(unwrapState(payload));
} catch {
// The REST poll remains authoritative if an unrelated event is received.
}
});
socket.addEventListener("error", () => onStatus("error"));
socket.addEventListener("close", () => onStatus("closed"));
return () => socket.close(1000, "Пункт управления закрыт");
}
@@ -1,173 +0,0 @@
import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "../../core/runtime/contracts";
import type {
AcquisitionState,
XgridsAcquisition,
XgridsK1State,
XgridsOperation,
} from "./api";
const TERMINAL_ACQUISITION_STATES = new Set<AcquisitionState>([
"completed",
"failed",
"aborted",
"interrupted",
]);
const FAILED_OPERATION_STATUSES = new Set([
"failed",
"cancelled",
"timed_out",
"interrupted",
]);
export type LiveStartPlan = "prepare" | "resume-prepared" | "already-running" | "blocked";
export function isTerminalAcquisitionState(
state: AcquisitionState | null | undefined,
): boolean {
return state ? TERMINAL_ACQUISITION_STATES.has(state) : false;
}
export function recoverableAcquisition(
state: XgridsK1State | null | undefined,
): XgridsAcquisition | null {
const acquisition = state?.acquisition;
return acquisition && !isTerminalAcquisitionState(acquisition.state) ? acquisition : null;
}
export function isConfirmedLiveState(state: XgridsK1State | null | undefined): boolean {
return state?.source_mode === "live" && state.acquisition?.state === "acquiring";
}
export function isSourceRuntimeBusy(state: XgridsK1State | null | undefined): boolean {
return state?.source_mode === "live" || state?.source_mode === "replay";
}
export function confirmedRuntimeSourceMode(
state: XgridsK1State | null | undefined,
): RuntimeSourceMode {
if (state?.source_mode === "replay") return "replay";
if (isConfirmedLiveState(state)) return "live";
return "idle";
}
export function effectiveAcquisition(
state: XgridsK1State | null | undefined,
): XgridsAcquisition | null {
if (state?.source_mode === "replay") return null;
return state?.acquisition ?? null;
}
export function liveStartPlan(state: XgridsK1State | null | undefined): LiveStartPlan {
if (state?.source_mode === "replay") return "blocked";
const acquisition = recoverableAcquisition(state);
if (!acquisition) return state?.source_mode === "live" ? "blocked" : "prepare";
if (acquisition.state === "prepared") return "resume-prepared";
if (
acquisition.state === "starting" ||
acquisition.state === "awaiting_external_start" ||
acquisition.state === "acquiring"
) {
return "already-running";
}
return "blocked";
}
export function normalizeRuntimePhase(
state: XgridsK1State | null | undefined,
): RuntimePhase {
const phase = state?.phase;
const acquisitionState = effectiveAcquisition(state)?.state;
if (acquisitionState === "failed" || acquisitionState === "interrupted") return "error";
if (acquisitionState === "awaiting_external_start" || acquisitionState === "starting") {
return "starting";
}
if (acquisitionState === "acquiring") return "streaming";
if (
acquisitionState === "awaiting_external_stop" ||
acquisitionState === "stopping" ||
acquisitionState === "finalizing"
) {
return "stopping";
}
if (acquisitionState === "prepared") return "connected";
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";
}
export function spatialSourceId(
state: XgridsK1State | null | undefined,
sourceUrl: string,
): string | null {
if (!sourceUrl) return null;
if (state?.source_mode === "replay") return `replay:${sourceUrl}`;
return state?.acquisition?.acquisition_id ?? sourceUrl;
}
export function sourceStatusLabel(state: XgridsK1State | null | undefined): string {
if (state?.source_mode === "replay") return "Повтор записи";
if (isConfirmedLiveState(state)) return "Реальное время · данные подтверждены";
if (state?.source_mode === "live") {
if (state.acquisition?.state === "failed" || state.phase === "error") {
return "Ошибка локального приёмника";
}
return "Ожидание реальных данных";
}
if (state?.acquisition?.state === "prepared") return "Приём подготовлен";
return "Ожидание";
}
export function operationByIdempotencyKey(
state: XgridsK1State | null | undefined,
action: string,
idempotencyKey: string | null | undefined,
): XgridsOperation | null {
if (!idempotencyKey) return null;
return (
[...(state?.operations ?? [])]
.reverse()
.find(
(operation) =>
operation.action === action && operation.idempotency_key === idempotencyKey,
) ?? null
);
}
export function operationNeedsReconciliation(
operation: XgridsOperation | null | undefined,
): boolean {
if (!operation || !FAILED_OPERATION_STATUSES.has(operation.status)) return false;
return operation.error?.safe_to_retry !== true;
}
function defaultUuid(): string {
const cryptoApi = globalThis.crypto;
if (!cryptoApi) {
throw new Error("Web Crypto недоступен; безопасный идентификатор операции не создан.");
}
if (typeof cryptoApi.randomUUID === "function") return cryptoApi.randomUUID();
const bytes = new Uint8Array(16);
cryptoApi.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
.slice(6, 8)
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}
export function provisioningIntentKey(
current: string | null,
createUuid: () => string = defaultUuid,
): string {
return current ?? `network-provision:${createUuid()}`;
}
@@ -1,33 +0,0 @@
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"),
deviceInspect: requirePluginAction(xgridsK1Manifest, "device.inspect"),
sensorCatalogRead: requirePluginAction(xgridsK1Manifest, "sensor.catalog.read"),
deviceCalibrationSnapshotRead: requirePluginAction(
xgridsK1Manifest,
"calibration.device-snapshot.read",
),
networkProvision: requirePluginAction(xgridsK1Manifest, "network.provision"),
connectionVerify: requirePluginAction(xgridsK1Manifest, "connection.verify"),
acquisitionPrepare: requirePluginAction(xgridsK1Manifest, "acquisition.prepare"),
acquisitionStart: requirePluginAction(xgridsK1Manifest, "acquisition.start"),
acquisitionStop: requirePluginAction(xgridsK1Manifest, "acquisition.stop"),
acquisitionAbort: requirePluginAction(xgridsK1Manifest, "acquisition.abort"),
acquisitionStateRead: requirePluginAction(xgridsK1Manifest, "acquisition.state.read"),
compatibilityStreamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"),
streamStartReplay: requirePluginAction(xgridsK1Manifest, "stream.start-replay"),
compatibilityStreamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"),
cameraPreviewSelect: requirePluginAction(xgridsK1Manifest, "camera.preview.select"),
cameraPreviewStop: requirePluginAction(xgridsK1Manifest, "camera.preview.stop"),
viewerSettingsUpdate: requirePluginAction(xgridsK1Manifest, "viewer.settings.update"),
});
@@ -1,30 +0,0 @@
const runtimeMessageReplacements: Array<[RegExp, string]> = [
[/broker connection ended/gi, "соединение с брокером завершено"],
[/Unspecified error/gi, "неуказанная ошибка"],
[
/Device was not rediscovered; keep the K1 powered and nearby\.?/gi,
"Устройство не обнаружено повторно; оставьте его включённым и рядом.",
],
[/Foxglove/gi, "локальный мост визуализации"],
[/MacBook/gi, "компьютер"],
[/\bK1\b/g, "устройство"],
];
export function localizeRuntimeMessage(message: string | null | undefined): string | null {
if (!message) return null;
const localized = runtimeMessageReplacements.reduce(
(localized, [pattern, replacement]) => localized.replace(pattern, replacement),
message,
);
const withoutTechnicalTerms = localized.replace(
/\b(?:API|BLE|Bluetooth|gRPC|IP|JSON|K1MQTT|MQTT|Rerun|RRD|TSV|UUID|Wi-Fi)\b/gi,
"",
);
if (/[A-Za-z]{3,}/.test(withoutTechnicalTerms)) {
return "Операция не выполнена. Технические подробности сохранены в журнале сервера.";
}
return localized;
}
@@ -1,298 +0,0 @@
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
import type {
ObservationSourceAvailability,
ObservationSourceDelivery,
ObservationSourceDescriptor,
ObservationSourceProvider,
} from "../../core/runtime/contracts";
import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
import { xgridsK1Manifest } from "./manifest";
import type {
XgridsCameraPreviewDelivery,
XgridsK1State,
XgridsSensorCatalogStream,
} from "./api";
function providerFor(
state: XgridsK1State,
activeModel: DeviceModelDefinition,
): ObservationSourceProvider {
return {
pluginId: xgridsK1Manifest.metadata.id,
pluginVersion: xgridsK1Manifest.metadata.version,
modelId: state.device_ref?.model_id || activeModel.id,
compatibilityProfileId:
state.device_session?.compatibility_profile_id ?? state.compatibility?.profile_id ?? null,
};
}
function bindingFor(state: XgridsK1State) {
const acquisition = effectiveAcquisition(state);
return {
deviceId: state.device_ref?.device_id ?? null,
deviceSessionId: state.device_session?.device_session_id ?? null,
acquisitionId: acquisition?.acquisition_id ?? null,
};
}
function catalogDeclares(state: XgridsK1State, streamId: string): boolean {
return Boolean(state.sensor_catalog?.streams?.some((stream) => stream.stream_id === streamId));
}
function spatialAvailability(state: XgridsK1State): ObservationSourceAvailability {
const mode = confirmedRuntimeSourceMode(state);
if (mode !== "idle" && state.rerun_grpc_url?.trim()) return "streaming";
if (state.rerun_grpc_url?.trim()) return "available";
if (state.device_session?.connectivity === "degraded") return "degraded";
if (state.device_session?.connectivity === "connected") return "available";
return catalogDeclares(state, "spatial.point-cloud.live") ? "declared" : "unavailable";
}
function catalogAvailability(value: string | null | undefined): ObservationSourceAvailability {
switch (value?.trim().toLowerCase()) {
case "observed":
case "available":
return "available";
case "connecting":
return "connecting";
case "streaming":
return "streaming";
case "degraded":
return "degraded";
case "unavailable":
return "unavailable";
case "error":
return "error";
case "declared":
return "declared";
default:
return "unverified";
}
}
function cameraCatalogEntry(stream: XgridsSensorCatalogStream): boolean {
const sourceId = stream.source_id?.trim();
const semanticChannelId = stream.semantic_channel_id?.trim();
if (!sourceId || !semanticChannelId) return false;
const modality = stream.modality?.trim().toLowerCase();
const sensorKind = stream.sensor_kind?.trim().toLowerCase();
return (
modality === "encoded-video" ||
modality === "video" ||
sensorKind === "camera" ||
semanticChannelId.startsWith("camera.")
);
}
function safeEndpointLabel(value: string | null | undefined): string | null {
const label = value?.trim();
if (!label || label.length > 128) return null;
if (label.includes("://") || /(?:^|\D)(?:\d{1,3}\.){3}\d{1,3}(?:\D|$)/.test(label)) {
return null;
}
return label;
}
const SENSITIVE_QUERY_KEYS = new Set([
"accesskey", "accesstoken", "apikey", "auth", "authorization", "clientsecret",
"credential", "credentials", "password", "passwd", "passphrase", "privatekey",
"psk", "pwd", "refreshtoken", "secret", "sessionkey", "token", "user",
"username", "wifipassword",
]);
function decodedForInspection(value: string): string | null {
let decoded = value;
try {
for (let depth = 0; depth < 4; depth += 1) {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
}
} catch {
return null;
}
return decoded;
}
function sensitiveQueryKey(value: string): boolean {
const canonical = value.toLowerCase().replace(/[^a-z0-9]/g, "");
return SENSITIVE_QUERY_KEYS.has(canonical) ||
/(?:token|secret|password|passwd|passphrase|credential|credentials)$/.test(canonical);
}
function safeBrowserDeliveryUrl(value: string): boolean {
if (value.length > 4_096 || !value.startsWith("/") || value.startsWith("//")) return false;
const inspected = decodedForInspection(value);
if (!inspected) return false;
if (
/[\\\r\n\u0000-\u001f\u007f]/.test(inspected) ||
/\brtsps?\s*:/i.test(inspected) ||
/(?:^|\D)(?:\d{1,3}\.){3}\d{1,3}(?:\D|$)/.test(inspected) ||
/\[[0-9a-f:]+\]/i.test(inspected) ||
/(?:^|[/?#&=])[^/?#&=\s:@]+:[^/?#&\s@]+@/.test(inspected) ||
/\b(?:basic|bearer)\s+[a-z0-9._~+/=-]+/i.test(inspected)
) return false;
const base = new URL("https://mission-core.invalid/");
let parsed: URL;
let inspectedParsed: URL;
try {
parsed = new URL(value, base);
inspectedParsed = new URL(inspected, base);
} catch {
return false;
}
if (
parsed.origin !== base.origin || inspectedParsed.origin !== base.origin ||
parsed.username || parsed.password || parsed.hash ||
inspectedParsed.username || inspectedParsed.password || inspectedParsed.hash
) return false;
for (const [key] of inspectedParsed.searchParams) {
if (sensitiveQueryKey(key)) return false;
}
return true;
}
function browserDelivery(
value: XgridsCameraPreviewDelivery | null | undefined,
): ObservationSourceDelivery | null {
const id = value?.id?.trim();
const url = value?.url?.trim();
const mediaType = value?.media_type?.trim();
if (!id || value?.kind !== "mse-fmp4-websocket" || !url || !mediaType) return null;
// Browser delivery is host-owned and same-origin. A device RTSP/IP endpoint
// must never cross the plugin boundary into the host descriptor catalog.
if (!safeBrowserDeliveryUrl(url)) return null;
if (!/^video\/mp4(?:\s*;|$)/i.test(mediaType)) return null;
return { id, kind: value.kind, url, mediaType };
}
function cameraAvailability(
state: XgridsK1State,
stream: XgridsSensorCatalogStream,
selected: boolean,
delivery: ObservationSourceDelivery | null,
attested: boolean,
): ObservationSourceAvailability {
if (!attested) return "unverified";
if (state.device_session?.connectivity === "degraded") return "degraded";
const base = catalogAvailability(stream.availability);
if (!selected) return base === "streaming" || base === "connecting" ? "available" : base;
const phase = state.camera_preview?.phase?.trim().toLowerCase();
if (phase === "error" || base === "error") return "error";
if (delivery && (phase === "streaming" || phase === "active" || phase === "ready")) {
return "streaming";
}
if (phase === "degraded") return "degraded";
return "connecting";
}
export function xgridsK1ObservationSources(
state: XgridsK1State,
activeModel: DeviceModelDefinition,
): ObservationSourceDescriptor[] {
const provider = providerFor(state, activeModel);
const binding = bindingFor(state);
const clockId = binding.acquisitionId ?? binding.deviceSessionId ?? binding.deviceId ?? null;
const descriptorId = (sourceId: string) =>
`${provider.pluginId}:${provider.modelId}:${sourceId}`;
const pointCloud: ObservationSourceDescriptor = {
id: descriptorId("sensor.lidar.primary"),
sourceId: "sensor.lidar.primary",
semanticChannelId: "spatial.point-cloud.live",
label: "K1 · облако точек",
description: "Облако точек, поза и траектория в общей 3D-сцене",
modality: "point-cloud",
role: "primary",
availability: spatialAvailability(state),
transport: "rerun-grpc",
endpointLabel: state.rerun_grpc_url?.trim() ? "Rerun gRPC" : "MQTT → Rerun",
previewUrl: state.rerun_grpc_url?.trim() || null,
delivery: null,
activation: null,
provider,
binding,
capabilities: {
overlay: false,
fullscreen: true,
resizable: false,
defaultVisible: true,
timelineMode: "live-only",
// Rerun can hold replay data, but the Mission Core host timeline is not
// wired to its time controller yet.
seekable: false,
sessionRecording: false,
clockId,
spatialRegistration: "native",
},
};
const cameraRows = (state.sensor_catalog?.streams ?? []).filter(cameraCatalogEntry);
const sourceIdCounts = new Map<string, number>();
for (const stream of cameraRows) {
const sourceId = stream.source_id?.trim();
if (sourceId) sourceIdCounts.set(sourceId, (sourceIdCounts.get(sourceId) ?? 0) + 1);
}
const attested = Boolean(provider.compatibilityProfileId && binding.deviceSessionId);
const sessionScope = binding.deviceSessionId ?? binding.deviceId ?? "unbound";
const activeSourceId = state.camera_preview?.active_source_id?.trim() ?? null;
const cameras = cameraRows.flatMap<ObservationSourceDescriptor>((stream) => {
const sourceId = stream.source_id?.trim();
const semanticChannelId = stream.semantic_channel_id?.trim();
if (!sourceId || !semanticChannelId || sourceIdCounts.get(sourceId) !== 1) return [];
const rawActivation = stream.activation;
const groupId = rawActivation?.group_id?.trim();
const maxActive = rawActivation?.max_active;
const activationValid = Boolean(
groupId && Number.isInteger(maxActive) && (maxActive ?? 0) > 0,
);
const selected = Boolean(
attested && activationValid && rawActivation?.selected === true && activeSourceId === sourceId,
);
const activation = activationValid
? {
groupId: `${provider.pluginId}:${sessionScope}:${groupId}`,
maxActive: maxActive as number,
selected,
controllable: Boolean(attested && rawActivation?.controllable),
}
: null;
const candidateDelivery = stream.delivery ?? state.camera_preview?.delivery;
const delivery = selected ? browserDelivery(candidateDelivery) : null;
const label = stream.label?.trim() || sourceId;
return [{
id: descriptorId(sourceId),
sourceId,
semanticChannelId,
label,
description: "Видеоканал, опубликованный активным device-плагином",
modality: "video",
role: "auxiliary",
availability: cameraAvailability(state, stream, selected, delivery, attested),
transport: delivery ? "websocket" : "other",
endpointLabel: safeEndpointLabel(stream.endpoint_label) ?? "Локальный video adapter",
previewUrl: null,
delivery,
activation,
provider,
binding,
capabilities: {
overlay: true,
fullscreen: true,
resizable: true,
defaultVisible: selected && Boolean(delivery),
timelineMode: "live-only",
seekable: false,
sessionRecording: false,
clockId,
spatialRegistration: "unresolved",
},
}];
});
return [pointCloud, ...cameras];
}
@@ -1,12 +0,0 @@
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,
}),
};
@@ -1,85 +0,0 @@
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 "Неизвестно";
}
@@ -1,166 +0,0 @@
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,
} from "../../core/runtime/contracts";
import {
confirmedRuntimeSourceMode,
effectiveAcquisition,
normalizeRuntimePhase,
spatialSourceId,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import { xgridsK1Manifest } from "./manifest";
import { finiteMetric, pipelineLatency } from "./presentation";
import { xgridsK1ObservationSources } from "./observationSources";
import { useXgridsK1Runtime } from "./useXgridsK1Runtime";
export type XgridsK1Controller = ReturnType<typeof useXgridsK1Runtime>;
const XgridsK1RuntimeContext = createContext<XgridsK1Controller | null>(null);
function normalizeState(
controller: XgridsK1Controller,
activeModel: DeviceModelDefinition,
): MissionRuntimeState | null {
const state = controller.state;
if (!state) return null;
const metrics = state.metrics;
const deviceRef = state.device_ref;
const deviceSession = state.device_session;
const acquisition = effectiveAcquisition(state);
const sourceUrl = state.rerun_grpc_url?.trim() ?? "";
const resolvedSpatialSourceId =
state.source_mode === "replay"
? spatialSourceId(state, sourceUrl)
: acquisition?.acquisition_id ?? spatialSourceId(state, sourceUrl);
return {
phase: normalizeRuntimePhase(state),
message: localizeRuntimeMessage(state.message),
activeDevice: deviceRef
? {
pluginId: xgridsK1Manifest.metadata.id,
modelId: deviceRef.model_id || activeModel.id,
displayName: activeModel.displayName,
instanceId: deviceRef.device_id,
endpointLabel: state.k1_ip,
}
: null,
deviceSession: deviceSession
? {
sessionId: deviceSession.device_session_id,
deviceId: deviceSession.device_id,
compatibilityProfileId: deviceSession.compatibility_profile_id,
connectivity: deviceSession.connectivity,
}
: null,
acquisition: acquisition
? {
acquisitionId: acquisition.acquisition_id,
deviceId: acquisition.device_id,
deviceSessionId: acquisition.device_session_id,
compatibilityProfileId: acquisition.compatibility_profile_id,
controlMode: acquisition.control_mode,
state: acquisition.state,
stateRevision: acquisition.state_revision,
operatorInstructions: acquisition.operator_instructions ?? [],
}
: null,
operations: (state.operations ?? []).map((operation) => ({
operationId: operation.operation_id,
action: operation.action,
status: operation.status,
stageCode: operation.stage_code,
messageCode: operation.message_code,
})),
spatialSource: sourceUrl && resolvedSpatialSourceId
? {
id: resolvedSpatialSourceId,
url: sourceUrl,
label:
state.source_mode === "replay"
? "Повтор пространственной записи"
: "Локальный пространственный поток",
kind: "rerun-grpc",
}
: null,
observationSources: xgridsK1ObservationSources(state, activeModel),
observationTimeline: {
// This is the shared host timeline. A replayable Rerun source alone does
// not make camera and point-cloud time jointly seekable.
mode: "live-only",
seekable: false,
sessionRecording: false,
synchronization: "host-arrival-best-effort",
range: null,
},
viewerSettings: state.viewer_settings,
sourceMode: confirmedRuntimeSourceMode(state),
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,
setObservationSourceActive: controller.setObservationSourceActive,
};
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,55 +0,0 @@
import type { XgridsCameraPreviewState, XgridsK1State } from "./api";
function monotonicInteger(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
}
function deviceSessionScope(state: XgridsK1State): string | null {
const sessionId = state.device_session?.device_session_id;
return typeof sessionId === "string" && sessionId.trim() ? sessionId : null;
}
function cameraSnapshotIsAtLeastAsNew(
current: XgridsCameraPreviewState,
incoming: XgridsCameraPreviewState,
): boolean {
const currentRevision = monotonicInteger(current.revision);
const incomingRevision = monotonicInteger(incoming.revision);
if (currentRevision !== null || incomingRevision !== null) {
if (incomingRevision === null) return false;
if (currentRevision === null) return true;
if (incomingRevision !== currentRevision) return incomingRevision > currentRevision;
}
const currentGeneration = monotonicInteger(current.generation);
const incomingGeneration = monotonicInteger(incoming.generation);
if (currentGeneration === null) return true;
if (incomingGeneration === null) return false;
return incomingGeneration >= currentGeneration;
}
/**
* Select an authoritative runtime snapshot without allowing asynchronous REST,
* mutation, or event responses to roll camera preview state backwards.
*
* Revision is the primary ordering key. In particular, a stop snapshot with a
* newer revision and a null generation is valid and must replace the active
* generation. Generation is only a tie-breaker when revisions are equal or
* absent.
*/
export function selectMonotonicXgridsState(
current: XgridsK1State | null,
incoming: XgridsK1State,
): XgridsK1State {
if (current && deviceSessionScope(current) !== deviceSessionScope(incoming)) {
return incoming;
}
if (!current?.camera_preview) return incoming;
if (!incoming.camera_preview) return current;
// Backend snapshots are atomic. Reject the whole stale response instead of
// combining camera/catalog/metrics fields captured at different moments.
return cameraSnapshotIsAtLeastAsNew(current.camera_preview, incoming.camera_preview)
? incoming
: current;
}
@@ -1,353 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { BackendStatus, ViewerSettings } from "../../core/runtime/contracts";
import {
ApiError,
xgridsK1Api,
openEventSocket,
type ConnectRequest,
type EventSocketStatus,
type PrepareAcquisitionRequest,
type ReplayRequest,
type XgridsK1State,
} from "./api";
import {
isTerminalAcquisitionState,
liveStartPlan,
operationByIdempotencyKey,
operationNeedsReconciliation,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import { selectMonotonicXgridsState } from "./stateOrdering";
export type PendingAction =
| "scan"
| "connect"
| "live"
| "replay"
| "stop"
| "abort"
| "camera"
| "viewer";
function messageFor(error: unknown): string {
if (error instanceof ApiError) {
const message = localizeRuntimeMessage(error.message) ?? error.message;
return error.status
? `${message} (HTTP ${error.status})`
: message;
}
return "Запрос к локальному сервису устройства завершился ошибкой.";
}
function measuredLatency(state: XgridsK1State | null): number | null {
if (state?.source_mode !== "live") return null;
const metrics = state?.metrics;
if (!metrics) return null;
const reported = metrics.pipeline_ms ?? metrics.end_to_end_ms;
if (typeof reported === "number" && Number.isFinite(reported)) return reported;
const segments = [
metrics.mqtt_to_decode_ms,
metrics.publish_ms,
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : 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);
const [error, setError] = useState<string | null>(null);
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
const mounted = useRef(true);
const acceptState = useCallback((nextState: XgridsK1State) => {
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
setBackendStatus("online");
}, []);
const refresh = useCallback(async (reportErrors = true) => {
if (!enabled) return;
const [healthResult, stateResult] = await Promise.allSettled([
xgridsK1Api.getHealth(),
xgridsK1Api.getState(),
]);
if (!mounted.current) return;
if (stateResult.status === "fulfilled") {
acceptState(stateResult.value);
if (reportErrors) setError(null);
}
if (healthResult.status === "fulfilled") {
const health = healthResult.value;
const healthy = health.ok !== false && health.status !== "error";
setBackendStatus(healthy && stateResult.status === "fulfilled" ? "online" : "degraded");
} else if (stateResult.status === "rejected") {
setBackendStatus("offline");
}
if (stateResult.status === "rejected" && reportErrors) {
setError(messageFor(stateResult.reason));
}
}, [acceptState, enabled]);
const run = useCallback(
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
if (!enabled) return false;
setPendingAction(action);
setError(null);
try {
const nextState = await operation();
if (mounted.current) acceptState(nextState);
return true;
} catch (operationError) {
if (mounted.current) {
setError(messageFor(operationError));
if (operationError instanceof ApiError && operationError.status === 0) {
setBackendStatus("offline");
}
}
return false;
} finally {
if (mounted.current) setPendingAction(null);
}
},
[acceptState, enabled],
);
const scan = useCallback(
() => run("scan", () => xgridsK1Api.scanBle({ duration_seconds: 6 })),
[run],
);
const connect = useCallback(
(request: ConnectRequest) =>
run("connect", async () => {
const previous = operationByIdempotencyKey(
state,
"network.provision",
request.idempotency_key,
);
if (previous?.status === "succeeded" && state) return state;
if (operationNeedsReconciliation(previous)) {
throw new ApiError(
"Предыдущая запись настроек завершилась с неопределённым результатом. Автоматический повтор заблокирован; измените параметры только после проверки устройства.",
);
}
const nextState = await xgridsK1Api.connect(request);
const operation = operationByIdempotencyKey(
nextState,
"network.provision",
request.idempotency_key,
);
if (operationNeedsReconciliation(operation)) {
throw new ApiError(
"Результат записи настроек требует ручной проверки. Повторная аппаратная запись не выполнена.",
);
}
if (operation && operation.status !== "succeeded") {
throw new ApiError("Запись настроек ещё выполняется; дождитесь обновления состояния.");
}
return nextState;
}),
[run, state],
);
const prepareAndStartAcquisition = useCallback(
(request: PrepareAcquisitionRequest) =>
run("live", async () => {
const plan = liveStartPlan(state);
if (plan === "blocked") {
throw new ApiError(
"Сначала завершите текущий приём или повтор записи.",
);
}
if (plan === "already-running" && state) return state;
const prepared =
plan === "resume-prepared" && state
? state
: await xgridsK1Api.prepareAcquisition(request);
const acquisition = prepared.acquisition;
if (!acquisition?.acquisition_id) {
throw new ApiError("Локальный сервис не вернул идентификатор подготовленного приёма.");
}
return xgridsK1Api.startAcquisition({
acquisition_id: acquisition.acquisition_id,
expected_state_revision: acquisition.state_revision,
});
}),
[run, state],
);
const startReplay = useCallback(
(request: ReplayRequest) => run("replay", () => xgridsK1Api.startReplay(request)),
[run],
);
const stop = useCallback(
() =>
run("stop", () => {
const acquisition = state?.acquisition;
const acquisitionTerminal = isTerminalAcquisitionState(acquisition?.state);
if (acquisition && !acquisitionTerminal) {
return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id,
mode: "capture-only",
});
}
// Replay and pre-v1alpha2 sessions remain a compatibility-only path.
return xgridsK1Api.stopSessionCompatibility();
}),
[run, state?.acquisition],
);
const abort = useCallback(() => {
const acquisition = state?.acquisition;
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
return Promise.resolve(false);
}
return run("abort", () =>
xgridsK1Api.abortAcquisition({ acquisition_id: acquisition.acquisition_id }),
);
}, [run, state?.acquisition]);
const setObservationSourceActive = useCallback(
(sourceId: string, active: boolean) =>
run("camera", async () => {
if (!state) {
throw new ApiError("Состояние устройства ещё не загружено.");
}
const deviceSessionId = state.device_session?.device_session_id?.trim();
if (!deviceSessionId) {
throw new ApiError("Для камеры нет активной сессии устройства.");
}
const catalogSource = state.sensor_catalog?.streams?.find(
(candidate) => candidate.source_id === sourceId,
);
if (!catalogSource || catalogSource.activation?.controllable !== true) {
throw new ApiError("Плагин не разрешает управление выбранным видеоканалом.");
}
if (active) {
if (
catalogSource.activation.selected &&
state.camera_preview?.active_source_id === sourceId &&
state.camera_preview?.phase !== "error" &&
state.camera_preview?.delivery
) {
return state;
}
return xgridsK1Api.selectCameraPreview({
source_id: sourceId,
device_session_id: deviceSessionId,
});
}
if (state.camera_preview?.active_source_id !== sourceId) return state;
const generation = state.camera_preview.generation;
if (!Number.isInteger(generation) || (generation ?? 0) < 1) {
throw new ApiError("Плагин не вернул поколение активной camera-preview сессии.");
}
return xgridsK1Api.stopCameraPreview({
device_session_id: deviceSessionId,
generation: generation as number,
});
}),
[run, state],
);
const updateViewerSettings = useCallback(
(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);
return () => {
mounted.current = false;
window.clearInterval(poll);
};
}, [enabled, refresh]);
useEffect(() => {
if (!enabled) return;
let dispose: (() => void) | undefined;
let retry: number | undefined;
let cancelled = false;
const connectEvents = () => {
if (cancelled) return;
dispose = openEventSocket(acceptState, (status) => {
if (cancelled) return;
setEventStatus(status);
if ((status === "closed" || status === "error") && retry === undefined) {
retry = window.setTimeout(() => {
retry = undefined;
connectEvents();
}, 3_000);
}
});
};
connectEvents();
return () => {
cancelled = true;
if (retry !== undefined) window.clearTimeout(retry);
dispose?.();
};
}, [acceptState, enabled]);
useEffect(() => {
const latency = measuredLatency(state);
if (latency === null) {
setLatencyHistory((values) => (values.length ? [] : values));
return;
}
setLatencyHistory((values) => [...values.slice(-23), latency]);
}, [state]);
return {
state,
backendStatus,
eventStatus,
pendingAction,
error,
latencyHistory,
refresh: () => refresh(true),
clearError: () => setError(null),
scan,
connect,
prepareAndStartAcquisition,
startReplay,
stop,
abort,
setObservationSourceActive,
updateViewerSettings,
};
}
+6 -445
View File
@@ -83,15 +83,8 @@
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 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;
@@ -117,439 +110,7 @@
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;
grid-template-columns: minmax(23rem, 0.78fr) minmax(34rem, 1.22fr);
align-items: start;
gap: 0.85rem;
}
.device-workspace__side {
display: grid;
min-width: 0;
gap: 0.85rem;
}
.connection-panel,
.status-panel,
.latency-panel,
.session-panel {
background: var(--station-panel);
}
.error-banner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.9rem;
border-radius: 1rem;
background: rgb(255 255 255 / 0.045);
color: var(--nodedc-text-primary);
padding: 0.85rem 1rem;
}
.error-banner__dot {
width: 0.46rem;
height: 0.46rem;
align-self: start;
margin-top: 0.28rem;
border-radius: 50%;
background: rgb(var(--nodedc-danger-rgb));
}
.error-banner strong,
.error-banner p {
margin: 0;
}
.error-banner strong {
color: var(--nodedc-text-primary);
font-size: 0.76rem;
}
.error-banner p {
margin-top: 0.2rem;
color: var(--nodedc-text-secondary);
font-size: 0.68rem;
line-height: 1.45;
}
.error-banner__actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.wizard-list {
display: grid;
margin-top: 1.4rem;
}
.wizard-step {
display: grid;
grid-template-columns: 2.2rem minmax(0, 1fr);
gap: 0.75rem;
}
.wizard-step__rail {
position: relative;
display: flex;
justify-content: center;
}
.wizard-step__rail::after {
position: absolute;
top: 2rem;
bottom: 0;
left: 50%;
width: 1px;
background: var(--station-hairline);
content: "";
}
.wizard-step:last-child .wizard-step__rail::after {
display: none;
}
.wizard-step__rail span {
position: relative;
z-index: 1;
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border-radius: 50%;
background: #27272a;
color: var(--nodedc-text-secondary);
font-size: 0.59rem;
font-weight: 800;
}
.wizard-step__content {
min-width: 0;
padding: 0.08rem 0 1.5rem;
}
.wizard-step:last-child .wizard-step__content {
padding-bottom: 0;
}
.wizard-step__content > header {
display: flex;
min-height: 2rem;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
margin-bottom: 0.75rem;
}
.wizard-step__content h3 {
margin: 0;
font-size: 0.8rem;
font-weight: 710;
}
.step-copy,
.safety-note,
.live-instruction {
margin: 0 0 0.72rem;
color: var(--nodedc-text-muted);
font-size: 0.65rem;
line-height: 1.48;
}
.safety-note,
.live-instruction {
margin: 0.7rem 0 0;
}
.device-list {
display: grid;
max-height: 20rem;
gap: 0.48rem;
margin-top: 0.72rem;
overflow-y: auto;
padding-right: 0.2rem;
}
.device-row {
display: grid;
gap: 0.62rem;
border-radius: 0.95rem;
background: rgb(255 255 255 / 0.035);
padding: 0.72rem;
}
.device-row[data-selected="true"] {
background: rgb(255 255 255 / 0.065);
box-shadow: none;
}
.device-row__identity,
.device-row__action {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.device-row__identity {
justify-content: flex-start;
}
.device-row__identity > div {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.device-row__name {
display: flex;
min-width: 0;
align-items: center;
gap: 0.42rem;
}
.device-row__name small {
flex: 0 0 auto;
color: var(--nodedc-text-muted);
font-size: 0.47rem;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.device-row__identity strong {
overflow: hidden;
font-size: 0.69rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-row code,
.detail-row code {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.58rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-row__signal {
width: 0.58rem;
height: 0.58rem;
flex: 0 0 0.58rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.device-row[data-compatible="true"] .device-row__signal {
background: rgb(var(--nodedc-success-rgb));
}
.device-row__action > span {
color: var(--nodedc-text-muted);
font-size: 0.62rem;
}
.empty-device-list {
border-radius: 0.95rem;
background: rgb(255 255 255 / 0.025);
color: var(--nodedc-text-muted);
padding: 0.9rem;
font-size: 0.65rem;
line-height: 1.45;
}
.field-stack,
.session-form {
display: grid;
gap: 0.85rem;
}
.connection-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin: 0.72rem 0;
border-radius: 0.85rem;
background: rgb(255 255 255 / 0.03);
padding: 0.62rem 0.75rem;
font-size: 0.65rem;
}
.connection-summary span {
color: var(--nodedc-text-muted);
}
.connection-summary strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-panel > .nodedc-segmented {
margin-top: 1.2rem;
}
.session-form {
margin-top: 1rem;
}
.session-form--replay {
grid-template-columns: minmax(0, 1.55fr) minmax(8rem, 0.45fr);
}
.session-form--replay > :first-child {
grid-column: 1 / -1;
}
.session-form--replay > .nodedc-button {
align-self: end;
}
.session-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1rem;
padding-top: 0.9rem;
}
.session-footer p {
max-width: 30rem;
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
line-height: 1.45;
}
.diagnostics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.detail-list {
display: grid;
margin: 0.9rem 0 0;
}
.detail-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(7rem, 0.72fr) minmax(0, 1.28fr);
gap: 1rem;
padding: 0.7rem 0;
}
.detail-row dt,
.detail-row dd {
min-width: 0;
margin: 0;
font-size: 0.65rem;
}
.detail-row dt {
color: var(--nodedc-text-muted);
}
.detail-row dd {
overflow: hidden;
color: var(--nodedc-text-secondary);
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.inline-state {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.inline-state::before {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.inline-state[data-state="open"]::before { background: rgb(var(--nodedc-success-rgb)); }
.inline-state[data-state="error"]::before,
.inline-state[data-state="closed"]::before { background: rgb(var(--nodedc-danger-rgb)); }
.latency-now {
color: var(--nodedc-text-primary);
font-size: 1.2rem;
font-weight: 660;
letter-spacing: -0.04em;
}
.latency-now span {
color: var(--nodedc-text-muted);
font-size: 0.61rem;
letter-spacing: 0;
}
.latency-trace {
display: flex;
height: 7rem;
align-items: end;
gap: 0.2rem;
margin-top: 1rem;
border-radius: 0.85rem;
background: rgb(255 255 255 / 0.018);
padding: 0.7rem;
}
.latency-trace span {
min-width: 0.16rem;
flex: 1 1 0;
border-radius: 999px 999px 0.12rem 0.12rem;
background: var(--nodedc-text-primary);
}
.latency-trace p {
align-self: center;
margin: auto;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
line-height: 1.45;
text-align: center;
}
.latency-legend {
display: flex;
justify-content: space-between;
margin-top: 0.38rem;
color: var(--nodedc-text-muted);
font-size: 0.54rem;
}
}
.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)); }
@@ -3,17 +3,12 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.xgrids-k1-plugin .device-workspace__grid {
grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr);
}
.landing-stage__copy {
width: min(35rem, 54%);
}
}
@media (max-width: 1280px) {
.xgrids-k1-plugin .device-workspace__grid,
.overview-grid,
.mission-layout {
grid-template-columns: 1fr;
@@ -72,10 +67,6 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.xgrids-k1-plugin .diagnostics-grid {
grid-template-columns: 1fr;
}
.workspace-lead {
align-items: flex-start;
flex-direction: column;
@@ -209,36 +200,12 @@
display: none;
}
.xgrids-k1-plugin .session-form--replay {
grid-template-columns: 1fr;
}
.xgrids-k1-plugin .session-form--replay > :first-child {
grid-column: auto;
}
.xgrids-k1-plugin .session-footer,
.xgrids-k1-plugin .error-banner,
.source-format-list > div {
align-items: stretch;
grid-template-columns: 1fr;
flex-direction: column;
}
.xgrids-k1-plugin .error-banner {
grid-template-columns: auto minmax(0, 1fr);
}
.xgrids-k1-plugin .error-banner__actions {
grid-column: 2;
justify-content: flex-end;
}
.xgrids-k1-plugin .device-row__action {
align-items: stretch;
flex-direction: column;
}
.feature-row {
grid-template-columns: auto minmax(0, 1fr);
}
@@ -24,13 +24,13 @@ before(async () => {
"/src/core/device-plugins/registry.ts",
));
({ xgridsK1Manifest, xgridsK1Actions } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/manifest.ts",
"@xgrids-k1/frontend/manifest.ts",
));
lifecycle = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/lifecycle.ts",
"@xgrids-k1/frontend/lifecycle.ts",
);
({ xgridsK1Api } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/api.ts",
"@xgrids-k1/frontend/api.ts",
));
});
@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { after, before, test } from "node:test";
import { createServer } from "vite";
const testRoot = dirname(fileURLToPath(import.meta.url));
const controlStationRoot = resolve(testRoot, "..");
const repositoryRoot = resolve(controlStationRoot, "../..");
const coreSourceRoot = join(controlStationRoot, "src");
const pluginFrontendRoot = join(repositoryRoot, "plugins/xgrids-k1/frontend/src");
const legacyPluginRoot = join(coreSourceRoot, "device-plugins/xgrids-k1");
function sourceFiles(root) {
return readdirSync(root).flatMap((entry) => {
const path = join(root, entry);
if (statSync(path).isDirectory()) return sourceFiles(path);
return /\.(?:css|ts|tsx)$/.test(entry) ? [path] : [];
});
}
let server;
let createDevicePluginRegistry;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ createDevicePluginRegistry } = await server.ssrLoadModule(
"/src/core/device-plugins/registry.ts",
));
});
after(async () => {
await server?.close();
});
function syntheticPlugin({ pluginId, modelId, componentKey, connectionView }) {
return {
manifest: {
apiVersion: "missioncore.nodedc/v1alpha2",
kind: "DevicePlugin",
metadata: { id: pluginId, version: "1.0.0", displayName: pluginId },
spec: {
hostApiRange: "v1alpha2",
runtime: { backendEntrypoint: `${pluginId}:build`, isolation: "transitional-in-process" },
permissions: ["device.read"],
actions: [{ id: "state.read", mutating: false, secretFields: [] }],
compatibilityProfiles: [{
profileId: `${modelId}.profile.v1`,
path: `profiles/${modelId}.json`,
modelId,
}],
models: [{
id: modelId,
vendor: pluginId,
displayName: modelId,
category: "Sensor",
description: "Synthetic frontend contribution",
verified: true,
capabilities: [{ id: "device.read", label: "Read" }],
ui: { slot: "device.connection", componentKey },
}],
},
},
RuntimeProvider: ({ children }) => children,
connectionViews: { [componentKey]: connectionView },
};
}
test("each device plugin contributes its own connection pipeline component", () => {
const alphaConnection = () => null;
const betaConnection = () => null;
const registry = createDevicePluginRegistry([
syntheticPlugin({
pluginId: "synthetic.alpha",
modelId: "synthetic.alpha.sensor",
componentKey: "alpha.connection",
connectionView: alphaConnection,
}),
syntheticPlugin({
pluginId: "synthetic.beta",
modelId: "synthetic.beta.sensor",
componentKey: "beta.connection",
connectionView: betaConnection,
}),
]);
assert.equal(registry.resolveModel("synthetic.alpha.sensor").ConnectionView, alphaConnection);
assert.equal(registry.resolveModel("synthetic.beta.sensor").ConnectionView, betaConnection);
assert.notEqual(
registry.resolveModel("synthetic.alpha.sensor").ConnectionView,
registry.resolveModel("synthetic.beta.sensor").ConnectionView,
);
});
test("XGRIDS frontend is physically plugin-owned and split by operator pipeline", () => {
if (existsSync(legacyPluginRoot)) {
assert.deepEqual(readdirSync(legacyPluginRoot), []);
}
for (const relativePath of [
"plugin.ts",
"runtimeContext.tsx",
"styles.css",
"components/K1ProvisioningPipeline.tsx",
"components/K1AcquisitionPipeline.tsx",
"components/K1Diagnostics.tsx",
]) {
assert.equal(existsSync(join(pluginFrontendRoot, relativePath)), true, relativePath);
}
});
test("generic Control Station has one composition import and no K1 implementation knowledge", () => {
const compositionPath = join(coreSourceRoot, "composition/devicePlugins.ts");
const composition = readFileSync(compositionPath, "utf8");
assert.match(composition, /from "@xgrids-k1\/frontend\/plugin"/);
for (const path of sourceFiles(coreSourceRoot)) {
if (path === compositionPath) continue;
const source = readFileSync(path, "utf8");
assert.doesNotMatch(source, /xgrids|lixel|\bk1\b/i, path);
}
for (const path of sourceFiles(pluginFrontendRoot)) {
const source = readFileSync(path, "utf8");
assert.doesNotMatch(source, /apps\/control-station|\.\.\/\.\.\/core|\.\.\/\.\.\/components/, path);
}
});
@@ -35,10 +35,10 @@ before(async () => {
server: { middlewareMode: true },
});
({ xgridsK1Manifest } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/manifest.ts",
"@xgrids-k1/frontend/manifest.ts",
));
({ xgridsK1ObservationSources } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/observationSources.ts",
"@xgrids-k1/frontend/observationSources.ts",
));
({ openObservationSource, shouldRestartObservationSource } = await server.ssrLoadModule(
"/src/core/observation/layoutPolicy.ts",
@@ -13,7 +13,7 @@ before(async () => {
server: { middlewareMode: true },
});
({ selectMonotonicXgridsState } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/stateOrdering.ts",
"@xgrids-k1/frontend/stateOrdering.ts",
));
});
+9 -1
View File
@@ -12,6 +12,14 @@
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"baseUrl": ".",
"paths": {
"@mission-core/plugin-sdk": ["src/core/device-plugins/frontendSdk.ts"],
"@xgrids-k1/frontend/*": ["../../plugins/xgrids-k1/frontend/src/*"],
"react": ["node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["node_modules/@types/react/jsx-runtime.d.ts"],
"@nodedc/ui-react": ["node_modules/@nodedc/ui-react/dist/index.d.ts"]
},
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
@@ -20,5 +28,5 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src", "../../plugins/xgrids-k1/frontend/src"]
}
+20
View File
@@ -1,3 +1,5 @@
import { fileURLToPath } from "node:url";
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import wasm from "vite-plugin-wasm";
@@ -8,6 +10,17 @@ export default defineConfig(({ mode }) => {
return {
plugins: [react(), wasm()],
resolve: {
alias: {
"@mission-core/plugin-sdk": fileURLToPath(
new URL("./src/core/device-plugins/frontendSdk.ts", import.meta.url),
),
"@xgrids-k1/frontend": fileURLToPath(
new URL("../../plugins/xgrids-k1/frontend/src", import.meta.url),
),
},
dedupe: ["react", "react-dom", "@nodedc/ui-react"],
},
optimizeDeps: {
// Rerun resolves its WASM asset relative to the package entrypoint. Vite's
// dependency pre-bundler flattens that entrypoint and leaves the WASM URL
@@ -21,6 +34,13 @@ export default defineConfig(({ mode }) => {
host: "127.0.0.1",
port: 5173,
strictPort: true,
fs: {
allow: [
fileURLToPath(new URL(".", import.meta.url)),
fileURLToPath(new URL("../../plugins/xgrids-k1", import.meta.url)),
fileURLToPath(new URL("../../../NODEDC_DESIGN_GUIDELINE/packages", import.meta.url)),
],
},
proxy: {
"/api": {
target: apiTarget,