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
|
||||
Foxglove bridge remains only as a legacy regression module.
|
||||
|
||||
The repository is intentionally migrating in stages. The current `src/k1link`
|
||||
package is the compatibility implementation of the first plugin path; vendor
|
||||
transport and codecs will move behind `plugins/xgrids-k1` and the Mission Core
|
||||
Plugin SDK without changing the verified wire protocol or raw evidence format.
|
||||
The backend vendor implementation is physically isolated below
|
||||
`src/k1link/device_plugins/xgrids_k1/`; its reviewed manifest and compatibility
|
||||
profile remain below `plugins/xgrids-k1/`. Mission Core host code discovers
|
||||
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,
|
||||
operation, stream, evidence and compatibility contracts. The meanings remain a
|
||||
local experimental vocabulary rather than a mutation of NODE.DC Platform
|
||||
Ontology. The current runtime is still transitional and in process: it 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.
|
||||
operation, runtime-action, stream, evidence and compatibility contracts. Every
|
||||
backend action crosses immutable SDK `RuntimeActionInvocation` and
|
||||
`RuntimeActionResult` validation in the actual dispatcher hot path. The meanings
|
||||
remain a local experimental vocabulary rather than a mutation of NODE.DC
|
||||
Platform Ontology. The device process is still transitional and in process: it
|
||||
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
|
||||
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
|
||||
a K1-specific Foxglove launcher. Its fixed shell contains six architectural
|
||||
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
|
||||
repository root:
|
||||
|
|
@ -170,7 +177,7 @@ index use Mac receive/arrival timestamps, not proven K1 sensor timestamps or a
|
|||
photon-to-screen measurement.
|
||||
|
||||
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 viewer runbook](docs/06_K1_LIVE_VIEWER.md) records the active Rerun path and
|
||||
its timing/security boundaries; the frontend contract is documented in
|
||||
|
|
|
|||
|
|
@ -18,13 +18,14 @@ device adapter, но структура интерфейса от него не
|
|||
| --- | --- | --- |
|
||||
| Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
|
||||
| Device plugin registry | Реализован, v1alpha1 + v1alpha2 | До выбора модели provider остаётся inert и не делает I/O. v1alpha1 сохраняет одну модель; v1alpha2 допускает одну или несколько моделей и требует profile coverage каждой. Custom `device.connection` UI key и backend factory подключаются одним reviewed import в composition root. |
|
||||
| Plugin-owned connection UI | Реализован | XGRIDS provisioning, acquisition/replay, diagnostics, metrics и scoped styles физически находятся в `plugins/xgrids-k1/frontend`; generic Control Station предоставляет только frontend SDK, model catalog и host slot. |
|
||||
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
|
||||
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
|
||||
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
|
||||
| Автоматический MQTT → Rerun | Реализован | Первый live/adapter file-replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие такие сессии переиспользуют его. Saved observation replay использует отдельный immutable HTTP RRD path. |
|
||||
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
|
||||
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. |
|
||||
| Сохранённые observation sessions | Реализованы для point/pose | Три последние сессии, background preparation, cache v6, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. |
|
||||
| Сохранённые observation sessions | Реализованы для point/pose | Три последние сессии, background preparation, cache v7, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. |
|
||||
| K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но реальная архивная K1 camera-session ещё не прошла physical acceptance. |
|
||||
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
|
||||
| Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. |
|
||||
|
|
@ -54,7 +55,7 @@ Mission Core Control Station ←→ REST /api/v1/device-plugins/*
|
|||
|
||||
sealed/recovered observation session
|
||||
└── SQLite catalog + bounded background preparation
|
||||
└── atomic RRD cache v6 + recorded-media manifest v2
|
||||
└── atomic RRD cache v7 + recorded-media manifest v2
|
||||
└── generation-bound same-origin HTTP
|
||||
└── aggregate admission
|
||||
└── Rerun native receiver + recorded fMP4 player
|
||||
|
|
@ -280,11 +281,11 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
|
|||
| --- | --- |
|
||||
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
|
||||
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
|
||||
| `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle и plugin host. |
|
||||
| `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle, plugin host и public frontend SDK surface. |
|
||||
| `src/core/runtime/` | Нормализованное состояние активного устройства и spatial source. |
|
||||
| `src/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. |
|
||||
| `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. |
|
||||
| `src/device-plugins/xgrids-k1/` | Реальный K1 BLE/Wi-Fi/live/replay UI, client и compatibility mapper. |
|
||||
| `../../plugins/xgrids-k1/frontend/` | Plugin-owned K1 BLE/Wi-Fi, acquisition/replay UI, API client, runtime mapper и scoped styles. |
|
||||
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
|
||||
| `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. |
|
||||
| `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
|
||||
import { xgridsK1Plugin } from "../device-plugins/xgrids-k1/plugin";
|
||||
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
|
||||
|
||||
// Composition root: this is the only place where Mission Core chooses which
|
||||
// statically reviewed device plugins are shipped in the current build.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Public frontend host surface for statically reviewed device plugins.
|
||||
*
|
||||
* Plugin source may depend on this module through the
|
||||
* `@mission-core/plugin-sdk` alias. It must not reach into Control Station
|
||||
* implementation paths directly.
|
||||
*/
|
||||
export * from "./contracts";
|
||||
export { parseDevicePluginManifest, requirePluginAction } from "./manifestParser";
|
||||
export {
|
||||
MissionRuntimeProvider,
|
||||
useMissionRuntime,
|
||||
} from "../runtime/MissionRuntimeContext";
|
||||
export type * from "../runtime/contracts";
|
||||
|
|
@ -1,703 +0,0 @@
|
|||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
type StatusTone,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { DevicePluginConnectionProps } from "../../core/device-plugins/contracts";
|
||||
import { MetricCard } from "../../components/MetricCard";
|
||||
import type { BleDevice, CompatibilityAttestation } from "./api";
|
||||
import {
|
||||
isConfirmedLiveState,
|
||||
isSourceRuntimeBusy,
|
||||
provisioningIntentKey,
|
||||
recoverableAcquisition,
|
||||
sourceStatusLabel,
|
||||
} from "./lifecycle";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import {
|
||||
backendLabel,
|
||||
backendTone,
|
||||
eventStatusLabel,
|
||||
finiteMetric,
|
||||
formatNumber,
|
||||
phaseLabel,
|
||||
phaseTone,
|
||||
pipelineLatency,
|
||||
} from "./presentation";
|
||||
import { useXgridsK1Controller } from "./runtimeContext";
|
||||
|
||||
type SessionIntent = "live" | "replay";
|
||||
|
||||
const sessionItems = [
|
||||
{ value: "live", label: "Реальное устройство" },
|
||||
{ value: "replay", label: "Повтор записи" },
|
||||
] satisfies Array<{ value: SessionIntent; label: string }>;
|
||||
|
||||
const EXACT_PROFILE_ATTESTATION: CompatibilityAttestation = {
|
||||
firmware_version: "3.0.2",
|
||||
topology: "direct-lan",
|
||||
operator_confirmed: true,
|
||||
};
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="detail-row">
|
||||
<dt>{label}</dt>
|
||||
<dd>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardStep({
|
||||
number,
|
||||
title,
|
||||
status,
|
||||
tone = "neutral",
|
||||
children,
|
||||
}: {
|
||||
number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
tone?: StatusTone;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="wizard-step">
|
||||
<div className="wizard-step__rail" aria-hidden="true">
|
||||
<span>{number}</span>
|
||||
</div>
|
||||
<div className="wizard-step__content">
|
||||
<header>
|
||||
<h3>{title}</h3>
|
||||
<StatusBadge tone={tone}>{status}</StatusBadge>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceRow({
|
||||
device,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
device: BleDevice;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="device-row"
|
||||
data-compatible={device.likely_k1 ? "true" : undefined}
|
||||
data-selected={selected ? "true" : undefined}
|
||||
>
|
||||
<div className="device-row__identity">
|
||||
<span className="device-row__signal" aria-hidden="true" />
|
||||
<div>
|
||||
<span className="device-row__name">
|
||||
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
|
||||
{device.likely_k1 ? <small>Кандидат по имени; профиль не подтверждён</small> : null}
|
||||
</span>
|
||||
<code>{device.device_id}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="device-row__action">
|
||||
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={selected ? "primary" : "secondary"}
|
||||
disabled={device.connectable === false}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{selected ? "Выбрано" : "Выбрать"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LatencyTrace({ values }: { values: number[] }) {
|
||||
const ceiling = Math.max(16, ...values);
|
||||
|
||||
return (
|
||||
<div className="latency-trace" aria-label="Последние измерения времени до публикации">
|
||||
{values.length ? (
|
||||
values.map((value, index) => (
|
||||
<span
|
||||
key={`${index}-${value}`}
|
||||
style={{ height: `${Math.max(8, Math.min(100, (value / ceiling) * 100))}%` }}
|
||||
title={`${value.toFixed(1)} мс`}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p>Измерений пока нет. График появится после получения реальных данных.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
|
||||
const console = useXgridsK1Controller();
|
||||
const {
|
||||
state,
|
||||
backendStatus,
|
||||
eventStatus,
|
||||
pendingAction,
|
||||
error,
|
||||
latencyHistory,
|
||||
refresh,
|
||||
clearError,
|
||||
scan,
|
||||
connect,
|
||||
prepareAndStartAcquisition,
|
||||
startReplay,
|
||||
stop,
|
||||
abort,
|
||||
} = console;
|
||||
|
||||
const [powerConfirmed, setPowerConfirmed] = useState(false);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
||||
const [ssid, setSsid] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [profileConfirmed, setProfileConfirmed] = useState(false);
|
||||
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||
const [liveHost, setLiveHost] = useState("");
|
||||
const [replayPath, setReplayPath] = useState("");
|
||||
const [replaySpeed, setReplaySpeed] = useState("1");
|
||||
const [replayLoop, setReplayLoop] = useState(false);
|
||||
const provisioningIntentRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.selected_device_id) {
|
||||
if (state.selected_device_id !== selectedDeviceId) {
|
||||
setProfileConfirmed(false);
|
||||
provisioningIntentRef.current = null;
|
||||
}
|
||||
setSelectedDeviceId(state.selected_device_id);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedDeviceId &&
|
||||
state?.devices &&
|
||||
!state.devices.some((device) => device.device_id === selectedDeviceId)
|
||||
) {
|
||||
setSelectedDeviceId("");
|
||||
}
|
||||
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.source_mode === "live" || state?.source_mode === "replay") {
|
||||
setSessionIntent(state.source_mode);
|
||||
} else if (state?.acquisition?.state === "prepared") {
|
||||
setSessionIntent("live");
|
||||
}
|
||||
}, [state?.acquisition?.state, state?.source_mode]);
|
||||
|
||||
const confirmedLive = isConfirmedLiveState(state);
|
||||
const streamActive = confirmedLive || state?.source_mode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
const droppedFrames = finiteMetric(metrics?.dropped_preview_frames);
|
||||
const devices = state?.devices ?? [];
|
||||
const isBusy = pendingAction !== null;
|
||||
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
||||
const canConnect =
|
||||
powerConfirmed &&
|
||||
profileConfirmed &&
|
||||
selectedDeviceId.length > 0 &&
|
||||
credentialsReady &&
|
||||
!isBusy;
|
||||
const activeAcquisition = recoverableAcquisition(state);
|
||||
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
|
||||
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
|
||||
const sessionLocked = sourceRuntimeBusy || activeAcquisition !== null;
|
||||
const effectiveSessionIntent: SessionIntent =
|
||||
state?.source_mode === "live" || state?.source_mode === "replay"
|
||||
? state.source_mode
|
||||
: activeAcquisition
|
||||
? "live"
|
||||
: sessionIntent;
|
||||
const liveTargetReady = Boolean(
|
||||
state?.k1_ip || liveHost.trim() || preparedAcquisition?.target_host,
|
||||
);
|
||||
const sourceLabel = sourceStatusLabel(state);
|
||||
const relevantAcquisitionFailed =
|
||||
state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
|
||||
const sourceTone: StatusTone =
|
||||
state?.phase === "error" || relevantAcquisitionFailed
|
||||
? "danger"
|
||||
: confirmedLive || state?.source_mode === "replay"
|
||||
? "success"
|
||||
: sourceRuntimeBusy || preparedAcquisition
|
||||
? "warning"
|
||||
: "neutral";
|
||||
const connectionPhaseLabel =
|
||||
sourceRuntimeBusy || preparedAcquisition ? sourceLabel : phaseLabel(state?.phase);
|
||||
const connectionPhaseTone =
|
||||
sourceRuntimeBusy || preparedAcquisition ? sourceTone : phaseTone(state?.phase);
|
||||
const selectableSessionItems = useMemo(
|
||||
() => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })),
|
||||
[sessionLocked],
|
||||
);
|
||||
|
||||
const deviceSummary = useMemo(
|
||||
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||
[devices, selectedDeviceId],
|
||||
);
|
||||
|
||||
const submitConnect = async () => {
|
||||
if (!canConnect) return;
|
||||
const idempotencyKey = provisioningIntentKey(provisioningIntentRef.current);
|
||||
provisioningIntentRef.current = idempotencyKey;
|
||||
const succeeded = await connect({
|
||||
device_id: selectedDeviceId,
|
||||
ssid: ssid.trim(),
|
||||
password,
|
||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
||||
idempotency_key: idempotencyKey,
|
||||
});
|
||||
if (succeeded) {
|
||||
provisioningIntentRef.current = null;
|
||||
setPassword("");
|
||||
}
|
||||
};
|
||||
|
||||
const submitLive = async () => {
|
||||
if (!profileConfirmed || sourceRuntimeBusy) return;
|
||||
const targetHost = liveHost.trim();
|
||||
const started = await prepareAndStartAcquisition({
|
||||
...(targetHost ? { host: targetHost } : {}),
|
||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
||||
});
|
||||
if (started) host.openSpatialScene();
|
||||
};
|
||||
|
||||
const submitReplay = async () => {
|
||||
const speed = Number(replaySpeed);
|
||||
const started = await startReplay({
|
||||
path: replayPath.trim(),
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
|
||||
loop: replayLoop,
|
||||
});
|
||||
if (started) host.openSpatialScene();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="device-workspace xgrids-k1-plugin">
|
||||
{error ? (
|
||||
<aside className="error-banner" role="alert">
|
||||
<span className="error-banner__dot" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Локальная операция завершилась ошибкой</strong>
|
||||
<p>{localizeRuntimeMessage(error)}</p>
|
||||
</div>
|
||||
<div className="error-banner__actions">
|
||||
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
|
||||
Обновить состояние
|
||||
</Button>
|
||||
<Button size="compact" variant="ghost" onClick={clearError}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">РАБОЧИЙ АДАПТЕР УСТРОЙСТВА</span>
|
||||
<h2>Подключение {model.displayName}</h2>
|
||||
<p>
|
||||
Этот путь уже работает физически, но остаётся изолированным адаптером. Парковая и
|
||||
операторская модель от конкретного устройства не зависят.
|
||||
</p>
|
||||
</div>
|
||||
<div className="workspace-lead__status">
|
||||
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
|
||||
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
|
||||
<MetricCard
|
||||
featured
|
||||
eyebrow="ДО ПУБЛИКАЦИИ"
|
||||
value={formatNumber(latency)}
|
||||
unit="мс"
|
||||
detail="MQTT callback → Rerun SDK; без экрана"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ЧАСТОТА КАДРОВ"
|
||||
value={formatNumber(frameRate)}
|
||||
unit="кадр/с"
|
||||
detail="Последнее измерение адаптера"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ТОЧЕК В КАДРЕ"
|
||||
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Реальное число декодированных точек"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
|
||||
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Исходные данные при этом сохраняются"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="device-workspace__grid">
|
||||
<GlassSurface className="connection-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span>
|
||||
<h2>Подключите устройство к сети</h2>
|
||||
</div>
|
||||
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
|
||||
</header>
|
||||
|
||||
<div className="wizard-list">
|
||||
<WizardStep
|
||||
number="01"
|
||||
title="Включите устройство"
|
||||
status={powerConfirmed ? "Подтверждено" : "Ожидает"}
|
||||
tone={powerConfirmed ? "success" : "warning"}
|
||||
>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">
|
||||
Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия.
|
||||
</span>
|
||||
<Checker
|
||||
checked={powerConfirmed}
|
||||
label="Устройство включено, индикатор стабилен"
|
||||
onChange={(checked) => {
|
||||
setPowerConfirmed(checked);
|
||||
if (!checked) {
|
||||
setProfileConfirmed(false);
|
||||
provisioningIntentRef.current = null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</WizardStep>
|
||||
|
||||
<WizardStep
|
||||
number="02"
|
||||
title="Выберите Bluetooth-устройство"
|
||||
status={
|
||||
pendingAction === "scan"
|
||||
? "Поиск…"
|
||||
: selectedDeviceId
|
||||
? "Устройство выбрано"
|
||||
: `Найдено: ${devices.length}`
|
||||
}
|
||||
tone={
|
||||
pendingAction === "scan"
|
||||
? "accent"
|
||||
: selectedDeviceId
|
||||
? "success"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
<p className="step-copy">
|
||||
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата
|
||||
основана только на имени и не подтверждает модель или прошивку; окончательный выбор
|
||||
и аттестацию всегда делает оператор.
|
||||
</p>
|
||||
<Button
|
||||
width="full"
|
||||
variant="secondary"
|
||||
icon={<Icon name="search" />}
|
||||
disabled={!powerConfirmed || isBusy}
|
||||
onClick={() => {
|
||||
setSelectedDeviceId("");
|
||||
setProfileConfirmed(false);
|
||||
provisioningIntentRef.current = null;
|
||||
void scan();
|
||||
}}
|
||||
>
|
||||
{pendingAction === "scan"
|
||||
? "Сканируем Bluetooth — 6 секунд…"
|
||||
: "Показать все BLE-устройства"}
|
||||
</Button>
|
||||
<div className="device-list">
|
||||
{devices.length ? (
|
||||
devices.map((device) => (
|
||||
<DeviceRow
|
||||
key={device.device_id}
|
||||
device={device}
|
||||
selected={device.device_id === selectedDeviceId}
|
||||
onSelect={() => {
|
||||
setSelectedDeviceId(device.device_id);
|
||||
setProfileConfirmed(false);
|
||||
provisioningIntentRef.current = null;
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="empty-device-list">
|
||||
Устройства пока не найдены. Проверьте питание и состояние индикатора, затем
|
||||
повторите поиск.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</WizardStep>
|
||||
|
||||
<WizardStep
|
||||
number="03"
|
||||
title="Передайте настройки Wi‑Fi"
|
||||
status={state?.k1_ip ? "Подключено" : "Не подключено"}
|
||||
tone={state?.k1_ip ? "success" : "neutral"}
|
||||
>
|
||||
<div className="field-stack">
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">
|
||||
Mission Core не определяет прошивку автоматически. Сверьте её на устройстве
|
||||
или в официальном приложении и подтвердите только точное соответствие профилю.
|
||||
</span>
|
||||
<Checker
|
||||
checked={profileConfirmed}
|
||||
label="Я вручную подтвердил FW 3.0.2 и прямое подключение в локальной сети"
|
||||
onChange={(checked) => {
|
||||
setProfileConfirmed(checked);
|
||||
provisioningIntentRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label="Название сети Wi‑Fi"
|
||||
hint="SSID"
|
||||
value={ssid}
|
||||
onChange={(event) => {
|
||||
setSsid(event.target.value);
|
||||
provisioningIntentRef.current = null;
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="Сеть локального контура"
|
||||
/>
|
||||
<TextField
|
||||
label="Пароль Wi‑Fi"
|
||||
hint="Только в оперативной памяти"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
provisioningIntentRef.current = null;
|
||||
}}
|
||||
autoComplete="off"
|
||||
placeholder="Введите пароль"
|
||||
/>
|
||||
</div>
|
||||
<div className="connection-summary">
|
||||
<span>Устройство</span>
|
||||
<strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong>
|
||||
</div>
|
||||
<Button
|
||||
width="full"
|
||||
variant="primary"
|
||||
icon={<Icon name="network" />}
|
||||
disabled={!canConnect}
|
||||
onClick={() => void submitConnect()}
|
||||
>
|
||||
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
|
||||
</Button>
|
||||
<p className="safety-note">
|
||||
Пароль передаётся только локальному сервису на этом компьютере, не сохраняется в браузере и
|
||||
удаляется из формы после успешного подключения.
|
||||
</p>
|
||||
</WizardStep>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<div className="device-workspace__side">
|
||||
<GlassSurface className="session-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
{effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
|
||||
</span>
|
||||
<h2>{effectiveSessionIntent === "live" ? "Подготовьте приём" : "Повторите запись"}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={sourceTone}>
|
||||
{sourceLabel}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<SegmentedControl
|
||||
label="Источник данных"
|
||||
value={effectiveSessionIntent}
|
||||
items={selectableSessionItems}
|
||||
onChange={(intent) => {
|
||||
if (!sessionLocked) setSessionIntent(intent);
|
||||
}}
|
||||
/>
|
||||
|
||||
{effectiveSessionIntent === "live" ? (
|
||||
<div className="session-form">
|
||||
<TextField
|
||||
label="Адрес устройства"
|
||||
hint="Обычно определяется автоматически"
|
||||
value={liveHost}
|
||||
onChange={(event) => setLiveHost(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={
|
||||
state?.k1_ip ||
|
||||
preparedAcquisition?.target_host ||
|
||||
"Сначала подключите устройство к Wi‑Fi"
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={
|
||||
isBusy ||
|
||||
!profileConfirmed ||
|
||||
!liveTargetReady ||
|
||||
sourceRuntimeBusy ||
|
||||
(activeAcquisition !== null && preparedAcquisition === null)
|
||||
}
|
||||
onClick={() => void submitLive()}
|
||||
>
|
||||
{pendingAction === "live"
|
||||
? "Подготавливаем приём…"
|
||||
: preparedAcquisition
|
||||
? "Продолжить подготовленный приём"
|
||||
: "Подготовить приём данных"}
|
||||
</Button>
|
||||
<p className="live-instruction">
|
||||
{!profileConfirmed
|
||||
? "Сначала вручную подтвердите точную прошивку 3.0.2 и direct-LAN топологию. Интерфейс не аттестует устройство автоматически."
|
||||
: liveTargetReady
|
||||
? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами."
|
||||
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-form session-form--replay">
|
||||
<TextField
|
||||
label="Путь к записи"
|
||||
hint="Локальный файл исходных данных"
|
||||
value={replayPath}
|
||||
onChange={(event) => setReplayPath(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="sessions/.../capture.tsv"
|
||||
/>
|
||||
<TextField
|
||||
label="Скорость повтора"
|
||||
hint="Множитель"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
value={replaySpeed}
|
||||
onChange={(event) => setReplaySpeed(event.target.value)}
|
||||
/>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">После последнего кадра начать запись заново.</span>
|
||||
<Checker
|
||||
checked={replayLoop}
|
||||
label="Повторять по кругу"
|
||||
onChange={setReplayLoop}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="video" />}
|
||||
disabled={isBusy || sessionLocked || replayPath.trim().length === 0}
|
||||
onClick={() => void submitReplay()}
|
||||
>
|
||||
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="session-footer">
|
||||
<p>
|
||||
{state?.source_mode === "replay"
|
||||
? "Остановка завершит фактически запущенный повтор записи."
|
||||
: activeAcquisition || state?.source_mode === "live"
|
||||
? "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
|
||||
: "Активного источника сейчас нет."}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isBusy || (!sourceRuntimeBusy && activeAcquisition === null)}
|
||||
onClick={() => void stop()}
|
||||
>
|
||||
{pendingAction === "stop"
|
||||
? state?.source_mode === "replay"
|
||||
? "Останавливаем повтор…"
|
||||
: "Останавливаем локальный приём…"
|
||||
: state?.source_mode === "replay"
|
||||
? "Остановить повтор"
|
||||
: preparedAcquisition
|
||||
? "Завершить подготовленный приём"
|
||||
: "Остановить локальный приём"}
|
||||
</Button>
|
||||
{activeAcquisition ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isBusy}
|
||||
onClick={() => void abort()}
|
||||
>
|
||||
{pendingAction === "abort"
|
||||
? "Прерываем локальную операцию…"
|
||||
: preparedAcquisition
|
||||
? "Отменить подготовку"
|
||||
: "Аварийно завершить локальный приём"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<div className="diagnostics-grid">
|
||||
<GlassSurface className="status-panel" padding="lg">
|
||||
<header className="panel-heading panel-heading--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">ТЕКУЩЕЕ СОСТОЯНИЕ</span>
|
||||
<h2>Локальный контур</h2>
|
||||
</div>
|
||||
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
|
||||
</header>
|
||||
<dl className="detail-list">
|
||||
<DetailRow label="Канал событий">
|
||||
<span className="inline-state" data-state={eventStatus}>
|
||||
{eventStatusLabel(eventStatus)}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Источник">{sourceLabel}</DetailRow>
|
||||
<DetailRow label="Адрес устройства">
|
||||
<code>{state?.k1_ip || "Не получен"}</code>
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="latency-panel" padding="lg">
|
||||
<header className="panel-heading panel-heading--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
|
||||
<h2>Время до публикации</h2>
|
||||
</div>
|
||||
<strong className="latency-now">
|
||||
{formatNumber(latency)} <span>мс</span>
|
||||
</strong>
|
||||
</header>
|
||||
<LatencyTrace values={latencyHistory} />
|
||||
<div className="latency-legend">
|
||||
<span>Старые</span>
|
||||
<span>Последние</span>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -83,15 +83,8 @@
|
|||
font-size: 0.61rem;
|
||||
}
|
||||
|
||||
.device-model-card dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.device-model-card dd {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
.device-model-card dt { color: var(--nodedc-text-muted); }
|
||||
.device-model-card dd { margin: 0; color: var(--nodedc-text-secondary); text-align: right; }
|
||||
|
||||
.device-model-card__capabilities {
|
||||
display: flex;
|
||||
|
|
@ -117,439 +110,7 @@
|
|||
padding: 0.72rem 0.85rem;
|
||||
}
|
||||
|
||||
.device-plugin-slot__bar > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.device-plugin-slot__bar strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.device-plugin-slot__bar small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.device-plugin-slot__bar .device-plugin-slot__error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin {
|
||||
.device-workspace__grid {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(23rem, 0.78fr) minmax(34rem, 1.22fr);
|
||||
align-items: start;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.device-workspace__side {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.connection-panel,
|
||||
.status-panel,
|
||||
.latency-panel,
|
||||
.session-panel {
|
||||
background: var(--station-panel);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.error-banner__dot {
|
||||
width: 0.46rem;
|
||||
height: 0.46rem;
|
||||
align-self: start;
|
||||
margin-top: 0.28rem;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.error-banner strong,
|
||||
.error-banner p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-banner strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.error-banner p {
|
||||
margin-top: 0.2rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.error-banner__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.wizard-list {
|
||||
display: grid;
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.wizard-step {
|
||||
display: grid;
|
||||
grid-template-columns: 2.2rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wizard-step__rail {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wizard-step__rail::after {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: var(--station-hairline);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.wizard-step:last-child .wizard-step__rail::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wizard-step__rail span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #27272a;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.59rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.wizard-step__content {
|
||||
min-width: 0;
|
||||
padding: 0.08rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.wizard-step:last-child .wizard-step__content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.wizard-step__content > header {
|
||||
display: flex;
|
||||
min-height: 2rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.wizard-step__content h3 {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 710;
|
||||
}
|
||||
|
||||
.step-copy,
|
||||
.safety-note,
|
||||
.live-instruction {
|
||||
margin: 0 0 0.72rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.65rem;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.safety-note,
|
||||
.live-instruction {
|
||||
margin: 0.7rem 0 0;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
display: grid;
|
||||
max-height: 20rem;
|
||||
gap: 0.48rem;
|
||||
margin-top: 0.72rem;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
.device-row {
|
||||
display: grid;
|
||||
gap: 0.62rem;
|
||||
border-radius: 0.95rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.72rem;
|
||||
}
|
||||
|
||||
.device-row[data-selected="true"] {
|
||||
background: rgb(255 255 255 / 0.065);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.device-row__identity,
|
||||
.device-row__action {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.device-row__identity {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.device-row__identity > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.device-row__name {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.device-row__name small {
|
||||
flex: 0 0 auto;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.47rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.device-row__identity strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.69rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-row code,
|
||||
.detail-row code {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-row__signal {
|
||||
width: 0.58rem;
|
||||
height: 0.58rem;
|
||||
flex: 0 0 0.58rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.device-row[data-compatible="true"] .device-row__signal {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.device-row__action > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.empty-device-list {
|
||||
border-radius: 0.95rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.9rem;
|
||||
font-size: 0.65rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.field-stack,
|
||||
.session-form {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.connection-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin: 0.72rem 0;
|
||||
border-radius: 0.85rem;
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
padding: 0.62rem 0.75rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.connection-summary span {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.connection-summary strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-panel > .nodedc-segmented {
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
.session-form {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.session-form--replay {
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(8rem, 0.45fr);
|
||||
}
|
||||
|
||||
.session-form--replay > :first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.session-form--replay > .nodedc-button {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.session-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.9rem;
|
||||
}
|
||||
|
||||
.session-footer p {
|
||||
max-width: 30rem;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.diagnostics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: grid;
|
||||
margin: 0.9rem 0 0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(7rem, 0.72fr) minmax(0, 1.28fr);
|
||||
gap: 1rem;
|
||||
padding: 0.7rem 0;
|
||||
}
|
||||
|
||||
.detail-row dt,
|
||||
.detail-row dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.detail-row dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.detail-row dd {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-secondary);
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inline-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.inline-state::before {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.inline-state[data-state="open"]::before { background: rgb(var(--nodedc-success-rgb)); }
|
||||
.inline-state[data-state="error"]::before,
|
||||
.inline-state[data-state="closed"]::before { background: rgb(var(--nodedc-danger-rgb)); }
|
||||
|
||||
.latency-now {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 660;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.latency-now span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.61rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.latency-trace {
|
||||
display: flex;
|
||||
height: 7rem;
|
||||
align-items: end;
|
||||
gap: 0.2rem;
|
||||
margin-top: 1rem;
|
||||
border-radius: 0.85rem;
|
||||
background: rgb(255 255 255 / 0.018);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.latency-trace span {
|
||||
min-width: 0.16rem;
|
||||
flex: 1 1 0;
|
||||
border-radius: 999px 999px 0.12rem 0.12rem;
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.latency-trace p {
|
||||
align-self: center;
|
||||
margin: auto;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.latency-legend {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
}
|
||||
.device-plugin-slot__bar > div { display: grid; min-width: 0; gap: 0.12rem; }
|
||||
.device-plugin-slot__bar strong { color: var(--nodedc-text-primary); font-size: 0.74rem; }
|
||||
.device-plugin-slot__bar small { color: var(--nodedc-text-muted); font-size: 0.58rem; }
|
||||
.device-plugin-slot__bar .device-plugin-slot__error { color: rgb(var(--nodedc-danger-rgb)); }
|
||||
|
|
|
|||
|
|
@ -3,17 +3,12 @@
|
|||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .device-workspace__grid {
|
||||
grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr);
|
||||
}
|
||||
|
||||
.landing-stage__copy {
|
||||
width: min(35rem, 54%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.xgrids-k1-plugin .device-workspace__grid,
|
||||
.overview-grid,
|
||||
.mission-layout {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -72,10 +67,6 @@
|
|||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .diagnostics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace-lead {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
|
@ -209,36 +200,12 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .session-form--replay {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .session-form--replay > :first-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .session-footer,
|
||||
.xgrids-k1-plugin .error-banner,
|
||||
.source-format-list > div {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .error-banner {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .error-banner__actions {
|
||||
grid-column: 2;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .device-row__action {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feature-row {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ before(async () => {
|
|||
"/src/core/device-plugins/registry.ts",
|
||||
));
|
||||
({ xgridsK1Manifest, xgridsK1Actions } = await server.ssrLoadModule(
|
||||
"/src/device-plugins/xgrids-k1/manifest.ts",
|
||||
"@xgrids-k1/frontend/manifest.ts",
|
||||
));
|
||||
lifecycle = await server.ssrLoadModule(
|
||||
"/src/device-plugins/xgrids-k1/lifecycle.ts",
|
||||
"@xgrids-k1/frontend/lifecycle.ts",
|
||||
);
|
||||
({ xgridsK1Api } = await server.ssrLoadModule(
|
||||
"/src/device-plugins/xgrids-k1/api.ts",
|
||||
"@xgrids-k1/frontend/api.ts",
|
||||
));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
const testRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const controlStationRoot = resolve(testRoot, "..");
|
||||
const repositoryRoot = resolve(controlStationRoot, "../..");
|
||||
const coreSourceRoot = join(controlStationRoot, "src");
|
||||
const pluginFrontendRoot = join(repositoryRoot, "plugins/xgrids-k1/frontend/src");
|
||||
const legacyPluginRoot = join(coreSourceRoot, "device-plugins/xgrids-k1");
|
||||
|
||||
function sourceFiles(root) {
|
||||
return readdirSync(root).flatMap((entry) => {
|
||||
const path = join(root, entry);
|
||||
if (statSync(path).isDirectory()) return sourceFiles(path);
|
||||
return /\.(?:css|ts|tsx)$/.test(entry) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
let server;
|
||||
let createDevicePluginRegistry;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ createDevicePluginRegistry } = await server.ssrLoadModule(
|
||||
"/src/core/device-plugins/registry.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function syntheticPlugin({ pluginId, modelId, componentKey, connectionView }) {
|
||||
return {
|
||||
manifest: {
|
||||
apiVersion: "missioncore.nodedc/v1alpha2",
|
||||
kind: "DevicePlugin",
|
||||
metadata: { id: pluginId, version: "1.0.0", displayName: pluginId },
|
||||
spec: {
|
||||
hostApiRange: "v1alpha2",
|
||||
runtime: { backendEntrypoint: `${pluginId}:build`, isolation: "transitional-in-process" },
|
||||
permissions: ["device.read"],
|
||||
actions: [{ id: "state.read", mutating: false, secretFields: [] }],
|
||||
compatibilityProfiles: [{
|
||||
profileId: `${modelId}.profile.v1`,
|
||||
path: `profiles/${modelId}.json`,
|
||||
modelId,
|
||||
}],
|
||||
models: [{
|
||||
id: modelId,
|
||||
vendor: pluginId,
|
||||
displayName: modelId,
|
||||
category: "Sensor",
|
||||
description: "Synthetic frontend contribution",
|
||||
verified: true,
|
||||
capabilities: [{ id: "device.read", label: "Read" }],
|
||||
ui: { slot: "device.connection", componentKey },
|
||||
}],
|
||||
},
|
||||
},
|
||||
RuntimeProvider: ({ children }) => children,
|
||||
connectionViews: { [componentKey]: connectionView },
|
||||
};
|
||||
}
|
||||
|
||||
test("each device plugin contributes its own connection pipeline component", () => {
|
||||
const alphaConnection = () => null;
|
||||
const betaConnection = () => null;
|
||||
const registry = createDevicePluginRegistry([
|
||||
syntheticPlugin({
|
||||
pluginId: "synthetic.alpha",
|
||||
modelId: "synthetic.alpha.sensor",
|
||||
componentKey: "alpha.connection",
|
||||
connectionView: alphaConnection,
|
||||
}),
|
||||
syntheticPlugin({
|
||||
pluginId: "synthetic.beta",
|
||||
modelId: "synthetic.beta.sensor",
|
||||
componentKey: "beta.connection",
|
||||
connectionView: betaConnection,
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(registry.resolveModel("synthetic.alpha.sensor").ConnectionView, alphaConnection);
|
||||
assert.equal(registry.resolveModel("synthetic.beta.sensor").ConnectionView, betaConnection);
|
||||
assert.notEqual(
|
||||
registry.resolveModel("synthetic.alpha.sensor").ConnectionView,
|
||||
registry.resolveModel("synthetic.beta.sensor").ConnectionView,
|
||||
);
|
||||
});
|
||||
|
||||
test("XGRIDS frontend is physically plugin-owned and split by operator pipeline", () => {
|
||||
if (existsSync(legacyPluginRoot)) {
|
||||
assert.deepEqual(readdirSync(legacyPluginRoot), []);
|
||||
}
|
||||
for (const relativePath of [
|
||||
"plugin.ts",
|
||||
"runtimeContext.tsx",
|
||||
"styles.css",
|
||||
"components/K1ProvisioningPipeline.tsx",
|
||||
"components/K1AcquisitionPipeline.tsx",
|
||||
"components/K1Diagnostics.tsx",
|
||||
]) {
|
||||
assert.equal(existsSync(join(pluginFrontendRoot, relativePath)), true, relativePath);
|
||||
}
|
||||
});
|
||||
|
||||
test("generic Control Station has one composition import and no K1 implementation knowledge", () => {
|
||||
const compositionPath = join(coreSourceRoot, "composition/devicePlugins.ts");
|
||||
const composition = readFileSync(compositionPath, "utf8");
|
||||
assert.match(composition, /from "@xgrids-k1\/frontend\/plugin"/);
|
||||
|
||||
for (const path of sourceFiles(coreSourceRoot)) {
|
||||
if (path === compositionPath) continue;
|
||||
const source = readFileSync(path, "utf8");
|
||||
assert.doesNotMatch(source, /xgrids|lixel|\bk1\b/i, path);
|
||||
}
|
||||
|
||||
for (const path of sourceFiles(pluginFrontendRoot)) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
assert.doesNotMatch(source, /apps\/control-station|\.\.\/\.\.\/core|\.\.\/\.\.\/components/, path);
|
||||
}
|
||||
});
|
||||
|
|
@ -35,10 +35,10 @@ before(async () => {
|
|||
server: { middlewareMode: true },
|
||||
});
|
||||
({ xgridsK1Manifest } = await server.ssrLoadModule(
|
||||
"/src/device-plugins/xgrids-k1/manifest.ts",
|
||||
"@xgrids-k1/frontend/manifest.ts",
|
||||
));
|
||||
({ xgridsK1ObservationSources } = await server.ssrLoadModule(
|
||||
"/src/device-plugins/xgrids-k1/observationSources.ts",
|
||||
"@xgrids-k1/frontend/observationSources.ts",
|
||||
));
|
||||
({ openObservationSource, shouldRestartObservationSource } = await server.ssrLoadModule(
|
||||
"/src/core/observation/layoutPolicy.ts",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ before(async () => {
|
|||
server: { middlewareMode: true },
|
||||
});
|
||||
({ selectMonotonicXgridsState } = await server.ssrLoadModule(
|
||||
"/src/device-plugins/xgrids-k1/stateOrdering.ts",
|
||||
"@xgrids-k1/frontend/stateOrdering.ts",
|
||||
));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@
|
|||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@mission-core/plugin-sdk": ["src/core/device-plugins/frontendSdk.ts"],
|
||||
"@xgrids-k1/frontend/*": ["../../plugins/xgrids-k1/frontend/src/*"],
|
||||
"react": ["node_modules/@types/react/index.d.ts"],
|
||||
"react/jsx-runtime": ["node_modules/@types/react/jsx-runtime.d.ts"],
|
||||
"@nodedc/ui-react": ["node_modules/@nodedc/ui-react/dist/index.d.ts"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
|
@ -20,5 +28,5 @@
|
|||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "../../plugins/xgrids-k1/frontend/src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
|
@ -8,6 +10,17 @@ export default defineConfig(({ mode }) => {
|
|||
|
||||
return {
|
||||
plugins: [react(), wasm()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@mission-core/plugin-sdk": fileURLToPath(
|
||||
new URL("./src/core/device-plugins/frontendSdk.ts", import.meta.url),
|
||||
),
|
||||
"@xgrids-k1/frontend": fileURLToPath(
|
||||
new URL("../../plugins/xgrids-k1/frontend/src", import.meta.url),
|
||||
),
|
||||
},
|
||||
dedupe: ["react", "react-dom", "@nodedc/ui-react"],
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Rerun resolves its WASM asset relative to the package entrypoint. Vite's
|
||||
// dependency pre-bundler flattens that entrypoint and leaves the WASM URL
|
||||
|
|
@ -21,6 +34,13 @@ export default defineConfig(({ mode }) => {
|
|||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
fs: {
|
||||
allow: [
|
||||
fileURLToPath(new URL(".", import.meta.url)),
|
||||
fileURLToPath(new URL("../../plugins/xgrids-k1", import.meta.url)),
|
||||
fileURLToPath(new URL("../../../NODEDC_DESIGN_GUIDELINE/packages", import.meta.url)),
|
||||
],
|
||||
},
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: apiTarget,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
queue cannot discard MQTT evidence. Stage 7 adds an independent durable
|
||||
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.
|
||||
The saved scene, controller and timeline remain hidden until the complete RRD
|
||||
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
|
||||
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.
|
||||
Replay-open never performs conversion: it returns HTTP 202 and a preparation
|
||||
handle or reuses the verified artifact. The embedded viewer opens the exact
|
||||
|
|
@ -302,7 +302,7 @@ sensor-to-screen latency.
|
|||
|
||||
## 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
|
||||
instantiated by `VisualizationRuntime`, are not the Control Station source, and
|
||||
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 |
|
||||
| --- | --- | --- |
|
||||
| `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 |
|
||||
| `plugins/xgrids-k1/` | v1alpha2 manifest, exact compatibility profile, fail-closed loader and extraction documentation | 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 |
|
||||
| `src/k1link/mqtt/` | Raw-first K1 MQTT capture | Plugin-owned transport behind an EvidenceStore |
|
||||
| `src/k1link/protocol/` | Firmware-scoped K1 codecs and explicit vendor normalizer | Plugin-owned protocol adapter |
|
||||
| `plugins/xgrids-k1/` | v1alpha2 manifest, exact profile/loader and plugin-owned React connection/acquisition UI | Independently versioned XGRIDS device plugin |
|
||||
| `plugins/xgrids-k1/frontend/` | XGRIDS provisioning, acquisition/replay, diagnostics, metrics, runtime mapping and scoped CSS | Plugin-owned reviewed frontend contribution |
|
||||
| `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/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/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/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 |
|
||||
|
|
@ -41,7 +42,7 @@ The host-owned recorded path is separate from the disposable live preview:
|
|||
```text
|
||||
sealed or recovered native observation session
|
||||
-> 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
|
||||
-> aggregate RRD/camera admission
|
||||
-> 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
|
||||
envelopes, payload handles, evidence lineage and compatibility assessments. It
|
||||
is importable through the root editable path dependency and independently
|
||||
buildable from `packages/plugin-sdk`. The hot K1 path does not yet instantiate
|
||||
all SDK envelopes or use the SDK `EvidenceStore` protocol.
|
||||
buildable from `packages/plugin-sdk`. Backend actions now instantiate immutable
|
||||
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
|
||||
|
||||
|
|
@ -138,17 +148,16 @@ for the measured gate and remaining limits.
|
|||
|
||||
## Remaining extraction order
|
||||
|
||||
1. Preserve native replay parity and repeat the exact-profile physical
|
||||
point/pose regression through the explicit normalizer boundary.
|
||||
2. Map portable SDK stream/evidence contracts onto the hot path without
|
||||
replacing raw evidence or changing consumer output.
|
||||
3. Extract codecs, normalizer and raw capture physically behind the XGRIDS
|
||||
plugin package boundary.
|
||||
4. Add process isolation and a durable operation/evidence supervisor.
|
||||
5. Replace compatibility routes and singleton state with multi-device session
|
||||
Native replay parity, exact-profile physical point/pose regression, immutable
|
||||
SDK runtime-action envelopes and physical extraction of K1 transports/codecs are
|
||||
complete. Portable SDK stream/evidence envelopes remain separate from the
|
||||
allocation-conscious in-process preview views.
|
||||
|
||||
1. Add process isolation and a durable operation/evidence supervisor.
|
||||
2. Replace compatibility routes and singleton state with multi-device session
|
||||
routing.
|
||||
6. Split Edge execution from the Control Station behind authenticated transport.
|
||||
7. Physically accept a newly archived left/right K1 session, then package the
|
||||
3. Split Edge execution from the Control Station behind authenticated transport.
|
||||
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
|
||||
caching and evolve same-host MSE delivery toward an authenticated Edge media
|
||||
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
|
||||
point/pose message is projected into RRD. Later openings reuse a verified,
|
||||
digest-bound cache. Derived RRD cache v6 is intentionally incompatible with v4
|
||||
and v5 because only v6 guarantees that the payload itself contains a real
|
||||
`session_time = 0` anchor; older generations are rebuilt once in the background.
|
||||
digest-bound cache. Derived RRD cache v7 is intentionally incompatible with v6
|
||||
and older generations. It retains v6's real `session_time = 0` payload anchor
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -69,7 +69,9 @@ firmware-specific phases remain inside the XGRIDS plugin adapter.
|
|||
- Canonical manifest: `plugins/xgrids-k1/plugin.manifest.json`.
|
||||
- Host contracts and registry: `apps/control-station/src/core/device-plugins/`.
|
||||
- Generic runtime envelope: `apps/control-station/src/core/runtime/`.
|
||||
- XGRIDS UI/runtime adapter: `apps/control-station/src/device-plugins/xgrids-k1/`.
|
||||
- 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
|
||||
`GET /api/v1/device-models`.
|
||||
- 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
|
||||
the proven physical implementation is unchanged.
|
||||
|
||||
The XGRIDS frontend code is statically linked into the Control Station during
|
||||
this v1alpha milestone. It is isolated by imports and contracts but is not yet a
|
||||
separately built npm workspace package. Backend composition is manifest-driven;
|
||||
The XGRIDS frontend code is physically plugin-owned and statically linked into
|
||||
the Control Station during this v1alpha milestone. Its provisioning,
|
||||
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
|
||||
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 inspect opaque plugin state or branch on a model ID.
|
||||
- Plugins render only inside declared slots and cannot own global navigation or
|
||||
global CSS.
|
||||
- Plugins render only inside declared slots, cannot own global navigation, and
|
||||
may ship only plugin-root-scoped CSS.
|
||||
- Plugins publish canonical spatial sources; they do not control the scene
|
||||
engine or route names.
|
||||
- Passwords and other secrets cannot enter manifests, runtime snapshots, event
|
||||
|
|
@ -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
|
||||
the generic local-device workspace.
|
||||
|
||||
Backend execution supports only reviewed `transitional-in-process` plugins in
|
||||
v1alpha1, but already enters through the manifest
|
||||
factory, allowlisted XGRIDS facade and host-owned dispatcher. Synchronous
|
||||
capture/runtime work is moved off the API event loop. The next milestone adds
|
||||
independent device-session IDs, operation IDs, cancellation, timeouts, audited
|
||||
secret references, and process isolation. Only after replay parity and a
|
||||
physical regression may BLE, MQTT, codecs, and raw evidence code move out of
|
||||
the compatibility package. Rerun must ultimately consume canonical
|
||||
PointCloud/Pose envelopes instead of K1 topics.
|
||||
Backend execution still supports only reviewed `transitional-in-process`
|
||||
plugins, but enters through the manifest factory, immutable SDK runtime-action
|
||||
envelopes, the allowlisted facade and host-owned dispatcher. Synchronous
|
||||
capture/runtime work is moved off the API event loop. ADR 0009 completes the
|
||||
physical BLE/MQTT/codec/evidence extraction and plugin-owned observation
|
||||
contribution after replay parity and physical regression. The remaining
|
||||
milestone is process isolation, durable operation supervision and portable SDK
|
||||
stream/evidence transport; the Rerun consumer already contains no K1 topics.
|
||||
|
||||
This ADR supersedes the compatibility names listed in ADR 0002: `K1Metrics`,
|
||||
`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
|
||||
settings actions remain compatibility shims for the proven UI and runtime;
|
||||
- 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
|
||||
state object, or compatibility shim already emits those contracts.
|
||||
SDK v0alpha2 runtime-action contracts are active in the backend hot path. This
|
||||
does not imply that frontend state objects, live spatial frames or stored raw
|
||||
evidence already use every SDK contract.
|
||||
|
||||
## Deliberately deferred
|
||||
|
||||
|
|
|
|||
|
|
@ -87,5 +87,5 @@ ADR 0008.
|
|||
- `apps/control-station/src/components/ObservationSources.tsx`
|
||||
- `apps/control-station/src/components/ObservationTimeline.tsx`
|
||||
- `apps/control-station/src/workspaces/Workspaces.tsx`
|
||||
- `apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts`
|
||||
- `src/k1link/web/xgrids_k1_camera.py`
|
||||
- `plugins/xgrids-k1/frontend/src/observationSources.ts`
|
||||
- `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
|
||||
|
||||
- `src/k1link/web/xgrids_k1_camera.py`
|
||||
- `src/k1link/device_plugins/xgrids_k1/camera.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/RecordedFmp4Player.tsx`
|
||||
- `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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
synthetic visualization layer while ensuring that the decoded RRD timeline,
|
||||
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
|
||||
background rebuild instead of silently reusing an archive without that row.
|
||||
derived payload contract. Cache v7 keeps that anchor and additionally binds the
|
||||
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
|
||||
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;
|
||||
- operation policy, request, acknowledgement, progress, completion, failure,
|
||||
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;
|
||||
- immutable evidence handles, raw transport records, lineage, and store
|
||||
protocol;
|
||||
|
|
@ -49,11 +51,13 @@ compatibility profile and adapter.
|
|||
|
||||
Current host implementation references:
|
||||
|
||||
- `apps/control-station/src/core/device-plugins/` — TypeScript manifest and UI
|
||||
contribution contracts;
|
||||
- `apps/control-station/src/core/device-plugins/frontendSdk.ts` — current
|
||||
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;
|
||||
- `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
|
||||
startup parity checks;
|
||||
- `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
|
||||
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
|
||||
|
||||
v0alpha2 may receive additive fields while it remains experimental. A breaking
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ from .operations import (
|
|||
validate_operation_request,
|
||||
)
|
||||
from .payloads import InlinePayload, PayloadHandle, ReferencedPayload
|
||||
from .runtime import RuntimeActionInvocation, RuntimeActionResult
|
||||
from .schema import contract_json_schemas
|
||||
from .session import (
|
||||
AcquisitionState,
|
||||
|
|
@ -120,6 +121,8 @@ __all__ = [
|
|||
"Quaternion",
|
||||
"RawTransportRecord",
|
||||
"RedactionState",
|
||||
"RuntimeActionInvocation",
|
||||
"RuntimeActionResult",
|
||||
"ReferencedPayload",
|
||||
"RuleOutcome",
|
||||
"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 .identity import DeviceInstanceRef
|
||||
from .operations import OperationEvent, OperationPolicy, OperationRequest
|
||||
from .runtime import RuntimeActionInvocation, RuntimeActionResult
|
||||
from .session import DeviceSessionContext, DeviceSessionSnapshot
|
||||
from .streams import CanonicalStreamEnvelope
|
||||
|
||||
|
|
@ -22,6 +23,8 @@ def contract_json_schemas() -> dict[str, dict[str, object]]:
|
|||
"OperationPolicy": OperationPolicy,
|
||||
"OperationRequest": OperationRequest,
|
||||
"OperationEvent": OperationEvent,
|
||||
"RuntimeActionInvocation": RuntimeActionInvocation,
|
||||
"RuntimeActionResult": RuntimeActionResult,
|
||||
"CanonicalStreamEnvelope": CanonicalStreamEnvelope,
|
||||
"EvidenceRecord": EvidenceRecord,
|
||||
"RawTransportRecord": RawTransportRecord,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
This directory is the package boundary for the first Mission Core device
|
||||
plugin. It now owns the canonical v1alpha2 manifest
|
||||
[`plugin.manifest.json`](plugin.manifest.json). The statically linked frontend
|
||||
contribution lives in `apps/control-station/src/device-plugins/xgrids-k1/`; the
|
||||
verified backend implementation remains temporarily in `src/k1link` so the
|
||||
real-device path stays operational during extraction.
|
||||
contribution lives in [`frontend/`](frontend/) beside the manifest and profiles;
|
||||
its provisioning, acquisition/replay, diagnostics and metrics are separate
|
||||
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.
|
||||
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
|
||||
the handoff of acquisition-owned init/fMP4/index evidence to the host archive;
|
||||
- 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:
|
||||
|
||||
|
|
@ -35,19 +39,20 @@ The plugin does not own:
|
|||
- the host observation catalog, RRD preparation/cache, recorded-camera player,
|
||||
workspace layout, platform storage, remote transport, or other devices.
|
||||
|
||||
Until extraction is complete, `src/k1link` is the compatibility source of
|
||||
truth and every move into this package must preserve replay and real-device
|
||||
acceptance.
|
||||
The `k1link` distribution and CLI names remain compatibility names. All
|
||||
device-specific implementation behind them is plugin-owned, and every change
|
||||
must preserve replay and real-device acceptance.
|
||||
|
||||
Mission Core imports this plugin only from
|
||||
`apps/control-station/src/composition/devicePlugins.ts`. The generic shell never
|
||||
branches on this plugin ID or reads `k1_ip`, BLE candidates, MQTT topics, or
|
||||
other vendor state.
|
||||
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
|
||||
cross-checks its plugin ID and complete action set against this manifest.
|
||||
Actions enter through the host dispatcher and the transitional facade
|
||||
`src/k1link/web/xgrids_k1_facade.py`. The old flat routes live in the plugin's
|
||||
`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
|
||||
`XgridsK1CompatibilityService` methods. Synchronous capture/runtime operations
|
||||
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
|
||||
semantic `acquisition.stop` in `capture-only` mode; replay and pre-v1alpha2
|
||||
sessions retain the legacy `stream.stop` shim. Neither path claims that the
|
||||
physical K1 stopped without separate operator evidence. BLE, MQTT, codec and
|
||||
evidence modules remain in `src/k1link` until replay parity and another physical
|
||||
K1 regression are complete.
|
||||
physical K1 stopped without separate operator evidence. BLE, MQTT, codec,
|
||||
archive discovery and RRD export modules now live under the plugin package
|
||||
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
|
||||
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";
|
||||
|
||||
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 {
|
||||
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 {
|
||||
DEVICE_STATE_READ_ACTION_ID,
|
||||
parseDevicePluginManifest,
|
||||
requirePluginAction,
|
||||
} from "../../core/device-plugins/manifestParser";
|
||||
} from "@mission-core/plugin-sdk";
|
||||
|
||||
export const xgridsK1Manifest = parseDevicePluginManifest(manifestDocument);
|
||||
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
// Vendor message normalization belongs to the XGRIDS frontend contribution.
|
||||
const runtimeMessageReplacements: Array<[RegExp, string]> = [
|
||||
[/broker connection ended/gi, "соединение с брокером завершено"],
|
||||
[/Unspecified error/gi, "неуказанная ошибка"],
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
|
||||
import type {
|
||||
DeviceModelDefinition,
|
||||
ObservationSourceAvailability,
|
||||
ObservationSourceDelivery,
|
||||
ObservationSourceDescriptor,
|
||||
ObservationSourceProvider,
|
||||
} from "../../core/runtime/contracts";
|
||||
} from "@mission-core/plugin-sdk";
|
||||
import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
|
||||
import { xgridsK1Manifest } from "./manifest";
|
||||
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 { xgridsK1Manifest } from "./manifest";
|
||||
import { XgridsK1RuntimeProvider } from "./runtimeContext";
|
||||
import "./styles.css";
|
||||
|
||||
export const xgridsK1Plugin: DeviceUiPlugin = {
|
||||
manifest: xgridsK1Manifest,
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
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";
|
||||
|
||||
const phaseLabels: Record<string, string> = {
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
import { createContext, useContext, useEffect, type ReactNode } from "react";
|
||||
|
||||
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
|
||||
import {
|
||||
MissionRuntimeProvider,
|
||||
useMissionRuntime,
|
||||
} from "../../core/runtime/MissionRuntimeContext";
|
||||
import type {
|
||||
MissionRuntimeController,
|
||||
MissionRuntimeState,
|
||||
} from "../../core/runtime/contracts";
|
||||
type DeviceModelDefinition,
|
||||
type MissionRuntimeController,
|
||||
type MissionRuntimeState,
|
||||
} from "@mission-core/plugin-sdk";
|
||||
import {
|
||||
confirmedRuntimeSourceMode,
|
||||
effectiveAcquisition,
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
// Ordering rules remain plugin-private because revisions are vendor runtime state.
|
||||
import type { XgridsCameraPreviewState, XgridsK1State } from "./api";
|
||||
|
||||
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 type { BackendStatus, ViewerSettings } from "../../core/runtime/contracts";
|
||||
import type { BackendStatus, ViewerSettings } from "@mission-core/plugin-sdk";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
"spec": {
|
||||
"hostApiRange": "v1alpha2",
|
||||
"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"
|
||||
},
|
||||
"compatibilityProfiles": [
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ dependencies = [
|
|||
missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true }
|
||||
|
||||
[project.scripts]
|
||||
k1link = "k1link.cli:app"
|
||||
k1link = "k1link.device_plugins.xgrids_k1.cli:app"
|
||||
|
||||
[dependency-groups]
|
||||
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."""
|
||||
|
||||
from k1link.analyze.stream_summary import (
|
||||
from k1link.device_plugins.xgrids_k1.analyze.stream_summary import (
|
||||
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
|
||||
MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
|
||||
StreamSummary,
|
||||
|
|
@ -9,8 +9,8 @@ from pathlib import Path
|
|||
from typing import TypedDict
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
from k1link.mqtt import DEFAULT_MAX_MESSAGE_BYTES, iter_capture_frames
|
||||
from k1link.protocol import (
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import DEFAULT_MAX_MESSAGE_BYTES, iter_capture_frames
|
||||
from k1link.device_plugins.xgrids_k1.protocol import (
|
||||
DecodeLimits,
|
||||
StreamDecodeError,
|
||||
decode_lio_pcl,
|
||||
|
|
@ -13,7 +13,7 @@ from functools import lru_cache
|
|||
from pathlib import Path
|
||||
from typing import IO, Any
|
||||
|
||||
from k1link.mqtt.capture import (
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import (
|
||||
FRAME_HEADER,
|
||||
GROUP_COMMIT_MAX_BYTES,
|
||||
GROUP_COMMIT_MAX_MESSAGES,
|
||||
|
|
@ -21,10 +21,8 @@ from k1link.mqtt.capture import (
|
|||
MAX_TOPIC_BYTES,
|
||||
RAW_MAGIC,
|
||||
)
|
||||
|
||||
from .models import (
|
||||
from k1link.sessions.models import (
|
||||
LegacyMediaSourceCandidate,
|
||||
LegacySessionCandidate,
|
||||
SessionModality,
|
||||
SessionStatus,
|
||||
)
|
||||
|
|
@ -41,6 +39,30 @@ MAX_RECOVERY_METADATA_LINE_BYTES = 64 * 1024
|
|||
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)
|
||||
class _RecoveredCapture:
|
||||
message_count: int
|
||||
|
|
@ -14,7 +14,7 @@ from typing import IO, Any, Final, Literal
|
|||
|
||||
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 (
|
||||
CameraArchiveError,
|
||||
CameraArchiveKind,
|
||||
|
|
@ -16,28 +16,28 @@ from rich.console import Console
|
|||
from rich.table import Table
|
||||
|
||||
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,
|
||||
MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
|
||||
summarize_mqtt_streams,
|
||||
)
|
||||
from k1link.artifacts import write_json_atomic
|
||||
from k1link.ble.gatt import dump_metadata
|
||||
from k1link.ble.scanner import scan
|
||||
from k1link.ble.wifi_provisioning import (
|
||||
from k1link.device_plugins.xgrids_k1.ble.gatt import dump_metadata
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import scan
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
PROFILE_ID,
|
||||
WriteMode,
|
||||
provision_wifi_once,
|
||||
)
|
||||
from k1link.macos_credentials import CredentialDialogError, prompt_wifi_credentials
|
||||
from k1link.mqtt import (
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import (
|
||||
DEFAULT_MAX_MESSAGE_BYTES,
|
||||
MAX_CONFIGURABLE_MESSAGE_BYTES,
|
||||
CaptureError,
|
||||
capture_mqtt,
|
||||
)
|
||||
from k1link.net.snapshot import snapshot
|
||||
from k1link.usb.snapshot import snapshot as usb_snapshot
|
||||
from k1link.device_plugins.xgrids_k1.net.snapshot import 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(
|
||||
name="k1link",
|
||||
|
|
@ -15,16 +15,31 @@ from pathlib import Path
|
|||
from typing import Any, Literal, Protocol, cast
|
||||
|
||||
from bleak.exc import BleakError
|
||||
from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
|
||||
|
||||
from k1link.artifacts import write_json_atomic
|
||||
from k1link.ble.scanner import scan
|
||||
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
|
||||
from k1link.mqtt import validate_private_ipv4
|
||||
from k1link.protocol.normalizer import normalize_k1_message
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import scan
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
AP_FALLBACK_IPV4,
|
||||
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.viewer.rerun_bridge import RerunSceneSettings
|
||||
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
|
||||
from k1link.web.device_lifecycle import (
|
||||
TERMINAL_ACQUISITION_STATES,
|
||||
AcquisitionRecord,
|
||||
|
|
@ -38,14 +53,6 @@ from k1link.web.plugin_runtime import (
|
|||
PluginActionNotFoundError,
|
||||
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_MODEL_ID = "xgrids.lixelkity-k1"
|
||||
|
|
@ -1497,9 +1504,14 @@ class XgridsK1PluginFacade:
|
|||
def __init__(self, service: XgridsK1ServicePort) -> None:
|
||||
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:
|
||||
return await self._invoke_validated(action_id, payload)
|
||||
return await self._invoke_validated(
|
||||
invocation.action_id,
|
||||
invocation.parameters,
|
||||
)
|
||||
except (ValidationError, ValueError, PluginActionNotFoundError):
|
||||
raise
|
||||
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:
|
||||
"""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)
|
||||
service = XgridsK1CompatibilityService(repository_root)
|
||||
|
|
@ -1817,5 +1830,6 @@ def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribu
|
|||
XGRIDS_K1_PLUGIN_ID,
|
||||
),
|
||||
),
|
||||
observation=build_xgrids_k1_observation(repository_root),
|
||||
close=service.close,
|
||||
)
|
||||
|
|
@ -5,8 +5,7 @@ from typing import Any
|
|||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
||||
|
||||
from k1link.web.plugin_runtime import PluginExecutionError
|
||||
from k1link.web.xgrids_k1_facade import (
|
||||
from k1link.device_plugins.xgrids_k1.facade import (
|
||||
ACTION_DISCOVERY_SCAN,
|
||||
ACTION_NETWORK_PROVISION,
|
||||
ACTION_STATE_READ,
|
||||
|
|
@ -21,6 +20,7 @@ from k1link.web.xgrids_k1_facade import (
|
|||
ViewerSettingsRequest,
|
||||
XgridsK1PluginFacade,
|
||||
)
|
||||
from k1link.web.plugin_runtime import PluginExecutionError, invoke_device_plugin_adapter
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
|
||||
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:
|
||||
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
|
||||
|
||||
@router.post("/api/connect", deprecated=True)
|
||||
async def connect(request: ConnectRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_NETWORK_PROVISION, request.model_dump())
|
||||
return await invoke_device_plugin_adapter(
|
||||
adapter,
|
||||
ACTION_NETWORK_PROVISION,
|
||||
request.model_dump(),
|
||||
)
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
|
|
@ -52,31 +60,51 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
|||
@router.post("/api/session/live", deprecated=True)
|
||||
async def start_live(request: LiveRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_STREAM_START_LIVE, request.model_dump())
|
||||
return await invoke_device_plugin_adapter(
|
||||
adapter,
|
||||
ACTION_STREAM_START_LIVE,
|
||||
request.model_dump(),
|
||||
)
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.post("/api/session/replay", deprecated=True)
|
||||
async def start_replay(request: ReplayRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_STREAM_START_REPLAY, request.model_dump())
|
||||
return await invoke_device_plugin_adapter(
|
||||
adapter,
|
||||
ACTION_STREAM_START_REPLAY,
|
||||
request.model_dump(),
|
||||
)
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.post("/api/session/stop", deprecated=True)
|
||||
async def stop_session() -> dict[str, Any]:
|
||||
return await adapter.invoke(ACTION_STREAM_STOP, {})
|
||||
return await invoke_device_plugin_adapter(adapter, ACTION_STREAM_STOP, {})
|
||||
|
||||
@router.post("/api/viewer/settings", deprecated=True)
|
||||
async def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
|
||||
return await adapter.invoke(ACTION_VIEWER_SETTINGS_UPDATE, request.model_dump())
|
||||
return await invoke_device_plugin_adapter(
|
||||
adapter,
|
||||
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||
request.model_dump(),
|
||||
)
|
||||
|
||||
@router.websocket("/api/events")
|
||||
async def events(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
try:
|
||||
while True:
|
||||
await websocket.send_json({"state": await adapter.invoke(ACTION_STATE_READ, {})})
|
||||
await websocket.send_json(
|
||||
{
|
||||
"state": await invoke_device_plugin_adapter(
|
||||
adapter,
|
||||
ACTION_STATE_READ,
|
||||
{},
|
||||
)
|
||||
}
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""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,
|
||||
MAX_CONFIGURABLE_MESSAGE_BYTES,
|
||||
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."""
|
||||
|
||||
from k1link.protocol.streams import (
|
||||
from k1link.device_plugins.xgrids_k1.protocol.streams import (
|
||||
DecodeLimits,
|
||||
LegacyPoint,
|
||||
LegacyPointCloudFrame,
|
||||
|
|
@ -9,7 +9,7 @@ from k1link.data_plane import (
|
|||
DecodedPoseView,
|
||||
NormalizationError,
|
||||
)
|
||||
from k1link.protocol.streams import (
|
||||
from k1link.device_plugins.xgrids_k1.protocol.streams import (
|
||||
LegacyPointCloudFrame,
|
||||
LegacyPoseFrame,
|
||||
LioPointCloudFrame,
|
||||
|
|
@ -7,7 +7,7 @@ from typing import NamedTuple
|
|||
|
||||
import lz4.block
|
||||
|
||||
from k1link.protocol.protobuf_wire import (
|
||||
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
|
||||
ProtobufWireError,
|
||||
ProtoField,
|
||||
decode_zigzag64,
|
||||
|
|
@ -16,8 +16,12 @@ import rerun as rr
|
|||
from rerun import blueprint as rrb
|
||||
|
||||
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError
|
||||
from k1link.protocol.normalizer import normalize_k1_message
|
||||
from k1link.viewer.replay import ReplayFormatError, detect_replay_format, iter_replay_messages
|
||||
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
|
||||
from k1link.device_plugins.xgrids_k1.viewer.replay import (
|
||||
ReplayFormatError,
|
||||
detect_replay_format,
|
||||
iter_replay_messages,
|
||||
)
|
||||
from k1link.viewer.rerun_bridge import (
|
||||
MAX_TRAJECTORY_POSES,
|
||||
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,
|
||||
)
|
||||
|
||||
from k1link.protocol.streams import (
|
||||
from k1link.device_plugins.xgrids_k1.protocol.streams import (
|
||||
LegacyPointCloudFrame,
|
||||
LegacyPoseFrame,
|
||||
LioPointCloudFrame,
|
||||
|
|
@ -40,7 +40,7 @@ from k1link.protocol.streams import (
|
|||
decode_lio_pcl,
|
||||
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
|
||||
|
||||
POINT_STRUCT = struct.Struct("<fffB3x")
|
||||
|
|
@ -7,9 +7,9 @@ from decimal import Decimal, InvalidOperation
|
|||
from pathlib import Path
|
||||
from typing import IO, Literal
|
||||
|
||||
from k1link.mqtt import CaptureFormatError, iter_capture_frames
|
||||
from k1link.mqtt.capture import RAW_MAGIC
|
||||
from k1link.viewer.messages import StreamMessage
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import CaptureFormatError, iter_capture_frames
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import RAW_MAGIC
|
||||
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
|
||||
|
||||
MAX_REPLAY_PAYLOAD_BYTES = 2 * 1024 * 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.data_plane import DecodedDataPlaneView, NormalizationError
|
||||
from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
|
||||
from k1link.viewer.messages import StreamMessage
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
|
||||
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.replay import iter_replay_messages
|
||||
from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings
|
||||
|
||||
RuntimePhase = Literal[
|
||||
|
|
@ -14,12 +14,22 @@ from .media import (
|
|||
)
|
||||
from .models import (
|
||||
LayoutConflictError,
|
||||
ObservationArtifactCandidate,
|
||||
ObservationSessionCandidate,
|
||||
RecordedMediaArtifact,
|
||||
ReplayArtifact,
|
||||
ReplayCommand,
|
||||
SessionIntegrityError,
|
||||
SessionNotFoundError,
|
||||
SessionNotReplayableError,
|
||||
)
|
||||
from .plugin_contract import (
|
||||
ObservationArchiveSource,
|
||||
ObservationRuntimeContribution,
|
||||
PluginRecordingExportCancelled,
|
||||
PluginRecordingExportError,
|
||||
RecordingExporter,
|
||||
)
|
||||
from .preparation import (
|
||||
RecordingPreparationQueueFull,
|
||||
RecordingPreparationSnapshot,
|
||||
|
|
@ -42,6 +52,12 @@ __all__ = [
|
|||
"ActiveSessionLease",
|
||||
"ActiveSessionLeaseError",
|
||||
"MaterializedRecording",
|
||||
"ObservationArchiveSource",
|
||||
"ObservationArtifactCandidate",
|
||||
"ObservationRuntimeContribution",
|
||||
"ObservationSessionCandidate",
|
||||
"PluginRecordingExportCancelled",
|
||||
"PluginRecordingExportError",
|
||||
"RecordingMaterializationCancelled",
|
||||
"RecordedMediaArtifact",
|
||||
"RECORDED_MEDIA_MANIFEST_SCHEMA",
|
||||
|
|
@ -49,6 +65,8 @@ __all__ = [
|
|||
"RecordedMediaInspector",
|
||||
"RecordedMediaManifest",
|
||||
"ReplayCommand",
|
||||
"ReplayArtifact",
|
||||
"RecordingExporter",
|
||||
"RecordingMaterializationError",
|
||||
"RecordingPreparationQueueFull",
|
||||
"RecordingPreparationSnapshot",
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ from dataclasses import dataclass
|
|||
from pathlib import Path
|
||||
from typing import Any, TypeGuard
|
||||
|
||||
from k1link.viewer.replay import MAX_METADATA_LINE_CHARS
|
||||
|
||||
from .models import RecordedMediaArtifact, ReplayCommand, SessionIntegrityError
|
||||
|
||||
CAMERA_ARCHIVE_SCHEMA = "missioncore.camera-recording/v1"
|
||||
|
|
@ -139,7 +137,7 @@ class RecordedMediaInspector:
|
|||
cached = self._cache.get(key)
|
||||
if cached is not None:
|
||||
identity = _prepared_source_identity(
|
||||
replay.source_path,
|
||||
replay,
|
||||
epoch_paths,
|
||||
cached.manifest.epochs,
|
||||
)
|
||||
|
|
@ -151,15 +149,14 @@ class RecordedMediaInspector:
|
|||
with self._lock:
|
||||
self._cache[key] = _CachedManifest(identity=identity, manifest=manifest)
|
||||
return manifest
|
||||
origin_epoch_ns, origin_monotonic_ns = _raw_timeline_origin(replay.source_path)
|
||||
manifest = _read_manifest(
|
||||
artifact,
|
||||
epoch_paths,
|
||||
origin_epoch_ns=origin_epoch_ns,
|
||||
origin_monotonic_ns=origin_monotonic_ns,
|
||||
origin_epoch_ns=replay.timeline_origin_epoch_ns,
|
||||
origin_monotonic_ns=replay.timeline_origin_monotonic_ns,
|
||||
)
|
||||
identity = _prepared_source_identity(
|
||||
replay.source_path,
|
||||
replay,
|
||||
epoch_paths,
|
||||
manifest.epochs,
|
||||
)
|
||||
|
|
@ -186,7 +183,7 @@ class RecordedMediaInspector:
|
|||
document = _decode_prepared_sidecar(payload)
|
||||
manifest = _manifest_from_sidecar(document, artifact, epoch_paths)
|
||||
identity = _prepared_source_identity(
|
||||
replay.source_path,
|
||||
replay,
|
||||
epoch_paths,
|
||||
manifest.epochs,
|
||||
)
|
||||
|
|
@ -631,15 +628,15 @@ def _epoch_paths(source_path: Path) -> tuple[Path, ...]:
|
|||
|
||||
|
||||
def _prepared_source_identity(
|
||||
raw_path: Path,
|
||||
replay: ReplayCommand,
|
||||
epoch_paths: tuple[Path, ...],
|
||||
epochs: tuple[RecordedMediaEpoch, ...],
|
||||
) -> tuple[tuple[int, int, int, int], ...]:
|
||||
if len(epoch_paths) != len(epochs):
|
||||
raise SessionIntegrityError("recorded media epoch identity is inconsistent")
|
||||
identity: list[tuple[int, int, int, int]] = []
|
||||
for source_file in (raw_path, raw_path.with_name("mqtt.metadata.jsonl")):
|
||||
metadata = _confined_file_stat(source_file, raw_path.parent)
|
||||
for artifact in replay.artifacts:
|
||||
metadata = _session_artifact_stat(artifact.path, replay.session_root)
|
||||
identity.append(
|
||||
(metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns)
|
||||
)
|
||||
|
|
@ -671,32 +668,15 @@ def _prepared_source_identity(
|
|||
return tuple(identity)
|
||||
|
||||
|
||||
def _raw_timeline_origin(raw_path: Path) -> tuple[int, int]:
|
||||
metadata_path = raw_path.with_name("mqtt.metadata.jsonl")
|
||||
def _session_artifact_stat(path: Path, session_root: Path) -> os.stat_result:
|
||||
try:
|
||||
line = _read_first_confined_line(
|
||||
metadata_path,
|
||||
raw_path.parent,
|
||||
MAX_METADATA_LINE_CHARS,
|
||||
).decode("utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise SessionIntegrityError("native capture timing metadata is unavailable") from exc
|
||||
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)
|
||||
session = session_root.resolve(strict=True)
|
||||
parent = path.parent.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("recording source artifact is missing") from exc
|
||||
if not parent.is_relative_to(session):
|
||||
raise SessionIntegrityError("recording source artifact escapes its session")
|
||||
return _confined_file_stat(path, parent)
|
||||
|
||||
|
||||
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)
|
||||
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
|
||||
source_path: Path
|
||||
plugin_id: str
|
||||
allowed_root: Path
|
||||
session_root: Path
|
||||
replay_byte_length: int
|
||||
metadata_byte_length: int
|
||||
expected_source_sha256: str | None
|
||||
primary_artifact_id: str
|
||||
artifacts: tuple[ReplayArtifact, ...]
|
||||
timeline_origin_epoch_ns: int
|
||||
timeline_origin_monotonic_ns: int
|
||||
speed: float
|
||||
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)
|
||||
class RecordedMediaArtifact:
|
||||
|
|
@ -191,7 +215,19 @@ class RecordedMediaArtifact:
|
|||
|
||||
|
||||
@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
|
||||
display_name: str
|
||||
status: SessionStatus
|
||||
|
|
@ -203,13 +239,11 @@ class LegacySessionCandidate:
|
|||
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, ...]
|
||||
primary_replay_artifact_id: str | None
|
||||
timeline_origin_epoch_ns: int | None
|
||||
timeline_origin_monotonic_ns: int | None
|
||||
sources: tuple[SessionSource, ...]
|
||||
artifacts: tuple[ObservationArtifactCandidate, ...]
|
||||
|
||||
|
||||
@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] = [
|
||||
command.session_id,
|
||||
str(command.source_path),
|
||||
command.replay_byte_length,
|
||||
command.metadata_byte_length,
|
||||
command.expected_source_sha256,
|
||||
command.plugin_id,
|
||||
command.primary_artifact_id,
|
||||
]
|
||||
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:
|
||||
value = os.lstat(path)
|
||||
except OSError:
|
||||
|
|
|
|||
|
|
@ -16,18 +16,17 @@ from pathlib import Path
|
|||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from k1link.viewer.rrd_export import (
|
||||
RrdExportCancelled,
|
||||
RrdExportError,
|
||||
export_k1mqtt_to_rrd,
|
||||
from .models import ReplayArtifact, ReplayCommand
|
||||
from .plugin_contract import (
|
||||
PluginRecordingExportCancelled,
|
||||
PluginRecordingExportError,
|
||||
RecordingExporter,
|
||||
)
|
||||
|
||||
from .models import ReplayCommand
|
||||
|
||||
# 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
|
||||
# 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})
|
||||
RERUN_RECORDING_MEDIA_TYPE = "application/vnd.rerun.rrd"
|
||||
RERUN_SESSION_TIMELINE = "session_time"
|
||||
|
|
@ -37,7 +36,6 @@ DEFAULT_FREE_SPACE_RESERVE_BYTES = 2 * 1024 * 1024 * 1024
|
|||
|
||||
RrdExporter = Callable[..., Mapping[str, object]]
|
||||
RecordingProgressCallback = Callable[[str, float], None]
|
||||
DEFAULT_RRD_EXPORTER = cast(RrdExporter, export_k1mqtt_to_rrd)
|
||||
|
||||
|
||||
class RecordingMaterializationError(RuntimeError):
|
||||
|
|
@ -71,29 +69,58 @@ class _ValidatedMemoryEntry:
|
|||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ValidatedSource:
|
||||
source: Path
|
||||
metadata: Path
|
||||
source_stat: os.stat_result
|
||||
metadata_stat: os.stat_result
|
||||
class _ValidatedArtifact:
|
||||
artifact_id: str
|
||||
path: Path
|
||||
media_type: str
|
||||
file_stat: os.stat_result
|
||||
replay_byte_length: int
|
||||
metadata_byte_length: int
|
||||
expected_source_sha256: str | None
|
||||
expected_sha256: str | None
|
||||
|
||||
@property
|
||||
def identity(self) -> tuple[object, ...]:
|
||||
return (
|
||||
*_stat_identity(self.source_stat),
|
||||
*_stat_identity(self.metadata_stat),
|
||||
self.artifact_id,
|
||||
self.media_type,
|
||||
*_stat_identity(self.file_stat),
|
||||
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:
|
||||
"""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
|
||||
source identity and output digest still match an atomically written cache
|
||||
sidecar. Calls for one session are serialized, so concurrent browser
|
||||
|
|
@ -104,7 +131,8 @@ class SessionRecordingMaterializer:
|
|||
self,
|
||||
data_dir: Path,
|
||||
*,
|
||||
exporter: RrdExporter = DEFAULT_RRD_EXPORTER,
|
||||
exporter: RrdExporter | None = None,
|
||||
exporters: Mapping[str, RecordingExporter] | None = None,
|
||||
cache_max_bytes: int | None = None,
|
||||
free_space_reserve_bytes: int | None = None,
|
||||
) -> None:
|
||||
|
|
@ -119,12 +147,12 @@ class SessionRecordingMaterializer:
|
|||
self.recordings_root = recordings_root.resolve()
|
||||
if not self.recordings_root.is_relative_to(private_root):
|
||||
raise RecordingMaterializationError("recording cache escapes the private data root")
|
||||
self._exporter = exporter
|
||||
self._exporter_accepts_cancel = _callable_accepts_keyword(exporter, "cancel_event")
|
||||
self._exporter_accepts_activity = _callable_accepts_keyword(
|
||||
exporter,
|
||||
"activity_callback",
|
||||
)
|
||||
if exporter is not None and exporters is not None:
|
||||
raise ValueError("configure either one test exporter or plugin exporters")
|
||||
self._fallback_exporter = exporter
|
||||
self._exporters = dict(exporters or {})
|
||||
if len(self._exporters) != len(set(self._exporters)):
|
||||
raise ValueError("recording exporter plugin ids must be unique")
|
||||
self.cache_max_bytes = _positive_configuration(
|
||||
cache_max_bytes,
|
||||
environment_name="MISSIONCORE_RRD_CACHE_MAX_BYTES",
|
||||
|
|
@ -152,7 +180,12 @@ class SessionRecordingMaterializer:
|
|||
def supports_cooperative_cancellation(self) -> bool:
|
||||
"""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:
|
||||
"""Cheap no-follow check for a previously validated ready handle."""
|
||||
|
|
@ -463,11 +496,11 @@ class SessionRecordingMaterializer:
|
|||
) from exc
|
||||
|
||||
staged_root: Path | None = None
|
||||
export_source = source.source
|
||||
export_source = source.primary.path
|
||||
try:
|
||||
if (
|
||||
source.replay_byte_length != source.source_stat.st_size
|
||||
or source.metadata_byte_length != source.metadata_stat.st_size
|
||||
if any(
|
||||
artifact.replay_byte_length != artifact.file_stat.st_size
|
||||
for artifact in source.artifacts
|
||||
):
|
||||
staged_root, export_source = _stage_replay_prefix(
|
||||
resolved_session_root,
|
||||
|
|
@ -480,6 +513,7 @@ class SessionRecordingMaterializer:
|
|||
),
|
||||
)
|
||||
summary = self._invoke_exporter(
|
||||
source.plugin_id,
|
||||
export_source,
|
||||
candidate_path,
|
||||
cancel_event=cancel_event,
|
||||
|
|
@ -491,12 +525,12 @@ class SessionRecordingMaterializer:
|
|||
)
|
||||
_raise_if_cancelled(cancel_event)
|
||||
_report_progress(progress_callback, "finalizing", 0.9)
|
||||
except RrdExportCancelled as exc:
|
||||
except PluginRecordingExportCancelled as exc:
|
||||
candidate_path.unlink(missing_ok=True)
|
||||
raise RecordingMaterializationCancelled(
|
||||
"recording preparation was cancelled"
|
||||
) from exc
|
||||
except RrdExportError as exc:
|
||||
except PluginRecordingExportError as exc:
|
||||
candidate_path.unlink(missing_ok=True)
|
||||
raise RecordingMaterializationError("native capture could not be exported") from exc
|
||||
except OSError as exc:
|
||||
|
|
@ -515,13 +549,13 @@ class SessionRecordingMaterializer:
|
|||
raise RecordingMaterializationError("native capture changed during RRD export")
|
||||
_chmod_best_effort(candidate_path, 0o600)
|
||||
source_sha256 = _sha256_prefix_stable(
|
||||
source.source,
|
||||
source.source_stat,
|
||||
source.replay_byte_length,
|
||||
source.primary.path,
|
||||
source.primary.file_stat,
|
||||
source.primary.replay_byte_length,
|
||||
)
|
||||
if (
|
||||
source.expected_source_sha256 is not None
|
||||
and source_sha256 != source.expected_source_sha256
|
||||
source.primary.expected_sha256 is not None
|
||||
and source_sha256 != source.primary.expected_sha256
|
||||
):
|
||||
raise RecordingMaterializationError(
|
||||
"native capture digest no longer matches catalog"
|
||||
|
|
@ -574,18 +608,25 @@ class SessionRecordingMaterializer:
|
|||
|
||||
def _invoke_exporter(
|
||||
self,
|
||||
plugin_id: str,
|
||||
source: Path,
|
||||
destination: Path,
|
||||
*,
|
||||
cancel_event: threading.Event | None,
|
||||
activity_callback: Callable[[], None],
|
||||
) -> 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] = {}
|
||||
if self._exporter_accepts_cancel:
|
||||
if _callable_accepts_keyword(exporter, "cancel_event"):
|
||||
kwargs["cancel_event"] = cancel_event
|
||||
if self._exporter_accepts_activity:
|
||||
if _callable_accepts_keyword(exporter, "activity_callback"):
|
||||
kwargs["activity_callback"] = activity_callback
|
||||
return self._exporter(source, destination, **kwargs)
|
||||
return exporter(source, destination, **kwargs)
|
||||
|
||||
def _load_memory_cache(
|
||||
self,
|
||||
|
|
@ -639,21 +680,25 @@ class SessionRecordingMaterializer:
|
|||
except (OSError, ValueError, json.JSONDecodeError, RecordingMaterializationError):
|
||||
return None
|
||||
|
||||
if document["source_file_byte_length"] != source.source_stat.st_size:
|
||||
if document["plugin_id"] != source.plugin_id:
|
||||
return None
|
||||
if document["source_mtime_ns"] != source.source_stat.st_mtime_ns:
|
||||
if document["primary_artifact_id"] != source.primary_artifact_id:
|
||||
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
|
||||
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
|
||||
if document["metadata_file_byte_length"] != source.metadata_stat.st_size:
|
||||
if cached["media_type"] != artifact.media_type:
|
||||
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
|
||||
if document["metadata_ctime_ns"] != source.metadata_stat.st_ctime_ns:
|
||||
if cached["mtime_ns"] != artifact.file_stat.st_mtime_ns:
|
||||
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
|
||||
if document["recording_byte_length"] != recording_stat.st_size:
|
||||
return None
|
||||
|
|
@ -661,25 +706,26 @@ class SessionRecordingMaterializer:
|
|||
return None
|
||||
|
||||
source_sha256 = _sha256_prefix_stable(
|
||||
source.source,
|
||||
source.source_stat,
|
||||
source.replay_byte_length,
|
||||
source.primary.path,
|
||||
source.primary.file_stat,
|
||||
source.primary.replay_byte_length,
|
||||
)
|
||||
if (
|
||||
source.expected_source_sha256 is not None
|
||||
and source_sha256 != source.expected_source_sha256
|
||||
source.primary.expected_sha256 is not None
|
||||
and source_sha256 != source.primary.expected_sha256
|
||||
):
|
||||
raise RecordingMaterializationError(
|
||||
"native capture digest no longer matches catalog"
|
||||
)
|
||||
if source_sha256 != document["source_sha256"]:
|
||||
return None
|
||||
metadata_sha256 = _sha256_prefix_stable(
|
||||
source.metadata,
|
||||
source.metadata_stat,
|
||||
source.metadata_byte_length,
|
||||
for cached, artifact in zip(cached_artifacts, source.artifacts, strict=True):
|
||||
digest = _sha256_prefix_stable(
|
||||
artifact.path,
|
||||
artifact.file_stat,
|
||||
artifact.replay_byte_length,
|
||||
)
|
||||
if metadata_sha256 != document["metadata_sha256"]:
|
||||
if digest != cached["sha256"]:
|
||||
return None
|
||||
recording_sha256 = _sha256_stable(recording_path, recording_stat)
|
||||
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:
|
||||
source_path = getattr(command, "source_path", None)
|
||||
plugin_id = getattr(command, "plugin_id", None)
|
||||
allowed_root = getattr(command, "allowed_root", None)
|
||||
session_root = getattr(command, "session_root", None)
|
||||
replay_byte_length = getattr(command, "replay_byte_length", None)
|
||||
metadata_byte_length = getattr(command, "metadata_byte_length", None)
|
||||
expected_source_sha256 = getattr(command, "expected_source_sha256", None)
|
||||
if not isinstance(source_path, Path):
|
||||
raise RecordingMaterializationError("replay command has no native capture")
|
||||
primary_artifact_id = getattr(command, "primary_artifact_id", None)
|
||||
artifacts = getattr(command, "artifacts", None)
|
||||
if not isinstance(plugin_id, str) or SESSION_ID_PATTERN.fullmatch(plugin_id) is None:
|
||||
raise RecordingMaterializationError("replay command has an invalid plugin id")
|
||||
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:
|
||||
if not isinstance(allowed_root, Path) or not isinstance(session_root, Path):
|
||||
raise RecordingMaterializationError("replay command has no confinement roots")
|
||||
allowed = allowed_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:
|
||||
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 (
|
||||
not allowed.is_dir()
|
||||
or not session.is_dir()
|
||||
or not session.is_relative_to(allowed)
|
||||
or not source_parent.is_relative_to(session)
|
||||
SESSION_ID_PATTERN.fullmatch(artifact.artifact_id) is None
|
||||
or artifact.artifact_id in seen_ids
|
||||
):
|
||||
raise RecordingMaterializationError("native capture escapes its allowed session root")
|
||||
if source.suffix.casefold() != ".k1mqtt":
|
||||
raise RecordingMaterializationError("native capture has an unsupported format")
|
||||
source_stat = _regular_file_stat_nofollow(source, "native capture")
|
||||
metadata = source.with_name("mqtt.metadata.jsonl")
|
||||
if not metadata.parent.resolve(strict=True).is_relative_to(session):
|
||||
raise RecordingMaterializationError("native metadata escapes its allowed session root")
|
||||
metadata_stat = _regular_file_stat_nofollow(metadata, "native metadata")
|
||||
raise RecordingMaterializationError("replay artifact id is invalid or duplicated")
|
||||
path = artifact.path.expanduser().absolute()
|
||||
try:
|
||||
parent = path.parent.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise RecordingMaterializationError("recording source artifact is missing") from exc
|
||||
if not parent.is_relative_to(session):
|
||||
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 (
|
||||
not isinstance(replay_byte_length, int)
|
||||
or isinstance(replay_byte_length, bool)
|
||||
or not 1 <= replay_byte_length <= source_stat.st_size
|
||||
isinstance(artifact.replay_byte_length, bool)
|
||||
or not 1 <= artifact.replay_byte_length <= file_stat.st_size
|
||||
):
|
||||
raise RecordingMaterializationError("native capture replay boundary is invalid")
|
||||
if (
|
||||
not isinstance(metadata_byte_length, int)
|
||||
or isinstance(metadata_byte_length, bool)
|
||||
or not 1 <= metadata_byte_length <= metadata_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("recording artifact replay boundary is invalid")
|
||||
if artifact.expected_sha256 is not None:
|
||||
if not _is_sha256(artifact.expected_sha256):
|
||||
raise RecordingMaterializationError("recording artifact digest is invalid")
|
||||
if artifact.replay_byte_length != file_stat.st_size:
|
||||
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(
|
||||
source=source,
|
||||
metadata=metadata,
|
||||
source_stat=source_stat,
|
||||
metadata_stat=metadata_stat,
|
||||
replay_byte_length=replay_byte_length,
|
||||
metadata_byte_length=metadata_byte_length,
|
||||
expected_source_sha256=expected_source_sha256,
|
||||
plugin_id=plugin_id,
|
||||
primary_artifact_id=primary_artifact_id,
|
||||
artifacts=tuple(validated),
|
||||
)
|
||||
|
||||
|
||||
def _validate_source_state(source: _ValidatedSource) -> _ValidatedSource:
|
||||
return _ValidatedSource(
|
||||
source=source.source,
|
||||
metadata=source.metadata,
|
||||
source_stat=_regular_file_stat_nofollow(source.source, "native capture"),
|
||||
metadata_stat=_regular_file_stat_nofollow(source.metadata, "native metadata"),
|
||||
replay_byte_length=source.replay_byte_length,
|
||||
metadata_byte_length=source.metadata_byte_length,
|
||||
expected_source_sha256=source.expected_source_sha256,
|
||||
plugin_id=source.plugin_id,
|
||||
primary_artifact_id=source.primary_artifact_id,
|
||||
artifacts=tuple(
|
||||
_ValidatedArtifact(
|
||||
artifact_id=artifact.artifact_id,
|
||||
path=artifact.path,
|
||||
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 {
|
||||
"schema_version": CACHE_SCHEMA,
|
||||
"session_id": recording.session_id,
|
||||
"source_file_byte_length": source.source_stat.st_size,
|
||||
"source_replay_byte_length": source.replay_byte_length,
|
||||
"source_mtime_ns": source.source_stat.st_mtime_ns,
|
||||
"source_ctime_ns": source.source_stat.st_ctime_ns,
|
||||
"plugin_id": source.plugin_id,
|
||||
"primary_artifact_id": source.primary_artifact_id,
|
||||
"source_sha256": recording.source_sha256,
|
||||
"metadata_file_byte_length": source.metadata_stat.st_size,
|
||||
"metadata_replay_byte_length": source.metadata_byte_length,
|
||||
"metadata_mtime_ns": source.metadata_stat.st_mtime_ns,
|
||||
"metadata_ctime_ns": source.metadata_stat.st_ctime_ns,
|
||||
"metadata_sha256": _sha256_prefix_stable(
|
||||
source.metadata,
|
||||
source.metadata_stat,
|
||||
source.metadata_byte_length,
|
||||
"source_artifacts": [
|
||||
{
|
||||
"artifact_id": artifact.artifact_id,
|
||||
"media_type": artifact.media_type,
|
||||
"file_byte_length": artifact.file_stat.st_size,
|
||||
"replay_byte_length": artifact.replay_byte_length,
|
||||
"mtime_ns": artifact.file_stat.st_mtime_ns,
|
||||
"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_mtime_ns": recording_stat.st_mtime_ns,
|
||||
"recording_sha256": recording.sha256,
|
||||
|
|
@ -909,16 +987,10 @@ def _validate_cache_document(value: object, session_id: str) -> dict[str, Any]:
|
|||
expected_keys = {
|
||||
"schema_version",
|
||||
"session_id",
|
||||
"source_file_byte_length",
|
||||
"source_replay_byte_length",
|
||||
"source_mtime_ns",
|
||||
"source_ctime_ns",
|
||||
"plugin_id",
|
||||
"primary_artifact_id",
|
||||
"source_sha256",
|
||||
"metadata_file_byte_length",
|
||||
"metadata_replay_byte_length",
|
||||
"metadata_mtime_ns",
|
||||
"metadata_ctime_ns",
|
||||
"metadata_sha256",
|
||||
"source_artifacts",
|
||||
"recording_byte_length",
|
||||
"recording_mtime_ns",
|
||||
"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")
|
||||
if value["timeline"] != RERUN_SESSION_TIMELINE:
|
||||
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 (
|
||||
"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_mtime_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:
|
||||
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]
|
||||
if not isinstance(digest, str) or not _is_sha256(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"]:
|
||||
raise ValueError("recording cache timeline bounds are invalid")
|
||||
return cast(dict[str, Any], value)
|
||||
|
|
@ -1061,26 +1169,23 @@ def _stage_replay_prefix(
|
|||
staged_root = session_cache_root / f".source.{uuid4().hex}.tmp"
|
||||
try:
|
||||
staged_root.mkdir(mode=0o700)
|
||||
staged_source = staged_root / "mqtt.raw.k1mqtt"
|
||||
staged_metadata = staged_root / "mqtt.metadata.jsonl"
|
||||
staged_primary: Path | None = None
|
||||
for artifact in source.artifacts:
|
||||
staged = staged_root / artifact.path.name
|
||||
_copy_prefix_nofollow(
|
||||
source.source,
|
||||
source.source_stat,
|
||||
staged_source,
|
||||
source.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,
|
||||
artifact.path,
|
||||
artifact.file_stat,
|
||||
staged,
|
||||
artifact.replay_byte_length,
|
||||
cancel_event=cancel_event,
|
||||
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)
|
||||
return staged_root, staged_source
|
||||
return staged_root, staged_primary
|
||||
except BaseException:
|
||||
shutil.rmtree(staged_root, ignore_errors=True)
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -12,11 +12,12 @@ from typing import Any, cast
|
|||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
from .legacy import discover_legacy_viewer_sessions
|
||||
from .models import (
|
||||
LayoutConflictError,
|
||||
LegacySessionCandidate,
|
||||
ObservationArtifactCandidate,
|
||||
ObservationSessionCandidate,
|
||||
RecordedMediaArtifact,
|
||||
ReplayArtifact,
|
||||
ReplayCommand,
|
||||
SessionArtifact,
|
||||
SessionDetail,
|
||||
|
|
@ -30,6 +31,7 @@ from .models import (
|
|||
SessionSummary,
|
||||
WorkspaceLayout,
|
||||
)
|
||||
from .plugin_contract import ObservationArchiveSource
|
||||
|
||||
IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
MAX_LAYOUT_BYTES = 256 * 1024
|
||||
|
|
@ -38,6 +40,8 @@ DATABASE_NAME = "mission-core.sqlite3"
|
|||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS observation_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
plugin_id TEXT NOT NULL,
|
||||
archive_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('ready', 'interrupted', 'failed')),
|
||||
started_at_utc TEXT,
|
||||
|
|
@ -48,8 +52,9 @@ CREATE TABLE IF NOT EXISTS observation_sessions (
|
|||
origin TEXT NOT NULL,
|
||||
source_count INTEGER NOT NULL,
|
||||
total_bytes INTEGER NOT NULL,
|
||||
replay_raw_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
replay_metadata_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
primary_replay_artifact_id TEXT,
|
||||
timeline_origin_epoch_ns INTEGER,
|
||||
timeline_origin_monotonic_ns INTEGER,
|
||||
allowed_root TEXT NOT NULL,
|
||||
session_root TEXT NOT NULL,
|
||||
created_at_utc TEXT NOT NULL,
|
||||
|
|
@ -68,6 +73,7 @@ CREATE TABLE IF NOT EXISTS observation_session_artifacts (
|
|||
sha256 TEXT,
|
||||
integrity_status TEXT NOT NULL,
|
||||
locator TEXT NOT NULL,
|
||||
replay_byte_length INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (session_id, artifact_id)
|
||||
);
|
||||
|
||||
|
|
@ -129,20 +135,22 @@ class SessionStore:
|
|||
self._lock = threading.RLock()
|
||||
self._initialize()
|
||||
|
||||
def import_legacy_viewer_live(self, root: Path) -> tuple[str, ...]:
|
||||
allowed_root = root.expanduser().resolve()
|
||||
candidates = discover_legacy_viewer_sessions(allowed_root)
|
||||
def reconcile_archive(self, source: ObservationArchiveSource) -> tuple[str, ...]:
|
||||
"""Reconcile one plugin-owned evidence namespace into the host catalog."""
|
||||
|
||||
allowed_root = source.root.expanduser().resolve()
|
||||
candidates = source.discover(allowed_root)
|
||||
imported: list[str] = []
|
||||
for candidate in candidates:
|
||||
self._upsert_legacy(candidate)
|
||||
self._upsert_candidate(source, candidate)
|
||||
imported.append(candidate.session_id)
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
discovered = set(imported)
|
||||
indexed = connection.execute(
|
||||
"SELECT session_id FROM observation_sessions "
|
||||
"WHERE origin = 'legacy-viewer-live' AND allowed_root = ?",
|
||||
(str(allowed_root),),
|
||||
"WHERE plugin_id = ? AND archive_id = ? AND allowed_root = ?",
|
||||
(source.plugin_id, source.archive_id, str(allowed_root)),
|
||||
).fetchall()
|
||||
stale = [row["session_id"] for row in indexed if row["session_id"] not in discovered]
|
||||
connection.executemany(
|
||||
|
|
@ -240,29 +248,57 @@ class SessionStore:
|
|||
if not 0 <= speed <= 100:
|
||||
raise ValueError("speed must be within 0..100")
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT s.allowed_root, s.session_root, s.replay_raw_bytes, "
|
||||
"s.replay_metadata_bytes, a.locator, a.sha256 "
|
||||
"FROM observation_sessions AS s "
|
||||
"JOIN observation_session_artifacts AS a ON a.session_id = s.session_id "
|
||||
"WHERE s.session_id = ? AND a.artifact_id = 'raw-mqtt'",
|
||||
session = connection.execute(
|
||||
"SELECT plugin_id, allowed_root, session_root, "
|
||||
"primary_replay_artifact_id, timeline_origin_epoch_ns, "
|
||||
"timeline_origin_monotonic_ns FROM observation_sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).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")
|
||||
source_path = _resolve_confined_artifact(
|
||||
Path(row["allowed_root"]),
|
||||
Path(row["session_root"]),
|
||||
primary_artifact_id = session["primary_replay_artifact_id"]
|
||||
epoch_ns = session["timeline_origin_epoch_ns"]
|
||||
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"]),
|
||||
),
|
||||
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(
|
||||
session_id=session_id,
|
||||
source_path=source_path,
|
||||
allowed_root=Path(row["allowed_root"]),
|
||||
session_root=Path(row["session_root"]),
|
||||
replay_byte_length=int(row["replay_raw_bytes"]),
|
||||
metadata_byte_length=int(row["replay_metadata_bytes"]),
|
||||
expected_source_sha256=row["sha256"],
|
||||
plugin_id=session["plugin_id"],
|
||||
allowed_root=allowed_root,
|
||||
session_root=session_root,
|
||||
primary_artifact_id=primary_artifact_id,
|
||||
artifacts=artifacts,
|
||||
timeline_origin_epoch_ns=epoch_ns,
|
||||
timeline_origin_monotonic_ns=monotonic_ns,
|
||||
speed=float(speed),
|
||||
loop=loop,
|
||||
)
|
||||
|
|
@ -387,58 +423,92 @@ class SessionStore:
|
|||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(SCHEMA_SQL)
|
||||
columns = {
|
||||
session_columns = {
|
||||
row["name"]
|
||||
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(
|
||||
"ALTER TABLE observation_sessions "
|
||||
"ADD COLUMN replay_raw_bytes INTEGER NOT NULL DEFAULT 0"
|
||||
f"ALTER TABLE observation_sessions ADD COLUMN {name} {declaration}" # noqa: S608
|
||||
)
|
||||
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(
|
||||
"ALTER TABLE observation_sessions "
|
||||
"ADD COLUMN replay_metadata_bytes INTEGER NOT NULL DEFAULT 0"
|
||||
"ALTER TABLE observation_session_artifacts "
|
||||
"ADD COLUMN replay_byte_length INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
connection.commit()
|
||||
with _ignore_os_error():
|
||||
self.database_path.chmod(0o600)
|
||||
|
||||
def _upsert_legacy(self, candidate: LegacySessionCandidate) -> None:
|
||||
_validate_identifier(candidate.session_id, "legacy session id")
|
||||
def _upsert_candidate(
|
||||
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()
|
||||
session_root = candidate.session_root.resolve()
|
||||
if not session_root.is_relative_to(allowed_root):
|
||||
raise SessionIntegrityError("legacy session root escapes its allowed root")
|
||||
sources = _legacy_sources(candidate)
|
||||
artifacts = _legacy_artifacts(candidate, session_root)
|
||||
raise SessionIntegrityError("observation session root escapes its allowed root")
|
||||
if allowed_root != source.root.expanduser().resolve():
|
||||
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()
|
||||
modalities_json = json.dumps(list(candidate.modalities), separators=(",", ":"))
|
||||
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
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 = ?",
|
||||
(candidate.session_id,),
|
||||
).fetchone()
|
||||
unclaimed_pre_plugin_row = existing is not None and (
|
||||
existing["plugin_id"] == "" and existing["archive_id"] == ""
|
||||
)
|
||||
if existing is not None and (
|
||||
existing["origin"] != "legacy-viewer-live"
|
||||
or Path(existing["allowed_root"]).resolve() != allowed_root
|
||||
Path(existing["allowed_root"]).resolve() != allowed_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()
|
||||
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
|
||||
connection.execute(
|
||||
"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, "
|
||||
"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) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"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, "
|
||||
"started_at_utc = excluded.started_at_utc, "
|
||||
"completed_at_utc = excluded.completed_at_utc, "
|
||||
|
|
@ -446,11 +516,14 @@ class SessionStore:
|
|||
"modalities_json = excluded.modalities_json, "
|
||||
"replayable = excluded.replayable, source_count = excluded.source_count, "
|
||||
"total_bytes = excluded.total_bytes, "
|
||||
"replay_raw_bytes = excluded.replay_raw_bytes, "
|
||||
"replay_metadata_bytes = excluded.replay_metadata_bytes, "
|
||||
"primary_replay_artifact_id = excluded.primary_replay_artifact_id, "
|
||||
"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",
|
||||
(
|
||||
candidate.session_id,
|
||||
source.plugin_id,
|
||||
source.archive_id,
|
||||
candidate.display_name,
|
||||
candidate.status,
|
||||
candidate.started_at_utc,
|
||||
|
|
@ -458,11 +531,12 @@ class SessionStore:
|
|||
candidate.duration_seconds,
|
||||
modalities_json,
|
||||
int(candidate.replayable),
|
||||
"legacy-viewer-live",
|
||||
source.archive_id,
|
||||
len(sources),
|
||||
candidate.total_bytes,
|
||||
candidate.replay_raw_byte_length,
|
||||
candidate.replay_metadata_byte_length,
|
||||
candidate.primary_replay_artifact_id,
|
||||
candidate.timeline_origin_epoch_ns,
|
||||
candidate.timeline_origin_monotonic_ns,
|
||||
str(allowed_root),
|
||||
str(session_root),
|
||||
created_at,
|
||||
|
|
@ -480,9 +554,20 @@ class SessionStore:
|
|||
connection.executemany(
|
||||
"INSERT INTO observation_session_artifacts "
|
||||
"(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
|
||||
],
|
||||
)
|
||||
|
|
@ -519,82 +604,56 @@ class SessionStore:
|
|||
connection.close()
|
||||
|
||||
|
||||
def _legacy_sources(candidate: LegacySessionCandidate) -> tuple[SessionSource, ...]:
|
||||
rows: list[SessionSource] = []
|
||||
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,
|
||||
def _validated_candidate_artifacts(
|
||||
candidate: ObservationSessionCandidate,
|
||||
session_root: Path,
|
||||
) -> tuple[tuple[str, str, str, int, str | None, str, str], ...]:
|
||||
artifacts: list[tuple[str, str, str, int, str | None, str, str]] = [
|
||||
(
|
||||
"raw-mqtt",
|
||||
"raw-transport",
|
||||
"application/x-nodedc-k1mqtt",
|
||||
candidate.raw_byte_length,
|
||||
candidate.raw_sha256,
|
||||
candidate.raw_integrity_status,
|
||||
str(candidate.raw_path),
|
||||
)
|
||||
]
|
||||
for media in candidate.media_sources:
|
||||
) -> tuple[ObservationArtifactCandidate, ...]:
|
||||
artifacts = candidate.artifacts
|
||||
artifact_ids: set[str] = set()
|
||||
for artifact in artifacts:
|
||||
_validate_identifier(artifact.artifact_id, "observation artifact id")
|
||||
if artifact.artifact_id in artifact_ids:
|
||||
raise SessionIntegrityError("observation session contains duplicate artifacts")
|
||||
artifact_ids.add(artifact.artifact_id)
|
||||
if artifact.byte_length < 0 or not 0 <= artifact.replay_byte_length <= artifact.byte_length:
|
||||
raise SessionIntegrityError("observation artifact has invalid byte boundaries")
|
||||
try:
|
||||
locator = media.locator.resolve(strict=True)
|
||||
locator = artifact.locator.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("legacy video artifact is missing") from exc
|
||||
if not locator.is_dir() or not locator.is_relative_to(session_root):
|
||||
raise SessionIntegrityError("legacy video artifact escapes its session root")
|
||||
artifacts.append(
|
||||
(
|
||||
media.artifact_id,
|
||||
"recorded-video",
|
||||
"video/mp4",
|
||||
media.byte_length,
|
||||
None,
|
||||
"validated-structure",
|
||||
str(locator),
|
||||
)
|
||||
)
|
||||
if len({artifact[0] for artifact in artifacts}) != len(artifacts):
|
||||
raise SessionIntegrityError("legacy session contains duplicate artifact identifiers")
|
||||
return tuple(artifacts)
|
||||
raise SessionIntegrityError("observation artifact is missing") from exc
|
||||
if not locator.is_relative_to(session_root) or not (locator.is_file() or locator.is_dir()):
|
||||
raise SessionIntegrityError("observation artifact escapes its session root")
|
||||
if artifact.replay_byte_length > 0 and not locator.is_file():
|
||||
raise SessionIntegrityError("replay input artifact must be a regular file")
|
||||
source_ids: set[str] = set()
|
||||
for source in candidate.sources:
|
||||
_validate_identifier(source.source_id, "observation source id")
|
||||
if source.source_id in source_ids:
|
||||
raise SessionIntegrityError("observation session contains duplicate sources")
|
||||
if source.artifact_id not in artifact_ids:
|
||||
raise SessionIntegrityError("observation source references an unknown artifact")
|
||||
source_ids.add(source.source_id)
|
||||
return 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:
|
||||
|
|
|
|||
|
|
@ -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 k1link.viewer.replay import (
|
||||
ReplayFormatError,
|
||||
detect_replay_format,
|
||||
iter_replay_messages,
|
||||
)
|
||||
from .recorded import RecordedBlueprintError, recorded_blueprint_rrd
|
||||
from .rerun_bridge import RerunBridge, RerunSceneSettings
|
||||
|
||||
__all__ = [
|
||||
"ReplayFormatError",
|
||||
"StreamMessage",
|
||||
"detect_replay_format",
|
||||
"iter_replay_messages",
|
||||
"RecordedBlueprintError",
|
||||
"RerunBridge",
|
||||
"RerunSceneSettings",
|
||||
"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,
|
||||
SessionRecordingPreparationManager,
|
||||
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.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||
from k1link.web.plugin_runtime import (
|
||||
|
|
@ -42,13 +39,14 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
|||
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_catalog: DevicePluginCatalog = plugin_environment.catalog
|
||||
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
|
||||
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_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."""
|
||||
|
||||
imported = [
|
||||
*session_store.import_legacy_viewer_live(legacy_observation_sessions_root),
|
||||
*session_store.import_legacy_viewer_live(observation_sessions_root),
|
||||
session_id
|
||||
for archive in plugin_environment.observation_archives
|
||||
for session_id in session_store.reconcile_archive(archive)
|
||||
]
|
||||
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
|
||||
# helper owns a cross-process lease, while ordinary catalog requests
|
||||
# only perform discovery and therefore never touch a live writer.
|
||||
await asyncio.to_thread(recover_stale_active_session_marker, observation_sessions_root)
|
||||
for sessions_root in (
|
||||
legacy_observation_sessions_root,
|
||||
observation_sessions_root,
|
||||
):
|
||||
await asyncio.to_thread(recover_incomplete_camera_archives, sessions_root)
|
||||
for archive in plugin_environment.observation_archives:
|
||||
await asyncio.to_thread(archive.recover, archive.root)
|
||||
# Full evidence discovery, hashing and RRD queue reconciliation can be
|
||||
# expensive on field captures. Start it immediately in the background
|
||||
# instead of holding the ASGI startup gate.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from typing import Any
|
|||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from k1link.sessions.plugin_contract import ObservationArchiveSource, RecordingExporter
|
||||
from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest
|
||||
from k1link.web.plugin_runtime import (
|
||||
DevicePluginDispatcher,
|
||||
|
|
@ -25,6 +26,23 @@ class InstalledDevicePluginEnvironment:
|
|||
legacy_routers: tuple[APIRouter, ...]
|
||||
_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:
|
||||
cleanup_errors = _close_contributions(self._contributions)
|
||||
if cleanup_errors:
|
||||
|
|
@ -103,6 +121,31 @@ def _load_contribution(
|
|||
raise DevicePluginCompositionError(
|
||||
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:
|
||||
_add_cleanup_notes(exc, _close_contributions((contribution,)))
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -2,11 +2,16 @@ from __future__ import annotations
|
|||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter
|
||||
from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation, RuntimeActionResult
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.sessions.plugin_contract import ObservationRuntimeContribution
|
||||
|
||||
STATE_READ_ACTION_ID = "state.read"
|
||||
|
||||
|
||||
|
|
@ -20,7 +25,7 @@ class DevicePluginActionAdapter(Protocol):
|
|||
plugin_id: 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:
|
||||
|
|
@ -33,6 +38,7 @@ class DevicePluginRuntimeContribution:
|
|||
|
||||
adapter: DevicePluginActionAdapter
|
||||
legacy_routers: tuple[APIRouter, ...] = ()
|
||||
observation: ObservationRuntimeContribution | None = None
|
||||
close: Callable[[], None] = _noop
|
||||
|
||||
|
||||
|
|
@ -71,8 +77,33 @@ class DevicePluginDispatcher:
|
|||
adapter = self._adapters.get(plugin_id)
|
||||
if adapter is None:
|
||||
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:
|
||||
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,
|
||||
validate_recorded_media_timeline,
|
||||
)
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
from k1link.viewer.rrd_export import (
|
||||
from k1link.viewer.recorded import (
|
||||
APPLICATION_ID as RECORDED_APPLICATION_ID,
|
||||
)
|
||||
from k1link.viewer.rrd_export import (
|
||||
RrdExportError,
|
||||
from k1link.viewer.recorded import (
|
||||
RecordedBlueprintError,
|
||||
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"
|
||||
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}$")
|
||||
|
|
@ -234,7 +233,6 @@ def build_session_router(
|
|||
recording_preparation_manager: SessionRecordingPreparationManager | None = None,
|
||||
media_inspector: RecordedMediaInspector | None = None,
|
||||
allow_synchronous_recording_fallback: bool = False,
|
||||
replay_plugin_id: str = DEFAULT_REPLAY_PLUGIN_ID,
|
||||
replay_action_id: str = DEFAULT_REPLAY_ACTION_ID,
|
||||
) -> APIRouter:
|
||||
"""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",
|
||||
"launch": {
|
||||
"kind": "plugin-action",
|
||||
"plugin_id": replay_plugin_id,
|
||||
"plugin_id": command.plugin_id,
|
||||
"action_id": replay_action_id,
|
||||
"session_id": command.session_id,
|
||||
"speed": command.speed,
|
||||
|
|
@ -671,7 +669,7 @@ def build_session_router(
|
|||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (SessionNotReplayableError, SessionIntegrityError) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except RrdExportError as exc:
|
||||
except RecordedBlueprintError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Не удалось подготовить настройки визуализатора.",
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ from __future__ import annotations
|
|||
|
||||
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.legacy import discover_legacy_viewer_sessions
|
||||
|
||||
|
||||
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.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:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from pathlib import Path
|
|||
|
||||
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 (
|
||||
CAMERA_ARCHIVE_SCHEMA,
|
||||
CAMERA_COMMIT_POLICY,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from pathlib import Path
|
|||
import lz4.block
|
||||
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 (
|
||||
ConsumerFrameContext,
|
||||
DecodedDeviceStatusView,
|
||||
|
|
@ -16,11 +16,11 @@ from k1link.data_plane import (
|
|||
DecodedPoseView,
|
||||
NormalizationError,
|
||||
)
|
||||
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
||||
from k1link.protocol.normalizer import normalize_k1_message
|
||||
from k1link.viewer.messages import StreamMessage
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
||||
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
|
||||
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.runtime import VisualizationRuntime
|
||||
|
||||
|
||||
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:
|
||||
source = inspect.getsource(__import__("k1link.viewer.rerun_bridge", fromlist=["*"]))
|
||||
for forbidden in (
|
||||
"k1link.protocol",
|
||||
"k1link.device_plugins.xgrids_k1.protocol",
|
||||
"StreamMessage",
|
||||
"RealtimePointcloud",
|
||||
"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:
|
||||
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 "normalizer: CanonicalNormalizer" in source
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from typing import Any
|
|||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from k1link.cli import app
|
||||
from k1link.device_plugins.xgrids_k1.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ def test_mqtt_capture_cli_uses_bounded_read_only_capture(
|
|||
"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"
|
||||
result = runner.invoke(
|
||||
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