feat(plugins): isolate device integrations
This commit is contained in:
parent
f9ffb7bd1c
commit
24a47318f2
31
README.md
31
README.md
|
|
@ -16,18 +16,22 @@ commit complete fMP4 segments before their index rows. These are explicit
|
||||||
crash-RPO bounds, not a zero-loss or disk-replication claim. The former
|
crash-RPO bounds, not a zero-loss or disk-replication claim. The former
|
||||||
Foxglove bridge remains only as a legacy regression module.
|
Foxglove bridge remains only as a legacy regression module.
|
||||||
|
|
||||||
The repository is intentionally migrating in stages. The current `src/k1link`
|
The backend vendor implementation is physically isolated below
|
||||||
package is the compatibility implementation of the first plugin path; vendor
|
`src/k1link/device_plugins/xgrids_k1/`; its reviewed manifest and compatibility
|
||||||
transport and codecs will move behind `plugins/xgrids-k1` and the Mission Core
|
profile remain below `plugins/xgrids-k1/`. Mission Core host code discovers
|
||||||
Plugin SDK without changing the verified wire protocol or raw evidence format.
|
archive sources, recovery hooks and recording exporters only through the plugin
|
||||||
|
runtime contribution. The verified wire protocol and raw evidence format are
|
||||||
|
unchanged.
|
||||||
|
|
||||||
Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session,
|
Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session,
|
||||||
operation, stream, evidence and compatibility contracts. The meanings remain a
|
operation, runtime-action, stream, evidence and compatibility contracts. Every
|
||||||
local experimental vocabulary rather than a mutation of NODE.DC Platform
|
backend action crosses immutable SDK `RuntimeActionInvocation` and
|
||||||
Ontology. The current runtime is still transitional and in process: it uses an
|
`RuntimeActionResult` validation in the actual dispatcher hot path. The meanings
|
||||||
explicitly injected K1 normalizer to produce transport-neutral local consumer
|
remain a local experimental vocabulary rather than a mutation of NODE.DC
|
||||||
views; portable SDK stream envelopes, process isolation, durable operations,
|
Platform Ontology. The device process is still transitional and in process: it
|
||||||
multi-device routing and the remote Edge split remain later gates.
|
uses an explicitly injected K1 normalizer to produce transport-neutral local
|
||||||
|
consumer views; portable SDK stream envelopes, process isolation, durable
|
||||||
|
operations, multi-device routing and the remote Edge split remain later gates.
|
||||||
|
|
||||||
The current runtime cannot read K1 firmware automatically. It keeps the exact
|
The current runtime cannot read K1 firmware automatically. It keeps the exact
|
||||||
profile inactive until the operator explicitly attests firmware `3.0.2` and
|
profile inactive until the operator explicitly attests firmware `3.0.2` and
|
||||||
|
|
@ -94,7 +98,10 @@ uv run pytest
|
||||||
The browser application is the universal Mission Core Control Station rather than
|
The browser application is the universal Mission Core Control Station rather than
|
||||||
a K1-specific Foxglove launcher. Its fixed shell contains six architectural
|
a K1-specific Foxglove launcher. Its fixed shell contains six architectural
|
||||||
sections — Center, Fleet, Observation, Missions, Data and System — while the K1
|
sections — Center, Fleet, Observation, Missions, Data and System — while the K1
|
||||||
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter.
|
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter. Its
|
||||||
|
React provisioning, acquisition/replay and diagnostic blocks live beside the
|
||||||
|
plugin manifest under `plugins/xgrids-k1/frontend`; the generic application
|
||||||
|
mounts them through one reviewed composition import.
|
||||||
|
|
||||||
Install, type-check, build and serve the complete local application from the
|
Install, type-check, build and serve the complete local application from the
|
||||||
repository root:
|
repository root:
|
||||||
|
|
@ -170,7 +177,7 @@ index use Mac receive/arrival timestamps, not proven K1 sensor timestamps or a
|
||||||
photon-to-screen measurement.
|
photon-to-screen measurement.
|
||||||
|
|
||||||
The old Foxglove implementation is retained only in
|
The old Foxglove implementation is retained only in
|
||||||
`src/k1link/viewer/foxglove_bridge.py` and its regression tests. The current
|
`src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py` and its regression tests. The current
|
||||||
live/replay runtime does not start it or use TCP 8765. The
|
live/replay runtime does not start it or use TCP 8765. The
|
||||||
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records the active Rerun path and
|
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records the active Rerun path and
|
||||||
its timing/security boundaries; the frontend contract is documented in
|
its timing/security boundaries; the frontend contract is documented in
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,14 @@ device adapter, но структура интерфейса от него не
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
|
| 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. |
|
| 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. |
|
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
|
||||||
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
|
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
|
||||||
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
|
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
|
||||||
| Автоматический MQTT → Rerun | Реализован | Первый live/adapter file-replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие такие сессии переиспользуют его. Saved observation replay использует отдельный immutable HTTP RRD path. |
|
| Автоматический 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 Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
|
||||||
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. |
|
| Контролы сцены → 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. |
|
| 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. |
|
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
|
||||||
| Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. |
|
| Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. |
|
||||||
|
|
@ -54,7 +55,7 @@ Mission Core Control Station ←→ REST /api/v1/device-plugins/*
|
||||||
|
|
||||||
sealed/recovered observation session
|
sealed/recovered observation session
|
||||||
└── SQLite catalog + bounded background preparation
|
└── 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
|
└── generation-bound same-origin HTTP
|
||||||
└── aggregate admission
|
└── aggregate admission
|
||||||
└── Rerun native receiver + recorded fMP4 player
|
└── 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/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
|
||||||
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
|
| `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/core/runtime/` | Нормализованное состояние активного устройства и spatial source. |
|
||||||
| `src/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. |
|
| `src/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. |
|
||||||
| `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. |
|
| `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/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
|
||||||
| `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. |
|
| `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. |
|
||||||
| `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |
|
| `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
|
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
|
// Composition root: this is the only place where Mission Core chooses which
|
||||||
// statically reviewed device plugins are shipped in the current build.
|
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -83,15 +83,8 @@
|
||||||
font-size: 0.61rem;
|
font-size: 0.61rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-model-card dt {
|
.device-model-card dt { color: var(--nodedc-text-muted); }
|
||||||
color: var(--nodedc-text-muted);
|
.device-model-card dd { margin: 0; color: var(--nodedc-text-secondary); text-align: right; }
|
||||||
}
|
|
||||||
|
|
||||||
.device-model-card dd {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--nodedc-text-secondary);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-model-card__capabilities {
|
.device-model-card__capabilities {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -117,439 +110,7 @@
|
||||||
padding: 0.72rem 0.85rem;
|
padding: 0.72rem 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-plugin-slot__bar > div {
|
.device-plugin-slot__bar > div { display: grid; min-width: 0; gap: 0.12rem; }
|
||||||
display: grid;
|
.device-plugin-slot__bar strong { color: var(--nodedc-text-primary); font-size: 0.74rem; }
|
||||||
min-width: 0;
|
.device-plugin-slot__bar small { color: var(--nodedc-text-muted); font-size: 0.58rem; }
|
||||||
gap: 0.12rem;
|
.device-plugin-slot__bar .device-plugin-slot__error { color: rgb(var(--nodedc-danger-rgb)); }
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,12 @@
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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 {
|
.landing-stage__copy {
|
||||||
width: min(35rem, 54%);
|
width: min(35rem, 54%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1280px) {
|
||||||
.xgrids-k1-plugin .device-workspace__grid,
|
|
||||||
.overview-grid,
|
.overview-grid,
|
||||||
.mission-layout {
|
.mission-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
@ -72,10 +67,6 @@
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.xgrids-k1-plugin .diagnostics-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-lead {
|
.workspace-lead {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -209,36 +200,12 @@
|
||||||
display: none;
|
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 {
|
.source-format-list > div {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
flex-direction: column;
|
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 {
|
.feature-row {
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,13 @@ before(async () => {
|
||||||
"/src/core/device-plugins/registry.ts",
|
"/src/core/device-plugins/registry.ts",
|
||||||
));
|
));
|
||||||
({ xgridsK1Manifest, xgridsK1Actions } = await server.ssrLoadModule(
|
({ xgridsK1Manifest, xgridsK1Actions } = await server.ssrLoadModule(
|
||||||
"/src/device-plugins/xgrids-k1/manifest.ts",
|
"@xgrids-k1/frontend/manifest.ts",
|
||||||
));
|
));
|
||||||
lifecycle = await server.ssrLoadModule(
|
lifecycle = await server.ssrLoadModule(
|
||||||
"/src/device-plugins/xgrids-k1/lifecycle.ts",
|
"@xgrids-k1/frontend/lifecycle.ts",
|
||||||
);
|
);
|
||||||
({ xgridsK1Api } = await server.ssrLoadModule(
|
({ 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 },
|
server: { middlewareMode: true },
|
||||||
});
|
});
|
||||||
({ xgridsK1Manifest } = await server.ssrLoadModule(
|
({ xgridsK1Manifest } = await server.ssrLoadModule(
|
||||||
"/src/device-plugins/xgrids-k1/manifest.ts",
|
"@xgrids-k1/frontend/manifest.ts",
|
||||||
));
|
));
|
||||||
({ xgridsK1ObservationSources } = await server.ssrLoadModule(
|
({ xgridsK1ObservationSources } = await server.ssrLoadModule(
|
||||||
"/src/device-plugins/xgrids-k1/observationSources.ts",
|
"@xgrids-k1/frontend/observationSources.ts",
|
||||||
));
|
));
|
||||||
({ openObservationSource, shouldRestartObservationSource } = await server.ssrLoadModule(
|
({ openObservationSource, shouldRestartObservationSource } = await server.ssrLoadModule(
|
||||||
"/src/core/observation/layoutPolicy.ts",
|
"/src/core/observation/layoutPolicy.ts",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ before(async () => {
|
||||||
server: { middlewareMode: true },
|
server: { middlewareMode: true },
|
||||||
});
|
});
|
||||||
({ selectMonotonicXgridsState } = await server.ssrLoadModule(
|
({ selectMonotonicXgridsState } = await server.ssrLoadModule(
|
||||||
"/src/device-plugins/xgrids-k1/stateOrdering.ts",
|
"@xgrids-k1/frontend/stateOrdering.ts",
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,14 @@
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"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,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
@ -20,5 +28,5 @@
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": 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 { defineConfig, loadEnv } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import wasm from "vite-plugin-wasm";
|
import wasm from "vite-plugin-wasm";
|
||||||
|
|
@ -8,6 +10,17 @@ export default defineConfig(({ mode }) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react(), wasm()],
|
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: {
|
optimizeDeps: {
|
||||||
// Rerun resolves its WASM asset relative to the package entrypoint. Vite's
|
// Rerun resolves its WASM asset relative to the package entrypoint. Vite's
|
||||||
// dependency pre-bundler flattens that entrypoint and leaves the WASM URL
|
// dependency pre-bundler flattens that entrypoint and leaves the WASM URL
|
||||||
|
|
@ -21,6 +34,13 @@ export default defineConfig(({ mode }) => {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
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: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: apiTarget,
|
target: apiTarget,
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ yet modeled and the physical button remains a known-safe fallback.
|
||||||
The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
|
The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
|
||||||
queue cannot discard MQTT evidence. Stage 7 adds an independent durable
|
queue cannot discard MQTT evidence. Stage 7 adds an independent durable
|
||||||
observation-session lifecycle. Completed or recovered native captures are
|
observation-session lifecycle. Completed or recovered native captures are
|
||||||
prepared once by a bounded backend worker into digest-bound RRD/cache-v6 and
|
prepared once by a bounded backend worker into digest-bound RRD/cache-v7 and
|
||||||
camera-manifest generations; a browser replay request never performs conversion.
|
camera-manifest generations; a browser replay request never performs conversion.
|
||||||
The saved scene, controller and timeline remain hidden until the complete RRD
|
The saved scene, controller and timeline remain hidden until the complete RRD
|
||||||
range and every declared camera pass admission. Sensor-to-display latency and
|
range and every declared camera pass admission. Sensor-to-display latency and
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,7 @@ opaque same-origin RRD rather than starting a second live bridge.
|
||||||
|
|
||||||
Sealing, recovery or legacy import makes the session eligible for the bounded
|
Sealing, recovery or legacy import makes the session eligible for the bounded
|
||||||
backend preparation worker. The worker reads every native message once and
|
backend preparation worker. The worker reads every native message once and
|
||||||
atomically publishes a private cache-v6 RRD using the zero-based `session_time`
|
atomically publishes a private cache-v7 RRD using the zero-based `session_time`
|
||||||
duration timeline. A SHA sidecar binds the generation to native evidence.
|
duration timeline. A SHA sidecar binds the generation to native evidence.
|
||||||
Replay-open never performs conversion: it returns HTTP 202 and a preparation
|
Replay-open never performs conversion: it returns HTTP 202 and a preparation
|
||||||
handle or reuses the verified artifact. The embedded viewer opens the exact
|
handle or reuses the verified artifact. The embedded viewer opens the exact
|
||||||
|
|
@ -302,7 +302,7 @@ sensor-to-screen latency.
|
||||||
|
|
||||||
## Legacy Foxglove regression module
|
## Legacy Foxglove regression module
|
||||||
|
|
||||||
`src/k1link/viewer/foxglove_bridge.py` and its tests remain in the repository
|
`src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py` and its tests remain in the repository
|
||||||
to compare decoder/packing behavior and preserve earlier evidence. They are not
|
to compare decoder/packing behavior and preserve earlier evidence. They are not
|
||||||
instantiated by `VisualizationRuntime`, are not the Control Station source, and
|
instantiated by `VisualizationRuntime`, are not the Control Station source, and
|
||||||
do not make TCP 8765 part of the current operator path.
|
do not make TCP 8765 part of the current operator path.
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,15 @@ already exist. The accepted device-lifecycle decision is recorded in
|
||||||
|
|
||||||
| Path | Current responsibility | Target responsibility |
|
| Path | Current responsibility | Target responsibility |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `apps/control-station/` | React shell, device workflow, spatial scene | Vendor-neutral operator application |
|
| `apps/control-station/` | React shell, model catalog, plugin host slots, spatial scene | Vendor-neutral operator application |
|
||||||
|
| `apps/control-station/src/core/device-plugins/frontendSdk.ts` | Public React host surface for reviewed frontend contributions | Versioned frontend Plugin SDK package surface |
|
||||||
| `packages/plugin-sdk/` | Installable v0alpha2 Pydantic contracts and JSON Schema export | Portable host/plugin identity, lifecycle, stream and evidence boundary |
|
| `packages/plugin-sdk/` | Installable v0alpha2 Pydantic contracts and JSON Schema export | Portable host/plugin identity, lifecycle, stream and evidence boundary |
|
||||||
| `plugins/xgrids-k1/` | v1alpha2 manifest, exact compatibility profile, fail-closed loader and extraction documentation | Independently versioned XGRIDS device plugin |
|
| `plugins/xgrids-k1/` | v1alpha2 manifest, exact profile/loader and plugin-owned React connection/acquisition UI | Independently versioned XGRIDS device plugin |
|
||||||
| `src/k1link/web/` | Local FastAPI host, in-memory operation/acquisition lifecycle and transitional XGRIDS facade | Generic host APIs plus isolated plugin supervisor |
|
| `plugins/xgrids-k1/frontend/` | XGRIDS provisioning, acquisition/replay, diagnostics, metrics, runtime mapping and scoped CSS | Plugin-owned reviewed frontend contribution |
|
||||||
| `src/k1link/mqtt/` | Raw-first K1 MQTT capture | Plugin-owned transport behind an EvidenceStore |
|
| `src/k1link/web/` | Local FastAPI host, plugin composition, in-memory operation/acquisition lifecycle and generic session API | Generic host APIs plus isolated plugin supervisor |
|
||||||
| `src/k1link/protocol/` | Firmware-scoped K1 codecs and explicit vendor normalizer | Plugin-owned protocol adapter |
|
| `src/k1link/device_plugins/xgrids_k1/` | Physically isolated K1 BLE/MQTT/protobuf/LZ4/camera/replay/CLI compatibility implementation | Independently built XGRIDS device plugin process |
|
||||||
| `src/k1link/data_plane/` | Transport-neutral decoded in-process consumer views | Local projections hydrated from portable SDK envelopes |
|
| `src/k1link/data_plane/` | Transport-neutral decoded in-process consumer views | Local projections hydrated from portable SDK envelopes |
|
||||||
| `src/k1link/viewer/` | Rerun consumer, visualization runtime and legacy Foxglove regression module | Replaceable canonical scene sink and presentation adapters |
|
| `src/k1link/viewer/` | Vendor-neutral Rerun consumer, metrics and recorded blueprint | Replaceable canonical scene sink and presentation adapters |
|
||||||
| `src/k1link/sessions/` | Host-owned SQLite catalog, evidence discovery/recovery, background preparation and derived-cache lifecycle | Device-neutral observation archive service |
|
| `src/k1link/sessions/` | Host-owned SQLite catalog, evidence discovery/recovery, background preparation and derived-cache lifecycle | Device-neutral observation archive service |
|
||||||
| `src/k1link/web/session_api.py` | Opaque saved-session, immutable RRD/media and workspace-layout API | Versioned Control/Edge observation contract |
|
| `src/k1link/web/session_api.py` | Opaque saved-session, immutable RRD/media and workspace-layout API | Versioned Control/Edge observation contract |
|
||||||
| `apps/control-station/src/core/observation/` | Session selection, replay admission, recorded-source and layout state | Device-neutral observation UI runtime |
|
| `apps/control-station/src/core/observation/` | Session selection, replay admission, recorded-source and layout state | Device-neutral observation UI runtime |
|
||||||
|
|
@ -41,7 +42,7 @@ The host-owned recorded path is separate from the disposable live preview:
|
||||||
```text
|
```text
|
||||||
sealed or recovered native observation session
|
sealed or recovered native observation session
|
||||||
-> SQLite catalog and bounded single-worker preparation
|
-> SQLite catalog and bounded single-worker preparation
|
||||||
-> atomic RRD cache v6 + immutable recorded-media manifest v2
|
-> atomic RRD cache v7 + immutable recorded-media manifest v2
|
||||||
-> generation-bound same-origin HTTP
|
-> generation-bound same-origin HTTP
|
||||||
-> aggregate RRD/camera admission
|
-> aggregate RRD/camera admission
|
||||||
-> native Rerun HTTP receiver + recorded fMP4 player
|
-> native Rerun HTTP receiver + recorded fMP4 player
|
||||||
|
|
@ -56,8 +57,17 @@ The Plugin SDK v0alpha2 separately defines model/device/session identity,
|
||||||
operation events, acquisition-related session state, canonical stream
|
operation events, acquisition-related session state, canonical stream
|
||||||
envelopes, payload handles, evidence lineage and compatibility assessments. It
|
envelopes, payload handles, evidence lineage and compatibility assessments. It
|
||||||
is importable through the root editable path dependency and independently
|
is importable through the root editable path dependency and independently
|
||||||
buildable from `packages/plugin-sdk`. The hot K1 path does not yet instantiate
|
buildable from `packages/plugin-sdk`. Backend actions now instantiate immutable
|
||||||
all SDK envelopes or use the SDK `EvidenceStore` protocol.
|
SDK `RuntimeActionInvocation` and `RuntimeActionResult` envelopes in the real
|
||||||
|
dispatcher path. The live spatial path does not yet instantiate portable SDK
|
||||||
|
stream envelopes or use the SDK `EvidenceStore` protocol.
|
||||||
|
|
||||||
|
Frontend device workflows follow the same ownership rule. The generic host
|
||||||
|
selects a manifest model and mounts its `device.connection` component. Concrete
|
||||||
|
XGRIDS source lives under `plugins/xgrids-k1/frontend`, imports the host only via
|
||||||
|
`@mission-core/plugin-sdk`, and is connected by the single reviewed import in
|
||||||
|
`apps/control-station/src/composition/devicePlugins.ts`. BLE/Wi-Fi provisioning,
|
||||||
|
exact-profile confirmation and acquisition/replay UI are not Core components.
|
||||||
|
|
||||||
## Experimental vocabulary, not Platform Ontology
|
## Experimental vocabulary, not Platform Ontology
|
||||||
|
|
||||||
|
|
@ -138,17 +148,16 @@ for the measured gate and remaining limits.
|
||||||
|
|
||||||
## Remaining extraction order
|
## Remaining extraction order
|
||||||
|
|
||||||
1. Preserve native replay parity and repeat the exact-profile physical
|
Native replay parity, exact-profile physical point/pose regression, immutable
|
||||||
point/pose regression through the explicit normalizer boundary.
|
SDK runtime-action envelopes and physical extraction of K1 transports/codecs are
|
||||||
2. Map portable SDK stream/evidence contracts onto the hot path without
|
complete. Portable SDK stream/evidence envelopes remain separate from the
|
||||||
replacing raw evidence or changing consumer output.
|
allocation-conscious in-process preview views.
|
||||||
3. Extract codecs, normalizer and raw capture physically behind the XGRIDS
|
|
||||||
plugin package boundary.
|
1. Add process isolation and a durable operation/evidence supervisor.
|
||||||
4. Add process isolation and a durable operation/evidence supervisor.
|
2. Replace compatibility routes and singleton state with multi-device session
|
||||||
5. Replace compatibility routes and singleton state with multi-device session
|
|
||||||
routing.
|
routing.
|
||||||
6. Split Edge execution from the Control Station behind authenticated transport.
|
3. Split Edge execution from the Control Station behind authenticated transport.
|
||||||
7. Physically accept a newly archived left/right K1 session, then package the
|
4. Physically accept a newly archived left/right K1 session, then package the
|
||||||
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
|
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
|
||||||
caching and evolve same-host MSE delivery toward an authenticated Edge media
|
caching and evolve same-host MSE delivery toward an authenticated Edge media
|
||||||
plane; keep the modeling-command publisher disabled until its separate safety
|
plane; keep the modeling-command publisher disabled until its separate safety
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,11 @@ overall preparation deadline rather than the active-export heartbeat timeout.
|
||||||
|
|
||||||
The first preparation of a long capture can take time because every decodable
|
The first preparation of a long capture can take time because every decodable
|
||||||
point/pose message is projected into RRD. Later openings reuse a verified,
|
point/pose message is projected into RRD. Later openings reuse a verified,
|
||||||
digest-bound cache. Derived RRD cache v6 is intentionally incompatible with v4
|
digest-bound cache. Derived RRD cache v7 is intentionally incompatible with v6
|
||||||
and v5 because only v6 guarantees that the payload itself contains a real
|
and older generations. It retains v6's real `session_time = 0` payload anchor
|
||||||
`session_time = 0` anchor; older generations are rebuilt once in the background.
|
and additionally binds the cache to the owning plugin ID, primary artifact and
|
||||||
|
ordered set of confined source artifacts. Older generations are rebuilt once in
|
||||||
|
the background.
|
||||||
The browser may use the strict `source_url` with `If-Match`, while the embedded
|
The browser may use the strict `source_url` with `If-Match`, while the embedded
|
||||||
Rerun loader uses the canonical `viewer_source_url` whose lowercase SHA-256
|
Rerun loader uses the canonical `viewer_source_url` whose lowercase SHA-256
|
||||||
`generation` query is bound to the same launch descriptor. A missing or stale
|
`generation` query is bound to the same launch descriptor. A missing or stale
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,9 @@ firmware-specific phases remain inside the XGRIDS plugin adapter.
|
||||||
- Canonical manifest: `plugins/xgrids-k1/plugin.manifest.json`.
|
- Canonical manifest: `plugins/xgrids-k1/plugin.manifest.json`.
|
||||||
- Host contracts and registry: `apps/control-station/src/core/device-plugins/`.
|
- Host contracts and registry: `apps/control-station/src/core/device-plugins/`.
|
||||||
- Generic runtime envelope: `apps/control-station/src/core/runtime/`.
|
- Generic runtime envelope: `apps/control-station/src/core/runtime/`.
|
||||||
- XGRIDS UI/runtime adapter: `apps/control-station/src/device-plugins/xgrids-k1/`.
|
- Public frontend host surface:
|
||||||
|
`apps/control-station/src/core/device-plugins/frontendSdk.ts`.
|
||||||
|
- XGRIDS UI/runtime adapter: `plugins/xgrids-k1/frontend/src/`.
|
||||||
- Read-only backend catalog: `GET /api/v1/device-plugins` and
|
- Read-only backend catalog: `GET /api/v1/device-plugins` and
|
||||||
`GET /api/v1/device-models`.
|
`GET /api/v1/device-models`.
|
||||||
- Host-owned action dispatcher:
|
- Host-owned action dispatcher:
|
||||||
|
|
@ -81,9 +83,11 @@ firmware-specific phases remain inside the XGRIDS plugin adapter.
|
||||||
- Existing K1 action endpoints are compatibility shims over that dispatcher, so
|
- Existing K1 action endpoints are compatibility shims over that dispatcher, so
|
||||||
the proven physical implementation is unchanged.
|
the proven physical implementation is unchanged.
|
||||||
|
|
||||||
The XGRIDS frontend code is statically linked into the Control Station during
|
The XGRIDS frontend code is physically plugin-owned and statically linked into
|
||||||
this v1alpha milestone. It is isolated by imports and contracts but is not yet a
|
the Control Station during this v1alpha milestone. Its provisioning,
|
||||||
separately built npm workspace package. Backend composition is manifest-driven;
|
acquisition/replay and diagnostics blocks are separate components, and it may
|
||||||
|
import the host only through `@mission-core/plugin-sdk`. It is not yet a
|
||||||
|
separately built or signed npm package. Backend composition is manifest-driven;
|
||||||
frontend/backend installed-set parity is a reviewed build-time responsibility
|
frontend/backend installed-set parity is a reviewed build-time responsibility
|
||||||
until the host gains signed plugin bundles and a startup compatibility handshake.
|
until the host gains signed plugin bundles and a startup compatibility handshake.
|
||||||
|
|
||||||
|
|
@ -91,8 +95,8 @@ until the host gains signed plugin bundles and a startup compatibility handshake
|
||||||
|
|
||||||
- Core cannot import a concrete plugin outside the composition root.
|
- Core cannot import a concrete plugin outside the composition root.
|
||||||
- Core cannot inspect opaque plugin state or branch on a model ID.
|
- Core cannot inspect opaque plugin state or branch on a model ID.
|
||||||
- Plugins render only inside declared slots and cannot own global navigation or
|
- Plugins render only inside declared slots, cannot own global navigation, and
|
||||||
global CSS.
|
may ship only plugin-root-scoped CSS.
|
||||||
- Plugins publish canonical spatial sources; they do not control the scene
|
- Plugins publish canonical spatial sources; they do not control the scene
|
||||||
engine or route names.
|
engine or route names.
|
||||||
- Passwords and other secrets cannot enter manifests, runtime snapshots, event
|
- Passwords and other secrets cannot enter manifests, runtime snapshots, event
|
||||||
|
|
@ -139,15 +143,14 @@ The shell can now boot with no selected device and contains no K1 wire fields.
|
||||||
Adding another statically reviewed UI plugin does not require changing `App` or
|
Adding another statically reviewed UI plugin does not require changing `App` or
|
||||||
the generic local-device workspace.
|
the generic local-device workspace.
|
||||||
|
|
||||||
Backend execution supports only reviewed `transitional-in-process` plugins in
|
Backend execution still supports only reviewed `transitional-in-process`
|
||||||
v1alpha1, but already enters through the manifest
|
plugins, but enters through the manifest factory, immutable SDK runtime-action
|
||||||
factory, allowlisted XGRIDS facade and host-owned dispatcher. Synchronous
|
envelopes, the allowlisted facade and host-owned dispatcher. Synchronous
|
||||||
capture/runtime work is moved off the API event loop. The next milestone adds
|
capture/runtime work is moved off the API event loop. ADR 0009 completes the
|
||||||
independent device-session IDs, operation IDs, cancellation, timeouts, audited
|
physical BLE/MQTT/codec/evidence extraction and plugin-owned observation
|
||||||
secret references, and process isolation. Only after replay parity and a
|
contribution after replay parity and physical regression. The remaining
|
||||||
physical regression may BLE, MQTT, codecs, and raw evidence code move out of
|
milestone is process isolation, durable operation supervision and portable SDK
|
||||||
the compatibility package. Rerun must ultimately consume canonical
|
stream/evidence transport; the Rerun consumer already contains no K1 topics.
|
||||||
PointCloud/Pose envelopes instead of K1 topics.
|
|
||||||
|
|
||||||
This ADR supersedes the compatibility names listed in ADR 0002: `K1Metrics`,
|
This ADR supersedes the compatibility names listed in ADR 0002: `K1Metrics`,
|
||||||
`useK1Console`, and `ConsoleService` were renamed and isolated inside the
|
`useK1Console`, and `ConsoleService` were renamed and isolated inside the
|
||||||
|
|
|
||||||
|
|
@ -208,10 +208,14 @@ This milestone is additive:
|
||||||
- legacy `stream.start-live`, `stream.start-replay`, `stream.stop`, and viewer
|
- legacy `stream.start-live`, `stream.start-replay`, `stream.stop`, and viewer
|
||||||
settings actions remain compatibility shims for the proven UI and runtime;
|
settings actions remain compatibility shims for the proven UI and runtime;
|
||||||
- the XGRIDS backend still runs through the reviewed
|
- the XGRIDS backend still runs through the reviewed
|
||||||
`transitional-in-process` facade in `src/k1link`.
|
`transitional-in-process` facade in
|
||||||
|
`src/k1link/device_plugins/xgrids_k1`;
|
||||||
|
- every dispatcher and compatibility-route action now crosses immutable SDK
|
||||||
|
`RuntimeActionInvocation` and `RuntimeActionResult` validation.
|
||||||
|
|
||||||
SDK v0alpha2 contracts do not imply that every current host endpoint, frontend
|
SDK v0alpha2 runtime-action contracts are active in the backend hot path. This
|
||||||
state object, or compatibility shim already emits those contracts.
|
does not imply that frontend state objects, live spatial frames or stored raw
|
||||||
|
evidence already use every SDK contract.
|
||||||
|
|
||||||
## Deliberately deferred
|
## Deliberately deferred
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,5 +87,5 @@ ADR 0008.
|
||||||
- `apps/control-station/src/components/ObservationSources.tsx`
|
- `apps/control-station/src/components/ObservationSources.tsx`
|
||||||
- `apps/control-station/src/components/ObservationTimeline.tsx`
|
- `apps/control-station/src/components/ObservationTimeline.tsx`
|
||||||
- `apps/control-station/src/workspaces/Workspaces.tsx`
|
- `apps/control-station/src/workspaces/Workspaces.tsx`
|
||||||
- `apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts`
|
- `plugins/xgrids-k1/frontend/src/observationSources.ts`
|
||||||
- `src/k1link/web/xgrids_k1_camera.py`
|
- `src/k1link/device_plugins/xgrids_k1/camera.py`
|
||||||
|
|
|
||||||
|
|
@ -89,11 +89,11 @@ timeline once when the recording opens and does not poll or force the cursor.
|
||||||
|
|
||||||
## Code anchors
|
## Code anchors
|
||||||
|
|
||||||
- `src/k1link/web/xgrids_k1_camera.py`
|
- `src/k1link/device_plugins/xgrids_k1/camera.py`
|
||||||
- `src/k1link/web/camera_archive.py`
|
- `src/k1link/web/camera_archive.py`
|
||||||
- `src/k1link/web/xgrids_k1_facade.py`
|
- `src/k1link/device_plugins/xgrids_k1/facade.py`
|
||||||
- `apps/control-station/src/components/MseFmp4WebSocketPlayer.tsx`
|
- `apps/control-station/src/components/MseFmp4WebSocketPlayer.tsx`
|
||||||
- `apps/control-station/src/components/RecordedFmp4Player.tsx`
|
- `apps/control-station/src/components/RecordedFmp4Player.tsx`
|
||||||
- `apps/control-station/src/core/observation/useObservationLayout.ts`
|
- `apps/control-station/src/core/observation/useObservationLayout.ts`
|
||||||
- `apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts`
|
- `plugins/xgrids-k1/frontend/src/observationSources.ts`
|
||||||
- `tests/test_xgrids_camera_gateway.py`
|
- `tests/test_xgrids_camera_gateway.py`
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,10 @@ The derived RRD writes an actual zero-time anchor at the internal
|
||||||
`/__mission_core/session_origin` entity. Keeping it outside `/world` prevents a
|
`/__mission_core/session_origin` entity. Keeping it outside `/world` prevents a
|
||||||
synthetic visualization layer while ensuring that the decoded RRD timeline,
|
synthetic visualization layer while ensuring that the decoded RRD timeline,
|
||||||
not merely its summary document, begins at session time zero. This changes the
|
not merely its summary document, begins at session time zero. This changes the
|
||||||
derived payload contract: cache v6 rejects v4/v5 sidecars and performs one
|
derived payload contract. Cache v7 keeps that anchor and additionally binds the
|
||||||
background rebuild instead of silently reusing an archive without that row.
|
derived recording to the owning plugin ID, primary artifact and ordered source
|
||||||
|
artifact set; it rejects v6 and older sidecars and performs one background
|
||||||
|
rebuild.
|
||||||
|
|
||||||
Replay v2 exposes two paths to the same pinned generation. `source_url` retains
|
Replay v2 exposes two paths to the same pinned generation. `source_url` retains
|
||||||
the strict `If-Match: "sha256:…"` contract for Mission Core clients;
|
the strict `If-Match: "sha256:…"` contract for Mission Core clients;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
# ADR 0009: device-plugin observation runtime and K1 extraction
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-07-17
|
||||||
|
- Extends: ADR 0003, ADR 0004 and ADR 0008
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The manifest/action boundary isolated the browser shell, but the host observation
|
||||||
|
service still imported K1 archive discovery, `.k1mqtt` preparation, MQTT timing
|
||||||
|
metadata and the K1 normalizer. A second sensor could not provide durable replay
|
||||||
|
without adding vendor branches to `SessionStore`, `SessionRecordingMaterializer`
|
||||||
|
and `app.py`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Every installed device plugin may contribute an optional observation runtime:
|
||||||
|
|
||||||
|
- one or more `ObservationArchiveSource` values with a plugin ID, archive ID,
|
||||||
|
confined root, discovery callback and recovery callback;
|
||||||
|
- exactly one recording exporter for that plugin ID;
|
||||||
|
- generic `ObservationSessionCandidate`, `SessionSource` and
|
||||||
|
`ObservationArtifactCandidate` values returned to the host.
|
||||||
|
|
||||||
|
The host owns SQLite lifecycle, path confinement, preparation scheduling,
|
||||||
|
cross-process cache locking, atomic publication, RRD delivery, recorded-media
|
||||||
|
admission and browser APIs. A plugin owns native evidence discovery, transport
|
||||||
|
format validation, recovery semantics, timeline-origin extraction and conversion
|
||||||
|
of its primary artifact into the canonical recorded Rerun artifact.
|
||||||
|
|
||||||
|
`ReplayCommand` is now vendor-neutral. It contains an owning `plugin_id`, an
|
||||||
|
opaque primary artifact ID, an ordered tuple of confined artifacts and a ready
|
||||||
|
timeline origin. It contains no filename suffix, MQTT topic or sidecar name.
|
||||||
|
The materializer selects the exporter by `plugin_id` and stages every validated
|
||||||
|
artifact prefix while preserving only its plugin-owned basename.
|
||||||
|
|
||||||
|
Derived RRD cache v7 binds the published RRD to:
|
||||||
|
|
||||||
|
- plugin ID and primary artifact ID;
|
||||||
|
- ordered artifact IDs and media types;
|
||||||
|
- full file identity and replay boundary for every artifact;
|
||||||
|
- per-artifact prefix digest;
|
||||||
|
- derived recording digest and zero-based timeline bounds.
|
||||||
|
|
||||||
|
Cache v6 and older sidecars are rebuilt once. The native evidence is not
|
||||||
|
rewritten.
|
||||||
|
|
||||||
|
The concrete implementation is physically located below:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/k1link/device_plugins/xgrids_k1/
|
||||||
|
ble/ mqtt/ protocol/ analyze/ net/ usb/
|
||||||
|
viewer/
|
||||||
|
facade.py camera.py archive.py observation.py rrd_export.py cli.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest entrypoint is
|
||||||
|
`k1link.device_plugins.xgrids_k1.facade:build_xgrids_k1_plugin`. Generic
|
||||||
|
`sessions`, `web/app.py`, `web/session_api.py` and the Rerun consumer do not
|
||||||
|
import this package or contain K1 formats, topics or identifiers.
|
||||||
|
|
||||||
|
Plugin SDK v0alpha2 additionally defines immutable
|
||||||
|
`RuntimeActionInvocation` and `RuntimeActionResult`. The host dispatcher creates
|
||||||
|
and validates them for every plugin action, including legacy compatibility
|
||||||
|
routes, before returning the path-free output document.
|
||||||
|
|
||||||
|
## Acceptance gate
|
||||||
|
|
||||||
|
The automated gate builds an unrelated synthetic spatial-sensor plugin which:
|
||||||
|
|
||||||
|
1. discovers its own native evidence extension;
|
||||||
|
2. reconciles into the unchanged host SQLite catalog;
|
||||||
|
3. produces a generic `ReplayCommand`;
|
||||||
|
4. materializes and reuses an RRD through its plugin-keyed exporter.
|
||||||
|
|
||||||
|
A dependency test also fails if generic observation hot-path files regain K1,
|
||||||
|
XGRIDS, topic, native-format or concrete-plugin imports. Adding a second device
|
||||||
|
therefore requires a manifest/runtime contribution and a plugin-owned reviewed
|
||||||
|
UI contribution, not edits to the session API, preparation queue, Rerun consumer
|
||||||
|
or main application shell. ADR 0010 defines that frontend boundary.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The semantic and physical vendor boundary is complete for the current
|
||||||
|
single-process architecture. Existing K1 raw evidence, camera archives and
|
||||||
|
operator workflows remain compatible. The `k1link` distribution name and CLI
|
||||||
|
command remain transitional compatibility names; their implementation is now
|
||||||
|
plugin-owned.
|
||||||
|
|
||||||
|
This ADR does not claim process isolation, crash containment, a durable operation
|
||||||
|
journal, signed plugin bundles, multi-device concurrency, portable SDK stream
|
||||||
|
envelopes or SDK `EvidenceStore` persistence. Those require a supervisor and a
|
||||||
|
new physical regression gate; they must not be inferred from module extraction.
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
# ADR 0010: plugin-owned frontend device workflows
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-07-17
|
||||||
|
- Extends: ADR 0003, ADR 0004 and ADR 0009
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The device UI registry already mounted a model-specific `device.connection`
|
||||||
|
component, but the XGRIDS implementation still lived below
|
||||||
|
`apps/control-station/src/device-plugins`. Its 600-line connection view mixed
|
||||||
|
BLE discovery, Wi-Fi provisioning, acquisition/replay controls, diagnostics and
|
||||||
|
metrics. The boundary was logical, yet the plugin source and styles were still
|
||||||
|
physically owned by the generic application tree.
|
||||||
|
|
||||||
|
Different device families need different enrollment, connection and acquisition
|
||||||
|
workflows. Making those workflows generic would either produce a lowest-common-
|
||||||
|
denominator wizard or restore vendor branches in the Control Station.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Device-specific operator workflows are frontend plugin contributions. The
|
||||||
|
XGRIDS source, API adapter, runtime provider, observation mapping and scoped CSS
|
||||||
|
are physically located under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
plugins/xgrids-k1/frontend/src/
|
||||||
|
plugin.ts
|
||||||
|
XgridsK1Connection.tsx
|
||||||
|
components/
|
||||||
|
K1ProvisioningPipeline.tsx
|
||||||
|
K1AcquisitionPipeline.tsx
|
||||||
|
K1Diagnostics.tsx
|
||||||
|
K1Metrics.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
The generic host exposes one reviewed frontend surface through
|
||||||
|
`@mission-core/plugin-sdk`, implemented by
|
||||||
|
`apps/control-station/src/core/device-plugins/frontendSdk.ts`. A plugin may use
|
||||||
|
manifest/model/slot contracts, the generic Mission Runtime bridge, React and the
|
||||||
|
NODE.DC UI kit. It may not import Control Station implementation paths.
|
||||||
|
|
||||||
|
`apps/control-station/src/composition/devicePlugins.ts` is the only Core file
|
||||||
|
which imports the concrete XGRIDS frontend. The host owns model selection,
|
||||||
|
safe deactivation, the `device.connection` slot, navigation, the spatial scene,
|
||||||
|
saved-session UX and global layout. The plugin owns BLE candidates, exact-profile
|
||||||
|
attestation, credentials-in-memory form state, K1 endpoint state, connection
|
||||||
|
instructions and the live/replay acquisition controls.
|
||||||
|
|
||||||
|
Plugin CSS is imported with the contribution and scoped below
|
||||||
|
`.xgrids-k1-plugin`. A plugin cannot add global navigation, route ownership or
|
||||||
|
unscoped global selectors.
|
||||||
|
|
||||||
|
Frontend loading remains static and reviewed at build time. This ADR does not
|
||||||
|
introduce arbitrary remote JavaScript, an npm marketplace, signatures or
|
||||||
|
process/browser-realm isolation.
|
||||||
|
|
||||||
|
## Acceptance gate
|
||||||
|
|
||||||
|
The frontend boundary test builds two unrelated synthetic device plugins with
|
||||||
|
different connection components and proves that the unchanged registry resolves
|
||||||
|
the correct component for each model. It also fails when:
|
||||||
|
|
||||||
|
- XGRIDS implementation files return to the generic Control Station source;
|
||||||
|
- generic Core files other than the composition root contain XGRIDS/Lixel/K1
|
||||||
|
implementation knowledge;
|
||||||
|
- plugin source imports Control Station implementation paths;
|
||||||
|
- provisioning, acquisition, diagnostics or scoped-style modules disappear.
|
||||||
|
|
||||||
|
The accepted local gate is 114 frontend unit tests, TypeScript strict checking
|
||||||
|
and a Vite production build. Backend behavior and the physical K1 protocol are
|
||||||
|
unchanged by this source/UI decomposition.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
A second device family can provide a completely different connection pipeline
|
||||||
|
and UI while reusing the same model catalog, lifecycle transition, observation
|
||||||
|
scene and saved-session host. Adding it requires its own plugin-owned frontend
|
||||||
|
contribution and one reviewed composition import, not edits to `App`,
|
||||||
|
`DeviceWorkspace` or generic runtime state.
|
||||||
|
|
||||||
|
The remaining isolation gap is deployment-level: frontend contributions are
|
||||||
|
still compiled into one signed application build, and backend plugins still run
|
||||||
|
inside the control-plane process. Signed bundles, compatibility handshake,
|
||||||
|
dynamic loading policy and process/restart containment belong to the supervisor
|
||||||
|
milestone.
|
||||||
|
|
@ -36,6 +36,8 @@ The separate SDK v0alpha2 package establishes executable contracts for:
|
||||||
- independently revisioned enrollment, connectivity, and acquisition states;
|
- independently revisioned enrollment, connectivity, and acquisition states;
|
||||||
- operation policy, request, acknowledgement, progress, completion, failure,
|
- operation policy, request, acknowledgement, progress, completion, failure,
|
||||||
timeout, cancellation, secret reference, and idempotency boundaries;
|
timeout, cancellation, secret reference, and idempotency boundaries;
|
||||||
|
- immutable pre-session runtime-action invocation and result envelopes used by
|
||||||
|
the active backend dispatcher;
|
||||||
- canonical point cloud, pose, image, encoded video, and device-status streams;
|
- canonical point cloud, pose, image, encoded video, and device-status streams;
|
||||||
- immutable evidence handles, raw transport records, lineage, and store
|
- immutable evidence handles, raw transport records, lineage, and store
|
||||||
protocol;
|
protocol;
|
||||||
|
|
@ -49,11 +51,13 @@ compatibility profile and adapter.
|
||||||
|
|
||||||
Current host implementation references:
|
Current host implementation references:
|
||||||
|
|
||||||
- `apps/control-station/src/core/device-plugins/` — TypeScript manifest and UI
|
- `apps/control-station/src/core/device-plugins/frontendSdk.ts` — current
|
||||||
contribution contracts;
|
public TypeScript/React host surface for statically reviewed UI contributions;
|
||||||
|
- `plugins/xgrids-k1/frontend/` — first physically plugin-owned consumer of
|
||||||
|
that frontend surface;
|
||||||
- `apps/control-station/src/core/runtime/` — normalized runtime envelope;
|
- `apps/control-station/src/core/runtime/` — normalized runtime envelope;
|
||||||
- `src/k1link/web/plugin_catalog.py` — strict backend manifest validation;
|
- `src/k1link/web/plugin_catalog.py` — strict backend manifest validation;
|
||||||
- `src/k1link/web/plugin_runtime.py` — host-owned allowlisted action dispatcher;
|
- `src/k1link/web/plugin_runtime.py` — host-owned allowlisted SDK-envelope action dispatcher;
|
||||||
- `src/k1link/web/device_plugin_composition.py` — manifest factory loader and
|
- `src/k1link/web/device_plugin_composition.py` — manifest factory loader and
|
||||||
startup parity checks;
|
startup parity checks;
|
||||||
- `docs/adr/0003-device-plugin-ui-and-runtime-boundary.md` — accepted boundary
|
- `docs/adr/0003-device-plugin-ui-and-runtime-boundary.md` — accepted boundary
|
||||||
|
|
@ -66,6 +70,11 @@ milestone. v0alpha2 models secret references and operation policy, but host
|
||||||
authorization and the actual secret vault remain separate implementation
|
authorization and the actual secret vault remain separate implementation
|
||||||
responsibilities.
|
responsibilities.
|
||||||
|
|
||||||
|
The React surface is not yet an independently published npm package. Its alias
|
||||||
|
is intentionally narrow so plugin source cannot import Control Station
|
||||||
|
implementation paths; packaging and signing it belong to the supervisor/bundle
|
||||||
|
milestone.
|
||||||
|
|
||||||
## Version policy
|
## Version policy
|
||||||
|
|
||||||
v0alpha2 may receive additive fields while it remains experimental. A breaking
|
v0alpha2 may receive additive fields while it remains experimental. A breaking
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ from .operations import (
|
||||||
validate_operation_request,
|
validate_operation_request,
|
||||||
)
|
)
|
||||||
from .payloads import InlinePayload, PayloadHandle, ReferencedPayload
|
from .payloads import InlinePayload, PayloadHandle, ReferencedPayload
|
||||||
|
from .runtime import RuntimeActionInvocation, RuntimeActionResult
|
||||||
from .schema import contract_json_schemas
|
from .schema import contract_json_schemas
|
||||||
from .session import (
|
from .session import (
|
||||||
AcquisitionState,
|
AcquisitionState,
|
||||||
|
|
@ -120,6 +121,8 @@ __all__ = [
|
||||||
"Quaternion",
|
"Quaternion",
|
||||||
"RawTransportRecord",
|
"RawTransportRecord",
|
||||||
"RedactionState",
|
"RedactionState",
|
||||||
|
"RuntimeActionInvocation",
|
||||||
|
"RuntimeActionResult",
|
||||||
"ReferencedPayload",
|
"ReferencedPayload",
|
||||||
"RuleOutcome",
|
"RuleOutcome",
|
||||||
"SecretReference",
|
"SecretReference",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""Minimal pre-session action envelopes for the executable plugin runtime."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import AwareDatetime, Field
|
||||||
|
|
||||||
|
from .common import API_VERSION, ApiVersion, ContractModel, Identifier, JsonObject
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeActionInvocation(ContractModel):
|
||||||
|
"""Host-admitted invocation delivered to exactly one installed plugin."""
|
||||||
|
|
||||||
|
api_version: ApiVersion = API_VERSION
|
||||||
|
kind: Literal["RuntimeActionInvocation"] = "RuntimeActionInvocation"
|
||||||
|
invocation_id: Identifier
|
||||||
|
plugin_id: Identifier
|
||||||
|
action_id: Identifier
|
||||||
|
requested_at: AwareDatetime
|
||||||
|
parameters: JsonObject = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeActionResult(ContractModel):
|
||||||
|
"""Host-validated completion envelope for one runtime invocation."""
|
||||||
|
|
||||||
|
api_version: ApiVersion = API_VERSION
|
||||||
|
kind: Literal["RuntimeActionResult"] = "RuntimeActionResult"
|
||||||
|
invocation_id: Identifier
|
||||||
|
plugin_id: Identifier
|
||||||
|
action_id: Identifier
|
||||||
|
completed_at: AwareDatetime
|
||||||
|
output: JsonObject = Field(default_factory=dict)
|
||||||
|
|
@ -8,6 +8,7 @@ from .compatibility import CompatibilityAssessment
|
||||||
from .evidence import EvidenceRecord, RawTransportRecord
|
from .evidence import EvidenceRecord, RawTransportRecord
|
||||||
from .identity import DeviceInstanceRef
|
from .identity import DeviceInstanceRef
|
||||||
from .operations import OperationEvent, OperationPolicy, OperationRequest
|
from .operations import OperationEvent, OperationPolicy, OperationRequest
|
||||||
|
from .runtime import RuntimeActionInvocation, RuntimeActionResult
|
||||||
from .session import DeviceSessionContext, DeviceSessionSnapshot
|
from .session import DeviceSessionContext, DeviceSessionSnapshot
|
||||||
from .streams import CanonicalStreamEnvelope
|
from .streams import CanonicalStreamEnvelope
|
||||||
|
|
||||||
|
|
@ -22,6 +23,8 @@ def contract_json_schemas() -> dict[str, dict[str, object]]:
|
||||||
"OperationPolicy": OperationPolicy,
|
"OperationPolicy": OperationPolicy,
|
||||||
"OperationRequest": OperationRequest,
|
"OperationRequest": OperationRequest,
|
||||||
"OperationEvent": OperationEvent,
|
"OperationEvent": OperationEvent,
|
||||||
|
"RuntimeActionInvocation": RuntimeActionInvocation,
|
||||||
|
"RuntimeActionResult": RuntimeActionResult,
|
||||||
"CanonicalStreamEnvelope": CanonicalStreamEnvelope,
|
"CanonicalStreamEnvelope": CanonicalStreamEnvelope,
|
||||||
"EvidenceRecord": EvidenceRecord,
|
"EvidenceRecord": EvidenceRecord,
|
||||||
"RawTransportRecord": RawTransportRecord,
|
"RawTransportRecord": RawTransportRecord,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
This directory is the package boundary for the first Mission Core device
|
This directory is the package boundary for the first Mission Core device
|
||||||
plugin. It now owns the canonical v1alpha2 manifest
|
plugin. It now owns the canonical v1alpha2 manifest
|
||||||
[`plugin.manifest.json`](plugin.manifest.json). The statically linked frontend
|
[`plugin.manifest.json`](plugin.manifest.json). The statically linked frontend
|
||||||
contribution lives in `apps/control-station/src/device-plugins/xgrids-k1/`; the
|
contribution lives in [`frontend/`](frontend/) beside the manifest and profiles;
|
||||||
verified backend implementation remains temporarily in `src/k1link` so the
|
its provisioning, acquisition/replay, diagnostics and metrics are separate
|
||||||
real-device path stays operational during extraction.
|
plugin-owned components. The
|
||||||
|
verified backend implementation is physically isolated in
|
||||||
|
`src/k1link/device_plugins/xgrids_k1/`.
|
||||||
|
|
||||||
The v1alpha2 catalog can describe one or more independently profiled models.
|
The v1alpha2 catalog can describe one or more independently profiled models.
|
||||||
This manifest currently exposes only `xgrids.lixelkity-k1`, and the transitional
|
This manifest currently exposes only `xgrids.lixelkity-k1`, and the transitional
|
||||||
|
|
@ -27,6 +29,8 @@ The plugin owns:
|
||||||
- exact-profile left/right RTSP producer selection, H.264 copy-remux preview and
|
- exact-profile left/right RTSP producer selection, H.264 copy-remux preview and
|
||||||
the handoff of acquisition-owned init/fMP4/index evidence to the host archive;
|
the handoff of acquisition-owned init/fMP4/index evidence to the host archive;
|
||||||
- K1-specific operator instructions and compatibility tests.
|
- K1-specific operator instructions and compatibility tests.
|
||||||
|
- the scoped React `device.connection` contribution and its BLE/Wi-Fi and
|
||||||
|
acquisition pipeline UI.
|
||||||
|
|
||||||
The plugin does not own:
|
The plugin does not own:
|
||||||
|
|
||||||
|
|
@ -35,19 +39,20 @@ The plugin does not own:
|
||||||
- the host observation catalog, RRD preparation/cache, recorded-camera player,
|
- the host observation catalog, RRD preparation/cache, recorded-camera player,
|
||||||
workspace layout, platform storage, remote transport, or other devices.
|
workspace layout, platform storage, remote transport, or other devices.
|
||||||
|
|
||||||
Until extraction is complete, `src/k1link` is the compatibility source of
|
The `k1link` distribution and CLI names remain compatibility names. All
|
||||||
truth and every move into this package must preserve replay and real-device
|
device-specific implementation behind them is plugin-owned, and every change
|
||||||
acceptance.
|
must preserve replay and real-device acceptance.
|
||||||
|
|
||||||
Mission Core imports this plugin only from
|
Mission Core imports this plugin only from
|
||||||
`apps/control-station/src/composition/devicePlugins.ts`. The generic shell never
|
`apps/control-station/src/composition/devicePlugins.ts`. The generic shell never
|
||||||
branches on this plugin ID or reads `k1_ip`, BLE candidates, MQTT topics, or
|
branches on this plugin ID or reads `k1_ip`, BLE candidates, MQTT topics, or
|
||||||
other vendor state.
|
other vendor state. The frontend contribution imports the host only through
|
||||||
|
`@mission-core/plugin-sdk`; its CSS is scoped below `.xgrids-k1-plugin`.
|
||||||
|
|
||||||
Backend startup loads the reviewed factory declared by `backendEntrypoint`, then
|
Backend startup loads the reviewed factory declared by `backendEntrypoint`, then
|
||||||
cross-checks its plugin ID and complete action set against this manifest.
|
cross-checks its plugin ID and complete action set against this manifest.
|
||||||
Actions enter through the host dispatcher and the transitional facade
|
Actions enter through the host dispatcher and the transitional facade
|
||||||
`src/k1link/web/xgrids_k1_facade.py`. The old flat routes live in the plugin's
|
`src/k1link/device_plugins/xgrids_k1/facade.py`. The old flat routes live in the plugin's
|
||||||
legacy router; both paths delegate to the same proven
|
legacy router; both paths delegate to the same proven
|
||||||
`XgridsK1CompatibilityService` methods. Synchronous capture/runtime operations
|
`XgridsK1CompatibilityService` methods. Synchronous capture/runtime operations
|
||||||
run outside the FastAPI event loop.
|
run outside the FastAPI event loop.
|
||||||
|
|
@ -55,9 +60,10 @@ run outside the FastAPI event loop.
|
||||||
Model switching calls the plugin deactivation hook. An active acquisition uses
|
Model switching calls the plugin deactivation hook. An active acquisition uses
|
||||||
semantic `acquisition.stop` in `capture-only` mode; replay and pre-v1alpha2
|
semantic `acquisition.stop` in `capture-only` mode; replay and pre-v1alpha2
|
||||||
sessions retain the legacy `stream.stop` shim. Neither path claims that the
|
sessions retain the legacy `stream.stop` shim. Neither path claims that the
|
||||||
physical K1 stopped without separate operator evidence. BLE, MQTT, codec and
|
physical K1 stopped without separate operator evidence. BLE, MQTT, codec,
|
||||||
evidence modules remain in `src/k1link` until replay parity and another physical
|
archive discovery and RRD export modules now live under the plugin package
|
||||||
K1 regression are complete.
|
after replay parity and physical K1 regression. Process isolation and signed
|
||||||
|
deployment remain later supervisor gates.
|
||||||
|
|
||||||
The compatibility profile is descriptive and cannot itself authorize a vendor
|
The compatibility profile is descriptive and cannot itself authorize a vendor
|
||||||
write. The current runtime cannot inspect K1 firmware: it keeps the profile
|
write. The current runtime cannot inspect K1 firmware: it keeps the profile
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
# XGRIDS K1 frontend contribution
|
||||||
|
|
||||||
|
This source tree is owned by the XGRIDS device plugin and is statically linked
|
||||||
|
into the reviewed Mission Core Control Station build. It is not part of the
|
||||||
|
generic application source tree.
|
||||||
|
|
||||||
|
The contribution contains:
|
||||||
|
|
||||||
|
- `K1ProvisioningPipeline` for power confirmation, BLE discovery and the
|
||||||
|
reviewed Wi-Fi provisioning write;
|
||||||
|
- `K1AcquisitionPipeline` for live receiver preparation, operator-manual K1
|
||||||
|
start/stop and compatibility file replay;
|
||||||
|
- plugin-local diagnostics, metrics, API state, lifecycle mapping,
|
||||||
|
observation-source mapping and scoped styles;
|
||||||
|
- `plugin.ts`, which binds the manifest `device.connection` component key to
|
||||||
|
the runtime provider and connection view.
|
||||||
|
|
||||||
|
Imports from the host are limited to `@mission-core/plugin-sdk`, the public
|
||||||
|
frontend host surface, plus React and the NODE.DC UI kit. The only generic
|
||||||
|
Control Station import of this plugin is the reviewed composition root.
|
||||||
|
Arbitrary runtime JavaScript discovery is intentionally unsupported.
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Button, StatusBadge, type StatusTone } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
|
||||||
|
import { K1AcquisitionPipeline } from "./components/K1AcquisitionPipeline";
|
||||||
|
import { K1Diagnostics } from "./components/K1Diagnostics";
|
||||||
|
import { K1Metrics } from "./components/K1Metrics";
|
||||||
|
import { K1ProvisioningPipeline } from "./components/K1ProvisioningPipeline";
|
||||||
|
import {
|
||||||
|
isConfirmedLiveState,
|
||||||
|
isSourceRuntimeBusy,
|
||||||
|
recoverableAcquisition,
|
||||||
|
sourceStatusLabel,
|
||||||
|
} from "./lifecycle";
|
||||||
|
import { localizeRuntimeMessage } from "./messages";
|
||||||
|
import { phaseLabel, phaseTone } from "./presentation";
|
||||||
|
import { useXgridsK1Controller } from "./runtimeContext";
|
||||||
|
|
||||||
|
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
|
||||||
|
const controller = useXgridsK1Controller();
|
||||||
|
const { state, error, refresh, clearError } = controller;
|
||||||
|
const [profileConfirmed, setProfileConfirmed] = useState(false);
|
||||||
|
const updateProfileConfirmation = useCallback((confirmed: boolean) => {
|
||||||
|
setProfileConfirmed(confirmed);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmedLive = isConfirmedLiveState(state);
|
||||||
|
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
|
||||||
|
const preparedAcquisition = recoverableAcquisition(state)?.state === "prepared";
|
||||||
|
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);
|
||||||
|
|
||||||
|
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">XGRIDS K1 · PLUGIN UI</span>
|
||||||
|
<h2>Подключение {model.displayName}</h2>
|
||||||
|
<p>BLE/Wi‑Fi provisioning и acquisition pipeline принадлежат этому device plugin; Control Station предоставляет только host slot и переход в пространственную сцену.</p>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-lead__status">
|
||||||
|
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
|
||||||
|
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<K1Metrics controller={controller} />
|
||||||
|
|
||||||
|
<div className="device-workspace__grid">
|
||||||
|
<K1ProvisioningPipeline
|
||||||
|
controller={controller}
|
||||||
|
phaseLabel={connectionPhaseLabel}
|
||||||
|
phaseTone={connectionPhaseTone}
|
||||||
|
profileConfirmed={profileConfirmed}
|
||||||
|
onProfileConfirmedChange={updateProfileConfirmation}
|
||||||
|
/>
|
||||||
|
<div className="device-workspace__side">
|
||||||
|
<K1AcquisitionPipeline
|
||||||
|
controller={controller}
|
||||||
|
profileConfirmed={profileConfirmed}
|
||||||
|
openSpatialScene={host.openSpatialScene}
|
||||||
|
/>
|
||||||
|
<K1Diagnostics controller={controller} sourceLabel={sourceLabel} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ViewerSettings } from "../../core/runtime/contracts";
|
import type { ViewerSettings } from "@mission-core/plugin-sdk";
|
||||||
import { xgridsK1Actions, xgridsK1Manifest } from "./manifest";
|
import { xgridsK1Actions, xgridsK1Manifest } from "./manifest";
|
||||||
|
|
||||||
const PLUGIN_ID = xgridsK1Manifest.metadata.id;
|
const PLUGIN_ID = xgridsK1Manifest.metadata.id;
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { CompatibilityAttestation } from "./api";
|
||||||
|
|
||||||
|
export const EXACT_PROFILE_ATTESTATION: CompatibilityAttestation = Object.freeze({
|
||||||
|
firmware_version: "3.0.2",
|
||||||
|
topology: "direct-lan",
|
||||||
|
operator_confirmed: true,
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checker,
|
||||||
|
GlassSurface,
|
||||||
|
Icon,
|
||||||
|
SegmentedControl,
|
||||||
|
StatusBadge,
|
||||||
|
TextField,
|
||||||
|
type StatusTone,
|
||||||
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
|
||||||
|
import {
|
||||||
|
isConfirmedLiveState,
|
||||||
|
isSourceRuntimeBusy,
|
||||||
|
recoverableAcquisition,
|
||||||
|
sourceStatusLabel,
|
||||||
|
} from "../lifecycle";
|
||||||
|
import type { XgridsK1Controller } from "../runtimeContext";
|
||||||
|
|
||||||
|
type SessionIntent = "live" | "replay";
|
||||||
|
|
||||||
|
const sessionItems = [
|
||||||
|
{ value: "live", label: "Реальное устройство" },
|
||||||
|
{ value: "replay", label: "Повтор записи" },
|
||||||
|
] satisfies Array<{ value: SessionIntent; label: string }>;
|
||||||
|
|
||||||
|
export function K1AcquisitionPipeline({
|
||||||
|
controller,
|
||||||
|
profileConfirmed,
|
||||||
|
openSpatialScene,
|
||||||
|
}: {
|
||||||
|
controller: XgridsK1Controller;
|
||||||
|
profileConfirmed: boolean;
|
||||||
|
openSpatialScene: () => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
pendingAction,
|
||||||
|
prepareAndStartAcquisition,
|
||||||
|
startReplay,
|
||||||
|
stop,
|
||||||
|
abort,
|
||||||
|
} = controller;
|
||||||
|
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||||
|
const [liveHost, setLiveHost] = useState("");
|
||||||
|
const [replayPath, setReplayPath] = useState("");
|
||||||
|
const [replaySpeed, setReplaySpeed] = useState("1");
|
||||||
|
const [replayLoop, setReplayLoop] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state?.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 isBusy = pendingAction !== null;
|
||||||
|
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"
|
||||||
|
: isConfirmedLiveState(state) || state?.source_mode === "replay"
|
||||||
|
? "success"
|
||||||
|
: sourceRuntimeBusy || preparedAcquisition
|
||||||
|
? "warning"
|
||||||
|
: "neutral";
|
||||||
|
const selectableSessionItems = useMemo(
|
||||||
|
() => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })),
|
||||||
|
[sessionLocked],
|
||||||
|
);
|
||||||
|
|
||||||
|
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) 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) openSpatialScene();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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
|
||||||
|
? "Сначала вручную подтвердите FW 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { GlassSurface, StatusBadge } from "@nodedc/ui-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
backendLabel,
|
||||||
|
backendTone,
|
||||||
|
eventStatusLabel,
|
||||||
|
formatNumber,
|
||||||
|
pipelineLatency,
|
||||||
|
} from "../presentation";
|
||||||
|
import { isConfirmedLiveState } from "../lifecycle";
|
||||||
|
import type { XgridsK1Controller } from "../runtimeContext";
|
||||||
|
|
||||||
|
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return <div className="detail-row"><dt>{label}</dt><dd>{children}</dd></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 K1Diagnostics({ controller, sourceLabel }: {
|
||||||
|
controller: XgridsK1Controller;
|
||||||
|
sourceLabel: string;
|
||||||
|
}) {
|
||||||
|
const { state, backendStatus, eventStatus, latencyHistory } = controller;
|
||||||
|
const streamActive = isConfirmedLiveState(state) || state?.source_mode === "replay";
|
||||||
|
const latency = pipelineLatency(streamActive ? state?.metrics : undefined);
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { isConfirmedLiveState } from "../lifecycle";
|
||||||
|
import { finiteMetric, formatNumber, pipelineLatency } from "../presentation";
|
||||||
|
import type { XgridsK1Controller } from "../runtimeContext";
|
||||||
|
import { MetricCard } from "./MetricCard";
|
||||||
|
|
||||||
|
export function K1Metrics({ controller }: { controller: XgridsK1Controller }) {
|
||||||
|
const { state } = controller;
|
||||||
|
const streamActive = isConfirmedLiveState(state) || 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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checker,
|
||||||
|
GlassSurface,
|
||||||
|
Icon,
|
||||||
|
StatusBadge,
|
||||||
|
TextField,
|
||||||
|
type StatusTone,
|
||||||
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import type { BleDevice } from "../api";
|
||||||
|
import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
|
||||||
|
import { provisioningIntentKey } from "../lifecycle";
|
||||||
|
import { finiteMetric } from "../presentation";
|
||||||
|
import type { XgridsK1Controller } from "../runtimeContext";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function K1ProvisioningPipeline({
|
||||||
|
controller,
|
||||||
|
phaseLabel,
|
||||||
|
phaseTone,
|
||||||
|
profileConfirmed,
|
||||||
|
onProfileConfirmedChange,
|
||||||
|
}: {
|
||||||
|
controller: XgridsK1Controller;
|
||||||
|
phaseLabel: string;
|
||||||
|
phaseTone: StatusTone;
|
||||||
|
profileConfirmed: boolean;
|
||||||
|
onProfileConfirmedChange: (confirmed: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { state, pendingAction, scan, connect } = controller;
|
||||||
|
const [powerConfirmed, setPowerConfirmed] = useState(false);
|
||||||
|
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
||||||
|
const [ssid, setSsid] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const provisioningIntentRef = useRef<string | null>(null);
|
||||||
|
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;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state?.selected_device_id) {
|
||||||
|
if (state.selected_device_id !== selectedDeviceId) {
|
||||||
|
onProfileConfirmedChange(false);
|
||||||
|
provisioningIntentRef.current = null;
|
||||||
|
}
|
||||||
|
setSelectedDeviceId(state.selected_device_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedDeviceId && state?.devices && !state.devices.some((device) => device.device_id === selectedDeviceId)) {
|
||||||
|
setSelectedDeviceId("");
|
||||||
|
}
|
||||||
|
}, [onProfileConfirmedChange, selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||||
|
|
||||||
|
const deviceSummary = useMemo(
|
||||||
|
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||||
|
[devices, selectedDeviceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetProfile = () => {
|
||||||
|
onProfileConfirmedChange(false);
|
||||||
|
provisioningIntentRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassSurface className="connection-panel" padding="lg">
|
||||||
|
<header className="panel-heading">
|
||||||
|
<div><span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span><h2>Подключите устройство к сети</h2></div>
|
||||||
|
<StatusBadge tone={phaseTone}>{phaseLabel}</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) resetProfile(); }}
|
||||||
|
/>
|
||||||
|
</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(""); resetProfile(); 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); resetProfile(); }}
|
||||||
|
/>
|
||||||
|
)) : <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 и direct-LAN"
|
||||||
|
onChange={(checked) => { onProfileConfirmedChange(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { GlassSurface } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
export interface MetricCardProps {
|
||||||
|
eyebrow: string;
|
||||||
|
value: string;
|
||||||
|
unit?: string;
|
||||||
|
detail: string;
|
||||||
|
featured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricCard({ eyebrow, value, unit, detail, featured = false }: MetricCardProps) {
|
||||||
|
return (
|
||||||
|
<GlassSurface className="metric-card" padding="md" data-featured={featured ? "true" : undefined}>
|
||||||
|
<span className="metric-card__eyebrow">{eyebrow}</span>
|
||||||
|
<div className="metric-card__reading">
|
||||||
|
<strong>{value}</strong>
|
||||||
|
{unit ? <span>{unit}</span> : null}
|
||||||
|
</div>
|
||||||
|
<p>{detail}</p>
|
||||||
|
</GlassSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "../../core/runtime/contracts";
|
import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "@mission-core/plugin-sdk";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AcquisitionState,
|
AcquisitionState,
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import manifestDocument from "../../../../../plugins/xgrids-k1/plugin.manifest.json";
|
import manifestDocument from "../../plugin.manifest.json";
|
||||||
|
|
||||||
import { DEVICE_STATE_READ_ACTION_ID } from "../../core/device-plugins/contracts";
|
|
||||||
import {
|
import {
|
||||||
|
DEVICE_STATE_READ_ACTION_ID,
|
||||||
parseDevicePluginManifest,
|
parseDevicePluginManifest,
|
||||||
requirePluginAction,
|
requirePluginAction,
|
||||||
} from "../../core/device-plugins/manifestParser";
|
} from "@mission-core/plugin-sdk";
|
||||||
|
|
||||||
export const xgridsK1Manifest = parseDevicePluginManifest(manifestDocument);
|
export const xgridsK1Manifest = parseDevicePluginManifest(manifestDocument);
|
||||||
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Vendor message normalization belongs to the XGRIDS frontend contribution.
|
||||||
const runtimeMessageReplacements: Array<[RegExp, string]> = [
|
const runtimeMessageReplacements: Array<[RegExp, string]> = [
|
||||||
[/broker connection ended/gi, "соединение с брокером завершено"],
|
[/broker connection ended/gi, "соединение с брокером завершено"],
|
||||||
[/Unspecified error/gi, "неуказанная ошибка"],
|
[/Unspecified error/gi, "неуказанная ошибка"],
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
|
|
||||||
import type {
|
import type {
|
||||||
|
DeviceModelDefinition,
|
||||||
ObservationSourceAvailability,
|
ObservationSourceAvailability,
|
||||||
ObservationSourceDelivery,
|
ObservationSourceDelivery,
|
||||||
ObservationSourceDescriptor,
|
ObservationSourceDescriptor,
|
||||||
ObservationSourceProvider,
|
ObservationSourceProvider,
|
||||||
} from "../../core/runtime/contracts";
|
} from "@mission-core/plugin-sdk";
|
||||||
import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
|
import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
|
||||||
import { xgridsK1Manifest } from "./manifest";
|
import { xgridsK1Manifest } from "./manifest";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import type { DeviceUiPlugin } from "../../core/device-plugins/contracts";
|
import type { DeviceUiPlugin } from "@mission-core/plugin-sdk";
|
||||||
import { XgridsK1Connection } from "./XgridsK1Connection";
|
import { XgridsK1Connection } from "./XgridsK1Connection";
|
||||||
import { xgridsK1Manifest } from "./manifest";
|
import { xgridsK1Manifest } from "./manifest";
|
||||||
import { XgridsK1RuntimeProvider } from "./runtimeContext";
|
import { XgridsK1RuntimeProvider } from "./runtimeContext";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
export const xgridsK1Plugin: DeviceUiPlugin = {
|
export const xgridsK1Plugin: DeviceUiPlugin = {
|
||||||
manifest: xgridsK1Manifest,
|
manifest: xgridsK1Manifest,
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { StatusTone } from "@nodedc/ui-react";
|
import type { StatusTone } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type { BackendStatus } from "../../core/runtime/contracts";
|
import type { BackendStatus } from "@mission-core/plugin-sdk";
|
||||||
import type { XgridsK1Metrics } from "./api";
|
import type { XgridsK1Metrics } from "./api";
|
||||||
|
|
||||||
const phaseLabels: Record<string, string> = {
|
const phaseLabels: Record<string, string> = {
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
import { createContext, useContext, useEffect, type ReactNode } from "react";
|
import { createContext, useContext, useEffect, type ReactNode } from "react";
|
||||||
|
|
||||||
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
|
|
||||||
import {
|
import {
|
||||||
MissionRuntimeProvider,
|
MissionRuntimeProvider,
|
||||||
useMissionRuntime,
|
useMissionRuntime,
|
||||||
} from "../../core/runtime/MissionRuntimeContext";
|
type DeviceModelDefinition,
|
||||||
import type {
|
type MissionRuntimeController,
|
||||||
MissionRuntimeController,
|
type MissionRuntimeState,
|
||||||
MissionRuntimeState,
|
} from "@mission-core/plugin-sdk";
|
||||||
} from "../../core/runtime/contracts";
|
|
||||||
import {
|
import {
|
||||||
confirmedRuntimeSourceMode,
|
confirmedRuntimeSourceMode,
|
||||||
effectiveAcquisition,
|
effectiveAcquisition,
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// Ordering rules remain plugin-private because revisions are vendor runtime state.
|
||||||
import type { XgridsCameraPreviewState, XgridsK1State } from "./api";
|
import type { XgridsCameraPreviewState, XgridsK1State } from "./api";
|
||||||
|
|
||||||
function monotonicInteger(value: number | null | undefined): number | null {
|
function monotonicInteger(value: number | null | undefined): number | null {
|
||||||
|
|
@ -0,0 +1,466 @@
|
||||||
|
/* All selectors below are scoped to the XGRIDS frontend contribution. */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1480px) {
|
||||||
|
.xgrids-k1-plugin .device-workspace__grid {
|
||||||
|
grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.xgrids-k1-plugin .device-workspace__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1040px) {
|
||||||
|
.xgrids-k1-plugin .diagnostics-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.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 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { BackendStatus, ViewerSettings } from "../../core/runtime/contracts";
|
import type { BackendStatus, ViewerSettings } from "@mission-core/plugin-sdk";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"spec": {
|
"spec": {
|
||||||
"hostApiRange": "v1alpha2",
|
"hostApiRange": "v1alpha2",
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"backendEntrypoint": "k1link.web.xgrids_k1_facade:build_xgrids_k1_plugin",
|
"backendEntrypoint": "k1link.device_plugins.xgrids_k1.facade:build_xgrids_k1_plugin",
|
||||||
"isolation": "transitional-in-process"
|
"isolation": "transitional-in-process"
|
||||||
},
|
},
|
||||||
"compatibilityProfiles": [
|
"compatibilityProfiles": [
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ dependencies = [
|
||||||
missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true }
|
missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true }
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
k1link = "k1link.cli:app"
|
k1link = "k1link.device_plugins.xgrids_k1.cli:app"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Concrete device integrations loaded only through reviewed plugin manifests."""
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""XGRIDS/LixelKity K1 compatibility plugin implementation."""
|
||||||
|
|
||||||
|
from .observation import build_xgrids_k1_observation, xgrids_k1_archive_source
|
||||||
|
|
||||||
|
__all__ = ["build_xgrids_k1_observation", "xgrids_k1_archive_source"]
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Bounded, offline analysis of sensitive K1 evidence artifacts."""
|
"""Bounded, offline analysis of sensitive K1 evidence artifacts."""
|
||||||
|
|
||||||
from k1link.analyze.stream_summary import (
|
from k1link.device_plugins.xgrids_k1.analyze.stream_summary import (
|
||||||
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
|
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
|
||||||
MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
|
MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
|
||||||
StreamSummary,
|
StreamSummary,
|
||||||
|
|
@ -9,8 +9,8 @@ from pathlib import Path
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
from k1link.artifacts import utc_now_iso
|
from k1link.artifacts import utc_now_iso
|
||||||
from k1link.mqtt import DEFAULT_MAX_MESSAGE_BYTES, iter_capture_frames
|
from k1link.device_plugins.xgrids_k1.mqtt import DEFAULT_MAX_MESSAGE_BYTES, iter_capture_frames
|
||||||
from k1link.protocol import (
|
from k1link.device_plugins.xgrids_k1.protocol import (
|
||||||
DecodeLimits,
|
DecodeLimits,
|
||||||
StreamDecodeError,
|
StreamDecodeError,
|
||||||
decode_lio_pcl,
|
decode_lio_pcl,
|
||||||
|
|
@ -13,7 +13,7 @@ from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import IO, Any
|
from typing import IO, Any
|
||||||
|
|
||||||
from k1link.mqtt.capture import (
|
from k1link.device_plugins.xgrids_k1.mqtt.capture import (
|
||||||
FRAME_HEADER,
|
FRAME_HEADER,
|
||||||
GROUP_COMMIT_MAX_BYTES,
|
GROUP_COMMIT_MAX_BYTES,
|
||||||
GROUP_COMMIT_MAX_MESSAGES,
|
GROUP_COMMIT_MAX_MESSAGES,
|
||||||
|
|
@ -21,10 +21,8 @@ from k1link.mqtt.capture import (
|
||||||
MAX_TOPIC_BYTES,
|
MAX_TOPIC_BYTES,
|
||||||
RAW_MAGIC,
|
RAW_MAGIC,
|
||||||
)
|
)
|
||||||
|
from k1link.sessions.models import (
|
||||||
from .models import (
|
|
||||||
LegacyMediaSourceCandidate,
|
LegacyMediaSourceCandidate,
|
||||||
LegacySessionCandidate,
|
|
||||||
SessionModality,
|
SessionModality,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
)
|
)
|
||||||
|
|
@ -41,6 +39,30 @@ MAX_RECOVERY_METADATA_LINE_BYTES = 64 * 1024
|
||||||
MAX_RECOVERY_MESSAGES = 500_000
|
MAX_RECOVERY_MESSAGES = 500_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LegacySessionCandidate:
|
||||||
|
"""K1 archive discovery result retained inside the compatibility adapter."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
display_name: str
|
||||||
|
status: SessionStatus
|
||||||
|
started_at_utc: str | None
|
||||||
|
completed_at_utc: str | None
|
||||||
|
duration_seconds: float | None
|
||||||
|
modalities: tuple[SessionModality, ...]
|
||||||
|
replayable: bool
|
||||||
|
total_bytes: int
|
||||||
|
allowed_root: Path
|
||||||
|
session_root: Path
|
||||||
|
raw_path: Path
|
||||||
|
raw_byte_length: int
|
||||||
|
replay_raw_byte_length: int
|
||||||
|
replay_metadata_byte_length: int
|
||||||
|
raw_sha256: str | None
|
||||||
|
raw_integrity_status: str
|
||||||
|
media_sources: tuple[LegacyMediaSourceCandidate, ...]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class _RecoveredCapture:
|
class _RecoveredCapture:
|
||||||
message_count: int
|
message_count: int
|
||||||
|
|
@ -14,7 +14,7 @@ from typing import IO, Any, Final, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from k1link.mqtt import validate_private_ipv4
|
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
||||||
from k1link.web.camera_archive import (
|
from k1link.web.camera_archive import (
|
||||||
CameraArchiveError,
|
CameraArchiveError,
|
||||||
CameraArchiveKind,
|
CameraArchiveKind,
|
||||||
|
|
@ -16,28 +16,28 @@ from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from k1link import __version__
|
from k1link import __version__
|
||||||
from k1link.analyze import (
|
from k1link.artifacts import write_json_atomic
|
||||||
|
from k1link.device_plugins.xgrids_k1.analyze import (
|
||||||
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
|
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
|
||||||
MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
|
MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
|
||||||
summarize_mqtt_streams,
|
summarize_mqtt_streams,
|
||||||
)
|
)
|
||||||
from k1link.artifacts import write_json_atomic
|
from k1link.device_plugins.xgrids_k1.ble.gatt import dump_metadata
|
||||||
from k1link.ble.gatt import dump_metadata
|
from k1link.device_plugins.xgrids_k1.ble.scanner import scan
|
||||||
from k1link.ble.scanner import scan
|
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||||
from k1link.ble.wifi_provisioning import (
|
|
||||||
PROFILE_ID,
|
PROFILE_ID,
|
||||||
WriteMode,
|
WriteMode,
|
||||||
provision_wifi_once,
|
provision_wifi_once,
|
||||||
)
|
)
|
||||||
from k1link.macos_credentials import CredentialDialogError, prompt_wifi_credentials
|
from k1link.device_plugins.xgrids_k1.mqtt import (
|
||||||
from k1link.mqtt import (
|
|
||||||
DEFAULT_MAX_MESSAGE_BYTES,
|
DEFAULT_MAX_MESSAGE_BYTES,
|
||||||
MAX_CONFIGURABLE_MESSAGE_BYTES,
|
MAX_CONFIGURABLE_MESSAGE_BYTES,
|
||||||
CaptureError,
|
CaptureError,
|
||||||
capture_mqtt,
|
capture_mqtt,
|
||||||
)
|
)
|
||||||
from k1link.net.snapshot import snapshot
|
from k1link.device_plugins.xgrids_k1.net.snapshot import snapshot
|
||||||
from k1link.usb.snapshot import snapshot as usb_snapshot
|
from k1link.device_plugins.xgrids_k1.usb.snapshot import snapshot as usb_snapshot
|
||||||
|
from k1link.macos_credentials import CredentialDialogError, prompt_wifi_credentials
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
name="k1link",
|
name="k1link",
|
||||||
|
|
@ -15,16 +15,31 @@ from pathlib import Path
|
||||||
from typing import Any, Literal, Protocol, cast
|
from typing import Any, Literal, Protocol, cast
|
||||||
|
|
||||||
from bleak.exc import BleakError
|
from bleak.exc import BleakError
|
||||||
|
from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation
|
||||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
|
||||||
|
|
||||||
from k1link.artifacts import write_json_atomic
|
from k1link.artifacts import write_json_atomic
|
||||||
from k1link.ble.scanner import scan
|
from k1link.device_plugins.xgrids_k1.ble.scanner import scan
|
||||||
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
|
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||||
from k1link.mqtt import validate_private_ipv4
|
AP_FALLBACK_IPV4,
|
||||||
from k1link.protocol.normalizer import normalize_k1_message
|
provision_wifi_once,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.camera import (
|
||||||
|
CAMERA_EXCLUSIVE_GROUP,
|
||||||
|
CAMERA_SOURCE_LABELS,
|
||||||
|
CAMERA_SOURCE_PATHS,
|
||||||
|
CameraSourceId,
|
||||||
|
XgridsK1CameraGateway,
|
||||||
|
build_xgrids_k1_camera_router,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
|
||||||
|
from k1link.device_plugins.xgrids_k1.viewer.runtime import (
|
||||||
|
VisualizationRuntime,
|
||||||
|
new_live_session_dir,
|
||||||
|
)
|
||||||
from k1link.sessions import ActiveSessionLease, resolve_missioncore_evidence_dir
|
from k1link.sessions import ActiveSessionLease, resolve_missioncore_evidence_dir
|
||||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||||
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
|
|
||||||
from k1link.web.device_lifecycle import (
|
from k1link.web.device_lifecycle import (
|
||||||
TERMINAL_ACQUISITION_STATES,
|
TERMINAL_ACQUISITION_STATES,
|
||||||
AcquisitionRecord,
|
AcquisitionRecord,
|
||||||
|
|
@ -38,14 +53,6 @@ from k1link.web.plugin_runtime import (
|
||||||
PluginActionNotFoundError,
|
PluginActionNotFoundError,
|
||||||
PluginExecutionError,
|
PluginExecutionError,
|
||||||
)
|
)
|
||||||
from k1link.web.xgrids_k1_camera import (
|
|
||||||
CAMERA_EXCLUSIVE_GROUP,
|
|
||||||
CAMERA_SOURCE_LABELS,
|
|
||||||
CAMERA_SOURCE_PATHS,
|
|
||||||
CameraSourceId,
|
|
||||||
XgridsK1CameraGateway,
|
|
||||||
build_xgrids_k1_camera_router,
|
|
||||||
)
|
|
||||||
|
|
||||||
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
||||||
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
|
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
|
||||||
|
|
@ -1497,9 +1504,14 @@ class XgridsK1PluginFacade:
|
||||||
def __init__(self, service: XgridsK1ServicePort) -> None:
|
def __init__(self, service: XgridsK1ServicePort) -> None:
|
||||||
self.service = service
|
self.service = service
|
||||||
|
|
||||||
async def invoke(self, action_id: str, payload: Mapping[str, Any]) -> dict[str, Any]:
|
async def invoke(self, invocation: RuntimeActionInvocation) -> dict[str, Any]:
|
||||||
|
if invocation.plugin_id != self.plugin_id:
|
||||||
|
raise PluginActionNotFoundError("runtime invocation targets another plugin")
|
||||||
try:
|
try:
|
||||||
return await self._invoke_validated(action_id, payload)
|
return await self._invoke_validated(
|
||||||
|
invocation.action_id,
|
||||||
|
invocation.parameters,
|
||||||
|
)
|
||||||
except (ValidationError, ValueError, PluginActionNotFoundError):
|
except (ValidationError, ValueError, PluginActionNotFoundError):
|
||||||
raise
|
raise
|
||||||
except (BleakError, OSError, TimeoutError, RuntimeError) as exc:
|
except (BleakError, OSError, TimeoutError, RuntimeError) as exc:
|
||||||
|
|
@ -1803,7 +1815,8 @@ def _validate_installed_compatibility_profile(repository_root: Path) -> None:
|
||||||
def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribution:
|
def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribution:
|
||||||
"""Manifest entrypoint for the reviewed XGRIDS compatibility adapter."""
|
"""Manifest entrypoint for the reviewed XGRIDS compatibility adapter."""
|
||||||
|
|
||||||
from k1link.web.xgrids_k1_legacy_api import build_xgrids_k1_legacy_router
|
from k1link.device_plugins.xgrids_k1 import build_xgrids_k1_observation
|
||||||
|
from k1link.device_plugins.xgrids_k1.legacy_api import build_xgrids_k1_legacy_router
|
||||||
|
|
||||||
_validate_installed_compatibility_profile(repository_root)
|
_validate_installed_compatibility_profile(repository_root)
|
||||||
service = XgridsK1CompatibilityService(repository_root)
|
service = XgridsK1CompatibilityService(repository_root)
|
||||||
|
|
@ -1817,5 +1830,6 @@ def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribu
|
||||||
XGRIDS_K1_PLUGIN_ID,
|
XGRIDS_K1_PLUGIN_ID,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
observation=build_xgrids_k1_observation(repository_root),
|
||||||
close=service.close,
|
close=service.close,
|
||||||
)
|
)
|
||||||
|
|
@ -5,8 +5,7 @@ from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from k1link.web.plugin_runtime import PluginExecutionError
|
from k1link.device_plugins.xgrids_k1.facade import (
|
||||||
from k1link.web.xgrids_k1_facade import (
|
|
||||||
ACTION_DISCOVERY_SCAN,
|
ACTION_DISCOVERY_SCAN,
|
||||||
ACTION_NETWORK_PROVISION,
|
ACTION_NETWORK_PROVISION,
|
||||||
ACTION_STATE_READ,
|
ACTION_STATE_READ,
|
||||||
|
|
@ -21,6 +20,7 @@ from k1link.web.xgrids_k1_facade import (
|
||||||
ViewerSettingsRequest,
|
ViewerSettingsRequest,
|
||||||
XgridsK1PluginFacade,
|
XgridsK1PluginFacade,
|
||||||
)
|
)
|
||||||
|
from k1link.web.plugin_runtime import PluginExecutionError, invoke_device_plugin_adapter
|
||||||
|
|
||||||
|
|
||||||
def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
||||||
|
|
@ -30,19 +30,27 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
||||||
|
|
||||||
@router.get("/api/state", deprecated=True)
|
@router.get("/api/state", deprecated=True)
|
||||||
async def get_state() -> dict[str, Any]:
|
async def get_state() -> dict[str, Any]:
|
||||||
return await adapter.invoke(ACTION_STATE_READ, {})
|
return await invoke_device_plugin_adapter(adapter, ACTION_STATE_READ, {})
|
||||||
|
|
||||||
@router.post("/api/ble/scan", deprecated=True)
|
@router.post("/api/ble/scan", deprecated=True)
|
||||||
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
|
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return await adapter.invoke(ACTION_DISCOVERY_SCAN, request.model_dump())
|
return await invoke_device_plugin_adapter(
|
||||||
|
adapter,
|
||||||
|
ACTION_DISCOVERY_SCAN,
|
||||||
|
request.model_dump(),
|
||||||
|
)
|
||||||
except (PluginExecutionError, ValueError) as exc:
|
except (PluginExecutionError, ValueError) as exc:
|
||||||
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
|
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
|
||||||
|
|
||||||
@router.post("/api/connect", deprecated=True)
|
@router.post("/api/connect", deprecated=True)
|
||||||
async def connect(request: ConnectRequest) -> dict[str, Any]:
|
async def connect(request: ConnectRequest) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return await adapter.invoke(ACTION_NETWORK_PROVISION, request.model_dump())
|
return await invoke_device_plugin_adapter(
|
||||||
|
adapter,
|
||||||
|
ACTION_NETWORK_PROVISION,
|
||||||
|
request.model_dump(),
|
||||||
|
)
|
||||||
except (PluginExecutionError, ValueError) as exc:
|
except (PluginExecutionError, ValueError) as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=502,
|
status_code=502,
|
||||||
|
|
@ -52,31 +60,51 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
||||||
@router.post("/api/session/live", deprecated=True)
|
@router.post("/api/session/live", deprecated=True)
|
||||||
async def start_live(request: LiveRequest) -> dict[str, Any]:
|
async def start_live(request: LiveRequest) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return await adapter.invoke(ACTION_STREAM_START_LIVE, request.model_dump())
|
return await invoke_device_plugin_adapter(
|
||||||
|
adapter,
|
||||||
|
ACTION_STREAM_START_LIVE,
|
||||||
|
request.model_dump(),
|
||||||
|
)
|
||||||
except (PluginExecutionError, ValueError) as exc:
|
except (PluginExecutionError, ValueError) as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
@router.post("/api/session/replay", deprecated=True)
|
@router.post("/api/session/replay", deprecated=True)
|
||||||
async def start_replay(request: ReplayRequest) -> dict[str, Any]:
|
async def start_replay(request: ReplayRequest) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return await adapter.invoke(ACTION_STREAM_START_REPLAY, request.model_dump())
|
return await invoke_device_plugin_adapter(
|
||||||
|
adapter,
|
||||||
|
ACTION_STREAM_START_REPLAY,
|
||||||
|
request.model_dump(),
|
||||||
|
)
|
||||||
except (PluginExecutionError, ValueError) as exc:
|
except (PluginExecutionError, ValueError) as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
@router.post("/api/session/stop", deprecated=True)
|
@router.post("/api/session/stop", deprecated=True)
|
||||||
async def stop_session() -> dict[str, Any]:
|
async def stop_session() -> dict[str, Any]:
|
||||||
return await adapter.invoke(ACTION_STREAM_STOP, {})
|
return await invoke_device_plugin_adapter(adapter, ACTION_STREAM_STOP, {})
|
||||||
|
|
||||||
@router.post("/api/viewer/settings", deprecated=True)
|
@router.post("/api/viewer/settings", deprecated=True)
|
||||||
async def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
|
async def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
|
||||||
return await adapter.invoke(ACTION_VIEWER_SETTINGS_UPDATE, request.model_dump())
|
return await invoke_device_plugin_adapter(
|
||||||
|
adapter,
|
||||||
|
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||||
|
request.model_dump(),
|
||||||
|
)
|
||||||
|
|
||||||
@router.websocket("/api/events")
|
@router.websocket("/api/events")
|
||||||
async def events(websocket: WebSocket) -> None:
|
async def events(websocket: WebSocket) -> None:
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
await websocket.send_json({"state": await adapter.invoke(ACTION_STATE_READ, {})})
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"state": await invoke_device_plugin_adapter(
|
||||||
|
adapter,
|
||||||
|
ACTION_STATE_READ,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
return
|
return
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Read-only MQTT evidence capture for an owner-controlled K1."""
|
"""Read-only MQTT evidence capture for an owner-controlled K1."""
|
||||||
|
|
||||||
from k1link.mqtt.capture import (
|
from k1link.device_plugins.xgrids_k1.mqtt.capture import (
|
||||||
DEFAULT_MAX_MESSAGE_BYTES,
|
DEFAULT_MAX_MESSAGE_BYTES,
|
||||||
MAX_CONFIGURABLE_MESSAGE_BYTES,
|
MAX_CONFIGURABLE_MESSAGE_BYTES,
|
||||||
REPORT_TOPICS,
|
REPORT_TOPICS,
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
"""K1-native observation archive and Rerun preparation adapter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1.archive import (
|
||||||
|
LegacySessionCandidate,
|
||||||
|
discover_legacy_viewer_sessions,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.rrd_export import (
|
||||||
|
RrdExportCancelled,
|
||||||
|
RrdExportError,
|
||||||
|
export_k1mqtt_to_rrd,
|
||||||
|
)
|
||||||
|
from k1link.sessions.active import recover_stale_active_session_marker
|
||||||
|
from k1link.sessions.models import (
|
||||||
|
ObservationArtifactCandidate,
|
||||||
|
ObservationSessionCandidate,
|
||||||
|
SessionIntegrityError,
|
||||||
|
SessionSource,
|
||||||
|
)
|
||||||
|
from k1link.sessions.plugin_contract import (
|
||||||
|
ObservationArchiveSource,
|
||||||
|
ObservationRuntimeContribution,
|
||||||
|
PluginRecordingExportCancelled,
|
||||||
|
PluginRecordingExportError,
|
||||||
|
)
|
||||||
|
from k1link.sessions.store import resolve_missioncore_evidence_dir
|
||||||
|
from k1link.web.camera_archive import recover_incomplete_camera_archives
|
||||||
|
|
||||||
|
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
||||||
|
MAX_TIMELINE_ORIGIN_LINE_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def build_xgrids_k1_observation(repository_root: Path) -> ObservationRuntimeContribution:
|
||||||
|
"""Compose every K1 evidence root behind the generic observation ABI."""
|
||||||
|
|
||||||
|
roots = (
|
||||||
|
("xgrids-k1.viewer-live.repository", repository_root.resolve() / "sessions"),
|
||||||
|
(
|
||||||
|
"xgrids-k1.viewer-live.evidence",
|
||||||
|
resolve_missioncore_evidence_dir(repository_root),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ObservationRuntimeContribution(
|
||||||
|
archives=tuple(
|
||||||
|
ObservationArchiveSource(
|
||||||
|
plugin_id=XGRIDS_K1_PLUGIN_ID,
|
||||||
|
archive_id=archive_id,
|
||||||
|
root=root,
|
||||||
|
discover=_discover_archive,
|
||||||
|
recover=_recover_archive,
|
||||||
|
)
|
||||||
|
for archive_id, root in roots
|
||||||
|
),
|
||||||
|
recording_exporter=_export_recording,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def xgrids_k1_archive_source(
|
||||||
|
root: Path,
|
||||||
|
*,
|
||||||
|
archive_id: str = "xgrids-k1.viewer-live",
|
||||||
|
) -> ObservationArchiveSource:
|
||||||
|
"""Build one explicit archive source for tests, tools, or migration jobs."""
|
||||||
|
|
||||||
|
return ObservationArchiveSource(
|
||||||
|
plugin_id=XGRIDS_K1_PLUGIN_ID,
|
||||||
|
archive_id=archive_id,
|
||||||
|
root=root,
|
||||||
|
discover=_discover_archive,
|
||||||
|
recover=_recover_archive,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_archive(root: Path) -> None:
|
||||||
|
recover_stale_active_session_marker(root)
|
||||||
|
recover_incomplete_camera_archives(root)
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_archive(root: Path) -> tuple[ObservationSessionCandidate, ...]:
|
||||||
|
return tuple(
|
||||||
|
_to_host_candidate(candidate) for candidate in discover_legacy_viewer_sessions(root)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_host_candidate(candidate: LegacySessionCandidate) -> ObservationSessionCandidate:
|
||||||
|
session_id = candidate.session_id
|
||||||
|
raw_path = candidate.raw_path
|
||||||
|
raw_bytes = candidate.raw_byte_length
|
||||||
|
replay_raw_bytes = candidate.replay_raw_byte_length
|
||||||
|
replay_metadata_bytes = candidate.replay_metadata_byte_length
|
||||||
|
replayable = candidate.replayable
|
||||||
|
metadata_path = raw_path.with_name("mqtt.metadata.jsonl")
|
||||||
|
|
||||||
|
artifacts = [
|
||||||
|
ObservationArtifactCandidate(
|
||||||
|
artifact_id="raw-transport-primary",
|
||||||
|
kind="raw-transport",
|
||||||
|
media_type="application/x-nodedc-k1mqtt",
|
||||||
|
locator=raw_path,
|
||||||
|
byte_length=raw_bytes,
|
||||||
|
replay_byte_length=replay_raw_bytes if replayable else 0,
|
||||||
|
sha256=candidate.raw_sha256,
|
||||||
|
integrity_status=candidate.raw_integrity_status,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if replay_metadata_bytes > 0:
|
||||||
|
artifacts.append(
|
||||||
|
ObservationArtifactCandidate(
|
||||||
|
artifact_id="raw-transport-index",
|
||||||
|
kind="raw-transport-index",
|
||||||
|
media_type="application/x-ndjson",
|
||||||
|
locator=metadata_path,
|
||||||
|
byte_length=_regular_file_size(metadata_path),
|
||||||
|
replay_byte_length=replay_metadata_bytes if replayable else 0,
|
||||||
|
sha256=None,
|
||||||
|
integrity_status=candidate.raw_integrity_status,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
media_sources = candidate.media_sources
|
||||||
|
artifacts.extend(
|
||||||
|
ObservationArtifactCandidate(
|
||||||
|
artifact_id=media.artifact_id,
|
||||||
|
kind="recorded-video",
|
||||||
|
media_type="video/mp4",
|
||||||
|
locator=media.locator,
|
||||||
|
byte_length=media.byte_length,
|
||||||
|
replay_byte_length=0,
|
||||||
|
sha256=None,
|
||||||
|
integrity_status="validated-structure",
|
||||||
|
)
|
||||||
|
for media in media_sources
|
||||||
|
)
|
||||||
|
|
||||||
|
sources: list[SessionSource] = []
|
||||||
|
modalities = candidate.modalities
|
||||||
|
if "point-cloud" in modalities:
|
||||||
|
sources.append(
|
||||||
|
SessionSource(
|
||||||
|
source_id="sensor.lidar.primary",
|
||||||
|
semantic_channel_id="spatial.point-cloud.recorded",
|
||||||
|
modality="point-cloud",
|
||||||
|
status="recorded",
|
||||||
|
seekable=replayable,
|
||||||
|
artifact_id="raw-transport-primary",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "trajectory" in modalities:
|
||||||
|
sources.append(
|
||||||
|
SessionSource(
|
||||||
|
source_id="spatial.trajectory",
|
||||||
|
semantic_channel_id="spatial.pose.recorded",
|
||||||
|
modality="trajectory",
|
||||||
|
status="recorded",
|
||||||
|
seekable=replayable,
|
||||||
|
artifact_id="raw-transport-primary",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sources.extend(
|
||||||
|
SessionSource(
|
||||||
|
source_id=media.source_id,
|
||||||
|
semantic_channel_id="camera.video.recorded",
|
||||||
|
modality="video",
|
||||||
|
status="recorded",
|
||||||
|
seekable=True,
|
||||||
|
artifact_id=media.artifact_id,
|
||||||
|
)
|
||||||
|
for media in media_sources
|
||||||
|
)
|
||||||
|
|
||||||
|
timeline_origin = _timeline_origin(metadata_path) if replayable else None
|
||||||
|
return ObservationSessionCandidate(
|
||||||
|
session_id=session_id,
|
||||||
|
display_name=candidate.display_name,
|
||||||
|
status=candidate.status,
|
||||||
|
started_at_utc=candidate.started_at_utc,
|
||||||
|
completed_at_utc=candidate.completed_at_utc,
|
||||||
|
duration_seconds=candidate.duration_seconds,
|
||||||
|
modalities=modalities,
|
||||||
|
replayable=replayable,
|
||||||
|
total_bytes=candidate.total_bytes,
|
||||||
|
allowed_root=candidate.allowed_root,
|
||||||
|
session_root=candidate.session_root,
|
||||||
|
primary_replay_artifact_id="raw-transport-primary" if replayable else None,
|
||||||
|
timeline_origin_epoch_ns=None if timeline_origin is None else timeline_origin[0],
|
||||||
|
timeline_origin_monotonic_ns=None if timeline_origin is None else timeline_origin[1],
|
||||||
|
sources=tuple(sources),
|
||||||
|
artifacts=tuple(artifacts),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _regular_file_size(path: Path) -> int:
|
||||||
|
try:
|
||||||
|
value = path.lstat()
|
||||||
|
except OSError as exc:
|
||||||
|
raise SessionIntegrityError("K1 transport index is unavailable") from exc
|
||||||
|
if not stat.S_ISREG(value.st_mode) or stat.S_ISLNK(value.st_mode):
|
||||||
|
raise SessionIntegrityError("K1 transport index is not a regular file")
|
||||||
|
return value.st_size
|
||||||
|
|
||||||
|
|
||||||
|
def _timeline_origin(path: Path) -> tuple[int, int]:
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
try:
|
||||||
|
descriptor = os.open(path, flags)
|
||||||
|
try:
|
||||||
|
payload = os.read(descriptor, MAX_TIMELINE_ORIGIN_LINE_BYTES + 1)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
first_line = payload.splitlines(keepends=True)[0]
|
||||||
|
if len(first_line) > MAX_TIMELINE_ORIGIN_LINE_BYTES or not first_line.endswith(
|
||||||
|
(b"\n", b"\r")
|
||||||
|
):
|
||||||
|
raise ValueError
|
||||||
|
document = json.loads(first_line)
|
||||||
|
epoch_ns = document.get("received_at_epoch_ns")
|
||||||
|
monotonic_ns = document.get("received_monotonic_ns")
|
||||||
|
if (
|
||||||
|
document.get("record_type") != "message"
|
||||||
|
or document.get("sequence") != 1
|
||||||
|
or not _non_negative_int(epoch_ns)
|
||||||
|
or not _non_negative_int(monotonic_ns)
|
||||||
|
):
|
||||||
|
raise ValueError
|
||||||
|
return int(epoch_ns), int(monotonic_ns)
|
||||||
|
except (IndexError, OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
|
||||||
|
raise SessionIntegrityError("K1 transport timeline origin is invalid") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _non_negative_int(value: object) -> bool:
|
||||||
|
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
||||||
|
|
||||||
|
|
||||||
|
def _export_recording(
|
||||||
|
source: Path,
|
||||||
|
destination: Path,
|
||||||
|
*,
|
||||||
|
cancel_event: threading.Event | None = None,
|
||||||
|
activity_callback: object | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
return dict(
|
||||||
|
export_k1mqtt_to_rrd(
|
||||||
|
source,
|
||||||
|
destination,
|
||||||
|
cancel_event=cancel_event,
|
||||||
|
activity_callback=activity_callback if callable(activity_callback) else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except RrdExportCancelled as exc:
|
||||||
|
raise PluginRecordingExportCancelled("K1 recording export was cancelled") from exc
|
||||||
|
except RrdExportError as exc:
|
||||||
|
raise PluginRecordingExportError("K1 recording export failed") from exc
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Verified protocol decoders for captured K1 application streams."""
|
"""Verified protocol decoders for captured K1 application streams."""
|
||||||
|
|
||||||
from k1link.protocol.streams import (
|
from k1link.device_plugins.xgrids_k1.protocol.streams import (
|
||||||
DecodeLimits,
|
DecodeLimits,
|
||||||
LegacyPoint,
|
LegacyPoint,
|
||||||
LegacyPointCloudFrame,
|
LegacyPointCloudFrame,
|
||||||
|
|
@ -9,7 +9,7 @@ from k1link.data_plane import (
|
||||||
DecodedPoseView,
|
DecodedPoseView,
|
||||||
NormalizationError,
|
NormalizationError,
|
||||||
)
|
)
|
||||||
from k1link.protocol.streams import (
|
from k1link.device_plugins.xgrids_k1.protocol.streams import (
|
||||||
LegacyPointCloudFrame,
|
LegacyPointCloudFrame,
|
||||||
LegacyPoseFrame,
|
LegacyPoseFrame,
|
||||||
LioPointCloudFrame,
|
LioPointCloudFrame,
|
||||||
|
|
@ -7,7 +7,7 @@ from typing import NamedTuple
|
||||||
|
|
||||||
import lz4.block
|
import lz4.block
|
||||||
|
|
||||||
from k1link.protocol.protobuf_wire import (
|
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
|
||||||
ProtobufWireError,
|
ProtobufWireError,
|
||||||
ProtoField,
|
ProtoField,
|
||||||
decode_zigzag64,
|
decode_zigzag64,
|
||||||
|
|
@ -16,8 +16,12 @@ import rerun as rr
|
||||||
from rerun import blueprint as rrb
|
from rerun import blueprint as rrb
|
||||||
|
|
||||||
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError
|
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError
|
||||||
from k1link.protocol.normalizer import normalize_k1_message
|
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
|
||||||
from k1link.viewer.replay import ReplayFormatError, detect_replay_format, iter_replay_messages
|
from k1link.device_plugins.xgrids_k1.viewer.replay import (
|
||||||
|
ReplayFormatError,
|
||||||
|
detect_replay_format,
|
||||||
|
iter_replay_messages,
|
||||||
|
)
|
||||||
from k1link.viewer.rerun_bridge import (
|
from k1link.viewer.rerun_bridge import (
|
||||||
MAX_TRAJECTORY_POSES,
|
MAX_TRAJECTORY_POSES,
|
||||||
TRAJECTORY_APPEND_INTERVAL_NS,
|
TRAJECTORY_APPEND_INTERVAL_NS,
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
"""K1-native capture, replay, and compatibility visualization runtime."""
|
||||||
|
|
||||||
|
from .messages import StreamMessage
|
||||||
|
from .replay import ReplayFormatError, detect_replay_format, iter_replay_messages
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ReplayFormatError",
|
||||||
|
"StreamMessage",
|
||||||
|
"detect_replay_format",
|
||||||
|
"iter_replay_messages",
|
||||||
|
]
|
||||||
|
|
@ -29,7 +29,7 @@ from foxglove.messages import (
|
||||||
Vector3,
|
Vector3,
|
||||||
)
|
)
|
||||||
|
|
||||||
from k1link.protocol.streams import (
|
from k1link.device_plugins.xgrids_k1.protocol.streams import (
|
||||||
LegacyPointCloudFrame,
|
LegacyPointCloudFrame,
|
||||||
LegacyPoseFrame,
|
LegacyPoseFrame,
|
||||||
LioPointCloudFrame,
|
LioPointCloudFrame,
|
||||||
|
|
@ -40,7 +40,7 @@ from k1link.protocol.streams import (
|
||||||
decode_lio_pcl,
|
decode_lio_pcl,
|
||||||
decode_lio_pose,
|
decode_lio_pose,
|
||||||
)
|
)
|
||||||
from k1link.viewer.messages import StreamMessage
|
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
|
||||||
from k1link.viewer.metrics import BridgeMetrics
|
from k1link.viewer.metrics import BridgeMetrics
|
||||||
|
|
||||||
POINT_STRUCT = struct.Struct("<fffB3x")
|
POINT_STRUCT = struct.Struct("<fffB3x")
|
||||||
|
|
@ -7,9 +7,9 @@ from decimal import Decimal, InvalidOperation
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import IO, Literal
|
from typing import IO, Literal
|
||||||
|
|
||||||
from k1link.mqtt import CaptureFormatError, iter_capture_frames
|
from k1link.device_plugins.xgrids_k1.mqtt import CaptureFormatError, iter_capture_frames
|
||||||
from k1link.mqtt.capture import RAW_MAGIC
|
from k1link.device_plugins.xgrids_k1.mqtt.capture import RAW_MAGIC
|
||||||
from k1link.viewer.messages import StreamMessage
|
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
|
||||||
|
|
||||||
MAX_REPLAY_PAYLOAD_BYTES = 2 * 1024 * 1024
|
MAX_REPLAY_PAYLOAD_BYTES = 2 * 1024 * 1024
|
||||||
MAX_LEGACY_LINE_BYTES = MAX_REPLAY_PAYLOAD_BYTES * 2 + 64 * 1024
|
MAX_LEGACY_LINE_BYTES = MAX_REPLAY_PAYLOAD_BYTES * 2 + 64 * 1024
|
||||||
|
|
@ -11,10 +11,10 @@ from typing import Literal, Protocol, TypedDict
|
||||||
|
|
||||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||||
from k1link.data_plane import DecodedDataPlaneView, NormalizationError
|
from k1link.data_plane import DecodedDataPlaneView, NormalizationError
|
||||||
from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
|
from k1link.device_plugins.xgrids_k1.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
|
||||||
from k1link.viewer.messages import StreamMessage
|
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
|
||||||
|
from k1link.device_plugins.xgrids_k1.viewer.replay import iter_replay_messages
|
||||||
from k1link.viewer.metrics import BridgeMetrics, MetricsSnapshot
|
from k1link.viewer.metrics import BridgeMetrics, MetricsSnapshot
|
||||||
from k1link.viewer.replay import iter_replay_messages
|
|
||||||
from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings
|
from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings
|
||||||
|
|
||||||
RuntimePhase = Literal[
|
RuntimePhase = Literal[
|
||||||
|
|
@ -14,12 +14,22 @@ from .media import (
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
LayoutConflictError,
|
LayoutConflictError,
|
||||||
|
ObservationArtifactCandidate,
|
||||||
|
ObservationSessionCandidate,
|
||||||
RecordedMediaArtifact,
|
RecordedMediaArtifact,
|
||||||
|
ReplayArtifact,
|
||||||
ReplayCommand,
|
ReplayCommand,
|
||||||
SessionIntegrityError,
|
SessionIntegrityError,
|
||||||
SessionNotFoundError,
|
SessionNotFoundError,
|
||||||
SessionNotReplayableError,
|
SessionNotReplayableError,
|
||||||
)
|
)
|
||||||
|
from .plugin_contract import (
|
||||||
|
ObservationArchiveSource,
|
||||||
|
ObservationRuntimeContribution,
|
||||||
|
PluginRecordingExportCancelled,
|
||||||
|
PluginRecordingExportError,
|
||||||
|
RecordingExporter,
|
||||||
|
)
|
||||||
from .preparation import (
|
from .preparation import (
|
||||||
RecordingPreparationQueueFull,
|
RecordingPreparationQueueFull,
|
||||||
RecordingPreparationSnapshot,
|
RecordingPreparationSnapshot,
|
||||||
|
|
@ -42,6 +52,12 @@ __all__ = [
|
||||||
"ActiveSessionLease",
|
"ActiveSessionLease",
|
||||||
"ActiveSessionLeaseError",
|
"ActiveSessionLeaseError",
|
||||||
"MaterializedRecording",
|
"MaterializedRecording",
|
||||||
|
"ObservationArchiveSource",
|
||||||
|
"ObservationArtifactCandidate",
|
||||||
|
"ObservationRuntimeContribution",
|
||||||
|
"ObservationSessionCandidate",
|
||||||
|
"PluginRecordingExportCancelled",
|
||||||
|
"PluginRecordingExportError",
|
||||||
"RecordingMaterializationCancelled",
|
"RecordingMaterializationCancelled",
|
||||||
"RecordedMediaArtifact",
|
"RecordedMediaArtifact",
|
||||||
"RECORDED_MEDIA_MANIFEST_SCHEMA",
|
"RECORDED_MEDIA_MANIFEST_SCHEMA",
|
||||||
|
|
@ -49,6 +65,8 @@ __all__ = [
|
||||||
"RecordedMediaInspector",
|
"RecordedMediaInspector",
|
||||||
"RecordedMediaManifest",
|
"RecordedMediaManifest",
|
||||||
"ReplayCommand",
|
"ReplayCommand",
|
||||||
|
"ReplayArtifact",
|
||||||
|
"RecordingExporter",
|
||||||
"RecordingMaterializationError",
|
"RecordingMaterializationError",
|
||||||
"RecordingPreparationQueueFull",
|
"RecordingPreparationQueueFull",
|
||||||
"RecordingPreparationSnapshot",
|
"RecordingPreparationSnapshot",
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, TypeGuard
|
from typing import Any, TypeGuard
|
||||||
|
|
||||||
from k1link.viewer.replay import MAX_METADATA_LINE_CHARS
|
|
||||||
|
|
||||||
from .models import RecordedMediaArtifact, ReplayCommand, SessionIntegrityError
|
from .models import RecordedMediaArtifact, ReplayCommand, SessionIntegrityError
|
||||||
|
|
||||||
CAMERA_ARCHIVE_SCHEMA = "missioncore.camera-recording/v1"
|
CAMERA_ARCHIVE_SCHEMA = "missioncore.camera-recording/v1"
|
||||||
|
|
@ -139,7 +137,7 @@ class RecordedMediaInspector:
|
||||||
cached = self._cache.get(key)
|
cached = self._cache.get(key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
identity = _prepared_source_identity(
|
identity = _prepared_source_identity(
|
||||||
replay.source_path,
|
replay,
|
||||||
epoch_paths,
|
epoch_paths,
|
||||||
cached.manifest.epochs,
|
cached.manifest.epochs,
|
||||||
)
|
)
|
||||||
|
|
@ -151,15 +149,14 @@ class RecordedMediaInspector:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._cache[key] = _CachedManifest(identity=identity, manifest=manifest)
|
self._cache[key] = _CachedManifest(identity=identity, manifest=manifest)
|
||||||
return manifest
|
return manifest
|
||||||
origin_epoch_ns, origin_monotonic_ns = _raw_timeline_origin(replay.source_path)
|
|
||||||
manifest = _read_manifest(
|
manifest = _read_manifest(
|
||||||
artifact,
|
artifact,
|
||||||
epoch_paths,
|
epoch_paths,
|
||||||
origin_epoch_ns=origin_epoch_ns,
|
origin_epoch_ns=replay.timeline_origin_epoch_ns,
|
||||||
origin_monotonic_ns=origin_monotonic_ns,
|
origin_monotonic_ns=replay.timeline_origin_monotonic_ns,
|
||||||
)
|
)
|
||||||
identity = _prepared_source_identity(
|
identity = _prepared_source_identity(
|
||||||
replay.source_path,
|
replay,
|
||||||
epoch_paths,
|
epoch_paths,
|
||||||
manifest.epochs,
|
manifest.epochs,
|
||||||
)
|
)
|
||||||
|
|
@ -186,7 +183,7 @@ class RecordedMediaInspector:
|
||||||
document = _decode_prepared_sidecar(payload)
|
document = _decode_prepared_sidecar(payload)
|
||||||
manifest = _manifest_from_sidecar(document, artifact, epoch_paths)
|
manifest = _manifest_from_sidecar(document, artifact, epoch_paths)
|
||||||
identity = _prepared_source_identity(
|
identity = _prepared_source_identity(
|
||||||
replay.source_path,
|
replay,
|
||||||
epoch_paths,
|
epoch_paths,
|
||||||
manifest.epochs,
|
manifest.epochs,
|
||||||
)
|
)
|
||||||
|
|
@ -631,15 +628,15 @@ def _epoch_paths(source_path: Path) -> tuple[Path, ...]:
|
||||||
|
|
||||||
|
|
||||||
def _prepared_source_identity(
|
def _prepared_source_identity(
|
||||||
raw_path: Path,
|
replay: ReplayCommand,
|
||||||
epoch_paths: tuple[Path, ...],
|
epoch_paths: tuple[Path, ...],
|
||||||
epochs: tuple[RecordedMediaEpoch, ...],
|
epochs: tuple[RecordedMediaEpoch, ...],
|
||||||
) -> tuple[tuple[int, int, int, int], ...]:
|
) -> tuple[tuple[int, int, int, int], ...]:
|
||||||
if len(epoch_paths) != len(epochs):
|
if len(epoch_paths) != len(epochs):
|
||||||
raise SessionIntegrityError("recorded media epoch identity is inconsistent")
|
raise SessionIntegrityError("recorded media epoch identity is inconsistent")
|
||||||
identity: list[tuple[int, int, int, int]] = []
|
identity: list[tuple[int, int, int, int]] = []
|
||||||
for source_file in (raw_path, raw_path.with_name("mqtt.metadata.jsonl")):
|
for artifact in replay.artifacts:
|
||||||
metadata = _confined_file_stat(source_file, raw_path.parent)
|
metadata = _session_artifact_stat(artifact.path, replay.session_root)
|
||||||
identity.append(
|
identity.append(
|
||||||
(metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns)
|
(metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns)
|
||||||
)
|
)
|
||||||
|
|
@ -671,32 +668,15 @@ def _prepared_source_identity(
|
||||||
return tuple(identity)
|
return tuple(identity)
|
||||||
|
|
||||||
|
|
||||||
def _raw_timeline_origin(raw_path: Path) -> tuple[int, int]:
|
def _session_artifact_stat(path: Path, session_root: Path) -> os.stat_result:
|
||||||
metadata_path = raw_path.with_name("mqtt.metadata.jsonl")
|
|
||||||
try:
|
try:
|
||||||
line = _read_first_confined_line(
|
session = session_root.resolve(strict=True)
|
||||||
metadata_path,
|
parent = path.parent.resolve(strict=True)
|
||||||
raw_path.parent,
|
except OSError as exc:
|
||||||
MAX_METADATA_LINE_CHARS,
|
raise SessionIntegrityError("recording source artifact is missing") from exc
|
||||||
).decode("utf-8")
|
if not parent.is_relative_to(session):
|
||||||
except (OSError, UnicodeDecodeError) as exc:
|
raise SessionIntegrityError("recording source artifact escapes its session")
|
||||||
raise SessionIntegrityError("native capture timing metadata is unavailable") from exc
|
return _confined_file_stat(path, parent)
|
||||||
if not line or len(line) > MAX_METADATA_LINE_CHARS or not line.endswith(("\n", "\r")):
|
|
||||||
raise SessionIntegrityError("native capture timing origin is incomplete")
|
|
||||||
try:
|
|
||||||
record = json.loads(line)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise SessionIntegrityError("native capture timing origin is invalid") from exc
|
|
||||||
epoch_ns = record.get("received_at_epoch_ns") if isinstance(record, dict) else None
|
|
||||||
monotonic_ns = record.get("received_monotonic_ns") if isinstance(record, dict) else None
|
|
||||||
if (
|
|
||||||
record.get("record_type") != "message"
|
|
||||||
or record.get("sequence") != 1
|
|
||||||
or not _non_negative_int(epoch_ns)
|
|
||||||
or not _non_negative_int(monotonic_ns)
|
|
||||||
):
|
|
||||||
raise SessionIntegrityError("native capture timing origin is invalid")
|
|
||||||
return int(epoch_ns), int(monotonic_ns)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_manifest(
|
def _read_manifest(
|
||||||
|
|
|
||||||
|
|
@ -160,20 +160,44 @@ class WorkspaceLayout:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReplayArtifact:
|
||||||
|
"""One confined input artifact selected for plugin-owned preparation."""
|
||||||
|
|
||||||
|
artifact_id: str
|
||||||
|
path: Path
|
||||||
|
media_type: str
|
||||||
|
file_byte_length: int
|
||||||
|
replay_byte_length: int
|
||||||
|
expected_sha256: str | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ReplayCommand:
|
class ReplayCommand:
|
||||||
"""Internal-only replay command. ``source_path`` never enters an API DTO."""
|
"""Internal replay request containing no vendor format or channel names."""
|
||||||
|
|
||||||
session_id: str
|
session_id: str
|
||||||
source_path: Path
|
plugin_id: str
|
||||||
allowed_root: Path
|
allowed_root: Path
|
||||||
session_root: Path
|
session_root: Path
|
||||||
replay_byte_length: int
|
primary_artifact_id: str
|
||||||
metadata_byte_length: int
|
artifacts: tuple[ReplayArtifact, ...]
|
||||||
expected_source_sha256: str | None
|
timeline_origin_epoch_ns: int
|
||||||
|
timeline_origin_monotonic_ns: int
|
||||||
speed: float
|
speed: float
|
||||||
loop: bool
|
loop: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_artifact(self) -> ReplayArtifact:
|
||||||
|
matches = tuple(
|
||||||
|
artifact
|
||||||
|
for artifact in self.artifacts
|
||||||
|
if artifact.artifact_id == self.primary_artifact_id
|
||||||
|
)
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise SessionIntegrityError("replay command has no unique primary artifact")
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class RecordedMediaArtifact:
|
class RecordedMediaArtifact:
|
||||||
|
|
@ -191,7 +215,19 @@ class RecordedMediaArtifact:
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class LegacySessionCandidate:
|
class ObservationArtifactCandidate:
|
||||||
|
artifact_id: str
|
||||||
|
kind: str
|
||||||
|
media_type: str
|
||||||
|
locator: Path
|
||||||
|
byte_length: int
|
||||||
|
replay_byte_length: int
|
||||||
|
sha256: str | None
|
||||||
|
integrity_status: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ObservationSessionCandidate:
|
||||||
session_id: str
|
session_id: str
|
||||||
display_name: str
|
display_name: str
|
||||||
status: SessionStatus
|
status: SessionStatus
|
||||||
|
|
@ -203,13 +239,11 @@ class LegacySessionCandidate:
|
||||||
total_bytes: int
|
total_bytes: int
|
||||||
allowed_root: Path
|
allowed_root: Path
|
||||||
session_root: Path
|
session_root: Path
|
||||||
raw_path: Path
|
primary_replay_artifact_id: str | None
|
||||||
raw_byte_length: int
|
timeline_origin_epoch_ns: int | None
|
||||||
replay_raw_byte_length: int
|
timeline_origin_monotonic_ns: int | None
|
||||||
replay_metadata_byte_length: int
|
sources: tuple[SessionSource, ...]
|
||||||
raw_sha256: str | None
|
artifacts: tuple[ObservationArtifactCandidate, ...]
|
||||||
raw_integrity_status: str
|
|
||||||
media_sources: tuple[LegacyMediaSourceCandidate, ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Host-side runtime ABI for observation-capable device plugins.
|
||||||
|
|
||||||
|
The contract deliberately contains no transport name, vendor topic, capture
|
||||||
|
suffix, codec, or viewer implementation. Concrete plugins discover native
|
||||||
|
evidence and export it into the host's canonical recorded-viewer artifact.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from .models import ObservationSessionCandidate
|
||||||
|
|
||||||
|
RecordingProgressPulse = Callable[[], None]
|
||||||
|
RecordingExportResult = Mapping[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRecordingExportError(RuntimeError):
|
||||||
|
"""A plugin rejected or failed to convert one native recording."""
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRecordingExportCancelled(PluginRecordingExportError):
|
||||||
|
"""A plugin cooperatively stopped a recording conversion."""
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingExporter(Protocol):
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
source: Path,
|
||||||
|
destination: Path,
|
||||||
|
*,
|
||||||
|
cancel_event: threading.Event | None = None,
|
||||||
|
activity_callback: RecordingProgressPulse | None = None,
|
||||||
|
) -> RecordingExportResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
ObservationArchiveDiscovery = Callable[
|
||||||
|
[Path],
|
||||||
|
tuple[ObservationSessionCandidate, ...],
|
||||||
|
]
|
||||||
|
ObservationArchiveRecovery = Callable[[Path], object]
|
||||||
|
|
||||||
|
|
||||||
|
def _no_recovery(_: Path) -> object:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ObservationArchiveSource:
|
||||||
|
"""One plugin-owned evidence namespace reconciled by the host catalog."""
|
||||||
|
|
||||||
|
plugin_id: str
|
||||||
|
archive_id: str
|
||||||
|
root: Path
|
||||||
|
discover: ObservationArchiveDiscovery
|
||||||
|
recover: ObservationArchiveRecovery = _no_recovery
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ObservationRuntimeContribution:
|
||||||
|
"""Optional observation capabilities contributed by a device plugin."""
|
||||||
|
|
||||||
|
archives: tuple[ObservationArchiveSource, ...]
|
||||||
|
recording_exporter: RecordingExporter
|
||||||
|
|
@ -584,12 +584,20 @@ def _source_identity(command: ReplayCommand) -> tuple[object, ...]:
|
||||||
|
|
||||||
identities: list[object] = [
|
identities: list[object] = [
|
||||||
command.session_id,
|
command.session_id,
|
||||||
str(command.source_path),
|
command.plugin_id,
|
||||||
command.replay_byte_length,
|
command.primary_artifact_id,
|
||||||
command.metadata_byte_length,
|
|
||||||
command.expected_source_sha256,
|
|
||||||
]
|
]
|
||||||
for path in (command.source_path, command.source_path.with_name("mqtt.metadata.jsonl")):
|
for artifact in command.artifacts:
|
||||||
|
path = artifact.path
|
||||||
|
identities.extend(
|
||||||
|
(
|
||||||
|
artifact.artifact_id,
|
||||||
|
artifact.media_type,
|
||||||
|
artifact.file_byte_length,
|
||||||
|
artifact.replay_byte_length,
|
||||||
|
artifact.expected_sha256,
|
||||||
|
)
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
value = os.lstat(path)
|
value = os.lstat(path)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,17 @@ from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from k1link.viewer.rrd_export import (
|
from .models import ReplayArtifact, ReplayCommand
|
||||||
RrdExportCancelled,
|
from .plugin_contract import (
|
||||||
RrdExportError,
|
PluginRecordingExportCancelled,
|
||||||
export_k1mqtt_to_rrd,
|
PluginRecordingExportError,
|
||||||
|
RecordingExporter,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .models import ReplayCommand
|
|
||||||
|
|
||||||
# v6 adds a real session_time=0 row to the RRD itself. Older sidecars can
|
# v6 adds a real session_time=0 row to the RRD itself. Older sidecars can
|
||||||
# declare a zero start while their payload begins at the first decoded sensor
|
# declare a zero start while their payload begins at the first decoded sensor
|
||||||
# frame, so accepting them would violate the browser playback contract.
|
# frame, so accepting them would violate the browser playback contract.
|
||||||
CACHE_SCHEMA = "missioncore.derived-rerun-recording-cache/v6"
|
CACHE_SCHEMA = "missioncore.derived-rerun-recording-cache/v7"
|
||||||
COMPATIBLE_CACHE_SCHEMAS = frozenset({CACHE_SCHEMA})
|
COMPATIBLE_CACHE_SCHEMAS = frozenset({CACHE_SCHEMA})
|
||||||
RERUN_RECORDING_MEDIA_TYPE = "application/vnd.rerun.rrd"
|
RERUN_RECORDING_MEDIA_TYPE = "application/vnd.rerun.rrd"
|
||||||
RERUN_SESSION_TIMELINE = "session_time"
|
RERUN_SESSION_TIMELINE = "session_time"
|
||||||
|
|
@ -37,7 +36,6 @@ DEFAULT_FREE_SPACE_RESERVE_BYTES = 2 * 1024 * 1024 * 1024
|
||||||
|
|
||||||
RrdExporter = Callable[..., Mapping[str, object]]
|
RrdExporter = Callable[..., Mapping[str, object]]
|
||||||
RecordingProgressCallback = Callable[[str, float], None]
|
RecordingProgressCallback = Callable[[str, float], None]
|
||||||
DEFAULT_RRD_EXPORTER = cast(RrdExporter, export_k1mqtt_to_rrd)
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingMaterializationError(RuntimeError):
|
class RecordingMaterializationError(RuntimeError):
|
||||||
|
|
@ -71,29 +69,58 @@ class _ValidatedMemoryEntry:
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class _ValidatedSource:
|
class _ValidatedArtifact:
|
||||||
source: Path
|
artifact_id: str
|
||||||
metadata: Path
|
path: Path
|
||||||
source_stat: os.stat_result
|
media_type: str
|
||||||
metadata_stat: os.stat_result
|
file_stat: os.stat_result
|
||||||
replay_byte_length: int
|
replay_byte_length: int
|
||||||
metadata_byte_length: int
|
expected_sha256: str | None
|
||||||
expected_source_sha256: str | None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def identity(self) -> tuple[object, ...]:
|
def identity(self) -> tuple[object, ...]:
|
||||||
return (
|
return (
|
||||||
*_stat_identity(self.source_stat),
|
self.artifact_id,
|
||||||
*_stat_identity(self.metadata_stat),
|
self.media_type,
|
||||||
|
*_stat_identity(self.file_stat),
|
||||||
self.replay_byte_length,
|
self.replay_byte_length,
|
||||||
self.metadata_byte_length,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _ValidatedSource:
|
||||||
|
plugin_id: str
|
||||||
|
primary_artifact_id: str
|
||||||
|
artifacts: tuple[_ValidatedArtifact, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary(self) -> _ValidatedArtifact:
|
||||||
|
matches = tuple(
|
||||||
|
artifact
|
||||||
|
for artifact in self.artifacts
|
||||||
|
if artifact.artifact_id == self.primary_artifact_id
|
||||||
|
)
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise RecordingMaterializationError("recording source has no primary artifact")
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity(self) -> tuple[object, ...]:
|
||||||
|
return (
|
||||||
|
self.plugin_id,
|
||||||
|
self.primary_artifact_id,
|
||||||
|
*(item for artifact in self.artifacts for item in artifact.identity),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def replay_byte_length(self) -> int:
|
||||||
|
return sum(artifact.replay_byte_length for artifact in self.artifacts)
|
||||||
|
|
||||||
|
|
||||||
class SessionRecordingMaterializer:
|
class SessionRecordingMaterializer:
|
||||||
"""Build and validate a per-session seekable RRD under the private data root.
|
"""Build and validate a per-session seekable RRD under the private data root.
|
||||||
|
|
||||||
The native ``.k1mqtt`` capture remains the source of record. Derived RRDs
|
Native plugin evidence remains the source of record. Derived RRDs
|
||||||
live below ``data_dir/recordings`` and are reused only when both their
|
live below ``data_dir/recordings`` and are reused only when both their
|
||||||
source identity and output digest still match an atomically written cache
|
source identity and output digest still match an atomically written cache
|
||||||
sidecar. Calls for one session are serialized, so concurrent browser
|
sidecar. Calls for one session are serialized, so concurrent browser
|
||||||
|
|
@ -104,7 +131,8 @@ class SessionRecordingMaterializer:
|
||||||
self,
|
self,
|
||||||
data_dir: Path,
|
data_dir: Path,
|
||||||
*,
|
*,
|
||||||
exporter: RrdExporter = DEFAULT_RRD_EXPORTER,
|
exporter: RrdExporter | None = None,
|
||||||
|
exporters: Mapping[str, RecordingExporter] | None = None,
|
||||||
cache_max_bytes: int | None = None,
|
cache_max_bytes: int | None = None,
|
||||||
free_space_reserve_bytes: int | None = None,
|
free_space_reserve_bytes: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -119,12 +147,12 @@ class SessionRecordingMaterializer:
|
||||||
self.recordings_root = recordings_root.resolve()
|
self.recordings_root = recordings_root.resolve()
|
||||||
if not self.recordings_root.is_relative_to(private_root):
|
if not self.recordings_root.is_relative_to(private_root):
|
||||||
raise RecordingMaterializationError("recording cache escapes the private data root")
|
raise RecordingMaterializationError("recording cache escapes the private data root")
|
||||||
self._exporter = exporter
|
if exporter is not None and exporters is not None:
|
||||||
self._exporter_accepts_cancel = _callable_accepts_keyword(exporter, "cancel_event")
|
raise ValueError("configure either one test exporter or plugin exporters")
|
||||||
self._exporter_accepts_activity = _callable_accepts_keyword(
|
self._fallback_exporter = exporter
|
||||||
exporter,
|
self._exporters = dict(exporters or {})
|
||||||
"activity_callback",
|
if len(self._exporters) != len(set(self._exporters)):
|
||||||
)
|
raise ValueError("recording exporter plugin ids must be unique")
|
||||||
self.cache_max_bytes = _positive_configuration(
|
self.cache_max_bytes = _positive_configuration(
|
||||||
cache_max_bytes,
|
cache_max_bytes,
|
||||||
environment_name="MISSIONCORE_RRD_CACHE_MAX_BYTES",
|
environment_name="MISSIONCORE_RRD_CACHE_MAX_BYTES",
|
||||||
|
|
@ -152,7 +180,12 @@ class SessionRecordingMaterializer:
|
||||||
def supports_cooperative_cancellation(self) -> bool:
|
def supports_cooperative_cancellation(self) -> bool:
|
||||||
"""Whether the configured exporter observes a cancellation event."""
|
"""Whether the configured exporter observes a cancellation event."""
|
||||||
|
|
||||||
return self._exporter_accepts_cancel
|
exporters: tuple[Callable[..., object], ...] = tuple(self._exporters.values())
|
||||||
|
if self._fallback_exporter is not None:
|
||||||
|
exporters = (*exporters, self._fallback_exporter)
|
||||||
|
return bool(exporters) and all(
|
||||||
|
_callable_accepts_keyword(exporter, "cancel_event") for exporter in exporters
|
||||||
|
)
|
||||||
|
|
||||||
def is_recording_available(self, recording: MaterializedRecording) -> bool:
|
def is_recording_available(self, recording: MaterializedRecording) -> bool:
|
||||||
"""Cheap no-follow check for a previously validated ready handle."""
|
"""Cheap no-follow check for a previously validated ready handle."""
|
||||||
|
|
@ -463,11 +496,11 @@ class SessionRecordingMaterializer:
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
staged_root: Path | None = None
|
staged_root: Path | None = None
|
||||||
export_source = source.source
|
export_source = source.primary.path
|
||||||
try:
|
try:
|
||||||
if (
|
if any(
|
||||||
source.replay_byte_length != source.source_stat.st_size
|
artifact.replay_byte_length != artifact.file_stat.st_size
|
||||||
or source.metadata_byte_length != source.metadata_stat.st_size
|
for artifact in source.artifacts
|
||||||
):
|
):
|
||||||
staged_root, export_source = _stage_replay_prefix(
|
staged_root, export_source = _stage_replay_prefix(
|
||||||
resolved_session_root,
|
resolved_session_root,
|
||||||
|
|
@ -480,6 +513,7 @@ class SessionRecordingMaterializer:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
summary = self._invoke_exporter(
|
summary = self._invoke_exporter(
|
||||||
|
source.plugin_id,
|
||||||
export_source,
|
export_source,
|
||||||
candidate_path,
|
candidate_path,
|
||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
|
|
@ -491,12 +525,12 @@ class SessionRecordingMaterializer:
|
||||||
)
|
)
|
||||||
_raise_if_cancelled(cancel_event)
|
_raise_if_cancelled(cancel_event)
|
||||||
_report_progress(progress_callback, "finalizing", 0.9)
|
_report_progress(progress_callback, "finalizing", 0.9)
|
||||||
except RrdExportCancelled as exc:
|
except PluginRecordingExportCancelled as exc:
|
||||||
candidate_path.unlink(missing_ok=True)
|
candidate_path.unlink(missing_ok=True)
|
||||||
raise RecordingMaterializationCancelled(
|
raise RecordingMaterializationCancelled(
|
||||||
"recording preparation was cancelled"
|
"recording preparation was cancelled"
|
||||||
) from exc
|
) from exc
|
||||||
except RrdExportError as exc:
|
except PluginRecordingExportError as exc:
|
||||||
candidate_path.unlink(missing_ok=True)
|
candidate_path.unlink(missing_ok=True)
|
||||||
raise RecordingMaterializationError("native capture could not be exported") from exc
|
raise RecordingMaterializationError("native capture could not be exported") from exc
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
|
|
@ -515,13 +549,13 @@ class SessionRecordingMaterializer:
|
||||||
raise RecordingMaterializationError("native capture changed during RRD export")
|
raise RecordingMaterializationError("native capture changed during RRD export")
|
||||||
_chmod_best_effort(candidate_path, 0o600)
|
_chmod_best_effort(candidate_path, 0o600)
|
||||||
source_sha256 = _sha256_prefix_stable(
|
source_sha256 = _sha256_prefix_stable(
|
||||||
source.source,
|
source.primary.path,
|
||||||
source.source_stat,
|
source.primary.file_stat,
|
||||||
source.replay_byte_length,
|
source.primary.replay_byte_length,
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
source.expected_source_sha256 is not None
|
source.primary.expected_sha256 is not None
|
||||||
and source_sha256 != source.expected_source_sha256
|
and source_sha256 != source.primary.expected_sha256
|
||||||
):
|
):
|
||||||
raise RecordingMaterializationError(
|
raise RecordingMaterializationError(
|
||||||
"native capture digest no longer matches catalog"
|
"native capture digest no longer matches catalog"
|
||||||
|
|
@ -574,18 +608,25 @@ class SessionRecordingMaterializer:
|
||||||
|
|
||||||
def _invoke_exporter(
|
def _invoke_exporter(
|
||||||
self,
|
self,
|
||||||
|
plugin_id: str,
|
||||||
source: Path,
|
source: Path,
|
||||||
destination: Path,
|
destination: Path,
|
||||||
*,
|
*,
|
||||||
cancel_event: threading.Event | None,
|
cancel_event: threading.Event | None,
|
||||||
activity_callback: Callable[[], None],
|
activity_callback: Callable[[], None],
|
||||||
) -> Mapping[str, object]:
|
) -> Mapping[str, object]:
|
||||||
|
selected = self._fallback_exporter or self._exporters.get(plugin_id)
|
||||||
|
if selected is None:
|
||||||
|
raise PluginRecordingExportError(
|
||||||
|
f"device plugin has no recording exporter: {plugin_id}"
|
||||||
|
)
|
||||||
|
exporter = cast(RrdExporter, selected)
|
||||||
kwargs: dict[str, object] = {}
|
kwargs: dict[str, object] = {}
|
||||||
if self._exporter_accepts_cancel:
|
if _callable_accepts_keyword(exporter, "cancel_event"):
|
||||||
kwargs["cancel_event"] = cancel_event
|
kwargs["cancel_event"] = cancel_event
|
||||||
if self._exporter_accepts_activity:
|
if _callable_accepts_keyword(exporter, "activity_callback"):
|
||||||
kwargs["activity_callback"] = activity_callback
|
kwargs["activity_callback"] = activity_callback
|
||||||
return self._exporter(source, destination, **kwargs)
|
return exporter(source, destination, **kwargs)
|
||||||
|
|
||||||
def _load_memory_cache(
|
def _load_memory_cache(
|
||||||
self,
|
self,
|
||||||
|
|
@ -639,21 +680,25 @@ class SessionRecordingMaterializer:
|
||||||
except (OSError, ValueError, json.JSONDecodeError, RecordingMaterializationError):
|
except (OSError, ValueError, json.JSONDecodeError, RecordingMaterializationError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if document["source_file_byte_length"] != source.source_stat.st_size:
|
if document["plugin_id"] != source.plugin_id:
|
||||||
return None
|
return None
|
||||||
if document["source_mtime_ns"] != source.source_stat.st_mtime_ns:
|
if document["primary_artifact_id"] != source.primary_artifact_id:
|
||||||
return None
|
return None
|
||||||
if document["source_ctime_ns"] != source.source_stat.st_ctime_ns:
|
cached_artifacts = document["source_artifacts"]
|
||||||
|
if len(cached_artifacts) != len(source.artifacts):
|
||||||
return None
|
return None
|
||||||
if document["source_replay_byte_length"] != source.replay_byte_length:
|
for cached, artifact in zip(cached_artifacts, source.artifacts, strict=True):
|
||||||
|
if cached["artifact_id"] != artifact.artifact_id:
|
||||||
return None
|
return None
|
||||||
if document["metadata_file_byte_length"] != source.metadata_stat.st_size:
|
if cached["media_type"] != artifact.media_type:
|
||||||
return None
|
return None
|
||||||
if document["metadata_mtime_ns"] != source.metadata_stat.st_mtime_ns:
|
if cached["file_byte_length"] != artifact.file_stat.st_size:
|
||||||
return None
|
return None
|
||||||
if document["metadata_ctime_ns"] != source.metadata_stat.st_ctime_ns:
|
if cached["mtime_ns"] != artifact.file_stat.st_mtime_ns:
|
||||||
return None
|
return None
|
||||||
if document["metadata_replay_byte_length"] != source.metadata_byte_length:
|
if cached["ctime_ns"] != artifact.file_stat.st_ctime_ns:
|
||||||
|
return None
|
||||||
|
if cached["replay_byte_length"] != artifact.replay_byte_length:
|
||||||
return None
|
return None
|
||||||
if document["recording_byte_length"] != recording_stat.st_size:
|
if document["recording_byte_length"] != recording_stat.st_size:
|
||||||
return None
|
return None
|
||||||
|
|
@ -661,25 +706,26 @@ class SessionRecordingMaterializer:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
source_sha256 = _sha256_prefix_stable(
|
source_sha256 = _sha256_prefix_stable(
|
||||||
source.source,
|
source.primary.path,
|
||||||
source.source_stat,
|
source.primary.file_stat,
|
||||||
source.replay_byte_length,
|
source.primary.replay_byte_length,
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
source.expected_source_sha256 is not None
|
source.primary.expected_sha256 is not None
|
||||||
and source_sha256 != source.expected_source_sha256
|
and source_sha256 != source.primary.expected_sha256
|
||||||
):
|
):
|
||||||
raise RecordingMaterializationError(
|
raise RecordingMaterializationError(
|
||||||
"native capture digest no longer matches catalog"
|
"native capture digest no longer matches catalog"
|
||||||
)
|
)
|
||||||
if source_sha256 != document["source_sha256"]:
|
if source_sha256 != document["source_sha256"]:
|
||||||
return None
|
return None
|
||||||
metadata_sha256 = _sha256_prefix_stable(
|
for cached, artifact in zip(cached_artifacts, source.artifacts, strict=True):
|
||||||
source.metadata,
|
digest = _sha256_prefix_stable(
|
||||||
source.metadata_stat,
|
artifact.path,
|
||||||
source.metadata_byte_length,
|
artifact.file_stat,
|
||||||
|
artifact.replay_byte_length,
|
||||||
)
|
)
|
||||||
if metadata_sha256 != document["metadata_sha256"]:
|
if digest != cached["sha256"]:
|
||||||
return None
|
return None
|
||||||
recording_sha256 = _sha256_stable(recording_path, recording_stat)
|
recording_sha256 = _sha256_stable(recording_path, recording_stat)
|
||||||
if recording_sha256 != document["recording_sha256"]:
|
if recording_sha256 != document["recording_sha256"]:
|
||||||
|
|
@ -764,76 +810,103 @@ def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
|
||||||
|
|
||||||
|
|
||||||
def _validate_source(command: ReplayCommand) -> _ValidatedSource:
|
def _validate_source(command: ReplayCommand) -> _ValidatedSource:
|
||||||
source_path = getattr(command, "source_path", None)
|
plugin_id = getattr(command, "plugin_id", None)
|
||||||
allowed_root = getattr(command, "allowed_root", None)
|
allowed_root = getattr(command, "allowed_root", None)
|
||||||
session_root = getattr(command, "session_root", None)
|
session_root = getattr(command, "session_root", None)
|
||||||
replay_byte_length = getattr(command, "replay_byte_length", None)
|
primary_artifact_id = getattr(command, "primary_artifact_id", None)
|
||||||
metadata_byte_length = getattr(command, "metadata_byte_length", None)
|
artifacts = getattr(command, "artifacts", None)
|
||||||
expected_source_sha256 = getattr(command, "expected_source_sha256", None)
|
if not isinstance(plugin_id, str) or SESSION_ID_PATTERN.fullmatch(plugin_id) is None:
|
||||||
if not isinstance(source_path, Path):
|
raise RecordingMaterializationError("replay command has an invalid plugin id")
|
||||||
raise RecordingMaterializationError("replay command has no native capture")
|
if not isinstance(primary_artifact_id, str) or SESSION_ID_PATTERN.fullmatch(
|
||||||
|
primary_artifact_id
|
||||||
|
) is None:
|
||||||
|
raise RecordingMaterializationError("replay command has an invalid primary artifact")
|
||||||
|
if not isinstance(artifacts, tuple) or not artifacts:
|
||||||
|
raise RecordingMaterializationError("replay command has no source artifacts")
|
||||||
try:
|
try:
|
||||||
if not isinstance(allowed_root, Path) or not isinstance(session_root, Path):
|
if not isinstance(allowed_root, Path) or not isinstance(session_root, Path):
|
||||||
raise RecordingMaterializationError("replay command has no confinement roots")
|
raise RecordingMaterializationError("replay command has no confinement roots")
|
||||||
allowed = allowed_root.expanduser().resolve(strict=True)
|
allowed = allowed_root.expanduser().resolve(strict=True)
|
||||||
session = session_root.expanduser().resolve(strict=True)
|
session = session_root.expanduser().resolve(strict=True)
|
||||||
source = source_path.expanduser().absolute()
|
|
||||||
source_parent = source.parent.resolve(strict=True)
|
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise RecordingMaterializationError("native capture is missing") from exc
|
raise RecordingMaterializationError("recording confinement root is missing") from exc
|
||||||
|
if not allowed.is_dir() or not session.is_dir() or not session.is_relative_to(allowed):
|
||||||
|
raise RecordingMaterializationError("recording source escapes its allowed session root")
|
||||||
|
|
||||||
|
validated: list[_ValidatedArtifact] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
seen_names: set[str] = set()
|
||||||
|
for artifact in artifacts:
|
||||||
|
if not isinstance(artifact, ReplayArtifact):
|
||||||
|
raise RecordingMaterializationError("replay command contains an invalid artifact")
|
||||||
if (
|
if (
|
||||||
not allowed.is_dir()
|
SESSION_ID_PATTERN.fullmatch(artifact.artifact_id) is None
|
||||||
or not session.is_dir()
|
or artifact.artifact_id in seen_ids
|
||||||
or not session.is_relative_to(allowed)
|
|
||||||
or not source_parent.is_relative_to(session)
|
|
||||||
):
|
):
|
||||||
raise RecordingMaterializationError("native capture escapes its allowed session root")
|
raise RecordingMaterializationError("replay artifact id is invalid or duplicated")
|
||||||
if source.suffix.casefold() != ".k1mqtt":
|
path = artifact.path.expanduser().absolute()
|
||||||
raise RecordingMaterializationError("native capture has an unsupported format")
|
try:
|
||||||
source_stat = _regular_file_stat_nofollow(source, "native capture")
|
parent = path.parent.resolve(strict=True)
|
||||||
metadata = source.with_name("mqtt.metadata.jsonl")
|
except OSError as exc:
|
||||||
if not metadata.parent.resolve(strict=True).is_relative_to(session):
|
raise RecordingMaterializationError("recording source artifact is missing") from exc
|
||||||
raise RecordingMaterializationError("native metadata escapes its allowed session root")
|
if not parent.is_relative_to(session):
|
||||||
metadata_stat = _regular_file_stat_nofollow(metadata, "native metadata")
|
raise RecordingMaterializationError("recording source artifact escapes its session")
|
||||||
|
file_stat = _regular_file_stat_nofollow(path, "recording source artifact")
|
||||||
|
if artifact.file_byte_length > file_stat.st_size:
|
||||||
|
raise RecordingMaterializationError("recording artifact was truncated after cataloging")
|
||||||
if (
|
if (
|
||||||
not isinstance(replay_byte_length, int)
|
isinstance(artifact.replay_byte_length, bool)
|
||||||
or isinstance(replay_byte_length, bool)
|
or not 1 <= artifact.replay_byte_length <= file_stat.st_size
|
||||||
or not 1 <= replay_byte_length <= source_stat.st_size
|
|
||||||
):
|
):
|
||||||
raise RecordingMaterializationError("native capture replay boundary is invalid")
|
raise RecordingMaterializationError("recording artifact replay boundary is invalid")
|
||||||
if (
|
if artifact.expected_sha256 is not None:
|
||||||
not isinstance(metadata_byte_length, int)
|
if not _is_sha256(artifact.expected_sha256):
|
||||||
or isinstance(metadata_byte_length, bool)
|
raise RecordingMaterializationError("recording artifact digest is invalid")
|
||||||
or not 1 <= metadata_byte_length <= metadata_stat.st_size
|
if artifact.replay_byte_length != file_stat.st_size:
|
||||||
):
|
|
||||||
raise RecordingMaterializationError("native metadata replay boundary is invalid")
|
|
||||||
if expected_source_sha256 is not None:
|
|
||||||
if not isinstance(expected_source_sha256, str) or not _is_sha256(expected_source_sha256):
|
|
||||||
raise RecordingMaterializationError("native capture expected digest is invalid")
|
|
||||||
if replay_byte_length != source_stat.st_size:
|
|
||||||
raise RecordingMaterializationError(
|
raise RecordingMaterializationError(
|
||||||
"a full-capture digest cannot describe a replay prefix"
|
"a full-artifact digest cannot describe a replay prefix"
|
||||||
)
|
)
|
||||||
|
if path.name in seen_names:
|
||||||
|
raise RecordingMaterializationError("recording artifact filenames are duplicated")
|
||||||
|
seen_ids.add(artifact.artifact_id)
|
||||||
|
seen_names.add(path.name)
|
||||||
|
validated.append(
|
||||||
|
_ValidatedArtifact(
|
||||||
|
artifact_id=artifact.artifact_id,
|
||||||
|
path=path,
|
||||||
|
media_type=artifact.media_type,
|
||||||
|
file_stat=file_stat,
|
||||||
|
replay_byte_length=artifact.replay_byte_length,
|
||||||
|
expected_sha256=artifact.expected_sha256,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if sum(artifact.artifact_id == primary_artifact_id for artifact in validated) != 1:
|
||||||
|
raise RecordingMaterializationError("recording primary artifact is unavailable")
|
||||||
return _ValidatedSource(
|
return _ValidatedSource(
|
||||||
source=source,
|
plugin_id=plugin_id,
|
||||||
metadata=metadata,
|
primary_artifact_id=primary_artifact_id,
|
||||||
source_stat=source_stat,
|
artifacts=tuple(validated),
|
||||||
metadata_stat=metadata_stat,
|
|
||||||
replay_byte_length=replay_byte_length,
|
|
||||||
metadata_byte_length=metadata_byte_length,
|
|
||||||
expected_source_sha256=expected_source_sha256,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_source_state(source: _ValidatedSource) -> _ValidatedSource:
|
def _validate_source_state(source: _ValidatedSource) -> _ValidatedSource:
|
||||||
return _ValidatedSource(
|
return _ValidatedSource(
|
||||||
source=source.source,
|
plugin_id=source.plugin_id,
|
||||||
metadata=source.metadata,
|
primary_artifact_id=source.primary_artifact_id,
|
||||||
source_stat=_regular_file_stat_nofollow(source.source, "native capture"),
|
artifacts=tuple(
|
||||||
metadata_stat=_regular_file_stat_nofollow(source.metadata, "native metadata"),
|
_ValidatedArtifact(
|
||||||
replay_byte_length=source.replay_byte_length,
|
artifact_id=artifact.artifact_id,
|
||||||
metadata_byte_length=source.metadata_byte_length,
|
path=artifact.path,
|
||||||
expected_source_sha256=source.expected_source_sha256,
|
media_type=artifact.media_type,
|
||||||
|
file_stat=_regular_file_stat_nofollow(
|
||||||
|
artifact.path,
|
||||||
|
"recording source artifact",
|
||||||
|
),
|
||||||
|
replay_byte_length=artifact.replay_byte_length,
|
||||||
|
expected_sha256=artifact.expected_sha256,
|
||||||
|
)
|
||||||
|
for artifact in source.artifacts
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -880,20 +953,25 @@ def _cache_document(
|
||||||
return {
|
return {
|
||||||
"schema_version": CACHE_SCHEMA,
|
"schema_version": CACHE_SCHEMA,
|
||||||
"session_id": recording.session_id,
|
"session_id": recording.session_id,
|
||||||
"source_file_byte_length": source.source_stat.st_size,
|
"plugin_id": source.plugin_id,
|
||||||
"source_replay_byte_length": source.replay_byte_length,
|
"primary_artifact_id": source.primary_artifact_id,
|
||||||
"source_mtime_ns": source.source_stat.st_mtime_ns,
|
|
||||||
"source_ctime_ns": source.source_stat.st_ctime_ns,
|
|
||||||
"source_sha256": recording.source_sha256,
|
"source_sha256": recording.source_sha256,
|
||||||
"metadata_file_byte_length": source.metadata_stat.st_size,
|
"source_artifacts": [
|
||||||
"metadata_replay_byte_length": source.metadata_byte_length,
|
{
|
||||||
"metadata_mtime_ns": source.metadata_stat.st_mtime_ns,
|
"artifact_id": artifact.artifact_id,
|
||||||
"metadata_ctime_ns": source.metadata_stat.st_ctime_ns,
|
"media_type": artifact.media_type,
|
||||||
"metadata_sha256": _sha256_prefix_stable(
|
"file_byte_length": artifact.file_stat.st_size,
|
||||||
source.metadata,
|
"replay_byte_length": artifact.replay_byte_length,
|
||||||
source.metadata_stat,
|
"mtime_ns": artifact.file_stat.st_mtime_ns,
|
||||||
source.metadata_byte_length,
|
"ctime_ns": artifact.file_stat.st_ctime_ns,
|
||||||
|
"sha256": _sha256_prefix_stable(
|
||||||
|
artifact.path,
|
||||||
|
artifact.file_stat,
|
||||||
|
artifact.replay_byte_length,
|
||||||
),
|
),
|
||||||
|
}
|
||||||
|
for artifact in source.artifacts
|
||||||
|
],
|
||||||
"recording_byte_length": recording.byte_length,
|
"recording_byte_length": recording.byte_length,
|
||||||
"recording_mtime_ns": recording_stat.st_mtime_ns,
|
"recording_mtime_ns": recording_stat.st_mtime_ns,
|
||||||
"recording_sha256": recording.sha256,
|
"recording_sha256": recording.sha256,
|
||||||
|
|
@ -909,16 +987,10 @@ def _validate_cache_document(value: object, session_id: str) -> dict[str, Any]:
|
||||||
expected_keys = {
|
expected_keys = {
|
||||||
"schema_version",
|
"schema_version",
|
||||||
"session_id",
|
"session_id",
|
||||||
"source_file_byte_length",
|
"plugin_id",
|
||||||
"source_replay_byte_length",
|
"primary_artifact_id",
|
||||||
"source_mtime_ns",
|
|
||||||
"source_ctime_ns",
|
|
||||||
"source_sha256",
|
"source_sha256",
|
||||||
"metadata_file_byte_length",
|
"source_artifacts",
|
||||||
"metadata_replay_byte_length",
|
|
||||||
"metadata_mtime_ns",
|
|
||||||
"metadata_ctime_ns",
|
|
||||||
"metadata_sha256",
|
|
||||||
"recording_byte_length",
|
"recording_byte_length",
|
||||||
"recording_mtime_ns",
|
"recording_mtime_ns",
|
||||||
"recording_sha256",
|
"recording_sha256",
|
||||||
|
|
@ -932,15 +1004,10 @@ def _validate_cache_document(value: object, session_id: str) -> dict[str, Any]:
|
||||||
raise ValueError("recording cache sidecar identity does not match")
|
raise ValueError("recording cache sidecar identity does not match")
|
||||||
if value["timeline"] != RERUN_SESSION_TIMELINE:
|
if value["timeline"] != RERUN_SESSION_TIMELINE:
|
||||||
raise ValueError("recording cache timeline is unsupported")
|
raise ValueError("recording cache timeline is unsupported")
|
||||||
|
for key in ("plugin_id", "primary_artifact_id"):
|
||||||
|
if not isinstance(value[key], str) or SESSION_ID_PATTERN.fullmatch(value[key]) is None:
|
||||||
|
raise ValueError("recording cache contains an invalid identifier")
|
||||||
for key in (
|
for key in (
|
||||||
"source_file_byte_length",
|
|
||||||
"source_replay_byte_length",
|
|
||||||
"source_mtime_ns",
|
|
||||||
"source_ctime_ns",
|
|
||||||
"metadata_file_byte_length",
|
|
||||||
"metadata_replay_byte_length",
|
|
||||||
"metadata_mtime_ns",
|
|
||||||
"metadata_ctime_ns",
|
|
||||||
"recording_byte_length",
|
"recording_byte_length",
|
||||||
"recording_mtime_ns",
|
"recording_mtime_ns",
|
||||||
"timeline_start_ns",
|
"timeline_start_ns",
|
||||||
|
|
@ -948,10 +1015,51 @@ def _validate_cache_document(value: object, session_id: str) -> dict[str, Any]:
|
||||||
):
|
):
|
||||||
if not isinstance(value[key], int) or isinstance(value[key], bool) or value[key] < 0:
|
if not isinstance(value[key], int) or isinstance(value[key], bool) or value[key] < 0:
|
||||||
raise ValueError("recording cache contains an invalid integer")
|
raise ValueError("recording cache contains an invalid integer")
|
||||||
for key in ("source_sha256", "metadata_sha256", "recording_sha256"):
|
for key in ("source_sha256", "recording_sha256"):
|
||||||
digest = value[key]
|
digest = value[key]
|
||||||
if not isinstance(digest, str) or not _is_sha256(digest):
|
if not isinstance(digest, str) or not _is_sha256(digest):
|
||||||
raise ValueError("recording cache contains an invalid digest")
|
raise ValueError("recording cache contains an invalid digest")
|
||||||
|
source_artifacts = value["source_artifacts"]
|
||||||
|
if not isinstance(source_artifacts, list) or not source_artifacts:
|
||||||
|
raise ValueError("recording cache source artifacts are invalid")
|
||||||
|
artifact_ids: set[str] = set()
|
||||||
|
artifact_keys = {
|
||||||
|
"artifact_id",
|
||||||
|
"media_type",
|
||||||
|
"file_byte_length",
|
||||||
|
"replay_byte_length",
|
||||||
|
"mtime_ns",
|
||||||
|
"ctime_ns",
|
||||||
|
"sha256",
|
||||||
|
}
|
||||||
|
for artifact in source_artifacts:
|
||||||
|
if not isinstance(artifact, dict) or set(artifact) != artifact_keys:
|
||||||
|
raise ValueError("recording cache source artifact is invalid")
|
||||||
|
artifact_id = artifact["artifact_id"]
|
||||||
|
if (
|
||||||
|
not isinstance(artifact_id, str)
|
||||||
|
or SESSION_ID_PATTERN.fullmatch(artifact_id) is None
|
||||||
|
or artifact_id in artifact_ids
|
||||||
|
):
|
||||||
|
raise ValueError("recording cache source artifact id is invalid")
|
||||||
|
artifact_ids.add(artifact_id)
|
||||||
|
if not isinstance(artifact["media_type"], str) or not artifact["media_type"]:
|
||||||
|
raise ValueError("recording cache source media type is invalid")
|
||||||
|
for key in (
|
||||||
|
"file_byte_length",
|
||||||
|
"replay_byte_length",
|
||||||
|
"mtime_ns",
|
||||||
|
"ctime_ns",
|
||||||
|
):
|
||||||
|
item = artifact[key]
|
||||||
|
if not isinstance(item, int) or isinstance(item, bool) or item < 0:
|
||||||
|
raise ValueError("recording cache source artifact boundary is invalid")
|
||||||
|
if not 1 <= artifact["replay_byte_length"] <= artifact["file_byte_length"]:
|
||||||
|
raise ValueError("recording cache source replay boundary is invalid")
|
||||||
|
if not isinstance(artifact["sha256"], str) or not _is_sha256(artifact["sha256"]):
|
||||||
|
raise ValueError("recording cache source digest is invalid")
|
||||||
|
if value["primary_artifact_id"] not in artifact_ids:
|
||||||
|
raise ValueError("recording cache primary artifact is unavailable")
|
||||||
if value["timeline_end_ns"] < value["timeline_start_ns"]:
|
if value["timeline_end_ns"] < value["timeline_start_ns"]:
|
||||||
raise ValueError("recording cache timeline bounds are invalid")
|
raise ValueError("recording cache timeline bounds are invalid")
|
||||||
return cast(dict[str, Any], value)
|
return cast(dict[str, Any], value)
|
||||||
|
|
@ -1061,26 +1169,23 @@ def _stage_replay_prefix(
|
||||||
staged_root = session_cache_root / f".source.{uuid4().hex}.tmp"
|
staged_root = session_cache_root / f".source.{uuid4().hex}.tmp"
|
||||||
try:
|
try:
|
||||||
staged_root.mkdir(mode=0o700)
|
staged_root.mkdir(mode=0o700)
|
||||||
staged_source = staged_root / "mqtt.raw.k1mqtt"
|
staged_primary: Path | None = None
|
||||||
staged_metadata = staged_root / "mqtt.metadata.jsonl"
|
for artifact in source.artifacts:
|
||||||
|
staged = staged_root / artifact.path.name
|
||||||
_copy_prefix_nofollow(
|
_copy_prefix_nofollow(
|
||||||
source.source,
|
artifact.path,
|
||||||
source.source_stat,
|
artifact.file_stat,
|
||||||
staged_source,
|
staged,
|
||||||
source.replay_byte_length,
|
artifact.replay_byte_length,
|
||||||
cancel_event=cancel_event,
|
|
||||||
activity_callback=activity_callback,
|
|
||||||
)
|
|
||||||
_copy_prefix_nofollow(
|
|
||||||
source.metadata,
|
|
||||||
source.metadata_stat,
|
|
||||||
staged_metadata,
|
|
||||||
source.metadata_byte_length,
|
|
||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
activity_callback=activity_callback,
|
activity_callback=activity_callback,
|
||||||
)
|
)
|
||||||
|
if artifact.artifact_id == source.primary_artifact_id:
|
||||||
|
staged_primary = staged
|
||||||
|
if staged_primary is None:
|
||||||
|
raise RecordingMaterializationError("staged recording has no primary artifact")
|
||||||
_fsync_directory(staged_root)
|
_fsync_directory(staged_root)
|
||||||
return staged_root, staged_source
|
return staged_root, staged_primary
|
||||||
except BaseException:
|
except BaseException:
|
||||||
shutil.rmtree(staged_root, ignore_errors=True)
|
shutil.rmtree(staged_root, ignore_errors=True)
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,12 @@ from typing import Any, cast
|
||||||
|
|
||||||
from k1link.artifacts import utc_now_iso
|
from k1link.artifacts import utc_now_iso
|
||||||
|
|
||||||
from .legacy import discover_legacy_viewer_sessions
|
|
||||||
from .models import (
|
from .models import (
|
||||||
LayoutConflictError,
|
LayoutConflictError,
|
||||||
LegacySessionCandidate,
|
ObservationArtifactCandidate,
|
||||||
|
ObservationSessionCandidate,
|
||||||
RecordedMediaArtifact,
|
RecordedMediaArtifact,
|
||||||
|
ReplayArtifact,
|
||||||
ReplayCommand,
|
ReplayCommand,
|
||||||
SessionArtifact,
|
SessionArtifact,
|
||||||
SessionDetail,
|
SessionDetail,
|
||||||
|
|
@ -30,6 +31,7 @@ from .models import (
|
||||||
SessionSummary,
|
SessionSummary,
|
||||||
WorkspaceLayout,
|
WorkspaceLayout,
|
||||||
)
|
)
|
||||||
|
from .plugin_contract import ObservationArchiveSource
|
||||||
|
|
||||||
IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
MAX_LAYOUT_BYTES = 256 * 1024
|
MAX_LAYOUT_BYTES = 256 * 1024
|
||||||
|
|
@ -38,6 +40,8 @@ DATABASE_NAME = "mission-core.sqlite3"
|
||||||
SCHEMA_SQL = """
|
SCHEMA_SQL = """
|
||||||
CREATE TABLE IF NOT EXISTS observation_sessions (
|
CREATE TABLE IF NOT EXISTS observation_sessions (
|
||||||
session_id TEXT PRIMARY KEY,
|
session_id TEXT PRIMARY KEY,
|
||||||
|
plugin_id TEXT NOT NULL,
|
||||||
|
archive_id TEXT NOT NULL,
|
||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
status TEXT NOT NULL CHECK (status IN ('ready', 'interrupted', 'failed')),
|
status TEXT NOT NULL CHECK (status IN ('ready', 'interrupted', 'failed')),
|
||||||
started_at_utc TEXT,
|
started_at_utc TEXT,
|
||||||
|
|
@ -48,8 +52,9 @@ CREATE TABLE IF NOT EXISTS observation_sessions (
|
||||||
origin TEXT NOT NULL,
|
origin TEXT NOT NULL,
|
||||||
source_count INTEGER NOT NULL,
|
source_count INTEGER NOT NULL,
|
||||||
total_bytes INTEGER NOT NULL,
|
total_bytes INTEGER NOT NULL,
|
||||||
replay_raw_bytes INTEGER NOT NULL DEFAULT 0,
|
primary_replay_artifact_id TEXT,
|
||||||
replay_metadata_bytes INTEGER NOT NULL DEFAULT 0,
|
timeline_origin_epoch_ns INTEGER,
|
||||||
|
timeline_origin_monotonic_ns INTEGER,
|
||||||
allowed_root TEXT NOT NULL,
|
allowed_root TEXT NOT NULL,
|
||||||
session_root TEXT NOT NULL,
|
session_root TEXT NOT NULL,
|
||||||
created_at_utc TEXT NOT NULL,
|
created_at_utc TEXT NOT NULL,
|
||||||
|
|
@ -68,6 +73,7 @@ CREATE TABLE IF NOT EXISTS observation_session_artifacts (
|
||||||
sha256 TEXT,
|
sha256 TEXT,
|
||||||
integrity_status TEXT NOT NULL,
|
integrity_status TEXT NOT NULL,
|
||||||
locator TEXT NOT NULL,
|
locator TEXT NOT NULL,
|
||||||
|
replay_byte_length INTEGER NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (session_id, artifact_id)
|
PRIMARY KEY (session_id, artifact_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -129,20 +135,22 @@ class SessionStore:
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
self._initialize()
|
self._initialize()
|
||||||
|
|
||||||
def import_legacy_viewer_live(self, root: Path) -> tuple[str, ...]:
|
def reconcile_archive(self, source: ObservationArchiveSource) -> tuple[str, ...]:
|
||||||
allowed_root = root.expanduser().resolve()
|
"""Reconcile one plugin-owned evidence namespace into the host catalog."""
|
||||||
candidates = discover_legacy_viewer_sessions(allowed_root)
|
|
||||||
|
allowed_root = source.root.expanduser().resolve()
|
||||||
|
candidates = source.discover(allowed_root)
|
||||||
imported: list[str] = []
|
imported: list[str] = []
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
self._upsert_legacy(candidate)
|
self._upsert_candidate(source, candidate)
|
||||||
imported.append(candidate.session_id)
|
imported.append(candidate.session_id)
|
||||||
with self._lock, self._connect() as connection:
|
with self._lock, self._connect() as connection:
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
discovered = set(imported)
|
discovered = set(imported)
|
||||||
indexed = connection.execute(
|
indexed = connection.execute(
|
||||||
"SELECT session_id FROM observation_sessions "
|
"SELECT session_id FROM observation_sessions "
|
||||||
"WHERE origin = 'legacy-viewer-live' AND allowed_root = ?",
|
"WHERE plugin_id = ? AND archive_id = ? AND allowed_root = ?",
|
||||||
(str(allowed_root),),
|
(source.plugin_id, source.archive_id, str(allowed_root)),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
stale = [row["session_id"] for row in indexed if row["session_id"] not in discovered]
|
stale = [row["session_id"] for row in indexed if row["session_id"] not in discovered]
|
||||||
connection.executemany(
|
connection.executemany(
|
||||||
|
|
@ -240,29 +248,57 @@ class SessionStore:
|
||||||
if not 0 <= speed <= 100:
|
if not 0 <= speed <= 100:
|
||||||
raise ValueError("speed must be within 0..100")
|
raise ValueError("speed must be within 0..100")
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
row = connection.execute(
|
session = connection.execute(
|
||||||
"SELECT s.allowed_root, s.session_root, s.replay_raw_bytes, "
|
"SELECT plugin_id, allowed_root, session_root, "
|
||||||
"s.replay_metadata_bytes, a.locator, a.sha256 "
|
"primary_replay_artifact_id, timeline_origin_epoch_ns, "
|
||||||
"FROM observation_sessions AS s "
|
"timeline_origin_monotonic_ns FROM observation_sessions WHERE session_id = ?",
|
||||||
"JOIN observation_session_artifacts AS a ON a.session_id = s.session_id "
|
|
||||||
"WHERE s.session_id = ? AND a.artifact_id = 'raw-mqtt'",
|
|
||||||
(session_id,),
|
(session_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
artifact_rows = connection.execute(
|
||||||
|
"SELECT artifact_id, media_type, byte_length, replay_byte_length, locator, sha256 "
|
||||||
|
"FROM observation_session_artifacts WHERE session_id = ? "
|
||||||
|
"AND replay_byte_length > 0 ORDER BY artifact_id",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
if session is None or not artifact_rows:
|
||||||
raise SessionNotReplayableError("observation session replay artifact is unavailable")
|
raise SessionNotReplayableError("observation session replay artifact is unavailable")
|
||||||
source_path = _resolve_confined_artifact(
|
primary_artifact_id = session["primary_replay_artifact_id"]
|
||||||
Path(row["allowed_root"]),
|
epoch_ns = session["timeline_origin_epoch_ns"]
|
||||||
Path(row["session_root"]),
|
monotonic_ns = session["timeline_origin_monotonic_ns"]
|
||||||
|
if (
|
||||||
|
not isinstance(primary_artifact_id, str)
|
||||||
|
or not isinstance(epoch_ns, int)
|
||||||
|
or not isinstance(monotonic_ns, int)
|
||||||
|
):
|
||||||
|
raise SessionNotReplayableError("observation session replay contract is incomplete")
|
||||||
|
allowed_root = Path(session["allowed_root"])
|
||||||
|
session_root = Path(session["session_root"])
|
||||||
|
artifacts = tuple(
|
||||||
|
ReplayArtifact(
|
||||||
|
artifact_id=row["artifact_id"],
|
||||||
|
path=_resolve_confined_artifact(
|
||||||
|
allowed_root,
|
||||||
|
session_root,
|
||||||
Path(row["locator"]),
|
Path(row["locator"]),
|
||||||
|
),
|
||||||
|
media_type=row["media_type"],
|
||||||
|
file_byte_length=int(row["byte_length"]),
|
||||||
|
replay_byte_length=int(row["replay_byte_length"]),
|
||||||
|
expected_sha256=row["sha256"],
|
||||||
)
|
)
|
||||||
|
for row in artifact_rows
|
||||||
|
)
|
||||||
|
if sum(artifact.artifact_id == primary_artifact_id for artifact in artifacts) != 1:
|
||||||
|
raise SessionNotReplayableError("observation session primary artifact is unavailable")
|
||||||
return ReplayCommand(
|
return ReplayCommand(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
source_path=source_path,
|
plugin_id=session["plugin_id"],
|
||||||
allowed_root=Path(row["allowed_root"]),
|
allowed_root=allowed_root,
|
||||||
session_root=Path(row["session_root"]),
|
session_root=session_root,
|
||||||
replay_byte_length=int(row["replay_raw_bytes"]),
|
primary_artifact_id=primary_artifact_id,
|
||||||
metadata_byte_length=int(row["replay_metadata_bytes"]),
|
artifacts=artifacts,
|
||||||
expected_source_sha256=row["sha256"],
|
timeline_origin_epoch_ns=epoch_ns,
|
||||||
|
timeline_origin_monotonic_ns=monotonic_ns,
|
||||||
speed=float(speed),
|
speed=float(speed),
|
||||||
loop=loop,
|
loop=loop,
|
||||||
)
|
)
|
||||||
|
|
@ -387,58 +423,92 @@ class SessionStore:
|
||||||
def _initialize(self) -> None:
|
def _initialize(self) -> None:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.executescript(SCHEMA_SQL)
|
connection.executescript(SCHEMA_SQL)
|
||||||
columns = {
|
session_columns = {
|
||||||
row["name"]
|
row["name"]
|
||||||
for row in connection.execute("PRAGMA table_info(observation_sessions)")
|
for row in connection.execute("PRAGMA table_info(observation_sessions)")
|
||||||
}
|
}
|
||||||
if "replay_raw_bytes" not in columns:
|
for name, declaration in (
|
||||||
|
("plugin_id", "TEXT NOT NULL DEFAULT ''"),
|
||||||
|
("archive_id", "TEXT NOT NULL DEFAULT ''"),
|
||||||
|
("primary_replay_artifact_id", "TEXT"),
|
||||||
|
("timeline_origin_epoch_ns", "INTEGER"),
|
||||||
|
("timeline_origin_monotonic_ns", "INTEGER"),
|
||||||
|
):
|
||||||
|
if name in session_columns:
|
||||||
|
continue
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"ALTER TABLE observation_sessions "
|
f"ALTER TABLE observation_sessions ADD COLUMN {name} {declaration}" # noqa: S608
|
||||||
"ADD COLUMN replay_raw_bytes INTEGER NOT NULL DEFAULT 0"
|
|
||||||
)
|
)
|
||||||
if "replay_metadata_bytes" not in columns:
|
artifact_columns = {
|
||||||
|
row["name"]
|
||||||
|
for row in connection.execute(
|
||||||
|
"PRAGMA table_info(observation_session_artifacts)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if "replay_byte_length" not in artifact_columns:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"ALTER TABLE observation_sessions "
|
"ALTER TABLE observation_session_artifacts "
|
||||||
"ADD COLUMN replay_metadata_bytes INTEGER NOT NULL DEFAULT 0"
|
"ADD COLUMN replay_byte_length INTEGER NOT NULL DEFAULT 0"
|
||||||
)
|
)
|
||||||
connection.commit()
|
connection.commit()
|
||||||
with _ignore_os_error():
|
with _ignore_os_error():
|
||||||
self.database_path.chmod(0o600)
|
self.database_path.chmod(0o600)
|
||||||
|
|
||||||
def _upsert_legacy(self, candidate: LegacySessionCandidate) -> None:
|
def _upsert_candidate(
|
||||||
_validate_identifier(candidate.session_id, "legacy session id")
|
self,
|
||||||
|
source: ObservationArchiveSource,
|
||||||
|
candidate: ObservationSessionCandidate,
|
||||||
|
) -> None:
|
||||||
|
_validate_identifier(candidate.session_id, "observation session id")
|
||||||
|
_validate_identifier(source.plugin_id, "device plugin id")
|
||||||
|
_validate_identifier(source.archive_id, "observation archive id")
|
||||||
allowed_root = candidate.allowed_root.resolve()
|
allowed_root = candidate.allowed_root.resolve()
|
||||||
session_root = candidate.session_root.resolve()
|
session_root = candidate.session_root.resolve()
|
||||||
if not session_root.is_relative_to(allowed_root):
|
if not session_root.is_relative_to(allowed_root):
|
||||||
raise SessionIntegrityError("legacy session root escapes its allowed root")
|
raise SessionIntegrityError("observation session root escapes its allowed root")
|
||||||
sources = _legacy_sources(candidate)
|
if allowed_root != source.root.expanduser().resolve():
|
||||||
artifacts = _legacy_artifacts(candidate, session_root)
|
raise SessionIntegrityError("plugin candidate does not belong to its archive root")
|
||||||
|
sources = candidate.sources
|
||||||
|
artifacts = _validated_candidate_artifacts(candidate, session_root)
|
||||||
|
_validate_candidate_replay(candidate, artifacts)
|
||||||
now = utc_now_iso()
|
now = utc_now_iso()
|
||||||
modalities_json = json.dumps(list(candidate.modalities), separators=(",", ":"))
|
modalities_json = json.dumps(list(candidate.modalities), separators=(",", ":"))
|
||||||
|
|
||||||
with self._lock, self._connect() as connection:
|
with self._lock, self._connect() as connection:
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
existing = connection.execute(
|
existing = connection.execute(
|
||||||
"SELECT origin, allowed_root, session_root, created_at_utc "
|
"SELECT plugin_id, archive_id, allowed_root, session_root, created_at_utc "
|
||||||
"FROM observation_sessions WHERE session_id = ?",
|
"FROM observation_sessions WHERE session_id = ?",
|
||||||
(candidate.session_id,),
|
(candidate.session_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
unclaimed_pre_plugin_row = existing is not None and (
|
||||||
|
existing["plugin_id"] == "" and existing["archive_id"] == ""
|
||||||
|
)
|
||||||
if existing is not None and (
|
if existing is not None and (
|
||||||
existing["origin"] != "legacy-viewer-live"
|
Path(existing["allowed_root"]).resolve() != allowed_root
|
||||||
or Path(existing["allowed_root"]).resolve() != allowed_root
|
|
||||||
or Path(existing["session_root"]).resolve() != session_root
|
or Path(existing["session_root"]).resolve() != session_root
|
||||||
|
or (
|
||||||
|
not unclaimed_pre_plugin_row
|
||||||
|
and (
|
||||||
|
existing["plugin_id"] != source.plugin_id
|
||||||
|
or existing["archive_id"] != source.archive_id
|
||||||
|
)
|
||||||
|
)
|
||||||
):
|
):
|
||||||
connection.rollback()
|
connection.rollback()
|
||||||
raise SessionIntegrityError("session id is already bound to another origin")
|
raise SessionIntegrityError("session id is already bound to another archive")
|
||||||
created_at = existing["created_at_utc"] if existing is not None else now
|
created_at = existing["created_at_utc"] if existing is not None else now
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT INTO observation_sessions "
|
"INSERT INTO observation_sessions "
|
||||||
"(session_id, display_name, status, started_at_utc, completed_at_utc, "
|
"(session_id, plugin_id, archive_id, display_name, status, "
|
||||||
|
"started_at_utc, completed_at_utc, "
|
||||||
"duration_seconds, modalities_json, replayable, origin, source_count, "
|
"duration_seconds, modalities_json, replayable, origin, source_count, "
|
||||||
"total_bytes, replay_raw_bytes, replay_metadata_bytes, allowed_root, "
|
"total_bytes, primary_replay_artifact_id, timeline_origin_epoch_ns, "
|
||||||
|
"timeline_origin_monotonic_ns, allowed_root, "
|
||||||
"session_root, created_at_utc, updated_at_utc) "
|
"session_root, created_at_utc, updated_at_utc) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||||
"ON CONFLICT(session_id) DO UPDATE SET "
|
"ON CONFLICT(session_id) DO UPDATE SET "
|
||||||
|
"plugin_id = excluded.plugin_id, archive_id = excluded.archive_id, "
|
||||||
"display_name = excluded.display_name, status = excluded.status, "
|
"display_name = excluded.display_name, status = excluded.status, "
|
||||||
"started_at_utc = excluded.started_at_utc, "
|
"started_at_utc = excluded.started_at_utc, "
|
||||||
"completed_at_utc = excluded.completed_at_utc, "
|
"completed_at_utc = excluded.completed_at_utc, "
|
||||||
|
|
@ -446,11 +516,14 @@ class SessionStore:
|
||||||
"modalities_json = excluded.modalities_json, "
|
"modalities_json = excluded.modalities_json, "
|
||||||
"replayable = excluded.replayable, source_count = excluded.source_count, "
|
"replayable = excluded.replayable, source_count = excluded.source_count, "
|
||||||
"total_bytes = excluded.total_bytes, "
|
"total_bytes = excluded.total_bytes, "
|
||||||
"replay_raw_bytes = excluded.replay_raw_bytes, "
|
"primary_replay_artifact_id = excluded.primary_replay_artifact_id, "
|
||||||
"replay_metadata_bytes = excluded.replay_metadata_bytes, "
|
"timeline_origin_epoch_ns = excluded.timeline_origin_epoch_ns, "
|
||||||
|
"timeline_origin_monotonic_ns = excluded.timeline_origin_monotonic_ns, "
|
||||||
"updated_at_utc = excluded.updated_at_utc",
|
"updated_at_utc = excluded.updated_at_utc",
|
||||||
(
|
(
|
||||||
candidate.session_id,
|
candidate.session_id,
|
||||||
|
source.plugin_id,
|
||||||
|
source.archive_id,
|
||||||
candidate.display_name,
|
candidate.display_name,
|
||||||
candidate.status,
|
candidate.status,
|
||||||
candidate.started_at_utc,
|
candidate.started_at_utc,
|
||||||
|
|
@ -458,11 +531,12 @@ class SessionStore:
|
||||||
candidate.duration_seconds,
|
candidate.duration_seconds,
|
||||||
modalities_json,
|
modalities_json,
|
||||||
int(candidate.replayable),
|
int(candidate.replayable),
|
||||||
"legacy-viewer-live",
|
source.archive_id,
|
||||||
len(sources),
|
len(sources),
|
||||||
candidate.total_bytes,
|
candidate.total_bytes,
|
||||||
candidate.replay_raw_byte_length,
|
candidate.primary_replay_artifact_id,
|
||||||
candidate.replay_metadata_byte_length,
|
candidate.timeline_origin_epoch_ns,
|
||||||
|
candidate.timeline_origin_monotonic_ns,
|
||||||
str(allowed_root),
|
str(allowed_root),
|
||||||
str(session_root),
|
str(session_root),
|
||||||
created_at,
|
created_at,
|
||||||
|
|
@ -480,9 +554,20 @@ class SessionStore:
|
||||||
connection.executemany(
|
connection.executemany(
|
||||||
"INSERT INTO observation_session_artifacts "
|
"INSERT INTO observation_session_artifacts "
|
||||||
"(session_id, artifact_id, kind, media_type, byte_length, sha256, "
|
"(session_id, artifact_id, kind, media_type, byte_length, sha256, "
|
||||||
"integrity_status, locator) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
"integrity_status, locator, replay_byte_length) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
[
|
[
|
||||||
(candidate.session_id, *artifact)
|
(
|
||||||
|
candidate.session_id,
|
||||||
|
artifact.artifact_id,
|
||||||
|
artifact.kind,
|
||||||
|
artifact.media_type,
|
||||||
|
artifact.byte_length,
|
||||||
|
artifact.sha256,
|
||||||
|
artifact.integrity_status,
|
||||||
|
str(artifact.locator),
|
||||||
|
artifact.replay_byte_length,
|
||||||
|
)
|
||||||
for artifact in artifacts
|
for artifact in artifacts
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
@ -519,82 +604,56 @@ class SessionStore:
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
def _legacy_sources(candidate: LegacySessionCandidate) -> tuple[SessionSource, ...]:
|
def _validated_candidate_artifacts(
|
||||||
rows: list[SessionSource] = []
|
candidate: ObservationSessionCandidate,
|
||||||
if "point-cloud" in candidate.modalities:
|
|
||||||
rows.append(
|
|
||||||
SessionSource(
|
|
||||||
source_id="sensor.lidar.primary",
|
|
||||||
semantic_channel_id="spatial.point-cloud.recorded",
|
|
||||||
modality="point-cloud",
|
|
||||||
status="recorded",
|
|
||||||
seekable=candidate.replayable,
|
|
||||||
artifact_id="raw-mqtt",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if "trajectory" in candidate.modalities:
|
|
||||||
rows.append(
|
|
||||||
SessionSource(
|
|
||||||
source_id="spatial.trajectory",
|
|
||||||
semantic_channel_id="spatial.pose.recorded",
|
|
||||||
modality="trajectory",
|
|
||||||
status="recorded",
|
|
||||||
seekable=candidate.replayable,
|
|
||||||
artifact_id="raw-mqtt",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
rows.extend(
|
|
||||||
SessionSource(
|
|
||||||
source_id=media.source_id,
|
|
||||||
semantic_channel_id="camera.video.recorded",
|
|
||||||
modality="video",
|
|
||||||
status="recorded",
|
|
||||||
seekable=True,
|
|
||||||
artifact_id=media.artifact_id,
|
|
||||||
)
|
|
||||||
for media in candidate.media_sources
|
|
||||||
)
|
|
||||||
if len({source.source_id for source in rows}) != len(rows):
|
|
||||||
raise SessionIntegrityError("legacy session contains duplicate source identifiers")
|
|
||||||
return tuple(rows)
|
|
||||||
|
|
||||||
|
|
||||||
def _legacy_artifacts(
|
|
||||||
candidate: LegacySessionCandidate,
|
|
||||||
session_root: Path,
|
session_root: Path,
|
||||||
) -> tuple[tuple[str, str, str, int, str | None, str, str], ...]:
|
) -> tuple[ObservationArtifactCandidate, ...]:
|
||||||
artifacts: list[tuple[str, str, str, int, str | None, str, str]] = [
|
artifacts = candidate.artifacts
|
||||||
(
|
artifact_ids: set[str] = set()
|
||||||
"raw-mqtt",
|
for artifact in artifacts:
|
||||||
"raw-transport",
|
_validate_identifier(artifact.artifact_id, "observation artifact id")
|
||||||
"application/x-nodedc-k1mqtt",
|
if artifact.artifact_id in artifact_ids:
|
||||||
candidate.raw_byte_length,
|
raise SessionIntegrityError("observation session contains duplicate artifacts")
|
||||||
candidate.raw_sha256,
|
artifact_ids.add(artifact.artifact_id)
|
||||||
candidate.raw_integrity_status,
|
if artifact.byte_length < 0 or not 0 <= artifact.replay_byte_length <= artifact.byte_length:
|
||||||
str(candidate.raw_path),
|
raise SessionIntegrityError("observation artifact has invalid byte boundaries")
|
||||||
)
|
|
||||||
]
|
|
||||||
for media in candidate.media_sources:
|
|
||||||
try:
|
try:
|
||||||
locator = media.locator.resolve(strict=True)
|
locator = artifact.locator.resolve(strict=True)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise SessionIntegrityError("legacy video artifact is missing") from exc
|
raise SessionIntegrityError("observation artifact is missing") from exc
|
||||||
if not locator.is_dir() or not locator.is_relative_to(session_root):
|
if not locator.is_relative_to(session_root) or not (locator.is_file() or locator.is_dir()):
|
||||||
raise SessionIntegrityError("legacy video artifact escapes its session root")
|
raise SessionIntegrityError("observation artifact escapes its session root")
|
||||||
artifacts.append(
|
if artifact.replay_byte_length > 0 and not locator.is_file():
|
||||||
(
|
raise SessionIntegrityError("replay input artifact must be a regular file")
|
||||||
media.artifact_id,
|
source_ids: set[str] = set()
|
||||||
"recorded-video",
|
for source in candidate.sources:
|
||||||
"video/mp4",
|
_validate_identifier(source.source_id, "observation source id")
|
||||||
media.byte_length,
|
if source.source_id in source_ids:
|
||||||
None,
|
raise SessionIntegrityError("observation session contains duplicate sources")
|
||||||
"validated-structure",
|
if source.artifact_id not in artifact_ids:
|
||||||
str(locator),
|
raise SessionIntegrityError("observation source references an unknown artifact")
|
||||||
)
|
source_ids.add(source.source_id)
|
||||||
)
|
return artifacts
|
||||||
if len({artifact[0] for artifact in artifacts}) != len(artifacts):
|
|
||||||
raise SessionIntegrityError("legacy session contains duplicate artifact identifiers")
|
|
||||||
return tuple(artifacts)
|
def _validate_candidate_replay(
|
||||||
|
candidate: ObservationSessionCandidate,
|
||||||
|
artifacts: tuple[ObservationArtifactCandidate, ...],
|
||||||
|
) -> None:
|
||||||
|
primary_id = candidate.primary_replay_artifact_id
|
||||||
|
replay_artifacts = tuple(artifact for artifact in artifacts if artifact.replay_byte_length > 0)
|
||||||
|
if candidate.replayable:
|
||||||
|
if (
|
||||||
|
primary_id is None
|
||||||
|
or sum(artifact.artifact_id == primary_id for artifact in replay_artifacts) != 1
|
||||||
|
or candidate.timeline_origin_epoch_ns is None
|
||||||
|
or candidate.timeline_origin_monotonic_ns is None
|
||||||
|
):
|
||||||
|
raise SessionIntegrityError("replayable observation contract is incomplete")
|
||||||
|
if candidate.timeline_origin_epoch_ns < 0 or candidate.timeline_origin_monotonic_ns < 0:
|
||||||
|
raise SessionIntegrityError("observation timeline origin is invalid")
|
||||||
|
elif primary_id is not None or replay_artifacts:
|
||||||
|
raise SessionIntegrityError("non-replayable observation declares replay artifacts")
|
||||||
|
|
||||||
|
|
||||||
def _summary_from_row(row: sqlite3.Row) -> SessionSummary:
|
def _summary_from_row(row: sqlite3.Row) -> SessionSummary:
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
"""Live/replay visualization bridge for verified K1 MQTT streams."""
|
"""Mission Core viewer consumers and recorded-viewer contracts."""
|
||||||
|
|
||||||
from k1link.viewer.messages import StreamMessage
|
from .recorded import RecordedBlueprintError, recorded_blueprint_rrd
|
||||||
from k1link.viewer.replay import (
|
from .rerun_bridge import RerunBridge, RerunSceneSettings
|
||||||
ReplayFormatError,
|
|
||||||
detect_replay_format,
|
|
||||||
iter_replay_messages,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ReplayFormatError",
|
"RecordedBlueprintError",
|
||||||
"StreamMessage",
|
"RerunBridge",
|
||||||
"detect_replay_format",
|
"RerunSceneSettings",
|
||||||
"iter_replay_messages",
|
"recorded_blueprint_rrd",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Vendor-neutral Rerun blueprint for prepared observation recordings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import suppress
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import rerun as rr
|
||||||
|
from rerun import blueprint as rrb
|
||||||
|
|
||||||
|
from k1link.viewer.rerun_bridge import RerunSceneSettings, _parse_hex_color
|
||||||
|
|
||||||
|
APPLICATION_ID = "nodedc_mission_core_recorded"
|
||||||
|
SESSION_TIMELINE = "session_time"
|
||||||
|
|
||||||
|
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
|
||||||
|
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
|
||||||
|
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
|
||||||
|
|
||||||
|
|
||||||
|
class RecordedBlueprintError(RuntimeError):
|
||||||
|
"""A viewer blueprint update could not be serialized safely."""
|
||||||
|
|
||||||
|
|
||||||
|
def recorded_blueprint(
|
||||||
|
settings: RerunSceneSettings,
|
||||||
|
*,
|
||||||
|
include_initial_playback_state: bool = True,
|
||||||
|
) -> rrb.Blueprint:
|
||||||
|
accumulation = max(0.0, settings.accumulation_seconds)
|
||||||
|
time_ranges: list[rr.VisibleTimeRange] | None = None
|
||||||
|
if accumulation > 0:
|
||||||
|
time_ranges = [
|
||||||
|
rr.VisibleTimeRange(
|
||||||
|
SESSION_TIMELINE,
|
||||||
|
start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
|
||||||
|
end=rr.TimeRangeBoundary.cursor_relative(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
point_visualizer = rr.Points3D.from_fields(
|
||||||
|
radii=rr.Radius.ui_points(settings.point_size),
|
||||||
|
colors=(
|
||||||
|
[_parse_hex_color(settings.custom_color)]
|
||||||
|
if settings.palette == "custom"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
).visualizer()
|
||||||
|
point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
|
||||||
|
spatial_view = rrb.Spatial3DView(
|
||||||
|
origin="/world",
|
||||||
|
name="Пространственная сцена",
|
||||||
|
background=[7, 8, 10, 255],
|
||||||
|
line_grid=rrb.LineGrid3D(
|
||||||
|
visible=settings.show_grid,
|
||||||
|
color=[86, 91, 99, 110],
|
||||||
|
stroke_width=0.75,
|
||||||
|
),
|
||||||
|
overrides={
|
||||||
|
"/world/points": [
|
||||||
|
rrb.EntityBehavior(visible=settings.show_points),
|
||||||
|
point_visualizer,
|
||||||
|
],
|
||||||
|
"/world/trajectory": rrb.EntityBehavior(
|
||||||
|
visible=settings.show_trajectory,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
time_ranges=time_ranges,
|
||||||
|
)
|
||||||
|
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
|
||||||
|
root_container = rrb.Tabs(spatial_view)
|
||||||
|
root_container.id = RECORDED_ROOT_CONTAINER_ID
|
||||||
|
|
||||||
|
if include_initial_playback_state:
|
||||||
|
return rrb.Blueprint(
|
||||||
|
root_container,
|
||||||
|
rrb.TimePanel(
|
||||||
|
timeline=SESSION_TIMELINE,
|
||||||
|
play_state="paused",
|
||||||
|
state="hidden",
|
||||||
|
),
|
||||||
|
auto_layout=False,
|
||||||
|
auto_views=False,
|
||||||
|
collapse_panels=True,
|
||||||
|
)
|
||||||
|
return rrb.Blueprint(
|
||||||
|
root_container,
|
||||||
|
auto_layout=False,
|
||||||
|
auto_views=False,
|
||||||
|
collapse_panels=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def recorded_blueprint_rrd(
|
||||||
|
settings: RerunSceneSettings,
|
||||||
|
*,
|
||||||
|
application_id: str = APPLICATION_ID,
|
||||||
|
recording_id: str,
|
||||||
|
) -> bytes:
|
||||||
|
"""Serialize a bounded active blueprint update without recorded data."""
|
||||||
|
|
||||||
|
recording = rr.RecordingStream(
|
||||||
|
application_id,
|
||||||
|
recording_id=recording_id,
|
||||||
|
send_properties=False,
|
||||||
|
)
|
||||||
|
stream = rr.binary_stream(recording)
|
||||||
|
try:
|
||||||
|
recording.send_blueprint(
|
||||||
|
recorded_blueprint(
|
||||||
|
settings,
|
||||||
|
include_initial_playback_state=False,
|
||||||
|
),
|
||||||
|
make_active=True,
|
||||||
|
make_default=False,
|
||||||
|
)
|
||||||
|
payload = stream.read(flush=True, flush_timeout_sec=5.0)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RecordedBlueprintError("failed to serialize recorded blueprint") from exc
|
||||||
|
finally:
|
||||||
|
with suppress(Exception):
|
||||||
|
recording.disconnect()
|
||||||
|
if not payload or not payload.startswith(b"RRF2") or len(payload) > 1_048_576:
|
||||||
|
raise RecordedBlueprintError("serialized recorded blueprint is invalid")
|
||||||
|
return payload
|
||||||
|
|
@ -22,10 +22,7 @@ from k1link.sessions import (
|
||||||
SessionRecordingMaterializer,
|
SessionRecordingMaterializer,
|
||||||
SessionRecordingPreparationManager,
|
SessionRecordingPreparationManager,
|
||||||
SessionStore,
|
SessionStore,
|
||||||
recover_stale_active_session_marker,
|
|
||||||
resolve_missioncore_evidence_dir,
|
|
||||||
)
|
)
|
||||||
from k1link.web.camera_archive import recover_incomplete_camera_archives
|
|
||||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||||
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||||
from k1link.web.plugin_runtime import (
|
from k1link.web.plugin_runtime import (
|
||||||
|
|
@ -42,13 +39,14 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||||
INVALID_REQUEST_DETAIL = "Некорректные параметры запроса."
|
INVALID_REQUEST_DETAIL = "Некорректные параметры запроса."
|
||||||
|
|
||||||
|
|
||||||
legacy_observation_sessions_root = REPOSITORY_ROOT / "sessions"
|
|
||||||
observation_sessions_root = resolve_missioncore_evidence_dir(REPOSITORY_ROOT)
|
|
||||||
plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
|
plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
|
||||||
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
|
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
|
||||||
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
|
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
|
||||||
session_store = SessionStore(REPOSITORY_ROOT)
|
session_store = SessionStore(REPOSITORY_ROOT)
|
||||||
session_recording_materializer = SessionRecordingMaterializer(session_store.data_dir)
|
session_recording_materializer = SessionRecordingMaterializer(
|
||||||
|
session_store.data_dir,
|
||||||
|
exporters=plugin_environment.recording_exporters,
|
||||||
|
)
|
||||||
session_recorded_media_inspector = RecordedMediaInspector(
|
session_recorded_media_inspector = RecordedMediaInspector(
|
||||||
session_store.data_dir / "recorded-media-preparations"
|
session_store.data_dir / "recorded-media-preparations"
|
||||||
)
|
)
|
||||||
|
|
@ -76,8 +74,9 @@ def refresh_observation_catalog() -> tuple[str, ...]:
|
||||||
"""Discover completed or recoverable local evidence without copying payloads."""
|
"""Discover completed or recoverable local evidence without copying payloads."""
|
||||||
|
|
||||||
imported = [
|
imported = [
|
||||||
*session_store.import_legacy_viewer_live(legacy_observation_sessions_root),
|
session_id
|
||||||
*session_store.import_legacy_viewer_live(observation_sessions_root),
|
for archive in plugin_environment.observation_archives
|
||||||
|
for session_id in session_store.reconcile_archive(archive)
|
||||||
]
|
]
|
||||||
return tuple(dict.fromkeys(imported))
|
return tuple(dict.fromkeys(imported))
|
||||||
|
|
||||||
|
|
@ -138,12 +137,8 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||||
# Recovery is intentionally a one-shot startup phase. The archive
|
# Recovery is intentionally a one-shot startup phase. The archive
|
||||||
# helper owns a cross-process lease, while ordinary catalog requests
|
# helper owns a cross-process lease, while ordinary catalog requests
|
||||||
# only perform discovery and therefore never touch a live writer.
|
# only perform discovery and therefore never touch a live writer.
|
||||||
await asyncio.to_thread(recover_stale_active_session_marker, observation_sessions_root)
|
for archive in plugin_environment.observation_archives:
|
||||||
for sessions_root in (
|
await asyncio.to_thread(archive.recover, archive.root)
|
||||||
legacy_observation_sessions_root,
|
|
||||||
observation_sessions_root,
|
|
||||||
):
|
|
||||||
await asyncio.to_thread(recover_incomplete_camera_archives, sessions_root)
|
|
||||||
# Full evidence discovery, hashing and RRD queue reconciliation can be
|
# Full evidence discovery, hashing and RRD queue reconciliation can be
|
||||||
# expensive on field captures. Start it immediately in the background
|
# expensive on field captures. Start it immediately in the background
|
||||||
# instead of holding the ASGI startup gate.
|
# instead of holding the ASGI startup gate.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from k1link.sessions.plugin_contract import ObservationArchiveSource, RecordingExporter
|
||||||
from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest
|
from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest
|
||||||
from k1link.web.plugin_runtime import (
|
from k1link.web.plugin_runtime import (
|
||||||
DevicePluginDispatcher,
|
DevicePluginDispatcher,
|
||||||
|
|
@ -25,6 +26,23 @@ class InstalledDevicePluginEnvironment:
|
||||||
legacy_routers: tuple[APIRouter, ...]
|
legacy_routers: tuple[APIRouter, ...]
|
||||||
_contributions: tuple[DevicePluginRuntimeContribution, ...]
|
_contributions: tuple[DevicePluginRuntimeContribution, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def observation_archives(self) -> tuple[ObservationArchiveSource, ...]:
|
||||||
|
return tuple(
|
||||||
|
archive
|
||||||
|
for contribution in self._contributions
|
||||||
|
if contribution.observation is not None
|
||||||
|
for archive in contribution.observation.archives
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def recording_exporters(self) -> dict[str, RecordingExporter]:
|
||||||
|
return {
|
||||||
|
contribution.adapter.plugin_id: contribution.observation.recording_exporter
|
||||||
|
for contribution in self._contributions
|
||||||
|
if contribution.observation is not None
|
||||||
|
}
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
cleanup_errors = _close_contributions(self._contributions)
|
cleanup_errors = _close_contributions(self._contributions)
|
||||||
if cleanup_errors:
|
if cleanup_errors:
|
||||||
|
|
@ -103,6 +121,31 @@ def _load_contribution(
|
||||||
raise DevicePluginCompositionError(
|
raise DevicePluginCompositionError(
|
||||||
f"Device-plugin manifest/runtime actions mismatch for {adapter.plugin_id}"
|
f"Device-plugin manifest/runtime actions mismatch for {adapter.plugin_id}"
|
||||||
)
|
)
|
||||||
|
observation = contribution.observation
|
||||||
|
if observation is not None:
|
||||||
|
if not observation.archives:
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
f"Device-plugin observation contribution is empty for {adapter.plugin_id}"
|
||||||
|
)
|
||||||
|
archive_ids: set[str] = set()
|
||||||
|
archive_roots: set[Path] = set()
|
||||||
|
for archive in observation.archives:
|
||||||
|
if archive.plugin_id != adapter.plugin_id:
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
"Device-plugin observation/runtime id mismatch: "
|
||||||
|
f"{archive.plugin_id} != {adapter.plugin_id}"
|
||||||
|
)
|
||||||
|
if archive.archive_id in archive_ids:
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
f"Duplicate observation archive id for {adapter.plugin_id}"
|
||||||
|
)
|
||||||
|
resolved_root = archive.root.expanduser().resolve()
|
||||||
|
if resolved_root in archive_roots:
|
||||||
|
raise DevicePluginCompositionError(
|
||||||
|
f"Duplicate observation archive root for {adapter.plugin_id}"
|
||||||
|
)
|
||||||
|
archive_ids.add(archive.archive_id)
|
||||||
|
archive_roots.add(resolved_root)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_add_cleanup_notes(exc, _close_contributions((contribution,)))
|
_add_cleanup_notes(exc, _close_contributions((contribution,)))
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,16 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation, RuntimeActionResult
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from k1link.sessions.plugin_contract import ObservationRuntimeContribution
|
||||||
|
|
||||||
STATE_READ_ACTION_ID = "state.read"
|
STATE_READ_ACTION_ID = "state.read"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -20,7 +25,7 @@ class DevicePluginActionAdapter(Protocol):
|
||||||
plugin_id: str
|
plugin_id: str
|
||||||
action_ids: frozenset[str]
|
action_ids: frozenset[str]
|
||||||
|
|
||||||
async def invoke(self, action_id: str, payload: Mapping[str, Any]) -> dict[str, Any]: ...
|
async def invoke(self, invocation: RuntimeActionInvocation) -> Mapping[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
def _noop() -> None:
|
def _noop() -> None:
|
||||||
|
|
@ -33,6 +38,7 @@ class DevicePluginRuntimeContribution:
|
||||||
|
|
||||||
adapter: DevicePluginActionAdapter
|
adapter: DevicePluginActionAdapter
|
||||||
legacy_routers: tuple[APIRouter, ...] = ()
|
legacy_routers: tuple[APIRouter, ...] = ()
|
||||||
|
observation: ObservationRuntimeContribution | None = None
|
||||||
close: Callable[[], None] = _noop
|
close: Callable[[], None] = _noop
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -71,8 +77,33 @@ class DevicePluginDispatcher:
|
||||||
adapter = self._adapters.get(plugin_id)
|
adapter = self._adapters.get(plugin_id)
|
||||||
if adapter is None:
|
if adapter is None:
|
||||||
raise PluginNotFoundError(f"Device plugin is not installed: {plugin_id}")
|
raise PluginNotFoundError(f"Device plugin is not installed: {plugin_id}")
|
||||||
|
return await invoke_device_plugin_adapter(adapter, action_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def invoke_device_plugin_adapter(
|
||||||
|
adapter: DevicePluginActionAdapter,
|
||||||
|
action_id: str,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate SDK request/result envelopes around one plugin action call."""
|
||||||
|
|
||||||
if action_id not in adapter.action_ids:
|
if action_id not in adapter.action_ids:
|
||||||
raise PluginActionNotFoundError(
|
raise PluginActionNotFoundError(
|
||||||
f"Device plugin {plugin_id} does not declare action {action_id}"
|
f"Device plugin {adapter.plugin_id} does not declare action {action_id}"
|
||||||
)
|
)
|
||||||
return await adapter.invoke(action_id, payload)
|
invocation = RuntimeActionInvocation(
|
||||||
|
invocation_id=uuid4().hex,
|
||||||
|
plugin_id=adapter.plugin_id,
|
||||||
|
action_id=action_id,
|
||||||
|
requested_at=datetime.now(UTC),
|
||||||
|
parameters=dict(payload),
|
||||||
|
)
|
||||||
|
output = await adapter.invoke(invocation)
|
||||||
|
result = RuntimeActionResult(
|
||||||
|
invocation_id=invocation.invocation_id,
|
||||||
|
plugin_id=invocation.plugin_id,
|
||||||
|
action_id=invocation.action_id,
|
||||||
|
completed_at=datetime.now(UTC),
|
||||||
|
output=dict(output),
|
||||||
|
)
|
||||||
|
return dict(result.output)
|
||||||
|
|
|
||||||
|
|
@ -31,16 +31,15 @@ from k1link.sessions import (
|
||||||
SessionStore,
|
SessionStore,
|
||||||
validate_recorded_media_timeline,
|
validate_recorded_media_timeline,
|
||||||
)
|
)
|
||||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
from k1link.viewer.recorded import (
|
||||||
from k1link.viewer.rrd_export import (
|
|
||||||
APPLICATION_ID as RECORDED_APPLICATION_ID,
|
APPLICATION_ID as RECORDED_APPLICATION_ID,
|
||||||
)
|
)
|
||||||
from k1link.viewer.rrd_export import (
|
from k1link.viewer.recorded import (
|
||||||
RrdExportError,
|
RecordedBlueprintError,
|
||||||
recorded_blueprint_rrd,
|
recorded_blueprint_rrd,
|
||||||
)
|
)
|
||||||
|
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||||
|
|
||||||
DEFAULT_REPLAY_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
|
||||||
DEFAULT_REPLAY_ACTION_ID = "stream.start-replay"
|
DEFAULT_REPLAY_ACTION_ID = "stream.start-replay"
|
||||||
SAFE_SOURCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")
|
SAFE_SOURCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")
|
||||||
SAFE_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
SAFE_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||||
|
|
@ -234,7 +233,6 @@ def build_session_router(
|
||||||
recording_preparation_manager: SessionRecordingPreparationManager | None = None,
|
recording_preparation_manager: SessionRecordingPreparationManager | None = None,
|
||||||
media_inspector: RecordedMediaInspector | None = None,
|
media_inspector: RecordedMediaInspector | None = None,
|
||||||
allow_synchronous_recording_fallback: bool = False,
|
allow_synchronous_recording_fallback: bool = False,
|
||||||
replay_plugin_id: str = DEFAULT_REPLAY_PLUGIN_ID,
|
|
||||||
replay_action_id: str = DEFAULT_REPLAY_ACTION_ID,
|
replay_action_id: str = DEFAULT_REPLAY_ACTION_ID,
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
"""Build generic host APIs without exposing filesystem locators to clients."""
|
"""Build generic host APIs without exposing filesystem locators to clients."""
|
||||||
|
|
@ -388,7 +386,7 @@ def build_session_router(
|
||||||
"schema_version": "missioncore.observation-session-replay/v1",
|
"schema_version": "missioncore.observation-session-replay/v1",
|
||||||
"launch": {
|
"launch": {
|
||||||
"kind": "plugin-action",
|
"kind": "plugin-action",
|
||||||
"plugin_id": replay_plugin_id,
|
"plugin_id": command.plugin_id,
|
||||||
"action_id": replay_action_id,
|
"action_id": replay_action_id,
|
||||||
"session_id": command.session_id,
|
"session_id": command.session_id,
|
||||||
"speed": command.speed,
|
"speed": command.speed,
|
||||||
|
|
@ -671,7 +669,7 @@ def build_session_router(
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except (SessionNotReplayableError, SessionIntegrityError) as exc:
|
except (SessionNotReplayableError, SessionIntegrityError) as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
except RrdExportError as exc:
|
except RecordedBlueprintError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail="Не удалось подготовить настройки визуализатора.",
|
detail="Не удалось подготовить настройки визуализатора.",
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1.archive import discover_legacy_viewer_sessions
|
||||||
from k1link.sessions import ActiveSessionLease, recover_stale_active_session_marker
|
from k1link.sessions import ActiveSessionLease, recover_stale_active_session_marker
|
||||||
from k1link.sessions.legacy import discover_legacy_viewer_sessions
|
|
||||||
|
|
||||||
|
|
||||||
def test_active_session_lease_hides_live_evidence_until_release(tmp_path: Path) -> None:
|
def test_active_session_lease_hides_live_evidence_until_release(tmp_path: Path) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from bleak.backends.device import BLEDevice
|
from bleak.backends.device import BLEDevice
|
||||||
from bleak.backends.scanner import AdvertisementData
|
from bleak.backends.scanner import AdvertisementData
|
||||||
|
|
||||||
from k1link.ble.scanner import advertisement_record
|
from k1link.device_plugins.xgrids_k1.ble.scanner import advertisement_record
|
||||||
|
|
||||||
|
|
||||||
def test_advertisement_record_marks_k1_candidate() -> None:
|
def test_advertisement_record_marks_k1_candidate() -> None:
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from k1link.sessions.legacy import discover_legacy_viewer_sessions
|
from k1link.device_plugins.xgrids_k1.archive import discover_legacy_viewer_sessions
|
||||||
from k1link.web.camera_archive import (
|
from k1link.web.camera_archive import (
|
||||||
CAMERA_ARCHIVE_SCHEMA,
|
CAMERA_ARCHIVE_SCHEMA,
|
||||||
CAMERA_COMMIT_POLICY,
|
CAMERA_COMMIT_POLICY,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from pathlib import Path
|
||||||
import lz4.block
|
import lz4.block
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import k1link.viewer.runtime as runtime_module
|
import k1link.device_plugins.xgrids_k1.viewer.runtime as runtime_module
|
||||||
from k1link.data_plane import (
|
from k1link.data_plane import (
|
||||||
ConsumerFrameContext,
|
ConsumerFrameContext,
|
||||||
DecodedDeviceStatusView,
|
DecodedDeviceStatusView,
|
||||||
|
|
@ -16,11 +16,11 @@ from k1link.data_plane import (
|
||||||
DecodedPoseView,
|
DecodedPoseView,
|
||||||
NormalizationError,
|
NormalizationError,
|
||||||
)
|
)
|
||||||
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
||||||
from k1link.protocol.normalizer import normalize_k1_message
|
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
|
||||||
from k1link.viewer.messages import StreamMessage
|
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
|
||||||
|
from k1link.device_plugins.xgrids_k1.viewer.runtime import VisualizationRuntime
|
||||||
from k1link.viewer.rerun_bridge import RerunBridge
|
from k1link.viewer.rerun_bridge import RerunBridge
|
||||||
from k1link.viewer.runtime import VisualizationRuntime
|
|
||||||
|
|
||||||
|
|
||||||
class FakeRecording:
|
class FakeRecording:
|
||||||
|
|
@ -232,7 +232,7 @@ def test_canonical_contracts_reject_misaligned_data_and_support_status() -> None
|
||||||
def test_rerun_bridge_source_has_no_vendor_protocol_or_raw_transport_knowledge() -> None:
|
def test_rerun_bridge_source_has_no_vendor_protocol_or_raw_transport_knowledge() -> None:
|
||||||
source = inspect.getsource(__import__("k1link.viewer.rerun_bridge", fromlist=["*"]))
|
source = inspect.getsource(__import__("k1link.viewer.rerun_bridge", fromlist=["*"]))
|
||||||
for forbidden in (
|
for forbidden in (
|
||||||
"k1link.protocol",
|
"k1link.device_plugins.xgrids_k1.protocol",
|
||||||
"StreamMessage",
|
"StreamMessage",
|
||||||
"RealtimePointcloud",
|
"RealtimePointcloud",
|
||||||
"RealtimePath",
|
"RealtimePath",
|
||||||
|
|
@ -245,9 +245,14 @@ def test_rerun_bridge_source_has_no_vendor_protocol_or_raw_transport_knowledge()
|
||||||
|
|
||||||
|
|
||||||
def test_visual_runtime_has_no_implicit_vendor_normalizer() -> None:
|
def test_visual_runtime_has_no_implicit_vendor_normalizer() -> None:
|
||||||
source = inspect.getsource(__import__("k1link.viewer.runtime", fromlist=["*"]))
|
source = inspect.getsource(
|
||||||
|
__import__(
|
||||||
|
"k1link.device_plugins.xgrids_k1.viewer.runtime",
|
||||||
|
fromlist=["*"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
assert "k1link.protocol" not in source
|
assert "k1link.device_plugins.xgrids_k1.protocol" not in source
|
||||||
assert "normalize_k1_message" not in source
|
assert "normalize_k1_message" not in source
|
||||||
assert "normalizer: CanonicalNormalizer" in source
|
assert "normalizer: CanonicalNormalizer" in source
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from typing import Any
|
||||||
|
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from k1link.cli import app
|
from k1link.device_plugins.xgrids_k1.cli import app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ def test_mqtt_capture_cli_uses_bounded_read_only_capture(
|
||||||
"payload_bytes": 128,
|
"payload_bytes": 128,
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr("k1link.cli.capture_mqtt", fake_capture)
|
monkeypatch.setattr("k1link.device_plugins.xgrids_k1.cli.capture_mqtt", fake_capture)
|
||||||
out = tmp_path / "capture"
|
out = tmp_path / "capture"
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from k1link.sessions import (
|
||||||
|
ObservationArchiveSource,
|
||||||
|
ObservationArtifactCandidate,
|
||||||
|
ObservationSessionCandidate,
|
||||||
|
SessionRecordingMaterializer,
|
||||||
|
SessionStore,
|
||||||
|
)
|
||||||
|
from k1link.sessions.models import SessionSource
|
||||||
|
|
||||||
|
SYNTHETIC_PLUGIN_ID = "example.synthetic-spatial-sensor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_device_can_discover_catalog_and_prepare_without_core_changes(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
archive_root = tmp_path / "synthetic-evidence"
|
||||||
|
session_root = archive_root / "synthetic-session-001"
|
||||||
|
session_root.mkdir(parents=True)
|
||||||
|
native = session_root / "observation.synthetic"
|
||||||
|
native.write_bytes(b"synthetic vendor-native evidence")
|
||||||
|
source = ObservationArchiveSource(
|
||||||
|
plugin_id=SYNTHETIC_PLUGIN_ID,
|
||||||
|
archive_id="synthetic.archive.v1",
|
||||||
|
root=archive_root,
|
||||||
|
discover=lambda root: (_synthetic_candidate(root, session_root, native),),
|
||||||
|
)
|
||||||
|
store = SessionStore(tmp_path / "repository", data_dir=tmp_path / "host-data")
|
||||||
|
|
||||||
|
assert store.reconcile_archive(source) == ("synthetic-session-001",)
|
||||||
|
command = store.prepare_replay("synthetic-session-001")
|
||||||
|
assert command.plugin_id == SYNTHETIC_PLUGIN_ID
|
||||||
|
assert command.primary_artifact.media_type == "application/x-synthetic-spatial"
|
||||||
|
|
||||||
|
materializer = SessionRecordingMaterializer(
|
||||||
|
store.data_dir,
|
||||||
|
exporters={SYNTHETIC_PLUGIN_ID: _synthetic_exporter},
|
||||||
|
)
|
||||||
|
recording = materializer.materialize(command)
|
||||||
|
|
||||||
|
assert recording.path.read_bytes().startswith(b"synthetic-rrd:")
|
||||||
|
assert recording.timeline == "session_time"
|
||||||
|
assert recording.timeline_start_ns == 0
|
||||||
|
assert recording.timeline_end_ns == 1_000_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_observation_hot_path_contains_no_concrete_device_vocabulary() -> None:
|
||||||
|
repository_root = Path(__file__).resolve().parents[1]
|
||||||
|
generic_paths = (
|
||||||
|
*(repository_root / "src" / "k1link" / "sessions").glob("*.py"),
|
||||||
|
repository_root / "src" / "k1link" / "web" / "app.py",
|
||||||
|
repository_root / "src" / "k1link" / "web" / "session_api.py",
|
||||||
|
repository_root / "src" / "k1link" / "web" / "plugin_runtime.py",
|
||||||
|
repository_root / "src" / "k1link" / "web" / "device_plugin_composition.py",
|
||||||
|
repository_root / "src" / "k1link" / "viewer" / "recorded.py",
|
||||||
|
repository_root / "src" / "k1link" / "viewer" / "rerun_bridge.py",
|
||||||
|
)
|
||||||
|
forbidden = (
|
||||||
|
"xgrids",
|
||||||
|
"k1mqtt",
|
||||||
|
"realtimepointcloud",
|
||||||
|
"realtimepath",
|
||||||
|
"lio_pcl",
|
||||||
|
"lio_pose",
|
||||||
|
"mqtt.metadata",
|
||||||
|
"k1link.device_plugins",
|
||||||
|
)
|
||||||
|
|
||||||
|
violations = {
|
||||||
|
str(path.relative_to(repository_root)): token
|
||||||
|
for path in generic_paths
|
||||||
|
for token in forbidden
|
||||||
|
if token in path.read_text(encoding="utf-8").casefold()
|
||||||
|
}
|
||||||
|
|
||||||
|
assert violations == {}
|
||||||
|
|
||||||
|
|
||||||
|
def _synthetic_candidate(
|
||||||
|
archive_root: Path,
|
||||||
|
session_root: Path,
|
||||||
|
native: Path,
|
||||||
|
) -> ObservationSessionCandidate:
|
||||||
|
artifact_id = "native-primary"
|
||||||
|
return ObservationSessionCandidate(
|
||||||
|
session_id="synthetic-session-001",
|
||||||
|
display_name="Synthetic spatial sensor",
|
||||||
|
status="ready",
|
||||||
|
started_at_utc="2026-07-17T10:00:00Z",
|
||||||
|
completed_at_utc="2026-07-17T10:00:01Z",
|
||||||
|
duration_seconds=1.0,
|
||||||
|
modalities=("point-cloud",),
|
||||||
|
replayable=True,
|
||||||
|
total_bytes=native.stat().st_size,
|
||||||
|
allowed_root=archive_root.resolve(),
|
||||||
|
session_root=session_root.resolve(),
|
||||||
|
primary_replay_artifact_id=artifact_id,
|
||||||
|
timeline_origin_epoch_ns=1_700_000_000_000_000_000,
|
||||||
|
timeline_origin_monotonic_ns=10_000_000,
|
||||||
|
sources=(
|
||||||
|
SessionSource(
|
||||||
|
source_id="spatial.primary",
|
||||||
|
semantic_channel_id="spatial.point-cloud.recorded",
|
||||||
|
modality="point-cloud",
|
||||||
|
status="recorded",
|
||||||
|
seekable=True,
|
||||||
|
artifact_id=artifact_id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
artifacts=(
|
||||||
|
ObservationArtifactCandidate(
|
||||||
|
artifact_id=artifact_id,
|
||||||
|
kind="native-observation",
|
||||||
|
media_type="application/x-synthetic-spatial",
|
||||||
|
locator=native.resolve(),
|
||||||
|
byte_length=native.stat().st_size,
|
||||||
|
replay_byte_length=native.stat().st_size,
|
||||||
|
sha256=None,
|
||||||
|
integrity_status="validated-structure",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _synthetic_exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
payload = b"synthetic-rrd:" + source.read_bytes()
|
||||||
|
destination.write_bytes(payload)
|
||||||
|
return {
|
||||||
|
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||||
|
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
"rrd_bytes": len(payload),
|
||||||
|
"timeline": "session_time",
|
||||||
|
"timeline_start_ns": 0,
|
||||||
|
"timeline_end_ns": 1_000_000_000,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue