feat(plugins): isolate device integrations

This commit is contained in:
DCCONSTRUCTIONS 2026-07-17 19:29:32 +03:00
parent f9ffb7bd1c
commit 24a47318f2
122 changed files with 3304 additions and 1892 deletions

View File

@ -16,18 +16,22 @@ commit complete fMP4 segments before their index rows. These are explicit
crash-RPO bounds, not a zero-loss or disk-replication claim. The former crash-RPO bounds, not a zero-loss or disk-replication claim. The former
Foxglove bridge remains only as a legacy regression module. Foxglove bridge remains only as a legacy regression module.
The repository is intentionally migrating in stages. The current `src/k1link` The backend vendor implementation is physically isolated below
package is the compatibility implementation of the first plugin path; vendor `src/k1link/device_plugins/xgrids_k1/`; its reviewed manifest and compatibility
transport and codecs will move behind `plugins/xgrids-k1` and the Mission Core profile remain below `plugins/xgrids-k1/`. Mission Core host code discovers
Plugin SDK without changing the verified wire protocol or raw evidence format. archive sources, recovery hooks and recording exporters only through the plugin
runtime contribution. The verified wire protocol and raw evidence format are
unchanged.
Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session, Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session,
operation, stream, evidence and compatibility contracts. The meanings remain a operation, runtime-action, stream, evidence and compatibility contracts. Every
local experimental vocabulary rather than a mutation of NODE.DC Platform backend action crosses immutable SDK `RuntimeActionInvocation` and
Ontology. The current runtime is still transitional and in process: it uses an `RuntimeActionResult` validation in the actual dispatcher hot path. The meanings
explicitly injected K1 normalizer to produce transport-neutral local consumer remain a local experimental vocabulary rather than a mutation of NODE.DC
views; portable SDK stream envelopes, process isolation, durable operations, Platform Ontology. The device process is still transitional and in process: it
multi-device routing and the remote Edge split remain later gates. uses an explicitly injected K1 normalizer to produce transport-neutral local
consumer views; portable SDK stream envelopes, process isolation, durable
operations, multi-device routing and the remote Edge split remain later gates.
The current runtime cannot read K1 firmware automatically. It keeps the exact The current runtime cannot read K1 firmware automatically. It keeps the exact
profile inactive until the operator explicitly attests firmware `3.0.2` and profile inactive until the operator explicitly attests firmware `3.0.2` and
@ -94,7 +98,10 @@ uv run pytest
The browser application is the universal Mission Core Control Station rather than The browser application is the universal Mission Core Control Station rather than
a K1-specific Foxglove launcher. Its fixed shell contains six architectural a K1-specific Foxglove launcher. Its fixed shell contains six architectural
sections — Center, Fleet, Observation, Missions, Data and System — while the K1 sections — Center, Fleet, Observation, Missions, Data and System — while the K1
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter. BLE/Wi-Fi/live workflow remains isolated as the first real device adapter. Its
React provisioning, acquisition/replay and diagnostic blocks live beside the
plugin manifest under `plugins/xgrids-k1/frontend`; the generic application
mounts them through one reviewed composition import.
Install, type-check, build and serve the complete local application from the Install, type-check, build and serve the complete local application from the
repository root: repository root:
@ -170,7 +177,7 @@ index use Mac receive/arrival timestamps, not proven K1 sensor timestamps or a
photon-to-screen measurement. photon-to-screen measurement.
The old Foxglove implementation is retained only in The old Foxglove implementation is retained only in
`src/k1link/viewer/foxglove_bridge.py` and its regression tests. The current `src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py` and its regression tests. The current
live/replay runtime does not start it or use TCP 8765. The live/replay runtime does not start it or use TCP 8765. The
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records the active Rerun path and [live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records the active Rerun path and
its timing/security boundaries; the frontend contract is documented in its timing/security boundaries; the frontend contract is documented in

View File

@ -18,13 +18,14 @@ device adapter, но структура интерфейса от него не
| --- | --- | --- | | --- | --- | --- |
| Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. | | Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
| Device plugin registry | Реализован, v1alpha1 + v1alpha2 | До выбора модели provider остаётся inert и не делает I/O. v1alpha1 сохраняет одну модель; v1alpha2 допускает одну или несколько моделей и требует profile coverage каждой. Custom `device.connection` UI key и backend factory подключаются одним reviewed import в composition root. | | Device plugin registry | Реализован, v1alpha1 + v1alpha2 | До выбора модели provider остаётся inert и не делает I/O. v1alpha1 сохраняет одну модель; v1alpha2 допускает одну или несколько моделей и требует profile coverage каждой. Custom `device.connection` UI key и backend factory подключаются одним reviewed import в composition root. |
| Plugin-owned connection UI | Реализован | XGRIDS provisioning, acquisition/replay, diagnostics, metrics и scoped styles физически находятся в `plugins/xgrids-k1/frontend`; generic Control Station предоставляет только frontend SDK, model catalog и host slot. |
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. | | Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. | | K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. | | K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
| Автоматический MQTT → Rerun | Реализован | Первый live/adapter file-replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие такие сессии переиспользуют его. Saved observation replay использует отдельный immutable HTTP RRD path. | | Автоматический MQTT → Rerun | Реализован | Первый live/adapter file-replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие такие сессии переиспользуют его. Saved observation replay использует отдельный immutable HTTP RRD path. |
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. | | Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. | | Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. |
| Сохранённые observation sessions | Реализованы для point/pose | Три последние сессии, background preparation, cache v6, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. | | Сохранённые observation sessions | Реализованы для point/pose | Три последние сессии, background preparation, cache v7, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. |
| K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но реальная архивная K1 camera-session ещё не прошла physical acceptance. | | K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но реальная архивная K1 camera-session ещё не прошла physical acceptance. |
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. | | Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
| Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. | | Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. |
@ -54,7 +55,7 @@ Mission Core Control Station ←→ REST /api/v1/device-plugins/*
sealed/recovered observation session sealed/recovered observation session
└── SQLite catalog + bounded background preparation └── SQLite catalog + bounded background preparation
└── atomic RRD cache v6 + recorded-media manifest v2 └── atomic RRD cache v7 + recorded-media manifest v2
└── generation-bound same-origin HTTP └── generation-bound same-origin HTTP
└── aggregate admission └── aggregate admission
└── Rerun native receiver + recorded fMP4 player └── Rerun native receiver + recorded fMP4 player
@ -280,11 +281,11 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
| --- | --- | | --- | --- |
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. | | `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. | | `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
| `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle и plugin host. | | `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle, plugin host и public frontend SDK surface. |
| `src/core/runtime/` | Нормализованное состояние активного устройства и spatial source. | | `src/core/runtime/` | Нормализованное состояние активного устройства и spatial source. |
| `src/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. | | `src/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. |
| `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. | | `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. |
| `src/device-plugins/xgrids-k1/` | Реальный K1 BLE/Wi-Fi/live/replay UI, client и compatibility mapper. | | `../../plugins/xgrids-k1/frontend/` | Plugin-owned K1 BLE/Wi-Fi, acquisition/replay UI, API client, runtime mapper и scoped styles. |
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. | | `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
| `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. | | `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. |
| `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. | | `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |

View File

@ -1,5 +1,5 @@
import type { DeviceUiPlugin } from "../core/device-plugins/contracts"; import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
import { xgridsK1Plugin } from "../device-plugins/xgrids-k1/plugin"; import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
// Composition root: this is the only place where Mission Core chooses which // Composition root: this is the only place where Mission Core chooses which
// statically reviewed device plugins are shipped in the current build. // statically reviewed device plugins are shipped in the current build.

View File

@ -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";

View File

@ -1,703 +0,0 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Button,
Checker,
GlassSurface,
Icon,
SegmentedControl,
StatusBadge,
TextField,
type StatusTone,
} from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "../../core/device-plugins/contracts";
import { MetricCard } from "../../components/MetricCard";
import type { BleDevice, CompatibilityAttestation } from "./api";
import {
isConfirmedLiveState,
isSourceRuntimeBusy,
provisioningIntentKey,
recoverableAcquisition,
sourceStatusLabel,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import {
backendLabel,
backendTone,
eventStatusLabel,
finiteMetric,
formatNumber,
phaseLabel,
phaseTone,
pipelineLatency,
} from "./presentation";
import { useXgridsK1Controller } from "./runtimeContext";
type SessionIntent = "live" | "replay";
const sessionItems = [
{ value: "live", label: "Реальное устройство" },
{ value: "replay", label: "Повтор записи" },
] satisfies Array<{ value: SessionIntent; label: string }>;
const EXACT_PROFILE_ATTESTATION: CompatibilityAttestation = {
firmware_version: "3.0.2",
topology: "direct-lan",
operator_confirmed: true,
};
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="detail-row">
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
}
function WizardStep({
number,
title,
status,
tone = "neutral",
children,
}: {
number: string;
title: string;
status: string;
tone?: StatusTone;
children: ReactNode;
}) {
return (
<section className="wizard-step">
<div className="wizard-step__rail" aria-hidden="true">
<span>{number}</span>
</div>
<div className="wizard-step__content">
<header>
<h3>{title}</h3>
<StatusBadge tone={tone}>{status}</StatusBadge>
</header>
{children}
</div>
</section>
);
}
function DeviceRow({
device,
selected,
onSelect,
}: {
device: BleDevice;
selected: boolean;
onSelect: () => void;
}) {
return (
<div
className="device-row"
data-compatible={device.likely_k1 ? "true" : undefined}
data-selected={selected ? "true" : undefined}
>
<div className="device-row__identity">
<span className="device-row__signal" aria-hidden="true" />
<div>
<span className="device-row__name">
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
{device.likely_k1 ? <small>Кандидат по имени; профиль не подтверждён</small> : null}
</span>
<code>{device.device_id}</code>
</div>
</div>
<div className="device-row__action">
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
<Button
size="compact"
variant={selected ? "primary" : "secondary"}
disabled={device.connectable === false}
onClick={onSelect}
>
{selected ? "Выбрано" : "Выбрать"}
</Button>
</div>
</div>
);
}
function LatencyTrace({ values }: { values: number[] }) {
const ceiling = Math.max(16, ...values);
return (
<div className="latency-trace" aria-label="Последние измерения времени до публикации">
{values.length ? (
values.map((value, index) => (
<span
key={`${index}-${value}`}
style={{ height: `${Math.max(8, Math.min(100, (value / ceiling) * 100))}%` }}
title={`${value.toFixed(1)} мс`}
/>
))
) : (
<p>Измерений пока нет. График появится после получения реальных данных.</p>
)}
</div>
);
}
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
const console = useXgridsK1Controller();
const {
state,
backendStatus,
eventStatus,
pendingAction,
error,
latencyHistory,
refresh,
clearError,
scan,
connect,
prepareAndStartAcquisition,
startReplay,
stop,
abort,
} = console;
const [powerConfirmed, setPowerConfirmed] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [profileConfirmed, setProfileConfirmed] = useState(false);
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
const provisioningIntentRef = useRef<string | null>(null);
useEffect(() => {
if (state?.selected_device_id) {
if (state.selected_device_id !== selectedDeviceId) {
setProfileConfirmed(false);
provisioningIntentRef.current = null;
}
setSelectedDeviceId(state.selected_device_id);
return;
}
if (
selectedDeviceId &&
state?.devices &&
!state.devices.some((device) => device.device_id === selectedDeviceId)
) {
setSelectedDeviceId("");
}
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
useEffect(() => {
if (state?.source_mode === "live" || state?.source_mode === "replay") {
setSessionIntent(state.source_mode);
} else if (state?.acquisition?.state === "prepared") {
setSessionIntent("live");
}
}, [state?.acquisition?.state, state?.source_mode]);
const confirmedLive = isConfirmedLiveState(state);
const streamActive = confirmedLive || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const droppedFrames = finiteMetric(metrics?.dropped_preview_frames);
const devices = state?.devices ?? [];
const isBusy = pendingAction !== null;
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
const canConnect =
powerConfirmed &&
profileConfirmed &&
selectedDeviceId.length > 0 &&
credentialsReady &&
!isBusy;
const activeAcquisition = recoverableAcquisition(state);
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
const sessionLocked = sourceRuntimeBusy || activeAcquisition !== null;
const effectiveSessionIntent: SessionIntent =
state?.source_mode === "live" || state?.source_mode === "replay"
? state.source_mode
: activeAcquisition
? "live"
: sessionIntent;
const liveTargetReady = Boolean(
state?.k1_ip || liveHost.trim() || preparedAcquisition?.target_host,
);
const sourceLabel = sourceStatusLabel(state);
const relevantAcquisitionFailed =
state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
const sourceTone: StatusTone =
state?.phase === "error" || relevantAcquisitionFailed
? "danger"
: confirmedLive || state?.source_mode === "replay"
? "success"
: sourceRuntimeBusy || preparedAcquisition
? "warning"
: "neutral";
const connectionPhaseLabel =
sourceRuntimeBusy || preparedAcquisition ? sourceLabel : phaseLabel(state?.phase);
const connectionPhaseTone =
sourceRuntimeBusy || preparedAcquisition ? sourceTone : phaseTone(state?.phase);
const selectableSessionItems = useMemo(
() => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })),
[sessionLocked],
);
const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId],
);
const submitConnect = async () => {
if (!canConnect) return;
const idempotencyKey = provisioningIntentKey(provisioningIntentRef.current);
provisioningIntentRef.current = idempotencyKey;
const succeeded = await connect({
device_id: selectedDeviceId,
ssid: ssid.trim(),
password,
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
idempotency_key: idempotencyKey,
});
if (succeeded) {
provisioningIntentRef.current = null;
setPassword("");
}
};
const submitLive = async () => {
if (!profileConfirmed || sourceRuntimeBusy) return;
const targetHost = liveHost.trim();
const started = await prepareAndStartAcquisition({
...(targetHost ? { host: targetHost } : {}),
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
});
if (started) host.openSpatialScene();
};
const submitReplay = async () => {
const speed = Number(replaySpeed);
const started = await startReplay({
path: replayPath.trim(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
loop: replayLoop,
});
if (started) host.openSpatialScene();
};
return (
<div className="device-workspace xgrids-k1-plugin">
{error ? (
<aside className="error-banner" role="alert">
<span className="error-banner__dot" aria-hidden="true" />
<div>
<strong>Локальная операция завершилась ошибкой</strong>
<p>{localizeRuntimeMessage(error)}</p>
</div>
<div className="error-banner__actions">
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
Обновить состояние
</Button>
<Button size="compact" variant="ghost" onClick={clearError}>
Закрыть
</Button>
</div>
</aside>
) : null}
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">РАБОЧИЙ АДАПТЕР УСТРОЙСТВА</span>
<h2>Подключение {model.displayName}</h2>
<p>
Этот путь уже работает физически, но остаётся изолированным адаптером. Парковая и
операторская модель от конкретного устройства не зависят.
</p>
</div>
<div className="workspace-lead__status">
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
</div>
</section>
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
<MetricCard
featured
eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)}
unit="мс"
detail="MQTT callback → Rerun SDK; без экрана"
/>
<MetricCard
eyebrow="ЧАСТОТА КАДРОВ"
value={formatNumber(frameRate)}
unit="кадр/с"
detail="Последнее измерение адаптера"
/>
<MetricCard
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Реальное число декодированных точек"
/>
<MetricCard
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Исходные данные при этом сохраняются"
/>
</section>
<div className="device-workspace__grid">
<GlassSurface className="connection-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</span>
<h2>Подключите устройство к сети</h2>
</div>
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
</header>
<div className="wizard-list">
<WizardStep
number="01"
title="Включите устройство"
status={powerConfirmed ? "Подтверждено" : "Ожидает"}
tone={powerConfirmed ? "success" : "warning"}
>
<div className="nodedc-field">
<span className="nodedc-field__description">
Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия.
</span>
<Checker
checked={powerConfirmed}
label="Устройство включено, индикатор стабилен"
onChange={(checked) => {
setPowerConfirmed(checked);
if (!checked) {
setProfileConfirmed(false);
provisioningIntentRef.current = null;
}
}}
/>
</div>
</WizardStep>
<WizardStep
number="02"
title="Выберите Bluetooth-устройство"
status={
pendingAction === "scan"
? "Поиск…"
: selectedDeviceId
? "Устройство выбрано"
: `Найдено: ${devices.length}`
}
tone={
pendingAction === "scan"
? "accent"
: selectedDeviceId
? "success"
: "neutral"
}
>
<p className="step-copy">
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата
основана только на имени и не подтверждает модель или прошивку; окончательный выбор
и аттестацию всегда делает оператор.
</p>
<Button
width="full"
variant="secondary"
icon={<Icon name="search" />}
disabled={!powerConfirmed || isBusy}
onClick={() => {
setSelectedDeviceId("");
setProfileConfirmed(false);
provisioningIntentRef.current = null;
void scan();
}}
>
{pendingAction === "scan"
? "Сканируем Bluetooth — 6 секунд…"
: "Показать все BLE-устройства"}
</Button>
<div className="device-list">
{devices.length ? (
devices.map((device) => (
<DeviceRow
key={device.device_id}
device={device}
selected={device.device_id === selectedDeviceId}
onSelect={() => {
setSelectedDeviceId(device.device_id);
setProfileConfirmed(false);
provisioningIntentRef.current = null;
}}
/>
))
) : (
<div className="empty-device-list">
Устройства пока не найдены. Проверьте питание и состояние индикатора, затем
повторите поиск.
</div>
)}
</div>
</WizardStep>
<WizardStep
number="03"
title="Передайте настройки WiFi"
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="Название сети WiFi"
hint="SSID"
value={ssid}
onChange={(event) => {
setSsid(event.target.value);
provisioningIntentRef.current = null;
}}
autoComplete="off"
spellCheck={false}
placeholder="Сеть локального контура"
/>
<TextField
label="Пароль WiFi"
hint="Только в оперативной памяти"
type="password"
value={password}
onChange={(event) => {
setPassword(event.target.value);
provisioningIntentRef.current = null;
}}
autoComplete="off"
placeholder="Введите пароль"
/>
</div>
<div className="connection-summary">
<span>Устройство</span>
<strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong>
</div>
<Button
width="full"
variant="primary"
icon={<Icon name="network" />}
disabled={!canConnect}
onClick={() => void submitConnect()}
>
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к WiFi"}
</Button>
<p className="safety-note">
Пароль передаётся только локальному сервису на этом компьютере, не сохраняется в браузере и
удаляется из формы после успешного подключения.
</p>
</WizardStep>
</div>
</GlassSurface>
<div className="device-workspace__side">
<GlassSurface className="session-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">
{effectiveSessionIntent === "live" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
</span>
<h2>{effectiveSessionIntent === "live" ? "Подготовьте приём" : "Повторите запись"}</h2>
</div>
<StatusBadge tone={sourceTone}>
{sourceLabel}
</StatusBadge>
</header>
<SegmentedControl
label="Источник данных"
value={effectiveSessionIntent}
items={selectableSessionItems}
onChange={(intent) => {
if (!sessionLocked) setSessionIntent(intent);
}}
/>
{effectiveSessionIntent === "live" ? (
<div className="session-form">
<TextField
label="Адрес устройства"
hint="Обычно определяется автоматически"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
spellCheck={false}
placeholder={
state?.k1_ip ||
preparedAcquisition?.target_host ||
"Сначала подключите устройство к WiFi"
}
/>
<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 пока не отправляется; поток подтверждается только реальными кадрами."
: "Сначала подключите устройство к WiFi или укажите его локальный адрес."}
</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>
);
}

View File

@ -83,15 +83,8 @@
font-size: 0.61rem; font-size: 0.61rem;
} }
.device-model-card dt { .device-model-card dt { color: var(--nodedc-text-muted); }
color: var(--nodedc-text-muted); .device-model-card dd { margin: 0; color: var(--nodedc-text-secondary); text-align: right; }
}
.device-model-card dd {
margin: 0;
color: var(--nodedc-text-secondary);
text-align: right;
}
.device-model-card__capabilities { .device-model-card__capabilities {
display: flex; display: flex;
@ -117,439 +110,7 @@
padding: 0.72rem 0.85rem; padding: 0.72rem 0.85rem;
} }
.device-plugin-slot__bar > div { .device-plugin-slot__bar > div { display: grid; min-width: 0; gap: 0.12rem; }
display: grid; .device-plugin-slot__bar strong { color: var(--nodedc-text-primary); font-size: 0.74rem; }
min-width: 0; .device-plugin-slot__bar small { color: var(--nodedc-text-muted); font-size: 0.58rem; }
gap: 0.12rem; .device-plugin-slot__bar .device-plugin-slot__error { color: rgb(var(--nodedc-danger-rgb)); }
}
.device-plugin-slot__bar strong {
color: var(--nodedc-text-primary);
font-size: 0.74rem;
}
.device-plugin-slot__bar small {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
}
.device-plugin-slot__bar .device-plugin-slot__error {
color: rgb(var(--nodedc-danger-rgb));
}
.xgrids-k1-plugin {
.device-workspace__grid {
display: grid;
min-width: 0;
grid-template-columns: minmax(23rem, 0.78fr) minmax(34rem, 1.22fr);
align-items: start;
gap: 0.85rem;
}
.device-workspace__side {
display: grid;
min-width: 0;
gap: 0.85rem;
}
.connection-panel,
.status-panel,
.latency-panel,
.session-panel {
background: var(--station-panel);
}
.error-banner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.9rem;
border-radius: 1rem;
background: rgb(255 255 255 / 0.045);
color: var(--nodedc-text-primary);
padding: 0.85rem 1rem;
}
.error-banner__dot {
width: 0.46rem;
height: 0.46rem;
align-self: start;
margin-top: 0.28rem;
border-radius: 50%;
background: rgb(var(--nodedc-danger-rgb));
}
.error-banner strong,
.error-banner p {
margin: 0;
}
.error-banner strong {
color: var(--nodedc-text-primary);
font-size: 0.76rem;
}
.error-banner p {
margin-top: 0.2rem;
color: var(--nodedc-text-secondary);
font-size: 0.68rem;
line-height: 1.45;
}
.error-banner__actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.wizard-list {
display: grid;
margin-top: 1.4rem;
}
.wizard-step {
display: grid;
grid-template-columns: 2.2rem minmax(0, 1fr);
gap: 0.75rem;
}
.wizard-step__rail {
position: relative;
display: flex;
justify-content: center;
}
.wizard-step__rail::after {
position: absolute;
top: 2rem;
bottom: 0;
left: 50%;
width: 1px;
background: var(--station-hairline);
content: "";
}
.wizard-step:last-child .wizard-step__rail::after {
display: none;
}
.wizard-step__rail span {
position: relative;
z-index: 1;
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border-radius: 50%;
background: #27272a;
color: var(--nodedc-text-secondary);
font-size: 0.59rem;
font-weight: 800;
}
.wizard-step__content {
min-width: 0;
padding: 0.08rem 0 1.5rem;
}
.wizard-step:last-child .wizard-step__content {
padding-bottom: 0;
}
.wizard-step__content > header {
display: flex;
min-height: 2rem;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
margin-bottom: 0.75rem;
}
.wizard-step__content h3 {
margin: 0;
font-size: 0.8rem;
font-weight: 710;
}
.step-copy,
.safety-note,
.live-instruction {
margin: 0 0 0.72rem;
color: var(--nodedc-text-muted);
font-size: 0.65rem;
line-height: 1.48;
}
.safety-note,
.live-instruction {
margin: 0.7rem 0 0;
}
.device-list {
display: grid;
max-height: 20rem;
gap: 0.48rem;
margin-top: 0.72rem;
overflow-y: auto;
padding-right: 0.2rem;
}
.device-row {
display: grid;
gap: 0.62rem;
border-radius: 0.95rem;
background: rgb(255 255 255 / 0.035);
padding: 0.72rem;
}
.device-row[data-selected="true"] {
background: rgb(255 255 255 / 0.065);
box-shadow: none;
}
.device-row__identity,
.device-row__action {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.device-row__identity {
justify-content: flex-start;
}
.device-row__identity > div {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.device-row__name {
display: flex;
min-width: 0;
align-items: center;
gap: 0.42rem;
}
.device-row__name small {
flex: 0 0 auto;
color: var(--nodedc-text-muted);
font-size: 0.47rem;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.device-row__identity strong {
overflow: hidden;
font-size: 0.69rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-row code,
.detail-row code {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.58rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-row__signal {
width: 0.58rem;
height: 0.58rem;
flex: 0 0 0.58rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.device-row[data-compatible="true"] .device-row__signal {
background: rgb(var(--nodedc-success-rgb));
}
.device-row__action > span {
color: var(--nodedc-text-muted);
font-size: 0.62rem;
}
.empty-device-list {
border-radius: 0.95rem;
background: rgb(255 255 255 / 0.025);
color: var(--nodedc-text-muted);
padding: 0.9rem;
font-size: 0.65rem;
line-height: 1.45;
}
.field-stack,
.session-form {
display: grid;
gap: 0.85rem;
}
.connection-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin: 0.72rem 0;
border-radius: 0.85rem;
background: rgb(255 255 255 / 0.03);
padding: 0.62rem 0.75rem;
font-size: 0.65rem;
}
.connection-summary span {
color: var(--nodedc-text-muted);
}
.connection-summary strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-panel > .nodedc-segmented {
margin-top: 1.2rem;
}
.session-form {
margin-top: 1rem;
}
.session-form--replay {
grid-template-columns: minmax(0, 1.55fr) minmax(8rem, 0.45fr);
}
.session-form--replay > :first-child {
grid-column: 1 / -1;
}
.session-form--replay > .nodedc-button {
align-self: end;
}
.session-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1rem;
padding-top: 0.9rem;
}
.session-footer p {
max-width: 30rem;
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
line-height: 1.45;
}
.diagnostics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.detail-list {
display: grid;
margin: 0.9rem 0 0;
}
.detail-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(7rem, 0.72fr) minmax(0, 1.28fr);
gap: 1rem;
padding: 0.7rem 0;
}
.detail-row dt,
.detail-row dd {
min-width: 0;
margin: 0;
font-size: 0.65rem;
}
.detail-row dt {
color: var(--nodedc-text-muted);
}
.detail-row dd {
overflow: hidden;
color: var(--nodedc-text-secondary);
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.inline-state {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.inline-state::before {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.inline-state[data-state="open"]::before { background: rgb(var(--nodedc-success-rgb)); }
.inline-state[data-state="error"]::before,
.inline-state[data-state="closed"]::before { background: rgb(var(--nodedc-danger-rgb)); }
.latency-now {
color: var(--nodedc-text-primary);
font-size: 1.2rem;
font-weight: 660;
letter-spacing: -0.04em;
}
.latency-now span {
color: var(--nodedc-text-muted);
font-size: 0.61rem;
letter-spacing: 0;
}
.latency-trace {
display: flex;
height: 7rem;
align-items: end;
gap: 0.2rem;
margin-top: 1rem;
border-radius: 0.85rem;
background: rgb(255 255 255 / 0.018);
padding: 0.7rem;
}
.latency-trace span {
min-width: 0.16rem;
flex: 1 1 0;
border-radius: 999px 999px 0.12rem 0.12rem;
background: var(--nodedc-text-primary);
}
.latency-trace p {
align-self: center;
margin: auto;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
line-height: 1.45;
text-align: center;
}
.latency-legend {
display: flex;
justify-content: space-between;
margin-top: 0.38rem;
color: var(--nodedc-text-muted);
font-size: 0.54rem;
}
}

View File

@ -3,17 +3,12 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.xgrids-k1-plugin .device-workspace__grid {
grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr);
}
.landing-stage__copy { .landing-stage__copy {
width: min(35rem, 54%); width: min(35rem, 54%);
} }
} }
@media (max-width: 1280px) { @media (max-width: 1280px) {
.xgrids-k1-plugin .device-workspace__grid,
.overview-grid, .overview-grid,
.mission-layout { .mission-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -72,10 +67,6 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.xgrids-k1-plugin .diagnostics-grid {
grid-template-columns: 1fr;
}
.workspace-lead { .workspace-lead {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
@ -209,36 +200,12 @@
display: none; display: none;
} }
.xgrids-k1-plugin .session-form--replay {
grid-template-columns: 1fr;
}
.xgrids-k1-plugin .session-form--replay > :first-child {
grid-column: auto;
}
.xgrids-k1-plugin .session-footer,
.xgrids-k1-plugin .error-banner,
.source-format-list > div { .source-format-list > div {
align-items: stretch; align-items: stretch;
grid-template-columns: 1fr; grid-template-columns: 1fr;
flex-direction: column; flex-direction: column;
} }
.xgrids-k1-plugin .error-banner {
grid-template-columns: auto minmax(0, 1fr);
}
.xgrids-k1-plugin .error-banner__actions {
grid-column: 2;
justify-content: flex-end;
}
.xgrids-k1-plugin .device-row__action {
align-items: stretch;
flex-direction: column;
}
.feature-row { .feature-row {
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
} }

View File

@ -24,13 +24,13 @@ before(async () => {
"/src/core/device-plugins/registry.ts", "/src/core/device-plugins/registry.ts",
)); ));
({ xgridsK1Manifest, xgridsK1Actions } = await server.ssrLoadModule( ({ xgridsK1Manifest, xgridsK1Actions } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/manifest.ts", "@xgrids-k1/frontend/manifest.ts",
)); ));
lifecycle = await server.ssrLoadModule( lifecycle = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/lifecycle.ts", "@xgrids-k1/frontend/lifecycle.ts",
); );
({ xgridsK1Api } = await server.ssrLoadModule( ({ xgridsK1Api } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/api.ts", "@xgrids-k1/frontend/api.ts",
)); ));
}); });

View File

@ -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);
}
});

View File

@ -35,10 +35,10 @@ before(async () => {
server: { middlewareMode: true }, server: { middlewareMode: true },
}); });
({ xgridsK1Manifest } = await server.ssrLoadModule( ({ xgridsK1Manifest } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/manifest.ts", "@xgrids-k1/frontend/manifest.ts",
)); ));
({ xgridsK1ObservationSources } = await server.ssrLoadModule( ({ xgridsK1ObservationSources } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/observationSources.ts", "@xgrids-k1/frontend/observationSources.ts",
)); ));
({ openObservationSource, shouldRestartObservationSource } = await server.ssrLoadModule( ({ openObservationSource, shouldRestartObservationSource } = await server.ssrLoadModule(
"/src/core/observation/layoutPolicy.ts", "/src/core/observation/layoutPolicy.ts",

View File

@ -13,7 +13,7 @@ before(async () => {
server: { middlewareMode: true }, server: { middlewareMode: true },
}); });
({ selectMonotonicXgridsState } = await server.ssrLoadModule( ({ selectMonotonicXgridsState } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/stateOrdering.ts", "@xgrids-k1/frontend/stateOrdering.ts",
)); ));
}); });

View File

@ -12,6 +12,14 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"baseUrl": ".",
"paths": {
"@mission-core/plugin-sdk": ["src/core/device-plugins/frontendSdk.ts"],
"@xgrids-k1/frontend/*": ["../../plugins/xgrids-k1/frontend/src/*"],
"react": ["node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["node_modules/@types/react/jsx-runtime.d.ts"],
"@nodedc/ui-react": ["node_modules/@nodedc/ui-react/dist/index.d.ts"]
},
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
@ -20,5 +28,5 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"] "include": ["src", "../../plugins/xgrids-k1/frontend/src"]
} }

View File

@ -1,3 +1,5 @@
import { fileURLToPath } from "node:url";
import { defineConfig, loadEnv } from "vite"; import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import wasm from "vite-plugin-wasm"; import wasm from "vite-plugin-wasm";
@ -8,6 +10,17 @@ export default defineConfig(({ mode }) => {
return { return {
plugins: [react(), wasm()], plugins: [react(), wasm()],
resolve: {
alias: {
"@mission-core/plugin-sdk": fileURLToPath(
new URL("./src/core/device-plugins/frontendSdk.ts", import.meta.url),
),
"@xgrids-k1/frontend": fileURLToPath(
new URL("../../plugins/xgrids-k1/frontend/src", import.meta.url),
),
},
dedupe: ["react", "react-dom", "@nodedc/ui-react"],
},
optimizeDeps: { optimizeDeps: {
// Rerun resolves its WASM asset relative to the package entrypoint. Vite's // Rerun resolves its WASM asset relative to the package entrypoint. Vite's
// dependency pre-bundler flattens that entrypoint and leaves the WASM URL // dependency pre-bundler flattens that entrypoint and leaves the WASM URL
@ -21,6 +34,13 @@ export default defineConfig(({ mode }) => {
host: "127.0.0.1", host: "127.0.0.1",
port: 5173, port: 5173,
strictPort: true, strictPort: true,
fs: {
allow: [
fileURLToPath(new URL(".", import.meta.url)),
fileURLToPath(new URL("../../plugins/xgrids-k1", import.meta.url)),
fileURLToPath(new URL("../../../NODEDC_DESIGN_GUIDELINE/packages", import.meta.url)),
],
},
proxy: { proxy: {
"/api": { "/api": {
target: apiTarget, target: apiTarget,

View File

@ -30,7 +30,7 @@ yet modeled and the physical button remains a known-safe fallback.
The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
queue cannot discard MQTT evidence. Stage 7 adds an independent durable queue cannot discard MQTT evidence. Stage 7 adds an independent durable
observation-session lifecycle. Completed or recovered native captures are observation-session lifecycle. Completed or recovered native captures are
prepared once by a bounded backend worker into digest-bound RRD/cache-v6 and prepared once by a bounded backend worker into digest-bound RRD/cache-v7 and
camera-manifest generations; a browser replay request never performs conversion. camera-manifest generations; a browser replay request never performs conversion.
The saved scene, controller and timeline remain hidden until the complete RRD The saved scene, controller and timeline remain hidden until the complete RRD
range and every declared camera pass admission. Sensor-to-display latency and range and every declared camera pass admission. Sensor-to-display latency and

View File

@ -210,7 +210,7 @@ opaque same-origin RRD rather than starting a second live bridge.
Sealing, recovery or legacy import makes the session eligible for the bounded Sealing, recovery or legacy import makes the session eligible for the bounded
backend preparation worker. The worker reads every native message once and backend preparation worker. The worker reads every native message once and
atomically publishes a private cache-v6 RRD using the zero-based `session_time` atomically publishes a private cache-v7 RRD using the zero-based `session_time`
duration timeline. A SHA sidecar binds the generation to native evidence. duration timeline. A SHA sidecar binds the generation to native evidence.
Replay-open never performs conversion: it returns HTTP 202 and a preparation Replay-open never performs conversion: it returns HTTP 202 and a preparation
handle or reuses the verified artifact. The embedded viewer opens the exact handle or reuses the verified artifact. The embedded viewer opens the exact
@ -302,7 +302,7 @@ sensor-to-screen latency.
## Legacy Foxglove regression module ## Legacy Foxglove regression module
`src/k1link/viewer/foxglove_bridge.py` and its tests remain in the repository `src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py` and its tests remain in the repository
to compare decoder/packing behavior and preserve earlier evidence. They are not to compare decoder/packing behavior and preserve earlier evidence. They are not
instantiated by `VisualizationRuntime`, are not the Control Station source, and instantiated by `VisualizationRuntime`, are not the Control Station source, and
do not make TCP 8765 part of the current operator path. do not make TCP 8765 part of the current operator path.

View File

@ -9,14 +9,15 @@ already exist. The accepted device-lifecycle decision is recorded in
| Path | Current responsibility | Target responsibility | | Path | Current responsibility | Target responsibility |
| --- | --- | --- | | --- | --- | --- |
| `apps/control-station/` | React shell, device workflow, spatial scene | Vendor-neutral operator application | | `apps/control-station/` | React shell, model catalog, plugin host slots, spatial scene | Vendor-neutral operator application |
| `apps/control-station/src/core/device-plugins/frontendSdk.ts` | Public React host surface for reviewed frontend contributions | Versioned frontend Plugin SDK package surface |
| `packages/plugin-sdk/` | Installable v0alpha2 Pydantic contracts and JSON Schema export | Portable host/plugin identity, lifecycle, stream and evidence boundary | | `packages/plugin-sdk/` | Installable v0alpha2 Pydantic contracts and JSON Schema export | Portable host/plugin identity, lifecycle, stream and evidence boundary |
| `plugins/xgrids-k1/` | v1alpha2 manifest, exact compatibility profile, fail-closed loader and extraction documentation | Independently versioned XGRIDS device plugin | | `plugins/xgrids-k1/` | v1alpha2 manifest, exact profile/loader and plugin-owned React connection/acquisition UI | Independently versioned XGRIDS device plugin |
| `src/k1link/web/` | Local FastAPI host, in-memory operation/acquisition lifecycle and transitional XGRIDS facade | Generic host APIs plus isolated plugin supervisor | | `plugins/xgrids-k1/frontend/` | XGRIDS provisioning, acquisition/replay, diagnostics, metrics, runtime mapping and scoped CSS | Plugin-owned reviewed frontend contribution |
| `src/k1link/mqtt/` | Raw-first K1 MQTT capture | Plugin-owned transport behind an EvidenceStore | | `src/k1link/web/` | Local FastAPI host, plugin composition, in-memory operation/acquisition lifecycle and generic session API | Generic host APIs plus isolated plugin supervisor |
| `src/k1link/protocol/` | Firmware-scoped K1 codecs and explicit vendor normalizer | Plugin-owned protocol adapter | | `src/k1link/device_plugins/xgrids_k1/` | Physically isolated K1 BLE/MQTT/protobuf/LZ4/camera/replay/CLI compatibility implementation | Independently built XGRIDS device plugin process |
| `src/k1link/data_plane/` | Transport-neutral decoded in-process consumer views | Local projections hydrated from portable SDK envelopes | | `src/k1link/data_plane/` | Transport-neutral decoded in-process consumer views | Local projections hydrated from portable SDK envelopes |
| `src/k1link/viewer/` | Rerun consumer, visualization runtime and legacy Foxglove regression module | Replaceable canonical scene sink and presentation adapters | | `src/k1link/viewer/` | Vendor-neutral Rerun consumer, metrics and recorded blueprint | Replaceable canonical scene sink and presentation adapters |
| `src/k1link/sessions/` | Host-owned SQLite catalog, evidence discovery/recovery, background preparation and derived-cache lifecycle | Device-neutral observation archive service | | `src/k1link/sessions/` | Host-owned SQLite catalog, evidence discovery/recovery, background preparation and derived-cache lifecycle | Device-neutral observation archive service |
| `src/k1link/web/session_api.py` | Opaque saved-session, immutable RRD/media and workspace-layout API | Versioned Control/Edge observation contract | | `src/k1link/web/session_api.py` | Opaque saved-session, immutable RRD/media and workspace-layout API | Versioned Control/Edge observation contract |
| `apps/control-station/src/core/observation/` | Session selection, replay admission, recorded-source and layout state | Device-neutral observation UI runtime | | `apps/control-station/src/core/observation/` | Session selection, replay admission, recorded-source and layout state | Device-neutral observation UI runtime |
@ -41,7 +42,7 @@ The host-owned recorded path is separate from the disposable live preview:
```text ```text
sealed or recovered native observation session sealed or recovered native observation session
-> SQLite catalog and bounded single-worker preparation -> SQLite catalog and bounded single-worker preparation
-> atomic RRD cache v6 + immutable recorded-media manifest v2 -> atomic RRD cache v7 + immutable recorded-media manifest v2
-> generation-bound same-origin HTTP -> generation-bound same-origin HTTP
-> aggregate RRD/camera admission -> aggregate RRD/camera admission
-> native Rerun HTTP receiver + recorded fMP4 player -> native Rerun HTTP receiver + recorded fMP4 player
@ -56,8 +57,17 @@ The Plugin SDK v0alpha2 separately defines model/device/session identity,
operation events, acquisition-related session state, canonical stream operation events, acquisition-related session state, canonical stream
envelopes, payload handles, evidence lineage and compatibility assessments. It envelopes, payload handles, evidence lineage and compatibility assessments. It
is importable through the root editable path dependency and independently is importable through the root editable path dependency and independently
buildable from `packages/plugin-sdk`. The hot K1 path does not yet instantiate buildable from `packages/plugin-sdk`. Backend actions now instantiate immutable
all SDK envelopes or use the SDK `EvidenceStore` protocol. SDK `RuntimeActionInvocation` and `RuntimeActionResult` envelopes in the real
dispatcher path. The live spatial path does not yet instantiate portable SDK
stream envelopes or use the SDK `EvidenceStore` protocol.
Frontend device workflows follow the same ownership rule. The generic host
selects a manifest model and mounts its `device.connection` component. Concrete
XGRIDS source lives under `plugins/xgrids-k1/frontend`, imports the host only via
`@mission-core/plugin-sdk`, and is connected by the single reviewed import in
`apps/control-station/src/composition/devicePlugins.ts`. BLE/Wi-Fi provisioning,
exact-profile confirmation and acquisition/replay UI are not Core components.
## Experimental vocabulary, not Platform Ontology ## Experimental vocabulary, not Platform Ontology
@ -138,17 +148,16 @@ for the measured gate and remaining limits.
## Remaining extraction order ## Remaining extraction order
1. Preserve native replay parity and repeat the exact-profile physical Native replay parity, exact-profile physical point/pose regression, immutable
point/pose regression through the explicit normalizer boundary. SDK runtime-action envelopes and physical extraction of K1 transports/codecs are
2. Map portable SDK stream/evidence contracts onto the hot path without complete. Portable SDK stream/evidence envelopes remain separate from the
replacing raw evidence or changing consumer output. allocation-conscious in-process preview views.
3. Extract codecs, normalizer and raw capture physically behind the XGRIDS
plugin package boundary. 1. Add process isolation and a durable operation/evidence supervisor.
4. Add process isolation and a durable operation/evidence supervisor. 2. Replace compatibility routes and singleton state with multi-device session
5. Replace compatibility routes and singleton state with multi-device session
routing. routing.
6. Split Edge execution from the Control Station behind authenticated transport. 3. Split Edge execution from the Control Station behind authenticated transport.
7. Physically accept a newly archived left/right K1 session, then package the 4. Physically accept a newly archived left/right K1 session, then package the
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
caching and evolve same-host MSE delivery toward an authenticated Edge media caching and evolve same-host MSE delivery toward an authenticated Edge media
plane; keep the modeling-command publisher disabled until its separate safety plane; keep the modeling-command publisher disabled until its separate safety

View File

@ -76,9 +76,11 @@ overall preparation deadline rather than the active-export heartbeat timeout.
The first preparation of a long capture can take time because every decodable The first preparation of a long capture can take time because every decodable
point/pose message is projected into RRD. Later openings reuse a verified, point/pose message is projected into RRD. Later openings reuse a verified,
digest-bound cache. Derived RRD cache v6 is intentionally incompatible with v4 digest-bound cache. Derived RRD cache v7 is intentionally incompatible with v6
and v5 because only v6 guarantees that the payload itself contains a real and older generations. It retains v6's real `session_time = 0` payload anchor
`session_time = 0` anchor; older generations are rebuilt once in the background. and additionally binds the cache to the owning plugin ID, primary artifact and
ordered set of confined source artifacts. Older generations are rebuilt once in
the background.
The browser may use the strict `source_url` with `If-Match`, while the embedded The browser may use the strict `source_url` with `If-Match`, while the embedded
Rerun loader uses the canonical `viewer_source_url` whose lowercase SHA-256 Rerun loader uses the canonical `viewer_source_url` whose lowercase SHA-256
`generation` query is bound to the same launch descriptor. A missing or stale `generation` query is bound to the same launch descriptor. A missing or stale

View File

@ -69,7 +69,9 @@ firmware-specific phases remain inside the XGRIDS plugin adapter.
- Canonical manifest: `plugins/xgrids-k1/plugin.manifest.json`. - Canonical manifest: `plugins/xgrids-k1/plugin.manifest.json`.
- Host contracts and registry: `apps/control-station/src/core/device-plugins/`. - Host contracts and registry: `apps/control-station/src/core/device-plugins/`.
- Generic runtime envelope: `apps/control-station/src/core/runtime/`. - Generic runtime envelope: `apps/control-station/src/core/runtime/`.
- XGRIDS UI/runtime adapter: `apps/control-station/src/device-plugins/xgrids-k1/`. - Public frontend host surface:
`apps/control-station/src/core/device-plugins/frontendSdk.ts`.
- XGRIDS UI/runtime adapter: `plugins/xgrids-k1/frontend/src/`.
- Read-only backend catalog: `GET /api/v1/device-plugins` and - Read-only backend catalog: `GET /api/v1/device-plugins` and
`GET /api/v1/device-models`. `GET /api/v1/device-models`.
- Host-owned action dispatcher: - Host-owned action dispatcher:
@ -81,9 +83,11 @@ firmware-specific phases remain inside the XGRIDS plugin adapter.
- Existing K1 action endpoints are compatibility shims over that dispatcher, so - Existing K1 action endpoints are compatibility shims over that dispatcher, so
the proven physical implementation is unchanged. the proven physical implementation is unchanged.
The XGRIDS frontend code is statically linked into the Control Station during The XGRIDS frontend code is physically plugin-owned and statically linked into
this v1alpha milestone. It is isolated by imports and contracts but is not yet a the Control Station during this v1alpha milestone. Its provisioning,
separately built npm workspace package. Backend composition is manifest-driven; acquisition/replay and diagnostics blocks are separate components, and it may
import the host only through `@mission-core/plugin-sdk`. It is not yet a
separately built or signed npm package. Backend composition is manifest-driven;
frontend/backend installed-set parity is a reviewed build-time responsibility frontend/backend installed-set parity is a reviewed build-time responsibility
until the host gains signed plugin bundles and a startup compatibility handshake. until the host gains signed plugin bundles and a startup compatibility handshake.
@ -91,8 +95,8 @@ until the host gains signed plugin bundles and a startup compatibility handshake
- Core cannot import a concrete plugin outside the composition root. - Core cannot import a concrete plugin outside the composition root.
- Core cannot inspect opaque plugin state or branch on a model ID. - Core cannot inspect opaque plugin state or branch on a model ID.
- Plugins render only inside declared slots and cannot own global navigation or - Plugins render only inside declared slots, cannot own global navigation, and
global CSS. may ship only plugin-root-scoped CSS.
- Plugins publish canonical spatial sources; they do not control the scene - Plugins publish canonical spatial sources; they do not control the scene
engine or route names. engine or route names.
- Passwords and other secrets cannot enter manifests, runtime snapshots, event - Passwords and other secrets cannot enter manifests, runtime snapshots, event
@ -139,15 +143,14 @@ The shell can now boot with no selected device and contains no K1 wire fields.
Adding another statically reviewed UI plugin does not require changing `App` or Adding another statically reviewed UI plugin does not require changing `App` or
the generic local-device workspace. the generic local-device workspace.
Backend execution supports only reviewed `transitional-in-process` plugins in Backend execution still supports only reviewed `transitional-in-process`
v1alpha1, but already enters through the manifest plugins, but enters through the manifest factory, immutable SDK runtime-action
factory, allowlisted XGRIDS facade and host-owned dispatcher. Synchronous envelopes, the allowlisted facade and host-owned dispatcher. Synchronous
capture/runtime work is moved off the API event loop. The next milestone adds capture/runtime work is moved off the API event loop. ADR 0009 completes the
independent device-session IDs, operation IDs, cancellation, timeouts, audited physical BLE/MQTT/codec/evidence extraction and plugin-owned observation
secret references, and process isolation. Only after replay parity and a contribution after replay parity and physical regression. The remaining
physical regression may BLE, MQTT, codecs, and raw evidence code move out of milestone is process isolation, durable operation supervision and portable SDK
the compatibility package. Rerun must ultimately consume canonical stream/evidence transport; the Rerun consumer already contains no K1 topics.
PointCloud/Pose envelopes instead of K1 topics.
This ADR supersedes the compatibility names listed in ADR 0002: `K1Metrics`, This ADR supersedes the compatibility names listed in ADR 0002: `K1Metrics`,
`useK1Console`, and `ConsoleService` were renamed and isolated inside the `useK1Console`, and `ConsoleService` were renamed and isolated inside the

View File

@ -208,10 +208,14 @@ This milestone is additive:
- legacy `stream.start-live`, `stream.start-replay`, `stream.stop`, and viewer - legacy `stream.start-live`, `stream.start-replay`, `stream.stop`, and viewer
settings actions remain compatibility shims for the proven UI and runtime; settings actions remain compatibility shims for the proven UI and runtime;
- the XGRIDS backend still runs through the reviewed - the XGRIDS backend still runs through the reviewed
`transitional-in-process` facade in `src/k1link`. `transitional-in-process` facade in
`src/k1link/device_plugins/xgrids_k1`;
- every dispatcher and compatibility-route action now crosses immutable SDK
`RuntimeActionInvocation` and `RuntimeActionResult` validation.
SDK v0alpha2 contracts do not imply that every current host endpoint, frontend SDK v0alpha2 runtime-action contracts are active in the backend hot path. This
state object, or compatibility shim already emits those contracts. does not imply that frontend state objects, live spatial frames or stored raw
evidence already use every SDK contract.
## Deliberately deferred ## Deliberately deferred

View File

@ -87,5 +87,5 @@ ADR 0008.
- `apps/control-station/src/components/ObservationSources.tsx` - `apps/control-station/src/components/ObservationSources.tsx`
- `apps/control-station/src/components/ObservationTimeline.tsx` - `apps/control-station/src/components/ObservationTimeline.tsx`
- `apps/control-station/src/workspaces/Workspaces.tsx` - `apps/control-station/src/workspaces/Workspaces.tsx`
- `apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts` - `plugins/xgrids-k1/frontend/src/observationSources.ts`
- `src/k1link/web/xgrids_k1_camera.py` - `src/k1link/device_plugins/xgrids_k1/camera.py`

View File

@ -89,11 +89,11 @@ timeline once when the recording opens and does not poll or force the cursor.
## Code anchors ## Code anchors
- `src/k1link/web/xgrids_k1_camera.py` - `src/k1link/device_plugins/xgrids_k1/camera.py`
- `src/k1link/web/camera_archive.py` - `src/k1link/web/camera_archive.py`
- `src/k1link/web/xgrids_k1_facade.py` - `src/k1link/device_plugins/xgrids_k1/facade.py`
- `apps/control-station/src/components/MseFmp4WebSocketPlayer.tsx` - `apps/control-station/src/components/MseFmp4WebSocketPlayer.tsx`
- `apps/control-station/src/components/RecordedFmp4Player.tsx` - `apps/control-station/src/components/RecordedFmp4Player.tsx`
- `apps/control-station/src/core/observation/useObservationLayout.ts` - `apps/control-station/src/core/observation/useObservationLayout.ts`
- `apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts` - `plugins/xgrids-k1/frontend/src/observationSources.ts`
- `tests/test_xgrids_camera_gateway.py` - `tests/test_xgrids_camera_gateway.py`

View File

@ -134,8 +134,10 @@ The derived RRD writes an actual zero-time anchor at the internal
`/__mission_core/session_origin` entity. Keeping it outside `/world` prevents a `/__mission_core/session_origin` entity. Keeping it outside `/world` prevents a
synthetic visualization layer while ensuring that the decoded RRD timeline, synthetic visualization layer while ensuring that the decoded RRD timeline,
not merely its summary document, begins at session time zero. This changes the not merely its summary document, begins at session time zero. This changes the
derived payload contract: cache v6 rejects v4/v5 sidecars and performs one derived payload contract. Cache v7 keeps that anchor and additionally binds the
background rebuild instead of silently reusing an archive without that row. derived recording to the owning plugin ID, primary artifact and ordered source
artifact set; it rejects v6 and older sidecars and performs one background
rebuild.
Replay v2 exposes two paths to the same pinned generation. `source_url` retains Replay v2 exposes two paths to the same pinned generation. `source_url` retains
the strict `If-Match: "sha256:…"` contract for Mission Core clients; the strict `If-Match: "sha256:…"` contract for Mission Core clients;

View File

@ -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.

View File

@ -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.

View File

@ -36,6 +36,8 @@ The separate SDK v0alpha2 package establishes executable contracts for:
- independently revisioned enrollment, connectivity, and acquisition states; - independently revisioned enrollment, connectivity, and acquisition states;
- operation policy, request, acknowledgement, progress, completion, failure, - operation policy, request, acknowledgement, progress, completion, failure,
timeout, cancellation, secret reference, and idempotency boundaries; timeout, cancellation, secret reference, and idempotency boundaries;
- immutable pre-session runtime-action invocation and result envelopes used by
the active backend dispatcher;
- canonical point cloud, pose, image, encoded video, and device-status streams; - canonical point cloud, pose, image, encoded video, and device-status streams;
- immutable evidence handles, raw transport records, lineage, and store - immutable evidence handles, raw transport records, lineage, and store
protocol; protocol;
@ -49,11 +51,13 @@ compatibility profile and adapter.
Current host implementation references: Current host implementation references:
- `apps/control-station/src/core/device-plugins/` — TypeScript manifest and UI - `apps/control-station/src/core/device-plugins/frontendSdk.ts` — current
contribution contracts; public TypeScript/React host surface for statically reviewed UI contributions;
- `plugins/xgrids-k1/frontend/` — first physically plugin-owned consumer of
that frontend surface;
- `apps/control-station/src/core/runtime/` — normalized runtime envelope; - `apps/control-station/src/core/runtime/` — normalized runtime envelope;
- `src/k1link/web/plugin_catalog.py` — strict backend manifest validation; - `src/k1link/web/plugin_catalog.py` — strict backend manifest validation;
- `src/k1link/web/plugin_runtime.py` — host-owned allowlisted action dispatcher; - `src/k1link/web/plugin_runtime.py` — host-owned allowlisted SDK-envelope action dispatcher;
- `src/k1link/web/device_plugin_composition.py` — manifest factory loader and - `src/k1link/web/device_plugin_composition.py` — manifest factory loader and
startup parity checks; startup parity checks;
- `docs/adr/0003-device-plugin-ui-and-runtime-boundary.md` — accepted boundary - `docs/adr/0003-device-plugin-ui-and-runtime-boundary.md` — accepted boundary
@ -66,6 +70,11 @@ milestone. v0alpha2 models secret references and operation policy, but host
authorization and the actual secret vault remain separate implementation authorization and the actual secret vault remain separate implementation
responsibilities. responsibilities.
The React surface is not yet an independently published npm package. Its alias
is intentionally narrow so plugin source cannot import Control Station
implementation paths; packaging and signing it belong to the supervisor/bundle
milestone.
## Version policy ## Version policy
v0alpha2 may receive additive fields while it remains experimental. A breaking v0alpha2 may receive additive fields while it remains experimental. A breaking

View File

@ -47,6 +47,7 @@ from .operations import (
validate_operation_request, validate_operation_request,
) )
from .payloads import InlinePayload, PayloadHandle, ReferencedPayload from .payloads import InlinePayload, PayloadHandle, ReferencedPayload
from .runtime import RuntimeActionInvocation, RuntimeActionResult
from .schema import contract_json_schemas from .schema import contract_json_schemas
from .session import ( from .session import (
AcquisitionState, AcquisitionState,
@ -120,6 +121,8 @@ __all__ = [
"Quaternion", "Quaternion",
"RawTransportRecord", "RawTransportRecord",
"RedactionState", "RedactionState",
"RuntimeActionInvocation",
"RuntimeActionResult",
"ReferencedPayload", "ReferencedPayload",
"RuleOutcome", "RuleOutcome",
"SecretReference", "SecretReference",

View File

@ -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)

View File

@ -8,6 +8,7 @@ from .compatibility import CompatibilityAssessment
from .evidence import EvidenceRecord, RawTransportRecord from .evidence import EvidenceRecord, RawTransportRecord
from .identity import DeviceInstanceRef from .identity import DeviceInstanceRef
from .operations import OperationEvent, OperationPolicy, OperationRequest from .operations import OperationEvent, OperationPolicy, OperationRequest
from .runtime import RuntimeActionInvocation, RuntimeActionResult
from .session import DeviceSessionContext, DeviceSessionSnapshot from .session import DeviceSessionContext, DeviceSessionSnapshot
from .streams import CanonicalStreamEnvelope from .streams import CanonicalStreamEnvelope
@ -22,6 +23,8 @@ def contract_json_schemas() -> dict[str, dict[str, object]]:
"OperationPolicy": OperationPolicy, "OperationPolicy": OperationPolicy,
"OperationRequest": OperationRequest, "OperationRequest": OperationRequest,
"OperationEvent": OperationEvent, "OperationEvent": OperationEvent,
"RuntimeActionInvocation": RuntimeActionInvocation,
"RuntimeActionResult": RuntimeActionResult,
"CanonicalStreamEnvelope": CanonicalStreamEnvelope, "CanonicalStreamEnvelope": CanonicalStreamEnvelope,
"EvidenceRecord": EvidenceRecord, "EvidenceRecord": EvidenceRecord,
"RawTransportRecord": RawTransportRecord, "RawTransportRecord": RawTransportRecord,

View File

@ -3,9 +3,11 @@
This directory is the package boundary for the first Mission Core device This directory is the package boundary for the first Mission Core device
plugin. It now owns the canonical v1alpha2 manifest plugin. It now owns the canonical v1alpha2 manifest
[`plugin.manifest.json`](plugin.manifest.json). The statically linked frontend [`plugin.manifest.json`](plugin.manifest.json). The statically linked frontend
contribution lives in `apps/control-station/src/device-plugins/xgrids-k1/`; the contribution lives in [`frontend/`](frontend/) beside the manifest and profiles;
verified backend implementation remains temporarily in `src/k1link` so the its provisioning, acquisition/replay, diagnostics and metrics are separate
real-device path stays operational during extraction. plugin-owned components. The
verified backend implementation is physically isolated in
`src/k1link/device_plugins/xgrids_k1/`.
The v1alpha2 catalog can describe one or more independently profiled models. The v1alpha2 catalog can describe one or more independently profiled models.
This manifest currently exposes only `xgrids.lixelkity-k1`, and the transitional This manifest currently exposes only `xgrids.lixelkity-k1`, and the transitional
@ -27,6 +29,8 @@ The plugin owns:
- exact-profile left/right RTSP producer selection, H.264 copy-remux preview and - exact-profile left/right RTSP producer selection, H.264 copy-remux preview and
the handoff of acquisition-owned init/fMP4/index evidence to the host archive; the handoff of acquisition-owned init/fMP4/index evidence to the host archive;
- K1-specific operator instructions and compatibility tests. - K1-specific operator instructions and compatibility tests.
- the scoped React `device.connection` contribution and its BLE/Wi-Fi and
acquisition pipeline UI.
The plugin does not own: The plugin does not own:
@ -35,19 +39,20 @@ The plugin does not own:
- the host observation catalog, RRD preparation/cache, recorded-camera player, - the host observation catalog, RRD preparation/cache, recorded-camera player,
workspace layout, platform storage, remote transport, or other devices. workspace layout, platform storage, remote transport, or other devices.
Until extraction is complete, `src/k1link` is the compatibility source of The `k1link` distribution and CLI names remain compatibility names. All
truth and every move into this package must preserve replay and real-device device-specific implementation behind them is plugin-owned, and every change
acceptance. must preserve replay and real-device acceptance.
Mission Core imports this plugin only from Mission Core imports this plugin only from
`apps/control-station/src/composition/devicePlugins.ts`. The generic shell never `apps/control-station/src/composition/devicePlugins.ts`. The generic shell never
branches on this plugin ID or reads `k1_ip`, BLE candidates, MQTT topics, or branches on this plugin ID or reads `k1_ip`, BLE candidates, MQTT topics, or
other vendor state. other vendor state. The frontend contribution imports the host only through
`@mission-core/plugin-sdk`; its CSS is scoped below `.xgrids-k1-plugin`.
Backend startup loads the reviewed factory declared by `backendEntrypoint`, then Backend startup loads the reviewed factory declared by `backendEntrypoint`, then
cross-checks its plugin ID and complete action set against this manifest. cross-checks its plugin ID and complete action set against this manifest.
Actions enter through the host dispatcher and the transitional facade Actions enter through the host dispatcher and the transitional facade
`src/k1link/web/xgrids_k1_facade.py`. The old flat routes live in the plugin's `src/k1link/device_plugins/xgrids_k1/facade.py`. The old flat routes live in the plugin's
legacy router; both paths delegate to the same proven legacy router; both paths delegate to the same proven
`XgridsK1CompatibilityService` methods. Synchronous capture/runtime operations `XgridsK1CompatibilityService` methods. Synchronous capture/runtime operations
run outside the FastAPI event loop. run outside the FastAPI event loop.
@ -55,9 +60,10 @@ run outside the FastAPI event loop.
Model switching calls the plugin deactivation hook. An active acquisition uses Model switching calls the plugin deactivation hook. An active acquisition uses
semantic `acquisition.stop` in `capture-only` mode; replay and pre-v1alpha2 semantic `acquisition.stop` in `capture-only` mode; replay and pre-v1alpha2
sessions retain the legacy `stream.stop` shim. Neither path claims that the sessions retain the legacy `stream.stop` shim. Neither path claims that the
physical K1 stopped without separate operator evidence. BLE, MQTT, codec and physical K1 stopped without separate operator evidence. BLE, MQTT, codec,
evidence modules remain in `src/k1link` until replay parity and another physical archive discovery and RRD export modules now live under the plugin package
K1 regression are complete. after replay parity and physical K1 regression. Process isolation and signed
deployment remain later supervisor gates.
The compatibility profile is descriptive and cannot itself authorize a vendor The compatibility profile is descriptive and cannot itself authorize a vendor
write. The current runtime cannot inspect K1 firmware: it keeps the profile write. The current runtime cannot inspect K1 firmware: it keeps the profile

View File

@ -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.

View File

@ -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/WiFi 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>
);
}

View File

@ -1,4 +1,4 @@
import type { ViewerSettings } from "../../core/runtime/contracts"; import type { ViewerSettings } from "@mission-core/plugin-sdk";
import { xgridsK1Actions, xgridsK1Manifest } from "./manifest"; import { xgridsK1Actions, xgridsK1Manifest } from "./manifest";
const PLUGIN_ID = xgridsK1Manifest.metadata.id; const PLUGIN_ID = xgridsK1Manifest.metadata.id;

View File

@ -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,
});

View File

@ -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" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span>
<h2>{effectiveSessionIntent === "live" ? "Подготовьте приём" : "Повторите запись"}</h2>
</div>
<StatusBadge tone={sourceTone}>{sourceLabel}</StatusBadge>
</header>
<SegmentedControl
label="Источник данных"
value={effectiveSessionIntent}
items={selectableSessionItems}
onChange={(intent) => { if (!sessionLocked) setSessionIntent(intent); }}
/>
{effectiveSessionIntent === "live" ? (
<div className="session-form">
<TextField
label="Адрес устройства"
hint="Обычно определяется автоматически"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
spellCheck={false}
placeholder={state?.k1_ip || preparedAcquisition?.target_host || "Сначала подключите устройство к WiFi"}
/>
<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 пока не отправляется; поток подтверждается только реальными кадрами."
: "Сначала подключите устройство к WiFi или укажите локальный адрес."}
</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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</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="Передайте настройки WiFi" 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="Название сети WiFi" hint="SSID" value={ssid} onChange={(event) => { setSsid(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" spellCheck={false} placeholder="Сеть локального контура" />
<TextField label="Пароль WiFi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
</div>
<div className="connection-summary"><span>Устройство</span><strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong></div>
<Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}>
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к WiFi"}
</Button>
<p className="safety-note">Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
</WizardStep>
</div>
</GlassSurface>
);
}

View File

@ -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>
);
}

View File

@ -1,4 +1,4 @@
import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "../../core/runtime/contracts"; import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "@mission-core/plugin-sdk";
import type { import type {
AcquisitionState, AcquisitionState,

View File

@ -1,10 +1,10 @@
import manifestDocument from "../../../../../plugins/xgrids-k1/plugin.manifest.json"; import manifestDocument from "../../plugin.manifest.json";
import { DEVICE_STATE_READ_ACTION_ID } from "../../core/device-plugins/contracts";
import { import {
DEVICE_STATE_READ_ACTION_ID,
parseDevicePluginManifest, parseDevicePluginManifest,
requirePluginAction, requirePluginAction,
} from "../../core/device-plugins/manifestParser"; } from "@mission-core/plugin-sdk";
export const xgridsK1Manifest = parseDevicePluginManifest(manifestDocument); export const xgridsK1Manifest = parseDevicePluginManifest(manifestDocument);

View File

@ -1,3 +1,4 @@
// Vendor message normalization belongs to the XGRIDS frontend contribution.
const runtimeMessageReplacements: Array<[RegExp, string]> = [ const runtimeMessageReplacements: Array<[RegExp, string]> = [
[/broker connection ended/gi, "соединение с брокером завершено"], [/broker connection ended/gi, "соединение с брокером завершено"],
[/Unspecified error/gi, "неуказанная ошибка"], [/Unspecified error/gi, "неуказанная ошибка"],

View File

@ -1,10 +1,10 @@
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
import type { import type {
DeviceModelDefinition,
ObservationSourceAvailability, ObservationSourceAvailability,
ObservationSourceDelivery, ObservationSourceDelivery,
ObservationSourceDescriptor, ObservationSourceDescriptor,
ObservationSourceProvider, ObservationSourceProvider,
} from "../../core/runtime/contracts"; } from "@mission-core/plugin-sdk";
import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle"; import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
import { xgridsK1Manifest } from "./manifest"; import { xgridsK1Manifest } from "./manifest";
import type { import type {

View File

@ -1,7 +1,8 @@
import type { DeviceUiPlugin } from "../../core/device-plugins/contracts"; import type { DeviceUiPlugin } from "@mission-core/plugin-sdk";
import { XgridsK1Connection } from "./XgridsK1Connection"; import { XgridsK1Connection } from "./XgridsK1Connection";
import { xgridsK1Manifest } from "./manifest"; import { xgridsK1Manifest } from "./manifest";
import { XgridsK1RuntimeProvider } from "./runtimeContext"; import { XgridsK1RuntimeProvider } from "./runtimeContext";
import "./styles.css";
export const xgridsK1Plugin: DeviceUiPlugin = { export const xgridsK1Plugin: DeviceUiPlugin = {
manifest: xgridsK1Manifest, manifest: xgridsK1Manifest,

View File

@ -1,6 +1,6 @@
import type { StatusTone } from "@nodedc/ui-react"; import type { StatusTone } from "@nodedc/ui-react";
import type { BackendStatus } from "../../core/runtime/contracts"; import type { BackendStatus } from "@mission-core/plugin-sdk";
import type { XgridsK1Metrics } from "./api"; import type { XgridsK1Metrics } from "./api";
const phaseLabels: Record<string, string> = { const phaseLabels: Record<string, string> = {

View File

@ -1,14 +1,12 @@
import { createContext, useContext, useEffect, type ReactNode } from "react"; import { createContext, useContext, useEffect, type ReactNode } from "react";
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
import { import {
MissionRuntimeProvider, MissionRuntimeProvider,
useMissionRuntime, useMissionRuntime,
} from "../../core/runtime/MissionRuntimeContext"; type DeviceModelDefinition,
import type { type MissionRuntimeController,
MissionRuntimeController, type MissionRuntimeState,
MissionRuntimeState, } from "@mission-core/plugin-sdk";
} from "../../core/runtime/contracts";
import { import {
confirmedRuntimeSourceMode, confirmedRuntimeSourceMode,
effectiveAcquisition, effectiveAcquisition,

View File

@ -1,3 +1,4 @@
// Ordering rules remain plugin-private because revisions are vendor runtime state.
import type { XgridsCameraPreviewState, XgridsK1State } from "./api"; import type { XgridsCameraPreviewState, XgridsK1State } from "./api";
function monotonicInteger(value: number | null | undefined): number | null { function monotonicInteger(value: number | null | undefined): number | null {

View File

@ -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;
}
}

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { BackendStatus, ViewerSettings } from "../../core/runtime/contracts"; import type { BackendStatus, ViewerSettings } from "@mission-core/plugin-sdk";
import { import {
ApiError, ApiError,

View File

@ -9,7 +9,7 @@
"spec": { "spec": {
"hostApiRange": "v1alpha2", "hostApiRange": "v1alpha2",
"runtime": { "runtime": {
"backendEntrypoint": "k1link.web.xgrids_k1_facade:build_xgrids_k1_plugin", "backendEntrypoint": "k1link.device_plugins.xgrids_k1.facade:build_xgrids_k1_plugin",
"isolation": "transitional-in-process" "isolation": "transitional-in-process"
}, },
"compatibilityProfiles": [ "compatibilityProfiles": [

View File

@ -27,7 +27,7 @@ dependencies = [
missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true } missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true }
[project.scripts] [project.scripts]
k1link = "k1link.cli:app" k1link = "k1link.device_plugins.xgrids_k1.cli:app"
[dependency-groups] [dependency-groups]
dev = [ dev = [

View File

@ -0,0 +1 @@
"""Concrete device integrations loaded only through reviewed plugin manifests."""

View File

@ -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"]

View File

@ -1,6 +1,6 @@
"""Bounded, offline analysis of sensitive K1 evidence artifacts.""" """Bounded, offline analysis of sensitive K1 evidence artifacts."""
from k1link.analyze.stream_summary import ( from k1link.device_plugins.xgrids_k1.analyze.stream_summary import (
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES, DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
MAX_STREAM_SUMMARY_PAYLOAD_BYTES, MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
StreamSummary, StreamSummary,

View File

@ -9,8 +9,8 @@ from pathlib import Path
from typing import TypedDict from typing import TypedDict
from k1link.artifacts import utc_now_iso from k1link.artifacts import utc_now_iso
from k1link.mqtt import DEFAULT_MAX_MESSAGE_BYTES, iter_capture_frames from k1link.device_plugins.xgrids_k1.mqtt import DEFAULT_MAX_MESSAGE_BYTES, iter_capture_frames
from k1link.protocol import ( from k1link.device_plugins.xgrids_k1.protocol import (
DecodeLimits, DecodeLimits,
StreamDecodeError, StreamDecodeError,
decode_lio_pcl, decode_lio_pcl,

View File

@ -13,7 +13,7 @@ from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import IO, Any from typing import IO, Any
from k1link.mqtt.capture import ( from k1link.device_plugins.xgrids_k1.mqtt.capture import (
FRAME_HEADER, FRAME_HEADER,
GROUP_COMMIT_MAX_BYTES, GROUP_COMMIT_MAX_BYTES,
GROUP_COMMIT_MAX_MESSAGES, GROUP_COMMIT_MAX_MESSAGES,
@ -21,10 +21,8 @@ from k1link.mqtt.capture import (
MAX_TOPIC_BYTES, MAX_TOPIC_BYTES,
RAW_MAGIC, RAW_MAGIC,
) )
from k1link.sessions.models import (
from .models import (
LegacyMediaSourceCandidate, LegacyMediaSourceCandidate,
LegacySessionCandidate,
SessionModality, SessionModality,
SessionStatus, SessionStatus,
) )
@ -41,6 +39,30 @@ MAX_RECOVERY_METADATA_LINE_BYTES = 64 * 1024
MAX_RECOVERY_MESSAGES = 500_000 MAX_RECOVERY_MESSAGES = 500_000
@dataclass(frozen=True, slots=True)
class LegacySessionCandidate:
"""K1 archive discovery result retained inside the compatibility adapter."""
session_id: str
display_name: str
status: SessionStatus
started_at_utc: str | None
completed_at_utc: str | None
duration_seconds: float | None
modalities: tuple[SessionModality, ...]
replayable: bool
total_bytes: int
allowed_root: Path
session_root: Path
raw_path: Path
raw_byte_length: int
replay_raw_byte_length: int
replay_metadata_byte_length: int
raw_sha256: str | None
raw_integrity_status: str
media_sources: tuple[LegacyMediaSourceCandidate, ...]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class _RecoveredCapture: class _RecoveredCapture:
message_count: int message_count: int

View File

@ -14,7 +14,7 @@ from typing import IO, Any, Final, Literal
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from k1link.mqtt import validate_private_ipv4 from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
from k1link.web.camera_archive import ( from k1link.web.camera_archive import (
CameraArchiveError, CameraArchiveError,
CameraArchiveKind, CameraArchiveKind,

View File

@ -16,28 +16,28 @@ from rich.console import Console
from rich.table import Table from rich.table import Table
from k1link import __version__ from k1link import __version__
from k1link.analyze import ( from k1link.artifacts import write_json_atomic
from k1link.device_plugins.xgrids_k1.analyze import (
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES, DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
MAX_STREAM_SUMMARY_PAYLOAD_BYTES, MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
summarize_mqtt_streams, summarize_mqtt_streams,
) )
from k1link.artifacts import write_json_atomic from k1link.device_plugins.xgrids_k1.ble.gatt import dump_metadata
from k1link.ble.gatt import dump_metadata from k1link.device_plugins.xgrids_k1.ble.scanner import scan
from k1link.ble.scanner import scan from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
from k1link.ble.wifi_provisioning import (
PROFILE_ID, PROFILE_ID,
WriteMode, WriteMode,
provision_wifi_once, provision_wifi_once,
) )
from k1link.macos_credentials import CredentialDialogError, prompt_wifi_credentials from k1link.device_plugins.xgrids_k1.mqtt import (
from k1link.mqtt import (
DEFAULT_MAX_MESSAGE_BYTES, DEFAULT_MAX_MESSAGE_BYTES,
MAX_CONFIGURABLE_MESSAGE_BYTES, MAX_CONFIGURABLE_MESSAGE_BYTES,
CaptureError, CaptureError,
capture_mqtt, capture_mqtt,
) )
from k1link.net.snapshot import snapshot from k1link.device_plugins.xgrids_k1.net.snapshot import snapshot
from k1link.usb.snapshot import snapshot as usb_snapshot from k1link.device_plugins.xgrids_k1.usb.snapshot import snapshot as usb_snapshot
from k1link.macos_credentials import CredentialDialogError, prompt_wifi_credentials
app = typer.Typer( app = typer.Typer(
name="k1link", name="k1link",

View File

@ -15,16 +15,31 @@ from pathlib import Path
from typing import Any, Literal, Protocol, cast from typing import Any, Literal, Protocol, cast
from bleak.exc import BleakError from bleak.exc import BleakError
from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
from k1link.artifacts import write_json_atomic from k1link.artifacts import write_json_atomic
from k1link.ble.scanner import scan from k1link.device_plugins.xgrids_k1.ble.scanner import scan
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
from k1link.mqtt import validate_private_ipv4 AP_FALLBACK_IPV4,
from k1link.protocol.normalizer import normalize_k1_message provision_wifi_once,
)
from k1link.device_plugins.xgrids_k1.camera import (
CAMERA_EXCLUSIVE_GROUP,
CAMERA_SOURCE_LABELS,
CAMERA_SOURCE_PATHS,
CameraSourceId,
XgridsK1CameraGateway,
build_xgrids_k1_camera_router,
)
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
from k1link.device_plugins.xgrids_k1.viewer.runtime import (
VisualizationRuntime,
new_live_session_dir,
)
from k1link.sessions import ActiveSessionLease, resolve_missioncore_evidence_dir from k1link.sessions import ActiveSessionLease, resolve_missioncore_evidence_dir
from k1link.viewer.rerun_bridge import RerunSceneSettings from k1link.viewer.rerun_bridge import RerunSceneSettings
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
from k1link.web.device_lifecycle import ( from k1link.web.device_lifecycle import (
TERMINAL_ACQUISITION_STATES, TERMINAL_ACQUISITION_STATES,
AcquisitionRecord, AcquisitionRecord,
@ -38,14 +53,6 @@ from k1link.web.plugin_runtime import (
PluginActionNotFoundError, PluginActionNotFoundError,
PluginExecutionError, PluginExecutionError,
) )
from k1link.web.xgrids_k1_camera import (
CAMERA_EXCLUSIVE_GROUP,
CAMERA_SOURCE_LABELS,
CAMERA_SOURCE_PATHS,
CameraSourceId,
XgridsK1CameraGateway,
build_xgrids_k1_camera_router,
)
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1" XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1" XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
@ -1497,9 +1504,14 @@ class XgridsK1PluginFacade:
def __init__(self, service: XgridsK1ServicePort) -> None: def __init__(self, service: XgridsK1ServicePort) -> None:
self.service = service self.service = service
async def invoke(self, action_id: str, payload: Mapping[str, Any]) -> dict[str, Any]: async def invoke(self, invocation: RuntimeActionInvocation) -> dict[str, Any]:
if invocation.plugin_id != self.plugin_id:
raise PluginActionNotFoundError("runtime invocation targets another plugin")
try: try:
return await self._invoke_validated(action_id, payload) return await self._invoke_validated(
invocation.action_id,
invocation.parameters,
)
except (ValidationError, ValueError, PluginActionNotFoundError): except (ValidationError, ValueError, PluginActionNotFoundError):
raise raise
except (BleakError, OSError, TimeoutError, RuntimeError) as exc: except (BleakError, OSError, TimeoutError, RuntimeError) as exc:
@ -1803,7 +1815,8 @@ def _validate_installed_compatibility_profile(repository_root: Path) -> None:
def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribution: def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribution:
"""Manifest entrypoint for the reviewed XGRIDS compatibility adapter.""" """Manifest entrypoint for the reviewed XGRIDS compatibility adapter."""
from k1link.web.xgrids_k1_legacy_api import build_xgrids_k1_legacy_router from k1link.device_plugins.xgrids_k1 import build_xgrids_k1_observation
from k1link.device_plugins.xgrids_k1.legacy_api import build_xgrids_k1_legacy_router
_validate_installed_compatibility_profile(repository_root) _validate_installed_compatibility_profile(repository_root)
service = XgridsK1CompatibilityService(repository_root) service = XgridsK1CompatibilityService(repository_root)
@ -1817,5 +1830,6 @@ def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribu
XGRIDS_K1_PLUGIN_ID, XGRIDS_K1_PLUGIN_ID,
), ),
), ),
observation=build_xgrids_k1_observation(repository_root),
close=service.close, close=service.close,
) )

View File

@ -5,8 +5,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from k1link.web.plugin_runtime import PluginExecutionError from k1link.device_plugins.xgrids_k1.facade import (
from k1link.web.xgrids_k1_facade import (
ACTION_DISCOVERY_SCAN, ACTION_DISCOVERY_SCAN,
ACTION_NETWORK_PROVISION, ACTION_NETWORK_PROVISION,
ACTION_STATE_READ, ACTION_STATE_READ,
@ -21,6 +20,7 @@ from k1link.web.xgrids_k1_facade import (
ViewerSettingsRequest, ViewerSettingsRequest,
XgridsK1PluginFacade, XgridsK1PluginFacade,
) )
from k1link.web.plugin_runtime import PluginExecutionError, invoke_device_plugin_adapter
def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter: def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
@ -30,19 +30,27 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
@router.get("/api/state", deprecated=True) @router.get("/api/state", deprecated=True)
async def get_state() -> dict[str, Any]: async def get_state() -> dict[str, Any]:
return await adapter.invoke(ACTION_STATE_READ, {}) return await invoke_device_plugin_adapter(adapter, ACTION_STATE_READ, {})
@router.post("/api/ble/scan", deprecated=True) @router.post("/api/ble/scan", deprecated=True)
async def scan_ble(request: BleScanRequest) -> dict[str, Any]: async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
try: try:
return await adapter.invoke(ACTION_DISCOVERY_SCAN, request.model_dump()) return await invoke_device_plugin_adapter(
adapter,
ACTION_DISCOVERY_SCAN,
request.model_dump(),
)
except (PluginExecutionError, ValueError) as exc: except (PluginExecutionError, ValueError) as exc:
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
@router.post("/api/connect", deprecated=True) @router.post("/api/connect", deprecated=True)
async def connect(request: ConnectRequest) -> dict[str, Any]: async def connect(request: ConnectRequest) -> dict[str, Any]:
try: try:
return await adapter.invoke(ACTION_NETWORK_PROVISION, request.model_dump()) return await invoke_device_plugin_adapter(
adapter,
ACTION_NETWORK_PROVISION,
request.model_dump(),
)
except (PluginExecutionError, ValueError) as exc: except (PluginExecutionError, ValueError) as exc:
raise HTTPException( raise HTTPException(
status_code=502, status_code=502,
@ -52,31 +60,51 @@ def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
@router.post("/api/session/live", deprecated=True) @router.post("/api/session/live", deprecated=True)
async def start_live(request: LiveRequest) -> dict[str, Any]: async def start_live(request: LiveRequest) -> dict[str, Any]:
try: try:
return await adapter.invoke(ACTION_STREAM_START_LIVE, request.model_dump()) return await invoke_device_plugin_adapter(
adapter,
ACTION_STREAM_START_LIVE,
request.model_dump(),
)
except (PluginExecutionError, ValueError) as exc: except (PluginExecutionError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/api/session/replay", deprecated=True) @router.post("/api/session/replay", deprecated=True)
async def start_replay(request: ReplayRequest) -> dict[str, Any]: async def start_replay(request: ReplayRequest) -> dict[str, Any]:
try: try:
return await adapter.invoke(ACTION_STREAM_START_REPLAY, request.model_dump()) return await invoke_device_plugin_adapter(
adapter,
ACTION_STREAM_START_REPLAY,
request.model_dump(),
)
except (PluginExecutionError, ValueError) as exc: except (PluginExecutionError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/api/session/stop", deprecated=True) @router.post("/api/session/stop", deprecated=True)
async def stop_session() -> dict[str, Any]: async def stop_session() -> dict[str, Any]:
return await adapter.invoke(ACTION_STREAM_STOP, {}) return await invoke_device_plugin_adapter(adapter, ACTION_STREAM_STOP, {})
@router.post("/api/viewer/settings", deprecated=True) @router.post("/api/viewer/settings", deprecated=True)
async def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]: async def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
return await adapter.invoke(ACTION_VIEWER_SETTINGS_UPDATE, request.model_dump()) return await invoke_device_plugin_adapter(
adapter,
ACTION_VIEWER_SETTINGS_UPDATE,
request.model_dump(),
)
@router.websocket("/api/events") @router.websocket("/api/events")
async def events(websocket: WebSocket) -> None: async def events(websocket: WebSocket) -> None:
await websocket.accept() await websocket.accept()
try: try:
while True: while True:
await websocket.send_json({"state": await adapter.invoke(ACTION_STATE_READ, {})}) await websocket.send_json(
{
"state": await invoke_device_plugin_adapter(
adapter,
ACTION_STATE_READ,
{},
)
}
)
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
except WebSocketDisconnect: except WebSocketDisconnect:
return return

View File

@ -1,6 +1,6 @@
"""Read-only MQTT evidence capture for an owner-controlled K1.""" """Read-only MQTT evidence capture for an owner-controlled K1."""
from k1link.mqtt.capture import ( from k1link.device_plugins.xgrids_k1.mqtt.capture import (
DEFAULT_MAX_MESSAGE_BYTES, DEFAULT_MAX_MESSAGE_BYTES,
MAX_CONFIGURABLE_MESSAGE_BYTES, MAX_CONFIGURABLE_MESSAGE_BYTES,
REPORT_TOPICS, REPORT_TOPICS,

View File

@ -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

View File

@ -1,6 +1,6 @@
"""Verified protocol decoders for captured K1 application streams.""" """Verified protocol decoders for captured K1 application streams."""
from k1link.protocol.streams import ( from k1link.device_plugins.xgrids_k1.protocol.streams import (
DecodeLimits, DecodeLimits,
LegacyPoint, LegacyPoint,
LegacyPointCloudFrame, LegacyPointCloudFrame,

View File

@ -9,7 +9,7 @@ from k1link.data_plane import (
DecodedPoseView, DecodedPoseView,
NormalizationError, NormalizationError,
) )
from k1link.protocol.streams import ( from k1link.device_plugins.xgrids_k1.protocol.streams import (
LegacyPointCloudFrame, LegacyPointCloudFrame,
LegacyPoseFrame, LegacyPoseFrame,
LioPointCloudFrame, LioPointCloudFrame,

View File

@ -7,7 +7,7 @@ from typing import NamedTuple
import lz4.block import lz4.block
from k1link.protocol.protobuf_wire import ( from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
ProtobufWireError, ProtobufWireError,
ProtoField, ProtoField,
decode_zigzag64, decode_zigzag64,

View File

@ -16,8 +16,12 @@ import rerun as rr
from rerun import blueprint as rrb from rerun import blueprint as rrb
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError
from k1link.protocol.normalizer import normalize_k1_message from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
from k1link.viewer.replay import ReplayFormatError, detect_replay_format, iter_replay_messages from k1link.device_plugins.xgrids_k1.viewer.replay import (
ReplayFormatError,
detect_replay_format,
iter_replay_messages,
)
from k1link.viewer.rerun_bridge import ( from k1link.viewer.rerun_bridge import (
MAX_TRAJECTORY_POSES, MAX_TRAJECTORY_POSES,
TRAJECTORY_APPEND_INTERVAL_NS, TRAJECTORY_APPEND_INTERVAL_NS,

View File

@ -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",
]

View File

@ -29,7 +29,7 @@ from foxglove.messages import (
Vector3, Vector3,
) )
from k1link.protocol.streams import ( from k1link.device_plugins.xgrids_k1.protocol.streams import (
LegacyPointCloudFrame, LegacyPointCloudFrame,
LegacyPoseFrame, LegacyPoseFrame,
LioPointCloudFrame, LioPointCloudFrame,
@ -40,7 +40,7 @@ from k1link.protocol.streams import (
decode_lio_pcl, decode_lio_pcl,
decode_lio_pose, decode_lio_pose,
) )
from k1link.viewer.messages import StreamMessage from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
from k1link.viewer.metrics import BridgeMetrics from k1link.viewer.metrics import BridgeMetrics
POINT_STRUCT = struct.Struct("<fffB3x") POINT_STRUCT = struct.Struct("<fffB3x")

View File

@ -7,9 +7,9 @@ from decimal import Decimal, InvalidOperation
from pathlib import Path from pathlib import Path
from typing import IO, Literal from typing import IO, Literal
from k1link.mqtt import CaptureFormatError, iter_capture_frames from k1link.device_plugins.xgrids_k1.mqtt import CaptureFormatError, iter_capture_frames
from k1link.mqtt.capture import RAW_MAGIC from k1link.device_plugins.xgrids_k1.mqtt.capture import RAW_MAGIC
from k1link.viewer.messages import StreamMessage from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
MAX_REPLAY_PAYLOAD_BYTES = 2 * 1024 * 1024 MAX_REPLAY_PAYLOAD_BYTES = 2 * 1024 * 1024
MAX_LEGACY_LINE_BYTES = MAX_REPLAY_PAYLOAD_BYTES * 2 + 64 * 1024 MAX_LEGACY_LINE_BYTES = MAX_REPLAY_PAYLOAD_BYTES * 2 + 64 * 1024

View File

@ -11,10 +11,10 @@ from typing import Literal, Protocol, TypedDict
from k1link.artifacts import utc_now_iso, write_json_atomic from k1link.artifacts import utc_now_iso, write_json_atomic
from k1link.data_plane import DecodedDataPlaneView, NormalizationError from k1link.data_plane import DecodedDataPlaneView, NormalizationError
from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt from k1link.device_plugins.xgrids_k1.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
from k1link.viewer.messages import StreamMessage from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
from k1link.device_plugins.xgrids_k1.viewer.replay import iter_replay_messages
from k1link.viewer.metrics import BridgeMetrics, MetricsSnapshot from k1link.viewer.metrics import BridgeMetrics, MetricsSnapshot
from k1link.viewer.replay import iter_replay_messages
from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings
RuntimePhase = Literal[ RuntimePhase = Literal[

View File

@ -14,12 +14,22 @@ from .media import (
) )
from .models import ( from .models import (
LayoutConflictError, LayoutConflictError,
ObservationArtifactCandidate,
ObservationSessionCandidate,
RecordedMediaArtifact, RecordedMediaArtifact,
ReplayArtifact,
ReplayCommand, ReplayCommand,
SessionIntegrityError, SessionIntegrityError,
SessionNotFoundError, SessionNotFoundError,
SessionNotReplayableError, SessionNotReplayableError,
) )
from .plugin_contract import (
ObservationArchiveSource,
ObservationRuntimeContribution,
PluginRecordingExportCancelled,
PluginRecordingExportError,
RecordingExporter,
)
from .preparation import ( from .preparation import (
RecordingPreparationQueueFull, RecordingPreparationQueueFull,
RecordingPreparationSnapshot, RecordingPreparationSnapshot,
@ -42,6 +52,12 @@ __all__ = [
"ActiveSessionLease", "ActiveSessionLease",
"ActiveSessionLeaseError", "ActiveSessionLeaseError",
"MaterializedRecording", "MaterializedRecording",
"ObservationArchiveSource",
"ObservationArtifactCandidate",
"ObservationRuntimeContribution",
"ObservationSessionCandidate",
"PluginRecordingExportCancelled",
"PluginRecordingExportError",
"RecordingMaterializationCancelled", "RecordingMaterializationCancelled",
"RecordedMediaArtifact", "RecordedMediaArtifact",
"RECORDED_MEDIA_MANIFEST_SCHEMA", "RECORDED_MEDIA_MANIFEST_SCHEMA",
@ -49,6 +65,8 @@ __all__ = [
"RecordedMediaInspector", "RecordedMediaInspector",
"RecordedMediaManifest", "RecordedMediaManifest",
"ReplayCommand", "ReplayCommand",
"ReplayArtifact",
"RecordingExporter",
"RecordingMaterializationError", "RecordingMaterializationError",
"RecordingPreparationQueueFull", "RecordingPreparationQueueFull",
"RecordingPreparationSnapshot", "RecordingPreparationSnapshot",

View File

@ -14,8 +14,6 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, TypeGuard from typing import Any, TypeGuard
from k1link.viewer.replay import MAX_METADATA_LINE_CHARS
from .models import RecordedMediaArtifact, ReplayCommand, SessionIntegrityError from .models import RecordedMediaArtifact, ReplayCommand, SessionIntegrityError
CAMERA_ARCHIVE_SCHEMA = "missioncore.camera-recording/v1" CAMERA_ARCHIVE_SCHEMA = "missioncore.camera-recording/v1"
@ -139,7 +137,7 @@ class RecordedMediaInspector:
cached = self._cache.get(key) cached = self._cache.get(key)
if cached is not None: if cached is not None:
identity = _prepared_source_identity( identity = _prepared_source_identity(
replay.source_path, replay,
epoch_paths, epoch_paths,
cached.manifest.epochs, cached.manifest.epochs,
) )
@ -151,15 +149,14 @@ class RecordedMediaInspector:
with self._lock: with self._lock:
self._cache[key] = _CachedManifest(identity=identity, manifest=manifest) self._cache[key] = _CachedManifest(identity=identity, manifest=manifest)
return manifest return manifest
origin_epoch_ns, origin_monotonic_ns = _raw_timeline_origin(replay.source_path)
manifest = _read_manifest( manifest = _read_manifest(
artifact, artifact,
epoch_paths, epoch_paths,
origin_epoch_ns=origin_epoch_ns, origin_epoch_ns=replay.timeline_origin_epoch_ns,
origin_monotonic_ns=origin_monotonic_ns, origin_monotonic_ns=replay.timeline_origin_monotonic_ns,
) )
identity = _prepared_source_identity( identity = _prepared_source_identity(
replay.source_path, replay,
epoch_paths, epoch_paths,
manifest.epochs, manifest.epochs,
) )
@ -186,7 +183,7 @@ class RecordedMediaInspector:
document = _decode_prepared_sidecar(payload) document = _decode_prepared_sidecar(payload)
manifest = _manifest_from_sidecar(document, artifact, epoch_paths) manifest = _manifest_from_sidecar(document, artifact, epoch_paths)
identity = _prepared_source_identity( identity = _prepared_source_identity(
replay.source_path, replay,
epoch_paths, epoch_paths,
manifest.epochs, manifest.epochs,
) )
@ -631,15 +628,15 @@ def _epoch_paths(source_path: Path) -> tuple[Path, ...]:
def _prepared_source_identity( def _prepared_source_identity(
raw_path: Path, replay: ReplayCommand,
epoch_paths: tuple[Path, ...], epoch_paths: tuple[Path, ...],
epochs: tuple[RecordedMediaEpoch, ...], epochs: tuple[RecordedMediaEpoch, ...],
) -> tuple[tuple[int, int, int, int], ...]: ) -> tuple[tuple[int, int, int, int], ...]:
if len(epoch_paths) != len(epochs): if len(epoch_paths) != len(epochs):
raise SessionIntegrityError("recorded media epoch identity is inconsistent") raise SessionIntegrityError("recorded media epoch identity is inconsistent")
identity: list[tuple[int, int, int, int]] = [] identity: list[tuple[int, int, int, int]] = []
for source_file in (raw_path, raw_path.with_name("mqtt.metadata.jsonl")): for artifact in replay.artifacts:
metadata = _confined_file_stat(source_file, raw_path.parent) metadata = _session_artifact_stat(artifact.path, replay.session_root)
identity.append( identity.append(
(metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns) (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns)
) )
@ -671,32 +668,15 @@ def _prepared_source_identity(
return tuple(identity) return tuple(identity)
def _raw_timeline_origin(raw_path: Path) -> tuple[int, int]: def _session_artifact_stat(path: Path, session_root: Path) -> os.stat_result:
metadata_path = raw_path.with_name("mqtt.metadata.jsonl")
try: try:
line = _read_first_confined_line( session = session_root.resolve(strict=True)
metadata_path, parent = path.parent.resolve(strict=True)
raw_path.parent, except OSError as exc:
MAX_METADATA_LINE_CHARS, raise SessionIntegrityError("recording source artifact is missing") from exc
).decode("utf-8") if not parent.is_relative_to(session):
except (OSError, UnicodeDecodeError) as exc: raise SessionIntegrityError("recording source artifact escapes its session")
raise SessionIntegrityError("native capture timing metadata is unavailable") from exc return _confined_file_stat(path, parent)
if not line or len(line) > MAX_METADATA_LINE_CHARS or not line.endswith(("\n", "\r")):
raise SessionIntegrityError("native capture timing origin is incomplete")
try:
record = json.loads(line)
except json.JSONDecodeError as exc:
raise SessionIntegrityError("native capture timing origin is invalid") from exc
epoch_ns = record.get("received_at_epoch_ns") if isinstance(record, dict) else None
monotonic_ns = record.get("received_monotonic_ns") if isinstance(record, dict) else None
if (
record.get("record_type") != "message"
or record.get("sequence") != 1
or not _non_negative_int(epoch_ns)
or not _non_negative_int(monotonic_ns)
):
raise SessionIntegrityError("native capture timing origin is invalid")
return int(epoch_ns), int(monotonic_ns)
def _read_manifest( def _read_manifest(

View File

@ -160,20 +160,44 @@ class WorkspaceLayout:
} }
@dataclass(frozen=True, slots=True)
class ReplayArtifact:
"""One confined input artifact selected for plugin-owned preparation."""
artifact_id: str
path: Path
media_type: str
file_byte_length: int
replay_byte_length: int
expected_sha256: str | None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ReplayCommand: class ReplayCommand:
"""Internal-only replay command. ``source_path`` never enters an API DTO.""" """Internal replay request containing no vendor format or channel names."""
session_id: str session_id: str
source_path: Path plugin_id: str
allowed_root: Path allowed_root: Path
session_root: Path session_root: Path
replay_byte_length: int primary_artifact_id: str
metadata_byte_length: int artifacts: tuple[ReplayArtifact, ...]
expected_source_sha256: str | None timeline_origin_epoch_ns: int
timeline_origin_monotonic_ns: int
speed: float speed: float
loop: bool loop: bool
@property
def primary_artifact(self) -> ReplayArtifact:
matches = tuple(
artifact
for artifact in self.artifacts
if artifact.artifact_id == self.primary_artifact_id
)
if len(matches) != 1:
raise SessionIntegrityError("replay command has no unique primary artifact")
return matches[0]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class RecordedMediaArtifact: class RecordedMediaArtifact:
@ -191,7 +215,19 @@ class RecordedMediaArtifact:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class LegacySessionCandidate: class ObservationArtifactCandidate:
artifact_id: str
kind: str
media_type: str
locator: Path
byte_length: int
replay_byte_length: int
sha256: str | None
integrity_status: str
@dataclass(frozen=True, slots=True)
class ObservationSessionCandidate:
session_id: str session_id: str
display_name: str display_name: str
status: SessionStatus status: SessionStatus
@ -203,13 +239,11 @@ class LegacySessionCandidate:
total_bytes: int total_bytes: int
allowed_root: Path allowed_root: Path
session_root: Path session_root: Path
raw_path: Path primary_replay_artifact_id: str | None
raw_byte_length: int timeline_origin_epoch_ns: int | None
replay_raw_byte_length: int timeline_origin_monotonic_ns: int | None
replay_metadata_byte_length: int sources: tuple[SessionSource, ...]
raw_sha256: str | None artifacts: tuple[ObservationArtifactCandidate, ...]
raw_integrity_status: str
media_sources: tuple[LegacyMediaSourceCandidate, ...]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)

View File

@ -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

View File

@ -584,12 +584,20 @@ def _source_identity(command: ReplayCommand) -> tuple[object, ...]:
identities: list[object] = [ identities: list[object] = [
command.session_id, command.session_id,
str(command.source_path), command.plugin_id,
command.replay_byte_length, command.primary_artifact_id,
command.metadata_byte_length,
command.expected_source_sha256,
] ]
for path in (command.source_path, command.source_path.with_name("mqtt.metadata.jsonl")): for artifact in command.artifacts:
path = artifact.path
identities.extend(
(
artifact.artifact_id,
artifact.media_type,
artifact.file_byte_length,
artifact.replay_byte_length,
artifact.expected_sha256,
)
)
try: try:
value = os.lstat(path) value = os.lstat(path)
except OSError: except OSError:

View File

@ -16,18 +16,17 @@ from pathlib import Path
from typing import Any, cast from typing import Any, cast
from uuid import uuid4 from uuid import uuid4
from k1link.viewer.rrd_export import ( from .models import ReplayArtifact, ReplayCommand
RrdExportCancelled, from .plugin_contract import (
RrdExportError, PluginRecordingExportCancelled,
export_k1mqtt_to_rrd, PluginRecordingExportError,
RecordingExporter,
) )
from .models import ReplayCommand
# v6 adds a real session_time=0 row to the RRD itself. Older sidecars can # v6 adds a real session_time=0 row to the RRD itself. Older sidecars can
# declare a zero start while their payload begins at the first decoded sensor # declare a zero start while their payload begins at the first decoded sensor
# frame, so accepting them would violate the browser playback contract. # frame, so accepting them would violate the browser playback contract.
CACHE_SCHEMA = "missioncore.derived-rerun-recording-cache/v6" CACHE_SCHEMA = "missioncore.derived-rerun-recording-cache/v7"
COMPATIBLE_CACHE_SCHEMAS = frozenset({CACHE_SCHEMA}) COMPATIBLE_CACHE_SCHEMAS = frozenset({CACHE_SCHEMA})
RERUN_RECORDING_MEDIA_TYPE = "application/vnd.rerun.rrd" RERUN_RECORDING_MEDIA_TYPE = "application/vnd.rerun.rrd"
RERUN_SESSION_TIMELINE = "session_time" RERUN_SESSION_TIMELINE = "session_time"
@ -37,7 +36,6 @@ DEFAULT_FREE_SPACE_RESERVE_BYTES = 2 * 1024 * 1024 * 1024
RrdExporter = Callable[..., Mapping[str, object]] RrdExporter = Callable[..., Mapping[str, object]]
RecordingProgressCallback = Callable[[str, float], None] RecordingProgressCallback = Callable[[str, float], None]
DEFAULT_RRD_EXPORTER = cast(RrdExporter, export_k1mqtt_to_rrd)
class RecordingMaterializationError(RuntimeError): class RecordingMaterializationError(RuntimeError):
@ -71,29 +69,58 @@ class _ValidatedMemoryEntry:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class _ValidatedSource: class _ValidatedArtifact:
source: Path artifact_id: str
metadata: Path path: Path
source_stat: os.stat_result media_type: str
metadata_stat: os.stat_result file_stat: os.stat_result
replay_byte_length: int replay_byte_length: int
metadata_byte_length: int expected_sha256: str | None
expected_source_sha256: str | None
@property @property
def identity(self) -> tuple[object, ...]: def identity(self) -> tuple[object, ...]:
return ( return (
*_stat_identity(self.source_stat), self.artifact_id,
*_stat_identity(self.metadata_stat), self.media_type,
*_stat_identity(self.file_stat),
self.replay_byte_length, self.replay_byte_length,
self.metadata_byte_length,
) )
@dataclass(frozen=True, slots=True)
class _ValidatedSource:
plugin_id: str
primary_artifact_id: str
artifacts: tuple[_ValidatedArtifact, ...]
@property
def primary(self) -> _ValidatedArtifact:
matches = tuple(
artifact
for artifact in self.artifacts
if artifact.artifact_id == self.primary_artifact_id
)
if len(matches) != 1:
raise RecordingMaterializationError("recording source has no primary artifact")
return matches[0]
@property
def identity(self) -> tuple[object, ...]:
return (
self.plugin_id,
self.primary_artifact_id,
*(item for artifact in self.artifacts for item in artifact.identity),
)
@property
def replay_byte_length(self) -> int:
return sum(artifact.replay_byte_length for artifact in self.artifacts)
class SessionRecordingMaterializer: class SessionRecordingMaterializer:
"""Build and validate a per-session seekable RRD under the private data root. """Build and validate a per-session seekable RRD under the private data root.
The native ``.k1mqtt`` capture remains the source of record. Derived RRDs Native plugin evidence remains the source of record. Derived RRDs
live below ``data_dir/recordings`` and are reused only when both their live below ``data_dir/recordings`` and are reused only when both their
source identity and output digest still match an atomically written cache source identity and output digest still match an atomically written cache
sidecar. Calls for one session are serialized, so concurrent browser sidecar. Calls for one session are serialized, so concurrent browser
@ -104,7 +131,8 @@ class SessionRecordingMaterializer:
self, self,
data_dir: Path, data_dir: Path,
*, *,
exporter: RrdExporter = DEFAULT_RRD_EXPORTER, exporter: RrdExporter | None = None,
exporters: Mapping[str, RecordingExporter] | None = None,
cache_max_bytes: int | None = None, cache_max_bytes: int | None = None,
free_space_reserve_bytes: int | None = None, free_space_reserve_bytes: int | None = None,
) -> None: ) -> None:
@ -119,12 +147,12 @@ class SessionRecordingMaterializer:
self.recordings_root = recordings_root.resolve() self.recordings_root = recordings_root.resolve()
if not self.recordings_root.is_relative_to(private_root): if not self.recordings_root.is_relative_to(private_root):
raise RecordingMaterializationError("recording cache escapes the private data root") raise RecordingMaterializationError("recording cache escapes the private data root")
self._exporter = exporter if exporter is not None and exporters is not None:
self._exporter_accepts_cancel = _callable_accepts_keyword(exporter, "cancel_event") raise ValueError("configure either one test exporter or plugin exporters")
self._exporter_accepts_activity = _callable_accepts_keyword( self._fallback_exporter = exporter
exporter, self._exporters = dict(exporters or {})
"activity_callback", if len(self._exporters) != len(set(self._exporters)):
) raise ValueError("recording exporter plugin ids must be unique")
self.cache_max_bytes = _positive_configuration( self.cache_max_bytes = _positive_configuration(
cache_max_bytes, cache_max_bytes,
environment_name="MISSIONCORE_RRD_CACHE_MAX_BYTES", environment_name="MISSIONCORE_RRD_CACHE_MAX_BYTES",
@ -152,7 +180,12 @@ class SessionRecordingMaterializer:
def supports_cooperative_cancellation(self) -> bool: def supports_cooperative_cancellation(self) -> bool:
"""Whether the configured exporter observes a cancellation event.""" """Whether the configured exporter observes a cancellation event."""
return self._exporter_accepts_cancel exporters: tuple[Callable[..., object], ...] = tuple(self._exporters.values())
if self._fallback_exporter is not None:
exporters = (*exporters, self._fallback_exporter)
return bool(exporters) and all(
_callable_accepts_keyword(exporter, "cancel_event") for exporter in exporters
)
def is_recording_available(self, recording: MaterializedRecording) -> bool: def is_recording_available(self, recording: MaterializedRecording) -> bool:
"""Cheap no-follow check for a previously validated ready handle.""" """Cheap no-follow check for a previously validated ready handle."""
@ -463,11 +496,11 @@ class SessionRecordingMaterializer:
) from exc ) from exc
staged_root: Path | None = None staged_root: Path | None = None
export_source = source.source export_source = source.primary.path
try: try:
if ( if any(
source.replay_byte_length != source.source_stat.st_size artifact.replay_byte_length != artifact.file_stat.st_size
or source.metadata_byte_length != source.metadata_stat.st_size for artifact in source.artifacts
): ):
staged_root, export_source = _stage_replay_prefix( staged_root, export_source = _stage_replay_prefix(
resolved_session_root, resolved_session_root,
@ -480,6 +513,7 @@ class SessionRecordingMaterializer:
), ),
) )
summary = self._invoke_exporter( summary = self._invoke_exporter(
source.plugin_id,
export_source, export_source,
candidate_path, candidate_path,
cancel_event=cancel_event, cancel_event=cancel_event,
@ -491,12 +525,12 @@ class SessionRecordingMaterializer:
) )
_raise_if_cancelled(cancel_event) _raise_if_cancelled(cancel_event)
_report_progress(progress_callback, "finalizing", 0.9) _report_progress(progress_callback, "finalizing", 0.9)
except RrdExportCancelled as exc: except PluginRecordingExportCancelled as exc:
candidate_path.unlink(missing_ok=True) candidate_path.unlink(missing_ok=True)
raise RecordingMaterializationCancelled( raise RecordingMaterializationCancelled(
"recording preparation was cancelled" "recording preparation was cancelled"
) from exc ) from exc
except RrdExportError as exc: except PluginRecordingExportError as exc:
candidate_path.unlink(missing_ok=True) candidate_path.unlink(missing_ok=True)
raise RecordingMaterializationError("native capture could not be exported") from exc raise RecordingMaterializationError("native capture could not be exported") from exc
except OSError as exc: except OSError as exc:
@ -515,13 +549,13 @@ class SessionRecordingMaterializer:
raise RecordingMaterializationError("native capture changed during RRD export") raise RecordingMaterializationError("native capture changed during RRD export")
_chmod_best_effort(candidate_path, 0o600) _chmod_best_effort(candidate_path, 0o600)
source_sha256 = _sha256_prefix_stable( source_sha256 = _sha256_prefix_stable(
source.source, source.primary.path,
source.source_stat, source.primary.file_stat,
source.replay_byte_length, source.primary.replay_byte_length,
) )
if ( if (
source.expected_source_sha256 is not None source.primary.expected_sha256 is not None
and source_sha256 != source.expected_source_sha256 and source_sha256 != source.primary.expected_sha256
): ):
raise RecordingMaterializationError( raise RecordingMaterializationError(
"native capture digest no longer matches catalog" "native capture digest no longer matches catalog"
@ -574,18 +608,25 @@ class SessionRecordingMaterializer:
def _invoke_exporter( def _invoke_exporter(
self, self,
plugin_id: str,
source: Path, source: Path,
destination: Path, destination: Path,
*, *,
cancel_event: threading.Event | None, cancel_event: threading.Event | None,
activity_callback: Callable[[], None], activity_callback: Callable[[], None],
) -> Mapping[str, object]: ) -> Mapping[str, object]:
selected = self._fallback_exporter or self._exporters.get(plugin_id)
if selected is None:
raise PluginRecordingExportError(
f"device plugin has no recording exporter: {plugin_id}"
)
exporter = cast(RrdExporter, selected)
kwargs: dict[str, object] = {} kwargs: dict[str, object] = {}
if self._exporter_accepts_cancel: if _callable_accepts_keyword(exporter, "cancel_event"):
kwargs["cancel_event"] = cancel_event kwargs["cancel_event"] = cancel_event
if self._exporter_accepts_activity: if _callable_accepts_keyword(exporter, "activity_callback"):
kwargs["activity_callback"] = activity_callback kwargs["activity_callback"] = activity_callback
return self._exporter(source, destination, **kwargs) return exporter(source, destination, **kwargs)
def _load_memory_cache( def _load_memory_cache(
self, self,
@ -639,48 +680,53 @@ class SessionRecordingMaterializer:
except (OSError, ValueError, json.JSONDecodeError, RecordingMaterializationError): except (OSError, ValueError, json.JSONDecodeError, RecordingMaterializationError):
return None return None
if document["source_file_byte_length"] != source.source_stat.st_size: if document["plugin_id"] != source.plugin_id:
return None return None
if document["source_mtime_ns"] != source.source_stat.st_mtime_ns: if document["primary_artifact_id"] != source.primary_artifact_id:
return None return None
if document["source_ctime_ns"] != source.source_stat.st_ctime_ns: cached_artifacts = document["source_artifacts"]
return None if len(cached_artifacts) != len(source.artifacts):
if document["source_replay_byte_length"] != source.replay_byte_length:
return None
if document["metadata_file_byte_length"] != source.metadata_stat.st_size:
return None
if document["metadata_mtime_ns"] != source.metadata_stat.st_mtime_ns:
return None
if document["metadata_ctime_ns"] != source.metadata_stat.st_ctime_ns:
return None
if document["metadata_replay_byte_length"] != source.metadata_byte_length:
return None return None
for cached, artifact in zip(cached_artifacts, source.artifacts, strict=True):
if cached["artifact_id"] != artifact.artifact_id:
return None
if cached["media_type"] != artifact.media_type:
return None
if cached["file_byte_length"] != artifact.file_stat.st_size:
return None
if cached["mtime_ns"] != artifact.file_stat.st_mtime_ns:
return None
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: if document["recording_byte_length"] != recording_stat.st_size:
return None return None
if document["recording_mtime_ns"] != recording_stat.st_mtime_ns: if document["recording_mtime_ns"] != recording_stat.st_mtime_ns:
return None return None
source_sha256 = _sha256_prefix_stable( source_sha256 = _sha256_prefix_stable(
source.source, source.primary.path,
source.source_stat, source.primary.file_stat,
source.replay_byte_length, source.primary.replay_byte_length,
) )
if ( if (
source.expected_source_sha256 is not None source.primary.expected_sha256 is not None
and source_sha256 != source.expected_source_sha256 and source_sha256 != source.primary.expected_sha256
): ):
raise RecordingMaterializationError( raise RecordingMaterializationError(
"native capture digest no longer matches catalog" "native capture digest no longer matches catalog"
) )
if source_sha256 != document["source_sha256"]: if source_sha256 != document["source_sha256"]:
return None return None
metadata_sha256 = _sha256_prefix_stable( for cached, artifact in zip(cached_artifacts, source.artifacts, strict=True):
source.metadata, digest = _sha256_prefix_stable(
source.metadata_stat, artifact.path,
source.metadata_byte_length, artifact.file_stat,
) artifact.replay_byte_length,
if metadata_sha256 != document["metadata_sha256"]: )
return None if digest != cached["sha256"]:
return None
recording_sha256 = _sha256_stable(recording_path, recording_stat) recording_sha256 = _sha256_stable(recording_path, recording_stat)
if recording_sha256 != document["recording_sha256"]: if recording_sha256 != document["recording_sha256"]:
return None return None
@ -764,76 +810,103 @@ def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
def _validate_source(command: ReplayCommand) -> _ValidatedSource: def _validate_source(command: ReplayCommand) -> _ValidatedSource:
source_path = getattr(command, "source_path", None) plugin_id = getattr(command, "plugin_id", None)
allowed_root = getattr(command, "allowed_root", None) allowed_root = getattr(command, "allowed_root", None)
session_root = getattr(command, "session_root", None) session_root = getattr(command, "session_root", None)
replay_byte_length = getattr(command, "replay_byte_length", None) primary_artifact_id = getattr(command, "primary_artifact_id", None)
metadata_byte_length = getattr(command, "metadata_byte_length", None) artifacts = getattr(command, "artifacts", None)
expected_source_sha256 = getattr(command, "expected_source_sha256", None) if not isinstance(plugin_id, str) or SESSION_ID_PATTERN.fullmatch(plugin_id) is None:
if not isinstance(source_path, Path): raise RecordingMaterializationError("replay command has an invalid plugin id")
raise RecordingMaterializationError("replay command has no native capture") if not isinstance(primary_artifact_id, str) or SESSION_ID_PATTERN.fullmatch(
primary_artifact_id
) is None:
raise RecordingMaterializationError("replay command has an invalid primary artifact")
if not isinstance(artifacts, tuple) or not artifacts:
raise RecordingMaterializationError("replay command has no source artifacts")
try: try:
if not isinstance(allowed_root, Path) or not isinstance(session_root, Path): if not isinstance(allowed_root, Path) or not isinstance(session_root, Path):
raise RecordingMaterializationError("replay command has no confinement roots") raise RecordingMaterializationError("replay command has no confinement roots")
allowed = allowed_root.expanduser().resolve(strict=True) allowed = allowed_root.expanduser().resolve(strict=True)
session = session_root.expanduser().resolve(strict=True) session = session_root.expanduser().resolve(strict=True)
source = source_path.expanduser().absolute()
source_parent = source.parent.resolve(strict=True)
except OSError as exc: except OSError as exc:
raise RecordingMaterializationError("native capture is missing") from exc raise RecordingMaterializationError("recording confinement root is missing") from exc
if ( if not allowed.is_dir() or not session.is_dir() or not session.is_relative_to(allowed):
not allowed.is_dir() raise RecordingMaterializationError("recording source escapes its allowed session root")
or not session.is_dir()
or not session.is_relative_to(allowed) validated: list[_ValidatedArtifact] = []
or not source_parent.is_relative_to(session) seen_ids: set[str] = set()
): seen_names: set[str] = set()
raise RecordingMaterializationError("native capture escapes its allowed session root") for artifact in artifacts:
if source.suffix.casefold() != ".k1mqtt": if not isinstance(artifact, ReplayArtifact):
raise RecordingMaterializationError("native capture has an unsupported format") raise RecordingMaterializationError("replay command contains an invalid artifact")
source_stat = _regular_file_stat_nofollow(source, "native capture") if (
metadata = source.with_name("mqtt.metadata.jsonl") SESSION_ID_PATTERN.fullmatch(artifact.artifact_id) is None
if not metadata.parent.resolve(strict=True).is_relative_to(session): or artifact.artifact_id in seen_ids
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")
if ( path = artifact.path.expanduser().absolute()
not isinstance(replay_byte_length, int) try:
or isinstance(replay_byte_length, bool) parent = path.parent.resolve(strict=True)
or not 1 <= replay_byte_length <= source_stat.st_size except OSError as exc:
): raise RecordingMaterializationError("recording source artifact is missing") from exc
raise RecordingMaterializationError("native capture replay boundary is invalid") if not parent.is_relative_to(session):
if ( raise RecordingMaterializationError("recording source artifact escapes its session")
not isinstance(metadata_byte_length, int) file_stat = _regular_file_stat_nofollow(path, "recording source artifact")
or isinstance(metadata_byte_length, bool) if artifact.file_byte_length > file_stat.st_size:
or not 1 <= metadata_byte_length <= metadata_stat.st_size raise RecordingMaterializationError("recording artifact was truncated after cataloging")
): if (
raise RecordingMaterializationError("native metadata replay boundary is invalid") isinstance(artifact.replay_byte_length, bool)
if expected_source_sha256 is not None: or not 1 <= artifact.replay_byte_length <= file_stat.st_size
if not isinstance(expected_source_sha256, str) or not _is_sha256(expected_source_sha256): ):
raise RecordingMaterializationError("native capture expected digest is invalid") raise RecordingMaterializationError("recording artifact replay boundary is invalid")
if replay_byte_length != source_stat.st_size: if artifact.expected_sha256 is not None:
raise RecordingMaterializationError( if not _is_sha256(artifact.expected_sha256):
"a full-capture digest cannot describe a replay prefix" raise RecordingMaterializationError("recording artifact digest is invalid")
if artifact.replay_byte_length != file_stat.st_size:
raise RecordingMaterializationError(
"a full-artifact digest cannot describe a replay prefix"
)
if path.name in seen_names:
raise RecordingMaterializationError("recording artifact filenames are duplicated")
seen_ids.add(artifact.artifact_id)
seen_names.add(path.name)
validated.append(
_ValidatedArtifact(
artifact_id=artifact.artifact_id,
path=path,
media_type=artifact.media_type,
file_stat=file_stat,
replay_byte_length=artifact.replay_byte_length,
expected_sha256=artifact.expected_sha256,
) )
)
if sum(artifact.artifact_id == primary_artifact_id for artifact in validated) != 1:
raise RecordingMaterializationError("recording primary artifact is unavailable")
return _ValidatedSource( return _ValidatedSource(
source=source, plugin_id=plugin_id,
metadata=metadata, primary_artifact_id=primary_artifact_id,
source_stat=source_stat, artifacts=tuple(validated),
metadata_stat=metadata_stat,
replay_byte_length=replay_byte_length,
metadata_byte_length=metadata_byte_length,
expected_source_sha256=expected_source_sha256,
) )
def _validate_source_state(source: _ValidatedSource) -> _ValidatedSource: def _validate_source_state(source: _ValidatedSource) -> _ValidatedSource:
return _ValidatedSource( return _ValidatedSource(
source=source.source, plugin_id=source.plugin_id,
metadata=source.metadata, primary_artifact_id=source.primary_artifact_id,
source_stat=_regular_file_stat_nofollow(source.source, "native capture"), artifacts=tuple(
metadata_stat=_regular_file_stat_nofollow(source.metadata, "native metadata"), _ValidatedArtifact(
replay_byte_length=source.replay_byte_length, artifact_id=artifact.artifact_id,
metadata_byte_length=source.metadata_byte_length, path=artifact.path,
expected_source_sha256=source.expected_source_sha256, media_type=artifact.media_type,
file_stat=_regular_file_stat_nofollow(
artifact.path,
"recording source artifact",
),
replay_byte_length=artifact.replay_byte_length,
expected_sha256=artifact.expected_sha256,
)
for artifact in source.artifacts
),
) )
@ -880,20 +953,25 @@ def _cache_document(
return { return {
"schema_version": CACHE_SCHEMA, "schema_version": CACHE_SCHEMA,
"session_id": recording.session_id, "session_id": recording.session_id,
"source_file_byte_length": source.source_stat.st_size, "plugin_id": source.plugin_id,
"source_replay_byte_length": source.replay_byte_length, "primary_artifact_id": source.primary_artifact_id,
"source_mtime_ns": source.source_stat.st_mtime_ns,
"source_ctime_ns": source.source_stat.st_ctime_ns,
"source_sha256": recording.source_sha256, "source_sha256": recording.source_sha256,
"metadata_file_byte_length": source.metadata_stat.st_size, "source_artifacts": [
"metadata_replay_byte_length": source.metadata_byte_length, {
"metadata_mtime_ns": source.metadata_stat.st_mtime_ns, "artifact_id": artifact.artifact_id,
"metadata_ctime_ns": source.metadata_stat.st_ctime_ns, "media_type": artifact.media_type,
"metadata_sha256": _sha256_prefix_stable( "file_byte_length": artifact.file_stat.st_size,
source.metadata, "replay_byte_length": artifact.replay_byte_length,
source.metadata_stat, "mtime_ns": artifact.file_stat.st_mtime_ns,
source.metadata_byte_length, "ctime_ns": artifact.file_stat.st_ctime_ns,
), "sha256": _sha256_prefix_stable(
artifact.path,
artifact.file_stat,
artifact.replay_byte_length,
),
}
for artifact in source.artifacts
],
"recording_byte_length": recording.byte_length, "recording_byte_length": recording.byte_length,
"recording_mtime_ns": recording_stat.st_mtime_ns, "recording_mtime_ns": recording_stat.st_mtime_ns,
"recording_sha256": recording.sha256, "recording_sha256": recording.sha256,
@ -909,16 +987,10 @@ def _validate_cache_document(value: object, session_id: str) -> dict[str, Any]:
expected_keys = { expected_keys = {
"schema_version", "schema_version",
"session_id", "session_id",
"source_file_byte_length", "plugin_id",
"source_replay_byte_length", "primary_artifact_id",
"source_mtime_ns",
"source_ctime_ns",
"source_sha256", "source_sha256",
"metadata_file_byte_length", "source_artifacts",
"metadata_replay_byte_length",
"metadata_mtime_ns",
"metadata_ctime_ns",
"metadata_sha256",
"recording_byte_length", "recording_byte_length",
"recording_mtime_ns", "recording_mtime_ns",
"recording_sha256", "recording_sha256",
@ -932,15 +1004,10 @@ def _validate_cache_document(value: object, session_id: str) -> dict[str, Any]:
raise ValueError("recording cache sidecar identity does not match") raise ValueError("recording cache sidecar identity does not match")
if value["timeline"] != RERUN_SESSION_TIMELINE: if value["timeline"] != RERUN_SESSION_TIMELINE:
raise ValueError("recording cache timeline is unsupported") raise ValueError("recording cache timeline is unsupported")
for key in ("plugin_id", "primary_artifact_id"):
if not isinstance(value[key], str) or SESSION_ID_PATTERN.fullmatch(value[key]) is None:
raise ValueError("recording cache contains an invalid identifier")
for key in ( for key in (
"source_file_byte_length",
"source_replay_byte_length",
"source_mtime_ns",
"source_ctime_ns",
"metadata_file_byte_length",
"metadata_replay_byte_length",
"metadata_mtime_ns",
"metadata_ctime_ns",
"recording_byte_length", "recording_byte_length",
"recording_mtime_ns", "recording_mtime_ns",
"timeline_start_ns", "timeline_start_ns",
@ -948,10 +1015,51 @@ def _validate_cache_document(value: object, session_id: str) -> dict[str, Any]:
): ):
if not isinstance(value[key], int) or isinstance(value[key], bool) or value[key] < 0: if not isinstance(value[key], int) or isinstance(value[key], bool) or value[key] < 0:
raise ValueError("recording cache contains an invalid integer") raise ValueError("recording cache contains an invalid integer")
for key in ("source_sha256", "metadata_sha256", "recording_sha256"): for key in ("source_sha256", "recording_sha256"):
digest = value[key] digest = value[key]
if not isinstance(digest, str) or not _is_sha256(digest): if not isinstance(digest, str) or not _is_sha256(digest):
raise ValueError("recording cache contains an invalid digest") raise ValueError("recording cache contains an invalid digest")
source_artifacts = value["source_artifacts"]
if not isinstance(source_artifacts, list) or not source_artifacts:
raise ValueError("recording cache source artifacts are invalid")
artifact_ids: set[str] = set()
artifact_keys = {
"artifact_id",
"media_type",
"file_byte_length",
"replay_byte_length",
"mtime_ns",
"ctime_ns",
"sha256",
}
for artifact in source_artifacts:
if not isinstance(artifact, dict) or set(artifact) != artifact_keys:
raise ValueError("recording cache source artifact is invalid")
artifact_id = artifact["artifact_id"]
if (
not isinstance(artifact_id, str)
or SESSION_ID_PATTERN.fullmatch(artifact_id) is None
or artifact_id in artifact_ids
):
raise ValueError("recording cache source artifact id is invalid")
artifact_ids.add(artifact_id)
if not isinstance(artifact["media_type"], str) or not artifact["media_type"]:
raise ValueError("recording cache source media type is invalid")
for key in (
"file_byte_length",
"replay_byte_length",
"mtime_ns",
"ctime_ns",
):
item = artifact[key]
if not isinstance(item, int) or isinstance(item, bool) or item < 0:
raise ValueError("recording cache source artifact boundary is invalid")
if not 1 <= artifact["replay_byte_length"] <= artifact["file_byte_length"]:
raise ValueError("recording cache source replay boundary is invalid")
if not isinstance(artifact["sha256"], str) or not _is_sha256(artifact["sha256"]):
raise ValueError("recording cache source digest is invalid")
if value["primary_artifact_id"] not in artifact_ids:
raise ValueError("recording cache primary artifact is unavailable")
if value["timeline_end_ns"] < value["timeline_start_ns"]: if value["timeline_end_ns"] < value["timeline_start_ns"]:
raise ValueError("recording cache timeline bounds are invalid") raise ValueError("recording cache timeline bounds are invalid")
return cast(dict[str, Any], value) return cast(dict[str, Any], value)
@ -1061,26 +1169,23 @@ def _stage_replay_prefix(
staged_root = session_cache_root / f".source.{uuid4().hex}.tmp" staged_root = session_cache_root / f".source.{uuid4().hex}.tmp"
try: try:
staged_root.mkdir(mode=0o700) staged_root.mkdir(mode=0o700)
staged_source = staged_root / "mqtt.raw.k1mqtt" staged_primary: Path | None = None
staged_metadata = staged_root / "mqtt.metadata.jsonl" for artifact in source.artifacts:
_copy_prefix_nofollow( staged = staged_root / artifact.path.name
source.source, _copy_prefix_nofollow(
source.source_stat, artifact.path,
staged_source, artifact.file_stat,
source.replay_byte_length, staged,
cancel_event=cancel_event, artifact.replay_byte_length,
activity_callback=activity_callback, cancel_event=cancel_event,
) activity_callback=activity_callback,
_copy_prefix_nofollow( )
source.metadata, if artifact.artifact_id == source.primary_artifact_id:
source.metadata_stat, staged_primary = staged
staged_metadata, if staged_primary is None:
source.metadata_byte_length, raise RecordingMaterializationError("staged recording has no primary artifact")
cancel_event=cancel_event,
activity_callback=activity_callback,
)
_fsync_directory(staged_root) _fsync_directory(staged_root)
return staged_root, staged_source return staged_root, staged_primary
except BaseException: except BaseException:
shutil.rmtree(staged_root, ignore_errors=True) shutil.rmtree(staged_root, ignore_errors=True)
raise raise

View File

@ -12,11 +12,12 @@ from typing import Any, cast
from k1link.artifacts import utc_now_iso from k1link.artifacts import utc_now_iso
from .legacy import discover_legacy_viewer_sessions
from .models import ( from .models import (
LayoutConflictError, LayoutConflictError,
LegacySessionCandidate, ObservationArtifactCandidate,
ObservationSessionCandidate,
RecordedMediaArtifact, RecordedMediaArtifact,
ReplayArtifact,
ReplayCommand, ReplayCommand,
SessionArtifact, SessionArtifact,
SessionDetail, SessionDetail,
@ -30,6 +31,7 @@ from .models import (
SessionSummary, SessionSummary,
WorkspaceLayout, WorkspaceLayout,
) )
from .plugin_contract import ObservationArchiveSource
IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
MAX_LAYOUT_BYTES = 256 * 1024 MAX_LAYOUT_BYTES = 256 * 1024
@ -38,6 +40,8 @@ DATABASE_NAME = "mission-core.sqlite3"
SCHEMA_SQL = """ SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS observation_sessions ( CREATE TABLE IF NOT EXISTS observation_sessions (
session_id TEXT PRIMARY KEY, session_id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
archive_id TEXT NOT NULL,
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('ready', 'interrupted', 'failed')), status TEXT NOT NULL CHECK (status IN ('ready', 'interrupted', 'failed')),
started_at_utc TEXT, started_at_utc TEXT,
@ -48,8 +52,9 @@ CREATE TABLE IF NOT EXISTS observation_sessions (
origin TEXT NOT NULL, origin TEXT NOT NULL,
source_count INTEGER NOT NULL, source_count INTEGER NOT NULL,
total_bytes INTEGER NOT NULL, total_bytes INTEGER NOT NULL,
replay_raw_bytes INTEGER NOT NULL DEFAULT 0, primary_replay_artifact_id TEXT,
replay_metadata_bytes INTEGER NOT NULL DEFAULT 0, timeline_origin_epoch_ns INTEGER,
timeline_origin_monotonic_ns INTEGER,
allowed_root TEXT NOT NULL, allowed_root TEXT NOT NULL,
session_root TEXT NOT NULL, session_root TEXT NOT NULL,
created_at_utc TEXT NOT NULL, created_at_utc TEXT NOT NULL,
@ -68,6 +73,7 @@ CREATE TABLE IF NOT EXISTS observation_session_artifacts (
sha256 TEXT, sha256 TEXT,
integrity_status TEXT NOT NULL, integrity_status TEXT NOT NULL,
locator TEXT NOT NULL, locator TEXT NOT NULL,
replay_byte_length INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (session_id, artifact_id) PRIMARY KEY (session_id, artifact_id)
); );
@ -129,20 +135,22 @@ class SessionStore:
self._lock = threading.RLock() self._lock = threading.RLock()
self._initialize() self._initialize()
def import_legacy_viewer_live(self, root: Path) -> tuple[str, ...]: def reconcile_archive(self, source: ObservationArchiveSource) -> tuple[str, ...]:
allowed_root = root.expanduser().resolve() """Reconcile one plugin-owned evidence namespace into the host catalog."""
candidates = discover_legacy_viewer_sessions(allowed_root)
allowed_root = source.root.expanduser().resolve()
candidates = source.discover(allowed_root)
imported: list[str] = [] imported: list[str] = []
for candidate in candidates: for candidate in candidates:
self._upsert_legacy(candidate) self._upsert_candidate(source, candidate)
imported.append(candidate.session_id) imported.append(candidate.session_id)
with self._lock, self._connect() as connection: with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE") connection.execute("BEGIN IMMEDIATE")
discovered = set(imported) discovered = set(imported)
indexed = connection.execute( indexed = connection.execute(
"SELECT session_id FROM observation_sessions " "SELECT session_id FROM observation_sessions "
"WHERE origin = 'legacy-viewer-live' AND allowed_root = ?", "WHERE plugin_id = ? AND archive_id = ? AND allowed_root = ?",
(str(allowed_root),), (source.plugin_id, source.archive_id, str(allowed_root)),
).fetchall() ).fetchall()
stale = [row["session_id"] for row in indexed if row["session_id"] not in discovered] stale = [row["session_id"] for row in indexed if row["session_id"] not in discovered]
connection.executemany( connection.executemany(
@ -240,29 +248,57 @@ class SessionStore:
if not 0 <= speed <= 100: if not 0 <= speed <= 100:
raise ValueError("speed must be within 0..100") raise ValueError("speed must be within 0..100")
with self._connect() as connection: with self._connect() as connection:
row = connection.execute( session = connection.execute(
"SELECT s.allowed_root, s.session_root, s.replay_raw_bytes, " "SELECT plugin_id, allowed_root, session_root, "
"s.replay_metadata_bytes, a.locator, a.sha256 " "primary_replay_artifact_id, timeline_origin_epoch_ns, "
"FROM observation_sessions AS s " "timeline_origin_monotonic_ns FROM observation_sessions WHERE session_id = ?",
"JOIN observation_session_artifacts AS a ON a.session_id = s.session_id "
"WHERE s.session_id = ? AND a.artifact_id = 'raw-mqtt'",
(session_id,), (session_id,),
).fetchone() ).fetchone()
if row is None: artifact_rows = connection.execute(
"SELECT artifact_id, media_type, byte_length, replay_byte_length, locator, sha256 "
"FROM observation_session_artifacts WHERE session_id = ? "
"AND replay_byte_length > 0 ORDER BY artifact_id",
(session_id,),
).fetchall()
if session is None or not artifact_rows:
raise SessionNotReplayableError("observation session replay artifact is unavailable") raise SessionNotReplayableError("observation session replay artifact is unavailable")
source_path = _resolve_confined_artifact( primary_artifact_id = session["primary_replay_artifact_id"]
Path(row["allowed_root"]), epoch_ns = session["timeline_origin_epoch_ns"]
Path(row["session_root"]), monotonic_ns = session["timeline_origin_monotonic_ns"]
Path(row["locator"]), 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( return ReplayCommand(
session_id=session_id, session_id=session_id,
source_path=source_path, plugin_id=session["plugin_id"],
allowed_root=Path(row["allowed_root"]), allowed_root=allowed_root,
session_root=Path(row["session_root"]), session_root=session_root,
replay_byte_length=int(row["replay_raw_bytes"]), primary_artifact_id=primary_artifact_id,
metadata_byte_length=int(row["replay_metadata_bytes"]), artifacts=artifacts,
expected_source_sha256=row["sha256"], timeline_origin_epoch_ns=epoch_ns,
timeline_origin_monotonic_ns=monotonic_ns,
speed=float(speed), speed=float(speed),
loop=loop, loop=loop,
) )
@ -387,58 +423,92 @@ class SessionStore:
def _initialize(self) -> None: def _initialize(self) -> None:
with self._connect() as connection: with self._connect() as connection:
connection.executescript(SCHEMA_SQL) connection.executescript(SCHEMA_SQL)
columns = { session_columns = {
row["name"] row["name"]
for row in connection.execute("PRAGMA table_info(observation_sessions)") for row in connection.execute("PRAGMA table_info(observation_sessions)")
} }
if "replay_raw_bytes" not in columns: for name, declaration in (
("plugin_id", "TEXT NOT NULL DEFAULT ''"),
("archive_id", "TEXT NOT NULL DEFAULT ''"),
("primary_replay_artifact_id", "TEXT"),
("timeline_origin_epoch_ns", "INTEGER"),
("timeline_origin_monotonic_ns", "INTEGER"),
):
if name in session_columns:
continue
connection.execute( connection.execute(
"ALTER TABLE observation_sessions " f"ALTER TABLE observation_sessions ADD COLUMN {name} {declaration}" # noqa: S608
"ADD COLUMN replay_raw_bytes INTEGER NOT NULL DEFAULT 0"
) )
if "replay_metadata_bytes" not in columns: artifact_columns = {
row["name"]
for row in connection.execute(
"PRAGMA table_info(observation_session_artifacts)"
)
}
if "replay_byte_length" not in artifact_columns:
connection.execute( connection.execute(
"ALTER TABLE observation_sessions " "ALTER TABLE observation_session_artifacts "
"ADD COLUMN replay_metadata_bytes INTEGER NOT NULL DEFAULT 0" "ADD COLUMN replay_byte_length INTEGER NOT NULL DEFAULT 0"
) )
connection.commit() connection.commit()
with _ignore_os_error(): with _ignore_os_error():
self.database_path.chmod(0o600) self.database_path.chmod(0o600)
def _upsert_legacy(self, candidate: LegacySessionCandidate) -> None: def _upsert_candidate(
_validate_identifier(candidate.session_id, "legacy session id") self,
source: ObservationArchiveSource,
candidate: ObservationSessionCandidate,
) -> None:
_validate_identifier(candidate.session_id, "observation session id")
_validate_identifier(source.plugin_id, "device plugin id")
_validate_identifier(source.archive_id, "observation archive id")
allowed_root = candidate.allowed_root.resolve() allowed_root = candidate.allowed_root.resolve()
session_root = candidate.session_root.resolve() session_root = candidate.session_root.resolve()
if not session_root.is_relative_to(allowed_root): if not session_root.is_relative_to(allowed_root):
raise SessionIntegrityError("legacy session root escapes its allowed root") raise SessionIntegrityError("observation session root escapes its allowed root")
sources = _legacy_sources(candidate) if allowed_root != source.root.expanduser().resolve():
artifacts = _legacy_artifacts(candidate, session_root) raise SessionIntegrityError("plugin candidate does not belong to its archive root")
sources = candidate.sources
artifacts = _validated_candidate_artifacts(candidate, session_root)
_validate_candidate_replay(candidate, artifacts)
now = utc_now_iso() now = utc_now_iso()
modalities_json = json.dumps(list(candidate.modalities), separators=(",", ":")) modalities_json = json.dumps(list(candidate.modalities), separators=(",", ":"))
with self._lock, self._connect() as connection: with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE") connection.execute("BEGIN IMMEDIATE")
existing = connection.execute( existing = connection.execute(
"SELECT origin, allowed_root, session_root, created_at_utc " "SELECT plugin_id, archive_id, allowed_root, session_root, created_at_utc "
"FROM observation_sessions WHERE session_id = ?", "FROM observation_sessions WHERE session_id = ?",
(candidate.session_id,), (candidate.session_id,),
).fetchone() ).fetchone()
unclaimed_pre_plugin_row = existing is not None and (
existing["plugin_id"] == "" and existing["archive_id"] == ""
)
if existing is not None and ( if existing is not None and (
existing["origin"] != "legacy-viewer-live" Path(existing["allowed_root"]).resolve() != allowed_root
or Path(existing["allowed_root"]).resolve() != allowed_root
or Path(existing["session_root"]).resolve() != session_root or Path(existing["session_root"]).resolve() != session_root
or (
not unclaimed_pre_plugin_row
and (
existing["plugin_id"] != source.plugin_id
or existing["archive_id"] != source.archive_id
)
)
): ):
connection.rollback() connection.rollback()
raise SessionIntegrityError("session id is already bound to another origin") raise SessionIntegrityError("session id is already bound to another archive")
created_at = existing["created_at_utc"] if existing is not None else now created_at = existing["created_at_utc"] if existing is not None else now
connection.execute( connection.execute(
"INSERT INTO observation_sessions " "INSERT INTO observation_sessions "
"(session_id, display_name, status, started_at_utc, completed_at_utc, " "(session_id, plugin_id, archive_id, display_name, status, "
"started_at_utc, completed_at_utc, "
"duration_seconds, modalities_json, replayable, origin, source_count, " "duration_seconds, modalities_json, replayable, origin, source_count, "
"total_bytes, replay_raw_bytes, replay_metadata_bytes, allowed_root, " "total_bytes, primary_replay_artifact_id, timeline_origin_epoch_ns, "
"timeline_origin_monotonic_ns, allowed_root, "
"session_root, created_at_utc, updated_at_utc) " "session_root, created_at_utc, updated_at_utc) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(session_id) DO UPDATE SET " "ON CONFLICT(session_id) DO UPDATE SET "
"plugin_id = excluded.plugin_id, archive_id = excluded.archive_id, "
"display_name = excluded.display_name, status = excluded.status, " "display_name = excluded.display_name, status = excluded.status, "
"started_at_utc = excluded.started_at_utc, " "started_at_utc = excluded.started_at_utc, "
"completed_at_utc = excluded.completed_at_utc, " "completed_at_utc = excluded.completed_at_utc, "
@ -446,11 +516,14 @@ class SessionStore:
"modalities_json = excluded.modalities_json, " "modalities_json = excluded.modalities_json, "
"replayable = excluded.replayable, source_count = excluded.source_count, " "replayable = excluded.replayable, source_count = excluded.source_count, "
"total_bytes = excluded.total_bytes, " "total_bytes = excluded.total_bytes, "
"replay_raw_bytes = excluded.replay_raw_bytes, " "primary_replay_artifact_id = excluded.primary_replay_artifact_id, "
"replay_metadata_bytes = excluded.replay_metadata_bytes, " "timeline_origin_epoch_ns = excluded.timeline_origin_epoch_ns, "
"timeline_origin_monotonic_ns = excluded.timeline_origin_monotonic_ns, "
"updated_at_utc = excluded.updated_at_utc", "updated_at_utc = excluded.updated_at_utc",
( (
candidate.session_id, candidate.session_id,
source.plugin_id,
source.archive_id,
candidate.display_name, candidate.display_name,
candidate.status, candidate.status,
candidate.started_at_utc, candidate.started_at_utc,
@ -458,11 +531,12 @@ class SessionStore:
candidate.duration_seconds, candidate.duration_seconds,
modalities_json, modalities_json,
int(candidate.replayable), int(candidate.replayable),
"legacy-viewer-live", source.archive_id,
len(sources), len(sources),
candidate.total_bytes, candidate.total_bytes,
candidate.replay_raw_byte_length, candidate.primary_replay_artifact_id,
candidate.replay_metadata_byte_length, candidate.timeline_origin_epoch_ns,
candidate.timeline_origin_monotonic_ns,
str(allowed_root), str(allowed_root),
str(session_root), str(session_root),
created_at, created_at,
@ -480,9 +554,20 @@ class SessionStore:
connection.executemany( connection.executemany(
"INSERT INTO observation_session_artifacts " "INSERT INTO observation_session_artifacts "
"(session_id, artifact_id, kind, media_type, byte_length, sha256, " "(session_id, artifact_id, kind, media_type, byte_length, sha256, "
"integrity_status, locator) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", "integrity_status, locator, replay_byte_length) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
[ [
(candidate.session_id, *artifact) (
candidate.session_id,
artifact.artifact_id,
artifact.kind,
artifact.media_type,
artifact.byte_length,
artifact.sha256,
artifact.integrity_status,
str(artifact.locator),
artifact.replay_byte_length,
)
for artifact in artifacts for artifact in artifacts
], ],
) )
@ -519,82 +604,56 @@ class SessionStore:
connection.close() connection.close()
def _legacy_sources(candidate: LegacySessionCandidate) -> tuple[SessionSource, ...]: def _validated_candidate_artifacts(
rows: list[SessionSource] = [] candidate: ObservationSessionCandidate,
if "point-cloud" in candidate.modalities:
rows.append(
SessionSource(
source_id="sensor.lidar.primary",
semantic_channel_id="spatial.point-cloud.recorded",
modality="point-cloud",
status="recorded",
seekable=candidate.replayable,
artifact_id="raw-mqtt",
)
)
if "trajectory" in candidate.modalities:
rows.append(
SessionSource(
source_id="spatial.trajectory",
semantic_channel_id="spatial.pose.recorded",
modality="trajectory",
status="recorded",
seekable=candidate.replayable,
artifact_id="raw-mqtt",
)
)
rows.extend(
SessionSource(
source_id=media.source_id,
semantic_channel_id="camera.video.recorded",
modality="video",
status="recorded",
seekable=True,
artifact_id=media.artifact_id,
)
for media in candidate.media_sources
)
if len({source.source_id for source in rows}) != len(rows):
raise SessionIntegrityError("legacy session contains duplicate source identifiers")
return tuple(rows)
def _legacy_artifacts(
candidate: LegacySessionCandidate,
session_root: Path, session_root: Path,
) -> tuple[tuple[str, str, str, int, str | None, str, str], ...]: ) -> tuple[ObservationArtifactCandidate, ...]:
artifacts: list[tuple[str, str, str, int, str | None, str, str]] = [ artifacts = candidate.artifacts
( artifact_ids: set[str] = set()
"raw-mqtt", for artifact in artifacts:
"raw-transport", _validate_identifier(artifact.artifact_id, "observation artifact id")
"application/x-nodedc-k1mqtt", if artifact.artifact_id in artifact_ids:
candidate.raw_byte_length, raise SessionIntegrityError("observation session contains duplicate artifacts")
candidate.raw_sha256, artifact_ids.add(artifact.artifact_id)
candidate.raw_integrity_status, if artifact.byte_length < 0 or not 0 <= artifact.replay_byte_length <= artifact.byte_length:
str(candidate.raw_path), raise SessionIntegrityError("observation artifact has invalid byte boundaries")
)
]
for media in candidate.media_sources:
try: try:
locator = media.locator.resolve(strict=True) locator = artifact.locator.resolve(strict=True)
except OSError as exc: except OSError as exc:
raise SessionIntegrityError("legacy video artifact is missing") from exc raise SessionIntegrityError("observation artifact is missing") from exc
if not locator.is_dir() or not locator.is_relative_to(session_root): if not locator.is_relative_to(session_root) or not (locator.is_file() or locator.is_dir()):
raise SessionIntegrityError("legacy video artifact escapes its session root") raise SessionIntegrityError("observation artifact escapes its session root")
artifacts.append( if artifact.replay_byte_length > 0 and not locator.is_file():
( raise SessionIntegrityError("replay input artifact must be a regular file")
media.artifact_id, source_ids: set[str] = set()
"recorded-video", for source in candidate.sources:
"video/mp4", _validate_identifier(source.source_id, "observation source id")
media.byte_length, if source.source_id in source_ids:
None, raise SessionIntegrityError("observation session contains duplicate sources")
"validated-structure", if source.artifact_id not in artifact_ids:
str(locator), raise SessionIntegrityError("observation source references an unknown artifact")
) source_ids.add(source.source_id)
) return artifacts
if len({artifact[0] for artifact in artifacts}) != len(artifacts):
raise SessionIntegrityError("legacy session contains duplicate artifact identifiers")
return tuple(artifacts) def _validate_candidate_replay(
candidate: ObservationSessionCandidate,
artifacts: tuple[ObservationArtifactCandidate, ...],
) -> None:
primary_id = candidate.primary_replay_artifact_id
replay_artifacts = tuple(artifact for artifact in artifacts if artifact.replay_byte_length > 0)
if candidate.replayable:
if (
primary_id is None
or sum(artifact.artifact_id == primary_id for artifact in replay_artifacts) != 1
or candidate.timeline_origin_epoch_ns is None
or candidate.timeline_origin_monotonic_ns is None
):
raise SessionIntegrityError("replayable observation contract is incomplete")
if candidate.timeline_origin_epoch_ns < 0 or candidate.timeline_origin_monotonic_ns < 0:
raise SessionIntegrityError("observation timeline origin is invalid")
elif primary_id is not None or replay_artifacts:
raise SessionIntegrityError("non-replayable observation declares replay artifacts")
def _summary_from_row(row: sqlite3.Row) -> SessionSummary: def _summary_from_row(row: sqlite3.Row) -> SessionSummary:

View File

@ -1,15 +1,11 @@
"""Live/replay visualization bridge for verified K1 MQTT streams.""" """Mission Core viewer consumers and recorded-viewer contracts."""
from k1link.viewer.messages import StreamMessage from .recorded import RecordedBlueprintError, recorded_blueprint_rrd
from k1link.viewer.replay import ( from .rerun_bridge import RerunBridge, RerunSceneSettings
ReplayFormatError,
detect_replay_format,
iter_replay_messages,
)
__all__ = [ __all__ = [
"ReplayFormatError", "RecordedBlueprintError",
"StreamMessage", "RerunBridge",
"detect_replay_format", "RerunSceneSettings",
"iter_replay_messages", "recorded_blueprint_rrd",
] ]

View File

@ -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

View File

@ -22,10 +22,7 @@ from k1link.sessions import (
SessionRecordingMaterializer, SessionRecordingMaterializer,
SessionRecordingPreparationManager, SessionRecordingPreparationManager,
SessionStore, SessionStore,
recover_stale_active_session_marker,
resolve_missioncore_evidence_dir,
) )
from k1link.web.camera_archive import recover_incomplete_camera_archives
from k1link.web.device_plugin_composition import load_installed_device_plugins from k1link.web.device_plugin_composition import load_installed_device_plugins
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
from k1link.web.plugin_runtime import ( from k1link.web.plugin_runtime import (
@ -42,13 +39,14 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
INVALID_REQUEST_DETAIL = "Некорректные параметры запроса." INVALID_REQUEST_DETAIL = "Некорректные параметры запроса."
legacy_observation_sessions_root = REPOSITORY_ROOT / "sessions"
observation_sessions_root = resolve_missioncore_evidence_dir(REPOSITORY_ROOT)
plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT) plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
session_store = SessionStore(REPOSITORY_ROOT) session_store = SessionStore(REPOSITORY_ROOT)
session_recording_materializer = SessionRecordingMaterializer(session_store.data_dir) session_recording_materializer = SessionRecordingMaterializer(
session_store.data_dir,
exporters=plugin_environment.recording_exporters,
)
session_recorded_media_inspector = RecordedMediaInspector( session_recorded_media_inspector = RecordedMediaInspector(
session_store.data_dir / "recorded-media-preparations" session_store.data_dir / "recorded-media-preparations"
) )
@ -76,8 +74,9 @@ def refresh_observation_catalog() -> tuple[str, ...]:
"""Discover completed or recoverable local evidence without copying payloads.""" """Discover completed or recoverable local evidence without copying payloads."""
imported = [ imported = [
*session_store.import_legacy_viewer_live(legacy_observation_sessions_root), session_id
*session_store.import_legacy_viewer_live(observation_sessions_root), for archive in plugin_environment.observation_archives
for session_id in session_store.reconcile_archive(archive)
] ]
return tuple(dict.fromkeys(imported)) return tuple(dict.fromkeys(imported))
@ -138,12 +137,8 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
# Recovery is intentionally a one-shot startup phase. The archive # Recovery is intentionally a one-shot startup phase. The archive
# helper owns a cross-process lease, while ordinary catalog requests # helper owns a cross-process lease, while ordinary catalog requests
# only perform discovery and therefore never touch a live writer. # only perform discovery and therefore never touch a live writer.
await asyncio.to_thread(recover_stale_active_session_marker, observation_sessions_root) for archive in plugin_environment.observation_archives:
for sessions_root in ( await asyncio.to_thread(archive.recover, archive.root)
legacy_observation_sessions_root,
observation_sessions_root,
):
await asyncio.to_thread(recover_incomplete_camera_archives, sessions_root)
# Full evidence discovery, hashing and RRD queue reconciliation can be # Full evidence discovery, hashing and RRD queue reconciliation can be
# expensive on field captures. Start it immediately in the background # expensive on field captures. Start it immediately in the background
# instead of holding the ASGI startup gate. # instead of holding the ASGI startup gate.

View File

@ -7,6 +7,7 @@ from typing import Any
from fastapi import APIRouter from fastapi import APIRouter
from k1link.sessions.plugin_contract import ObservationArchiveSource, RecordingExporter
from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest
from k1link.web.plugin_runtime import ( from k1link.web.plugin_runtime import (
DevicePluginDispatcher, DevicePluginDispatcher,
@ -25,6 +26,23 @@ class InstalledDevicePluginEnvironment:
legacy_routers: tuple[APIRouter, ...] legacy_routers: tuple[APIRouter, ...]
_contributions: tuple[DevicePluginRuntimeContribution, ...] _contributions: tuple[DevicePluginRuntimeContribution, ...]
@property
def observation_archives(self) -> tuple[ObservationArchiveSource, ...]:
return tuple(
archive
for contribution in self._contributions
if contribution.observation is not None
for archive in contribution.observation.archives
)
@property
def recording_exporters(self) -> dict[str, RecordingExporter]:
return {
contribution.adapter.plugin_id: contribution.observation.recording_exporter
for contribution in self._contributions
if contribution.observation is not None
}
def close(self) -> None: def close(self) -> None:
cleanup_errors = _close_contributions(self._contributions) cleanup_errors = _close_contributions(self._contributions)
if cleanup_errors: if cleanup_errors:
@ -103,6 +121,31 @@ def _load_contribution(
raise DevicePluginCompositionError( raise DevicePluginCompositionError(
f"Device-plugin manifest/runtime actions mismatch for {adapter.plugin_id}" f"Device-plugin manifest/runtime actions mismatch for {adapter.plugin_id}"
) )
observation = contribution.observation
if observation is not None:
if not observation.archives:
raise DevicePluginCompositionError(
f"Device-plugin observation contribution is empty for {adapter.plugin_id}"
)
archive_ids: set[str] = set()
archive_roots: set[Path] = set()
for archive in observation.archives:
if archive.plugin_id != adapter.plugin_id:
raise DevicePluginCompositionError(
"Device-plugin observation/runtime id mismatch: "
f"{archive.plugin_id} != {adapter.plugin_id}"
)
if archive.archive_id in archive_ids:
raise DevicePluginCompositionError(
f"Duplicate observation archive id for {adapter.plugin_id}"
)
resolved_root = archive.root.expanduser().resolve()
if resolved_root in archive_roots:
raise DevicePluginCompositionError(
f"Duplicate observation archive root for {adapter.plugin_id}"
)
archive_ids.add(archive.archive_id)
archive_roots.add(resolved_root)
except Exception as exc: except Exception as exc:
_add_cleanup_notes(exc, _close_contributions((contribution,))) _add_cleanup_notes(exc, _close_contributions((contribution,)))
raise raise

View File

@ -2,11 +2,16 @@ from __future__ import annotations
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Protocol from typing import Any, Protocol
from uuid import uuid4
from fastapi import APIRouter from fastapi import APIRouter
from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation, RuntimeActionResult
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from k1link.sessions.plugin_contract import ObservationRuntimeContribution
STATE_READ_ACTION_ID = "state.read" STATE_READ_ACTION_ID = "state.read"
@ -20,7 +25,7 @@ class DevicePluginActionAdapter(Protocol):
plugin_id: str plugin_id: str
action_ids: frozenset[str] action_ids: frozenset[str]
async def invoke(self, action_id: str, payload: Mapping[str, Any]) -> dict[str, Any]: ... async def invoke(self, invocation: RuntimeActionInvocation) -> Mapping[str, Any]: ...
def _noop() -> None: def _noop() -> None:
@ -33,6 +38,7 @@ class DevicePluginRuntimeContribution:
adapter: DevicePluginActionAdapter adapter: DevicePluginActionAdapter
legacy_routers: tuple[APIRouter, ...] = () legacy_routers: tuple[APIRouter, ...] = ()
observation: ObservationRuntimeContribution | None = None
close: Callable[[], None] = _noop close: Callable[[], None] = _noop
@ -71,8 +77,33 @@ class DevicePluginDispatcher:
adapter = self._adapters.get(plugin_id) adapter = self._adapters.get(plugin_id)
if adapter is None: if adapter is None:
raise PluginNotFoundError(f"Device plugin is not installed: {plugin_id}") raise PluginNotFoundError(f"Device plugin is not installed: {plugin_id}")
if action_id not in adapter.action_ids: return await invoke_device_plugin_adapter(adapter, action_id, payload)
raise PluginActionNotFoundError(
f"Device plugin {plugin_id} does not declare action {action_id}"
) async def invoke_device_plugin_adapter(
return await adapter.invoke(action_id, payload) 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 {adapter.plugin_id} does not declare action {action_id}"
)
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)

View File

@ -31,16 +31,15 @@ from k1link.sessions import (
SessionStore, SessionStore,
validate_recorded_media_timeline, validate_recorded_media_timeline,
) )
from k1link.viewer.rerun_bridge import RerunSceneSettings from k1link.viewer.recorded import (
from k1link.viewer.rrd_export import (
APPLICATION_ID as RECORDED_APPLICATION_ID, APPLICATION_ID as RECORDED_APPLICATION_ID,
) )
from k1link.viewer.rrd_export import ( from k1link.viewer.recorded import (
RrdExportError, RecordedBlueprintError,
recorded_blueprint_rrd, recorded_blueprint_rrd,
) )
from k1link.viewer.rerun_bridge import RerunSceneSettings
DEFAULT_REPLAY_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
DEFAULT_REPLAY_ACTION_ID = "stream.start-replay" DEFAULT_REPLAY_ACTION_ID = "stream.start-replay"
SAFE_SOURCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$") SAFE_SOURCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")
SAFE_SHA256 = re.compile(r"^[a-f0-9]{64}$") SAFE_SHA256 = re.compile(r"^[a-f0-9]{64}$")
@ -234,7 +233,6 @@ def build_session_router(
recording_preparation_manager: SessionRecordingPreparationManager | None = None, recording_preparation_manager: SessionRecordingPreparationManager | None = None,
media_inspector: RecordedMediaInspector | None = None, media_inspector: RecordedMediaInspector | None = None,
allow_synchronous_recording_fallback: bool = False, allow_synchronous_recording_fallback: bool = False,
replay_plugin_id: str = DEFAULT_REPLAY_PLUGIN_ID,
replay_action_id: str = DEFAULT_REPLAY_ACTION_ID, replay_action_id: str = DEFAULT_REPLAY_ACTION_ID,
) -> APIRouter: ) -> APIRouter:
"""Build generic host APIs without exposing filesystem locators to clients.""" """Build generic host APIs without exposing filesystem locators to clients."""
@ -388,7 +386,7 @@ def build_session_router(
"schema_version": "missioncore.observation-session-replay/v1", "schema_version": "missioncore.observation-session-replay/v1",
"launch": { "launch": {
"kind": "plugin-action", "kind": "plugin-action",
"plugin_id": replay_plugin_id, "plugin_id": command.plugin_id,
"action_id": replay_action_id, "action_id": replay_action_id,
"session_id": command.session_id, "session_id": command.session_id,
"speed": command.speed, "speed": command.speed,
@ -671,7 +669,7 @@ def build_session_router(
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
except (SessionNotReplayableError, SessionIntegrityError) as exc: except (SessionNotReplayableError, SessionIntegrityError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
except RrdExportError as exc: except RecordedBlueprintError as exc:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="Не удалось подготовить настройки визуализатора.", detail="Не удалось подготовить настройки визуализатора.",

View File

@ -2,8 +2,8 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from k1link.device_plugins.xgrids_k1.archive import discover_legacy_viewer_sessions
from k1link.sessions import ActiveSessionLease, recover_stale_active_session_marker from k1link.sessions import ActiveSessionLease, recover_stale_active_session_marker
from k1link.sessions.legacy import discover_legacy_viewer_sessions
def test_active_session_lease_hides_live_evidence_until_release(tmp_path: Path) -> None: def test_active_session_lease_hides_live_evidence_until_release(tmp_path: Path) -> None:

View File

@ -1,7 +1,7 @@
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData from bleak.backends.scanner import AdvertisementData
from k1link.ble.scanner import advertisement_record from k1link.device_plugins.xgrids_k1.ble.scanner import advertisement_record
def test_advertisement_record_marks_k1_candidate() -> None: def test_advertisement_record_marks_k1_candidate() -> None:

View File

@ -10,7 +10,7 @@ from pathlib import Path
import pytest import pytest
from k1link.sessions.legacy import discover_legacy_viewer_sessions from k1link.device_plugins.xgrids_k1.archive import discover_legacy_viewer_sessions
from k1link.web.camera_archive import ( from k1link.web.camera_archive import (
CAMERA_ARCHIVE_SCHEMA, CAMERA_ARCHIVE_SCHEMA,
CAMERA_COMMIT_POLICY, CAMERA_COMMIT_POLICY,

View File

@ -8,7 +8,7 @@ from pathlib import Path
import lz4.block import lz4.block
import pytest import pytest
import k1link.viewer.runtime as runtime_module import k1link.device_plugins.xgrids_k1.viewer.runtime as runtime_module
from k1link.data_plane import ( from k1link.data_plane import (
ConsumerFrameContext, ConsumerFrameContext,
DecodedDeviceStatusView, DecodedDeviceStatusView,
@ -16,11 +16,11 @@ from k1link.data_plane import (
DecodedPoseView, DecodedPoseView,
NormalizationError, NormalizationError,
) )
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.protocol.normalizer import normalize_k1_message from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
from k1link.viewer.messages import StreamMessage from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
from k1link.device_plugins.xgrids_k1.viewer.runtime import VisualizationRuntime
from k1link.viewer.rerun_bridge import RerunBridge from k1link.viewer.rerun_bridge import RerunBridge
from k1link.viewer.runtime import VisualizationRuntime
class FakeRecording: class FakeRecording:
@ -232,7 +232,7 @@ def test_canonical_contracts_reject_misaligned_data_and_support_status() -> None
def test_rerun_bridge_source_has_no_vendor_protocol_or_raw_transport_knowledge() -> None: def test_rerun_bridge_source_has_no_vendor_protocol_or_raw_transport_knowledge() -> None:
source = inspect.getsource(__import__("k1link.viewer.rerun_bridge", fromlist=["*"])) source = inspect.getsource(__import__("k1link.viewer.rerun_bridge", fromlist=["*"]))
for forbidden in ( for forbidden in (
"k1link.protocol", "k1link.device_plugins.xgrids_k1.protocol",
"StreamMessage", "StreamMessage",
"RealtimePointcloud", "RealtimePointcloud",
"RealtimePath", "RealtimePath",
@ -245,9 +245,14 @@ def test_rerun_bridge_source_has_no_vendor_protocol_or_raw_transport_knowledge()
def test_visual_runtime_has_no_implicit_vendor_normalizer() -> None: def test_visual_runtime_has_no_implicit_vendor_normalizer() -> None:
source = inspect.getsource(__import__("k1link.viewer.runtime", fromlist=["*"])) source = inspect.getsource(
__import__(
"k1link.device_plugins.xgrids_k1.viewer.runtime",
fromlist=["*"],
)
)
assert "k1link.protocol" not in source assert "k1link.device_plugins.xgrids_k1.protocol" not in source
assert "normalize_k1_message" not in source assert "normalize_k1_message" not in source
assert "normalizer: CanonicalNormalizer" in source assert "normalizer: CanonicalNormalizer" in source

View File

@ -4,7 +4,7 @@ from typing import Any
from typer.testing import CliRunner from typer.testing import CliRunner
from k1link.cli import app from k1link.device_plugins.xgrids_k1.cli import app
runner = CliRunner() runner = CliRunner()
@ -59,7 +59,7 @@ def test_mqtt_capture_cli_uses_bounded_read_only_capture(
"payload_bytes": 128, "payload_bytes": 128,
} }
monkeypatch.setattr("k1link.cli.capture_mqtt", fake_capture) monkeypatch.setattr("k1link.device_plugins.xgrids_k1.cli.capture_mqtt", fake_capture)
out = tmp_path / "capture" out = tmp_path / "capture"
result = runner.invoke( result = runner.invoke(
app, app,

View File

@ -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