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

View File

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

View File

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

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

View File

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

View File

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

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 },
});
({ xgridsK1Manifest } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/manifest.ts",
"@xgrids-k1/frontend/manifest.ts",
));
({ xgridsK1ObservationSources } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/observationSources.ts",
"@xgrids-k1/frontend/observationSources.ts",
));
({ openObservationSource, shouldRestartObservationSource } = await server.ssrLoadModule(
"/src/core/observation/layoutPolicy.ts",

View File

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

View File

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

View File

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

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
queue cannot discard MQTT evidence. Stage 7 adds an independent durable
observation-session lifecycle. Completed or recovered native captures are
prepared once by a bounded backend worker into digest-bound RRD/cache-v6 and
prepared once by a bounded backend worker into digest-bound RRD/cache-v7 and
camera-manifest generations; a browser replay request never performs conversion.
The saved scene, controller and timeline remain hidden until the complete RRD
range and every declared camera pass admission. Sensor-to-display latency and

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

View File

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

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
point/pose message is projected into RRD. Later openings reuse a verified,
digest-bound cache. Derived RRD cache v6 is intentionally incompatible with v4
and v5 because only v6 guarantees that the payload itself contains a real
`session_time = 0` anchor; older generations are rebuilt once in the background.
digest-bound cache. Derived RRD cache v7 is intentionally incompatible with v6
and older generations. It retains v6's real `session_time = 0` payload anchor
and additionally binds the cache to the owning plugin ID, primary artifact and
ordered set of confined source artifacts. Older generations are rebuilt once in
the background.
The browser may use the strict `source_url` with `If-Match`, while the embedded
Rerun loader uses the canonical `viewer_source_url` whose lowercase SHA-256
`generation` query is bound to the same launch descriptor. A missing or stale

View File

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

View File

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

View File

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

View File

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

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
synthetic visualization layer while ensuring that the decoded RRD timeline,
not merely its summary document, begins at session time zero. This changes the
derived payload contract: cache v6 rejects v4/v5 sidecars and performs one
background rebuild instead of silently reusing an archive without that row.
derived payload contract. Cache v7 keeps that anchor and additionally binds the
derived recording to the owning plugin ID, primary artifact and ordered source
artifact set; it rejects v6 and older sidecars and performs one background
rebuild.
Replay v2 exposes two paths to the same pinned generation. `source_url` retains
the strict `If-Match: "sha256:…"` contract for Mission Core clients;

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

View File

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

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

View File

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

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";
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 {
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 {
DEVICE_STATE_READ_ACTION_ID,
parseDevicePluginManifest,
requirePluginAction,
} from "../../core/device-plugins/manifestParser";
} from "@mission-core/plugin-sdk";
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]> = [
[/broker connection ended/gi, "соединение с брокером завершено"],
[/Unspecified error/gi, "неуказанная ошибка"],

View File

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

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 { xgridsK1Manifest } from "./manifest";
import { XgridsK1RuntimeProvider } from "./runtimeContext";
import "./styles.css";
export const xgridsK1Plugin: DeviceUiPlugin = {
manifest: xgridsK1Manifest,

View File

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

View File

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

View File

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

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 type { BackendStatus, ViewerSettings } from "../../core/runtime/contracts";
import type { BackendStatus, ViewerSettings } from "@mission-core/plugin-sdk";
import {
ApiError,

View File

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

View File

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

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."""
from k1link.analyze.stream_summary import (
from k1link.device_plugins.xgrids_k1.analyze.stream_summary import (
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
StreamSummary,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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."""
from k1link.protocol.streams import (
from k1link.device_plugins.xgrids_k1.protocol.streams import (
DecodeLimits,
LegacyPoint,
LegacyPointCloudFrame,

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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] = [
command.session_id,
str(command.source_path),
command.replay_byte_length,
command.metadata_byte_length,
command.expected_source_sha256,
command.plugin_id,
command.primary_artifact_id,
]
for path in (command.source_path, command.source_path.with_name("mqtt.metadata.jsonl")):
for artifact in command.artifacts:
path = artifact.path
identities.extend(
(
artifact.artifact_id,
artifact.media_type,
artifact.file_byte_length,
artifact.replay_byte_length,
artifact.expected_sha256,
)
)
try:
value = os.lstat(path)
except OSError:

View File

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

View File

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

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 k1link.viewer.replay import (
ReplayFormatError,
detect_replay_format,
iter_replay_messages,
)
from .recorded import RecordedBlueprintError, recorded_blueprint_rrd
from .rerun_bridge import RerunBridge, RerunSceneSettings
__all__ = [
"ReplayFormatError",
"StreamMessage",
"detect_replay_format",
"iter_replay_messages",
"RecordedBlueprintError",
"RerunBridge",
"RerunSceneSettings",
"recorded_blueprint_rrd",
]

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

View File

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

View File

@ -2,11 +2,16 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Protocol
from uuid import uuid4
from fastapi import APIRouter
from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation, RuntimeActionResult
from pydantic import BaseModel, ConfigDict, Field
from k1link.sessions.plugin_contract import ObservationRuntimeContribution
STATE_READ_ACTION_ID = "state.read"
@ -20,7 +25,7 @@ class DevicePluginActionAdapter(Protocol):
plugin_id: str
action_ids: frozenset[str]
async def invoke(self, action_id: str, payload: Mapping[str, Any]) -> dict[str, Any]: ...
async def invoke(self, invocation: RuntimeActionInvocation) -> Mapping[str, Any]: ...
def _noop() -> None:
@ -33,6 +38,7 @@ class DevicePluginRuntimeContribution:
adapter: DevicePluginActionAdapter
legacy_routers: tuple[APIRouter, ...] = ()
observation: ObservationRuntimeContribution | None = None
close: Callable[[], None] = _noop
@ -71,8 +77,33 @@ class DevicePluginDispatcher:
adapter = self._adapters.get(plugin_id)
if adapter is None:
raise PluginNotFoundError(f"Device plugin is not installed: {plugin_id}")
if action_id not in adapter.action_ids:
raise PluginActionNotFoundError(
f"Device plugin {plugin_id} does not declare action {action_id}"
)
return await adapter.invoke(action_id, payload)
return await invoke_device_plugin_adapter(adapter, action_id, payload)
async def invoke_device_plugin_adapter(
adapter: DevicePluginActionAdapter,
action_id: str,
payload: Mapping[str, Any],
) -> dict[str, Any]:
"""Validate SDK request/result envelopes around one plugin action call."""
if action_id not in adapter.action_ids:
raise PluginActionNotFoundError(
f"Device plugin {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,
validate_recorded_media_timeline,
)
from k1link.viewer.rerun_bridge import RerunSceneSettings
from k1link.viewer.rrd_export import (
from k1link.viewer.recorded import (
APPLICATION_ID as RECORDED_APPLICATION_ID,
)
from k1link.viewer.rrd_export import (
RrdExportError,
from k1link.viewer.recorded import (
RecordedBlueprintError,
recorded_blueprint_rrd,
)
from k1link.viewer.rerun_bridge import RerunSceneSettings
DEFAULT_REPLAY_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
DEFAULT_REPLAY_ACTION_ID = "stream.start-replay"
SAFE_SOURCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")
SAFE_SHA256 = re.compile(r"^[a-f0-9]{64}$")
@ -234,7 +233,6 @@ def build_session_router(
recording_preparation_manager: SessionRecordingPreparationManager | None = None,
media_inspector: RecordedMediaInspector | None = None,
allow_synchronous_recording_fallback: bool = False,
replay_plugin_id: str = DEFAULT_REPLAY_PLUGIN_ID,
replay_action_id: str = DEFAULT_REPLAY_ACTION_ID,
) -> APIRouter:
"""Build generic host APIs without exposing filesystem locators to clients."""
@ -388,7 +386,7 @@ def build_session_router(
"schema_version": "missioncore.observation-session-replay/v1",
"launch": {
"kind": "plugin-action",
"plugin_id": replay_plugin_id,
"plugin_id": command.plugin_id,
"action_id": replay_action_id,
"session_id": command.session_id,
"speed": command.speed,
@ -671,7 +669,7 @@ def build_session_router(
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (SessionNotReplayableError, SessionIntegrityError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except RrdExportError as exc:
except RecordedBlueprintError as exc:
raise HTTPException(
status_code=500,
detail="Не удалось подготовить настройки визуализатора.",

View File

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

View File

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

View File

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

View File

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

View File

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

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