feat(plugins): isolate device integrations
This commit is contained in:
@@ -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">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</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="Пароль Wi‑Fi"
|
||||
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" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
|
||||
</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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
));
|
||||
});
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user