diff --git a/README.md b/README.md index e652aed..0f4f90a 100644 --- a/README.md +++ b/README.md @@ -16,18 +16,22 @@ commit complete fMP4 segments before their index rows. These are explicit crash-RPO bounds, not a zero-loss or disk-replication claim. The former Foxglove bridge remains only as a legacy regression module. -The repository is intentionally migrating in stages. The current `src/k1link` -package is the compatibility implementation of the first plugin path; vendor -transport and codecs will move behind `plugins/xgrids-k1` and the Mission Core -Plugin SDK without changing the verified wire protocol or raw evidence format. +The backend vendor implementation is physically isolated below +`src/k1link/device_plugins/xgrids_k1/`; its reviewed manifest and compatibility +profile remain below `plugins/xgrids-k1/`. Mission Core host code discovers +archive sources, recovery hooks and recording exporters only through the plugin +runtime contribution. The verified wire protocol and raw evidence format are +unchanged. Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session, -operation, stream, evidence and compatibility contracts. The meanings remain a -local experimental vocabulary rather than a mutation of NODE.DC Platform -Ontology. The current runtime is still transitional and in process: it uses an -explicitly injected K1 normalizer to produce transport-neutral local consumer -views; portable SDK stream envelopes, process isolation, durable operations, -multi-device routing and the remote Edge split remain later gates. +operation, runtime-action, stream, evidence and compatibility contracts. Every +backend action crosses immutable SDK `RuntimeActionInvocation` and +`RuntimeActionResult` validation in the actual dispatcher hot path. The meanings +remain a local experimental vocabulary rather than a mutation of NODE.DC +Platform Ontology. The device process is still transitional and in process: it +uses an explicitly injected K1 normalizer to produce transport-neutral local +consumer views; portable SDK stream envelopes, process isolation, durable +operations, multi-device routing and the remote Edge split remain later gates. The current runtime cannot read K1 firmware automatically. It keeps the exact profile inactive until the operator explicitly attests firmware `3.0.2` and @@ -94,7 +98,10 @@ uv run pytest The browser application is the universal Mission Core Control Station rather than a K1-specific Foxglove launcher. Its fixed shell contains six architectural sections — Center, Fleet, Observation, Missions, Data and System — while the K1 -BLE/Wi-Fi/live workflow remains isolated as the first real device adapter. +BLE/Wi-Fi/live workflow remains isolated as the first real device adapter. Its +React provisioning, acquisition/replay and diagnostic blocks live beside the +plugin manifest under `plugins/xgrids-k1/frontend`; the generic application +mounts them through one reviewed composition import. Install, type-check, build and serve the complete local application from the repository root: @@ -170,7 +177,7 @@ index use Mac receive/arrival timestamps, not proven K1 sensor timestamps or a photon-to-screen measurement. The old Foxglove implementation is retained only in -`src/k1link/viewer/foxglove_bridge.py` and its regression tests. The current +`src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py` and its regression tests. The current live/replay runtime does not start it or use TCP 8765. The [live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records the active Rerun path and its timing/security boundaries; the frontend contract is documented in diff --git a/apps/control-station/README.md b/apps/control-station/README.md index a68241f..72a5463 100644 --- a/apps/control-station/README.md +++ b/apps/control-station/README.md @@ -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` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. | diff --git a/apps/control-station/src/composition/devicePlugins.ts b/apps/control-station/src/composition/devicePlugins.ts index dac501a..8e77533 100644 --- a/apps/control-station/src/composition/devicePlugins.ts +++ b/apps/control-station/src/composition/devicePlugins.ts @@ -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. diff --git a/apps/control-station/src/core/device-plugins/frontendSdk.ts b/apps/control-station/src/core/device-plugins/frontendSdk.ts new file mode 100644 index 0000000..e073bd6 --- /dev/null +++ b/apps/control-station/src/core/device-plugins/frontendSdk.ts @@ -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"; diff --git a/apps/control-station/src/device-plugins/xgrids-k1/XgridsK1Connection.tsx b/apps/control-station/src/device-plugins/xgrids-k1/XgridsK1Connection.tsx deleted file mode 100644 index 9830a18..0000000 --- a/apps/control-station/src/device-plugins/xgrids-k1/XgridsK1Connection.tsx +++ /dev/null @@ -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 ( -
-
{label}
-
{children}
-
- ); -} - -function WizardStep({ - number, - title, - status, - tone = "neutral", - children, -}: { - number: string; - title: string; - status: string; - tone?: StatusTone; - children: ReactNode; -}) { - return ( -
- -
-
-

{title}

- {status} -
- {children} -
-
- ); -} - -function DeviceRow({ - device, - selected, - onSelect, -}: { - device: BleDevice; - selected: boolean; - onSelect: () => void; -}) { - return ( -
-
-
-
- {finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`} - -
-
- ); -} - -function LatencyTrace({ values }: { values: number[] }) { - const ceiling = Math.max(16, ...values); - - return ( -
- {values.length ? ( - values.map((value, index) => ( - - )) - ) : ( -

Измерений пока нет. График появится после получения реальных данных.

- )} -
- ); -} - -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("live"); - const [liveHost, setLiveHost] = useState(""); - const [replayPath, setReplayPath] = useState(""); - const [replaySpeed, setReplaySpeed] = useState("1"); - const [replayLoop, setReplayLoop] = useState(false); - const provisioningIntentRef = useRef(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 ( -
- {error ? ( - - ) : null} - -
-
- РАБОЧИЙ АДАПТЕР УСТРОЙСТВА -

Подключение {model.displayName}

-

- Этот путь уже работает физически, но остаётся изолированным адаптером. Парковая и - операторская модель от конкретного устройства не зависят. -

-
-
- {connectionPhaseLabel} - {localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."} -
-
- -
- - - - -
- -
- -
-
- ПОДКЛЮЧЕНИЕ · ШАГИ 01–03 -

Подключите устройство к сети

-
- {connectionPhaseLabel} -
- -
- -
- - Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия. - - { - setPowerConfirmed(checked); - if (!checked) { - setProfileConfirmed(false); - provisioningIntentRef.current = null; - } - }} - /> -
-
- - -

- Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата - основана только на имени и не подтверждает модель или прошивку; окончательный выбор - и аттестацию всегда делает оператор. -

- -
- {devices.length ? ( - devices.map((device) => ( - { - setSelectedDeviceId(device.device_id); - setProfileConfirmed(false); - provisioningIntentRef.current = null; - }} - /> - )) - ) : ( -
- Устройства пока не найдены. Проверьте питание и состояние индикатора, затем - повторите поиск. -
- )} -
-
- - -
-
- - Mission Core не определяет прошивку автоматически. Сверьте её на устройстве - или в официальном приложении и подтвердите только точное соответствие профилю. - - { - setProfileConfirmed(checked); - provisioningIntentRef.current = null; - }} - /> -
- { - setSsid(event.target.value); - provisioningIntentRef.current = null; - }} - autoComplete="off" - spellCheck={false} - placeholder="Сеть локального контура" - /> - { - setPassword(event.target.value); - provisioningIntentRef.current = null; - }} - autoComplete="off" - placeholder="Введите пароль" - /> -
-
- Устройство - {deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"} -
- -

- Пароль передаётся только локальному сервису на этом компьютере, не сохраняется в браузере и - удаляется из формы после успешного подключения. -

-
-
-
- -
- -
-
- - {effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"} - -

{effectiveSessionIntent === "live" ? "Подготовьте приём" : "Повторите запись"}

-
- - {sourceLabel} - -
- - { - if (!sessionLocked) setSessionIntent(intent); - }} - /> - - {effectiveSessionIntent === "live" ? ( -
- setLiveHost(event.target.value)} - spellCheck={false} - placeholder={ - state?.k1_ip || - preparedAcquisition?.target_host || - "Сначала подключите устройство к Wi‑Fi" - } - /> - -

- {!profileConfirmed - ? "Сначала вручную подтвердите точную прошивку 3.0.2 и direct-LAN топологию. Интерфейс не аттестует устройство автоматически." - : liveTargetReady - ? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами." - : "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."} -

-
- ) : ( -
- setReplayPath(event.target.value)} - spellCheck={false} - placeholder="sessions/.../capture.tsv" - /> - setReplaySpeed(event.target.value)} - /> -
- После последнего кадра начать запись заново. - -
- -
- )} - -
-

- {state?.source_mode === "replay" - ? "Остановка завершит фактически запущенный повтор записи." - : activeAcquisition || state?.source_mode === "live" - ? "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным." - : "Активного источника сейчас нет."} -

- - {activeAcquisition ? ( - - ) : null} -
-
- -
- -
-
- ТЕКУЩЕЕ СОСТОЯНИЕ -

Локальный контур

-
- {backendLabel(backendStatus)} -
-
- - - {eventStatusLabel(eventStatus)} - - - {sourceLabel} - - {state?.k1_ip || "Не получен"} - -
-
- - -
-
- ПОСЛЕДНИЕ ИЗМЕРЕНИЯ -

Время до публикации

-
- - {formatNumber(latency)} мс - -
- -
- Старые - Последние -
-
-
-
-
-
- ); -} diff --git a/apps/control-station/src/styles/device.css b/apps/control-station/src/styles/device.css index c3e3137..fac4648 100644 --- a/apps/control-station/src/styles/device.css +++ b/apps/control-station/src/styles/device.css @@ -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)); } diff --git a/apps/control-station/src/styles/responsive.css b/apps/control-station/src/styles/responsive.css index 77e2bbd..ab4bcad 100644 --- a/apps/control-station/src/styles/responsive.css +++ b/apps/control-station/src/styles/responsive.css @@ -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); } diff --git a/apps/control-station/test/devicePluginContracts.test.mjs b/apps/control-station/test/devicePluginContracts.test.mjs index 3c0184b..50df254 100644 --- a/apps/control-station/test/devicePluginContracts.test.mjs +++ b/apps/control-station/test/devicePluginContracts.test.mjs @@ -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", )); }); diff --git a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs new file mode 100644 index 0000000..b5c21e3 --- /dev/null +++ b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs @@ -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); + } +}); diff --git a/apps/control-station/test/observationSources.test.mjs b/apps/control-station/test/observationSources.test.mjs index 4df5859..d4a6db8 100644 --- a/apps/control-station/test/observationSources.test.mjs +++ b/apps/control-station/test/observationSources.test.mjs @@ -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", diff --git a/apps/control-station/test/runtimeStateOrdering.test.mjs b/apps/control-station/test/runtimeStateOrdering.test.mjs index 2306dcf..29d728a 100644 --- a/apps/control-station/test/runtimeStateOrdering.test.mjs +++ b/apps/control-station/test/runtimeStateOrdering.test.mjs @@ -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", )); }); diff --git a/apps/control-station/tsconfig.app.json b/apps/control-station/tsconfig.app.json index a5701b6..99cfa36 100644 --- a/apps/control-station/tsconfig.app.json +++ b/apps/control-station/tsconfig.app.json @@ -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"] } diff --git a/apps/control-station/vite.config.ts b/apps/control-station/vite.config.ts index f79c0db..642bb45 100644 --- a/apps/control-station/vite.config.ts +++ b/apps/control-station/vite.config.ts @@ -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, diff --git a/docs/01_IMPLEMENTATION_PLAN.md b/docs/01_IMPLEMENTATION_PLAN.md index db079e0..9ebd075 100644 --- a/docs/01_IMPLEMENTATION_PLAN.md +++ b/docs/01_IMPLEMENTATION_PLAN.md @@ -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 diff --git a/docs/06_K1_LIVE_VIEWER.md b/docs/06_K1_LIVE_VIEWER.md index 9001b94..5342aae 100644 --- a/docs/06_K1_LIVE_VIEWER.md +++ b/docs/06_K1_LIVE_VIEWER.md @@ -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. diff --git a/docs/07_MISSION_CORE_MONOREPO.md b/docs/07_MISSION_CORE_MONOREPO.md index a87cb1b..8142d82 100644 --- a/docs/07_MISSION_CORE_MONOREPO.md +++ b/docs/07_MISSION_CORE_MONOREPO.md @@ -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 diff --git a/docs/09_OBSERVATION_SESSIONS.md b/docs/09_OBSERVATION_SESSIONS.md index 469e847..d2aa5bc 100644 --- a/docs/09_OBSERVATION_SESSIONS.md +++ b/docs/09_OBSERVATION_SESSIONS.md @@ -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 diff --git a/docs/adr/0003-device-plugin-ui-and-runtime-boundary.md b/docs/adr/0003-device-plugin-ui-and-runtime-boundary.md index f877529..afbf0d1 100644 --- a/docs/adr/0003-device-plugin-ui-and-runtime-boundary.md +++ b/docs/adr/0003-device-plugin-ui-and-runtime-boundary.md @@ -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 diff --git a/docs/adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md b/docs/adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md index 977db78..6b45acd 100644 --- a/docs/adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md +++ b/docs/adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md @@ -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 diff --git a/docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md b/docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md index 62e35a8..5a1d66f 100644 --- a/docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md +++ b/docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md @@ -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` diff --git a/docs/adr/0007-k1-camera-preview-copy-remux-gateway.md b/docs/adr/0007-k1-camera-preview-copy-remux-gateway.md index 9808204..d3c8984 100644 --- a/docs/adr/0007-k1-camera-preview-copy-remux-gateway.md +++ b/docs/adr/0007-k1-camera-preview-copy-remux-gateway.md @@ -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` diff --git a/docs/adr/0008-durable-observation-sessions-and-workspace-layout.md b/docs/adr/0008-durable-observation-sessions-and-workspace-layout.md index 9168b17..ac09220 100644 --- a/docs/adr/0008-durable-observation-sessions-and-workspace-layout.md +++ b/docs/adr/0008-durable-observation-sessions-and-workspace-layout.md @@ -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; diff --git a/docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md b/docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md new file mode 100644 index 0000000..95229ea --- /dev/null +++ b/docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md @@ -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. diff --git a/docs/adr/0010-plugin-owned-frontend-device-workflows.md b/docs/adr/0010-plugin-owned-frontend-device-workflows.md new file mode 100644 index 0000000..20ec815 --- /dev/null +++ b/docs/adr/0010-plugin-owned-frontend-device-workflows.md @@ -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. diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 66e8174..ea194b5 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -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 diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/__init__.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/__init__.py index 61df6b4..b71a755 100644 --- a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/__init__.py +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/__init__.py @@ -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", diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/runtime.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/runtime.py new file mode 100644 index 0000000..fef9a23 --- /dev/null +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/runtime.py @@ -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) diff --git a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/schema.py b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/schema.py index 29e5fcc..4b91e5f 100644 --- a/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/schema.py +++ b/packages/plugin-sdk/python/missioncore_plugin_sdk/v0alpha2/schema.py @@ -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, diff --git a/plugins/xgrids-k1/README.md b/plugins/xgrids-k1/README.md index 7363ede..fde93bd 100644 --- a/plugins/xgrids-k1/README.md +++ b/plugins/xgrids-k1/README.md @@ -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 diff --git a/plugins/xgrids-k1/frontend/README.md b/plugins/xgrids-k1/frontend/README.md new file mode 100644 index 0000000..246f413 --- /dev/null +++ b/plugins/xgrids-k1/frontend/README.md @@ -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. diff --git a/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx b/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx new file mode 100644 index 0000000..14876e0 --- /dev/null +++ b/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx @@ -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 ( +
+ {error ? ( + + ) : null} + +
+
+ XGRIDS K1 · PLUGIN UI +

Подключение {model.displayName}

+

BLE/Wi‑Fi provisioning и acquisition pipeline принадлежат этому device plugin; Control Station предоставляет только host slot и переход в пространственную сцену.

+
+
+ {connectionPhaseLabel} + {localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."} +
+
+ + + +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/control-station/src/device-plugins/xgrids-k1/api.ts b/plugins/xgrids-k1/frontend/src/api.ts similarity index 99% rename from apps/control-station/src/device-plugins/xgrids-k1/api.ts rename to plugins/xgrids-k1/frontend/src/api.ts index 1b1fcf4..ea0bfc5 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/api.ts +++ b/plugins/xgrids-k1/frontend/src/api.ts @@ -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; diff --git a/plugins/xgrids-k1/frontend/src/compatibility.ts b/plugins/xgrids-k1/frontend/src/compatibility.ts new file mode 100644 index 0000000..54b1c8c --- /dev/null +++ b/plugins/xgrids-k1/frontend/src/compatibility.ts @@ -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, +}); diff --git a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx new file mode 100644 index 0000000..57d9548 --- /dev/null +++ b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx @@ -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("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 ( + +
+
+ {effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"} +

{effectiveSessionIntent === "live" ? "Подготовьте приём" : "Повторите запись"}

+
+ {sourceLabel} +
+ { if (!sessionLocked) setSessionIntent(intent); }} + /> + {effectiveSessionIntent === "live" ? ( +
+ setLiveHost(event.target.value)} + spellCheck={false} + placeholder={state?.k1_ip || preparedAcquisition?.target_host || "Сначала подключите устройство к Wi‑Fi"} + /> + +

+ {!profileConfirmed + ? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически." + : liveTargetReady + ? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами." + : "Сначала подключите устройство к Wi‑Fi или укажите локальный адрес."} +

+
+ ) : ( +
+ setReplayPath(event.target.value)} spellCheck={false} placeholder="sessions/.../capture.tsv" /> + setReplaySpeed(event.target.value)} /> +
+ После последнего кадра начать запись заново. + +
+ +
+ )} +
+

+ {state?.source_mode === "replay" + ? "Остановка завершит фактически запущенный повтор записи." + : activeAcquisition || state?.source_mode === "live" + ? "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным." + : "Активного источника сейчас нет."} +

+ + {activeAcquisition ? ( + + ) : null} +
+
+ ); +} diff --git a/plugins/xgrids-k1/frontend/src/components/K1Diagnostics.tsx b/plugins/xgrids-k1/frontend/src/components/K1Diagnostics.tsx new file mode 100644 index 0000000..aca35e3 --- /dev/null +++ b/plugins/xgrids-k1/frontend/src/components/K1Diagnostics.tsx @@ -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
{label}
{children}
; +} + +function LatencyTrace({ values }: { values: number[] }) { + const ceiling = Math.max(16, ...values); + return ( +
+ {values.length ? values.map((value, index) => ( + + )) :

Измерений пока нет. График появится после получения реальных данных.

} +
+ ); +} + +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 ( +
+ +
+
ТЕКУЩЕЕ СОСТОЯНИЕ

Локальный контур

+ {backendLabel(backendStatus)} +
+
+ {eventStatusLabel(eventStatus)} + {sourceLabel} + {state?.k1_ip || "Не получен"} +
+
+ +
+
ПОСЛЕДНИЕ ИЗМЕРЕНИЯ

Время до публикации

+ {formatNumber(latency)} мс +
+ +
СтарыеПоследние
+
+
+ ); +} diff --git a/plugins/xgrids-k1/frontend/src/components/K1Metrics.tsx b/plugins/xgrids-k1/frontend/src/components/K1Metrics.tsx new file mode 100644 index 0000000..8eb247d --- /dev/null +++ b/plugins/xgrids-k1/frontend/src/components/K1Metrics.tsx @@ -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 ( +
+ + + + +
+ ); +} diff --git a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx new file mode 100644 index 0000000..cd51bdd --- /dev/null +++ b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx @@ -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 ( +
+ +
+

{title}

{status}
+ {children} +
+
+ ); +} + +function DeviceRow({ device, selected, onSelect }: { + device: BleDevice; + selected: boolean; + onSelect: () => void; +}) { + return ( +
+
+
+
+ {finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`} + +
+
+ ); +} + +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(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 ( + +
+
ПОДКЛЮЧЕНИЕ · ШАГИ 01–03

Подключите устройство к сети

+ {phaseLabel} +
+
+ +
+ Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия. + { setPowerConfirmed(checked); if (!checked) resetProfile(); }} + /> +
+
+ +

Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; модель и прошивку подтверждает оператор.

+ +
+ {devices.length ? devices.map((device) => ( + { setSelectedDeviceId(device.device_id); resetProfile(); }} + /> + )) :
Устройства пока не найдены. Проверьте питание и повторите поиск.
} +
+
+ +
+
+ Mission Core не определяет прошивку автоматически. Подтвердите только точное соответствие профилю. + { onProfileConfirmedChange(checked); provisioningIntentRef.current = null; }} + /> +
+ { setSsid(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" spellCheck={false} placeholder="Сеть локального контура" /> + { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" /> +
+
Устройство{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}
+ +

Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.

+
+
+
+ ); +} diff --git a/plugins/xgrids-k1/frontend/src/components/MetricCard.tsx b/plugins/xgrids-k1/frontend/src/components/MetricCard.tsx new file mode 100644 index 0000000..b508ddb --- /dev/null +++ b/plugins/xgrids-k1/frontend/src/components/MetricCard.tsx @@ -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 ( + + {eyebrow} +
+ {value} + {unit ? {unit} : null} +
+

{detail}

+
+ ); +} diff --git a/apps/control-station/src/device-plugins/xgrids-k1/lifecycle.ts b/plugins/xgrids-k1/frontend/src/lifecycle.ts similarity index 99% rename from apps/control-station/src/device-plugins/xgrids-k1/lifecycle.ts rename to plugins/xgrids-k1/frontend/src/lifecycle.ts index a46c5ad..41c6d29 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/lifecycle.ts +++ b/plugins/xgrids-k1/frontend/src/lifecycle.ts @@ -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, diff --git a/apps/control-station/src/device-plugins/xgrids-k1/manifest.ts b/plugins/xgrids-k1/frontend/src/manifest.ts similarity index 88% rename from apps/control-station/src/device-plugins/xgrids-k1/manifest.ts rename to plugins/xgrids-k1/frontend/src/manifest.ts index 3b712da..59310ad 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/manifest.ts +++ b/plugins/xgrids-k1/frontend/src/manifest.ts @@ -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); diff --git a/apps/control-station/src/device-plugins/xgrids-k1/messages.ts b/plugins/xgrids-k1/frontend/src/messages.ts similarity index 94% rename from apps/control-station/src/device-plugins/xgrids-k1/messages.ts rename to plugins/xgrids-k1/frontend/src/messages.ts index 3c675ba..5a1d4cb 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/messages.ts +++ b/plugins/xgrids-k1/frontend/src/messages.ts @@ -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, "неуказанная ошибка"], diff --git a/apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts b/plugins/xgrids-k1/frontend/src/observationSources.ts similarity index 98% rename from apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts rename to plugins/xgrids-k1/frontend/src/observationSources.ts index 1dcbdff..d6774fd 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts +++ b/plugins/xgrids-k1/frontend/src/observationSources.ts @@ -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 { diff --git a/apps/control-station/src/device-plugins/xgrids-k1/plugin.ts b/plugins/xgrids-k1/frontend/src/plugin.ts similarity index 81% rename from apps/control-station/src/device-plugins/xgrids-k1/plugin.ts rename to plugins/xgrids-k1/frontend/src/plugin.ts index aff1aaa..af08926 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/plugin.ts +++ b/plugins/xgrids-k1/frontend/src/plugin.ts @@ -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, diff --git a/apps/control-station/src/device-plugins/xgrids-k1/presentation.ts b/plugins/xgrids-k1/frontend/src/presentation.ts similarity index 97% rename from apps/control-station/src/device-plugins/xgrids-k1/presentation.ts rename to plugins/xgrids-k1/frontend/src/presentation.ts index fe7f6b6..00a5ee9 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/presentation.ts +++ b/plugins/xgrids-k1/frontend/src/presentation.ts @@ -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 = { diff --git a/apps/control-station/src/device-plugins/xgrids-k1/runtimeContext.tsx b/plugins/xgrids-k1/frontend/src/runtimeContext.tsx similarity index 96% rename from apps/control-station/src/device-plugins/xgrids-k1/runtimeContext.tsx rename to plugins/xgrids-k1/frontend/src/runtimeContext.tsx index 1473159..0fa21b9 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/runtimeContext.tsx +++ b/plugins/xgrids-k1/frontend/src/runtimeContext.tsx @@ -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, diff --git a/apps/control-station/src/device-plugins/xgrids-k1/stateOrdering.ts b/plugins/xgrids-k1/frontend/src/stateOrdering.ts similarity index 96% rename from apps/control-station/src/device-plugins/xgrids-k1/stateOrdering.ts rename to plugins/xgrids-k1/frontend/src/stateOrdering.ts index ed5e38f..11c88d3 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/stateOrdering.ts +++ b/plugins/xgrids-k1/frontend/src/stateOrdering.ts @@ -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 { diff --git a/plugins/xgrids-k1/frontend/src/styles.css b/plugins/xgrids-k1/frontend/src/styles.css new file mode 100644 index 0000000..b463dce --- /dev/null +++ b/plugins/xgrids-k1/frontend/src/styles.css @@ -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; + } +} diff --git a/apps/control-station/src/device-plugins/xgrids-k1/useXgridsK1Runtime.ts b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts similarity index 99% rename from apps/control-station/src/device-plugins/xgrids-k1/useXgridsK1Runtime.ts rename to plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts index 08251e1..8296619 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/useXgridsK1Runtime.ts +++ b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts @@ -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, diff --git a/plugins/xgrids-k1/plugin.manifest.json b/plugins/xgrids-k1/plugin.manifest.json index 1596c66..3ccca32 100644 --- a/plugins/xgrids-k1/plugin.manifest.json +++ b/plugins/xgrids-k1/plugin.manifest.json @@ -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": [ diff --git a/pyproject.toml b/pyproject.toml index 6e19192..9525f89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/k1link/device_plugins/__init__.py b/src/k1link/device_plugins/__init__.py new file mode 100644 index 0000000..c2626b8 --- /dev/null +++ b/src/k1link/device_plugins/__init__.py @@ -0,0 +1 @@ +"""Concrete device integrations loaded only through reviewed plugin manifests.""" diff --git a/src/k1link/device_plugins/xgrids_k1/__init__.py b/src/k1link/device_plugins/xgrids_k1/__init__.py new file mode 100644 index 0000000..6c79f1d --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/__init__.py @@ -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"] diff --git a/src/k1link/analyze/__init__.py b/src/k1link/device_plugins/xgrids_k1/analyze/__init__.py similarity index 83% rename from src/k1link/analyze/__init__.py rename to src/k1link/device_plugins/xgrids_k1/analyze/__init__.py index 32cce79..91b3c38 100644 --- a/src/k1link/analyze/__init__.py +++ b/src/k1link/device_plugins/xgrids_k1/analyze/__init__.py @@ -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, diff --git a/src/k1link/analyze/stream_summary.py b/src/k1link/device_plugins/xgrids_k1/analyze/stream_summary.py similarity index 98% rename from src/k1link/analyze/stream_summary.py rename to src/k1link/device_plugins/xgrids_k1/analyze/stream_summary.py index 9adf504..e958c0d 100644 --- a/src/k1link/analyze/stream_summary.py +++ b/src/k1link/device_plugins/xgrids_k1/analyze/stream_summary.py @@ -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, diff --git a/src/k1link/sessions/legacy.py b/src/k1link/device_plugins/xgrids_k1/archive.py similarity index 97% rename from src/k1link/sessions/legacy.py rename to src/k1link/device_plugins/xgrids_k1/archive.py index 416166e..8a8b7a1 100644 --- a/src/k1link/sessions/legacy.py +++ b/src/k1link/device_plugins/xgrids_k1/archive.py @@ -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 diff --git a/src/k1link/ble/__init__.py b/src/k1link/device_plugins/xgrids_k1/ble/__init__.py similarity index 100% rename from src/k1link/ble/__init__.py rename to src/k1link/device_plugins/xgrids_k1/ble/__init__.py diff --git a/src/k1link/ble/gatt.py b/src/k1link/device_plugins/xgrids_k1/ble/gatt.py similarity index 100% rename from src/k1link/ble/gatt.py rename to src/k1link/device_plugins/xgrids_k1/ble/gatt.py diff --git a/src/k1link/ble/reader.py b/src/k1link/device_plugins/xgrids_k1/ble/reader.py similarity index 100% rename from src/k1link/ble/reader.py rename to src/k1link/device_plugins/xgrids_k1/ble/reader.py diff --git a/src/k1link/ble/scanner.py b/src/k1link/device_plugins/xgrids_k1/ble/scanner.py similarity index 100% rename from src/k1link/ble/scanner.py rename to src/k1link/device_plugins/xgrids_k1/ble/scanner.py diff --git a/src/k1link/ble/wifi_provisioning.py b/src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py similarity index 100% rename from src/k1link/ble/wifi_provisioning.py rename to src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py diff --git a/src/k1link/web/xgrids_k1_camera.py b/src/k1link/device_plugins/xgrids_k1/camera.py similarity index 99% rename from src/k1link/web/xgrids_k1_camera.py rename to src/k1link/device_plugins/xgrids_k1/camera.py index 97f3878..1467747 100644 --- a/src/k1link/web/xgrids_k1_camera.py +++ b/src/k1link/device_plugins/xgrids_k1/camera.py @@ -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, diff --git a/src/k1link/cli.py b/src/k1link/device_plugins/xgrids_k1/cli.py similarity index 97% rename from src/k1link/cli.py rename to src/k1link/device_plugins/xgrids_k1/cli.py index 2550229..297e68a 100644 --- a/src/k1link/cli.py +++ b/src/k1link/device_plugins/xgrids_k1/cli.py @@ -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", diff --git a/src/k1link/web/xgrids_k1_facade.py b/src/k1link/device_plugins/xgrids_k1/facade.py similarity index 98% rename from src/k1link/web/xgrids_k1_facade.py rename to src/k1link/device_plugins/xgrids_k1/facade.py index 75f3bb4..1753b66 100644 --- a/src/k1link/web/xgrids_k1_facade.py +++ b/src/k1link/device_plugins/xgrids_k1/facade.py @@ -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, ) diff --git a/src/k1link/web/xgrids_k1_legacy_api.py b/src/k1link/device_plugins/xgrids_k1/legacy_api.py similarity index 63% rename from src/k1link/web/xgrids_k1_legacy_api.py rename to src/k1link/device_plugins/xgrids_k1/legacy_api.py index b915ab3..518a122 100644 --- a/src/k1link/web/xgrids_k1_legacy_api.py +++ b/src/k1link/device_plugins/xgrids_k1/legacy_api.py @@ -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 diff --git a/src/k1link/mqtt/__init__.py b/src/k1link/device_plugins/xgrids_k1/mqtt/__init__.py similarity index 91% rename from src/k1link/mqtt/__init__.py rename to src/k1link/device_plugins/xgrids_k1/mqtt/__init__.py index 89afba8..1f6d83f 100644 --- a/src/k1link/mqtt/__init__.py +++ b/src/k1link/device_plugins/xgrids_k1/mqtt/__init__.py @@ -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, diff --git a/src/k1link/mqtt/capture.py b/src/k1link/device_plugins/xgrids_k1/mqtt/capture.py similarity index 100% rename from src/k1link/mqtt/capture.py rename to src/k1link/device_plugins/xgrids_k1/mqtt/capture.py diff --git a/src/k1link/net/__init__.py b/src/k1link/device_plugins/xgrids_k1/net/__init__.py similarity index 100% rename from src/k1link/net/__init__.py rename to src/k1link/device_plugins/xgrids_k1/net/__init__.py diff --git a/src/k1link/net/snapshot.py b/src/k1link/device_plugins/xgrids_k1/net/snapshot.py similarity index 100% rename from src/k1link/net/snapshot.py rename to src/k1link/device_plugins/xgrids_k1/net/snapshot.py diff --git a/src/k1link/device_plugins/xgrids_k1/observation.py b/src/k1link/device_plugins/xgrids_k1/observation.py new file mode 100644 index 0000000..d152d18 --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/observation.py @@ -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 diff --git a/src/k1link/protocol/__init__.py b/src/k1link/device_plugins/xgrids_k1/protocol/__init__.py similarity index 92% rename from src/k1link/protocol/__init__.py rename to src/k1link/device_plugins/xgrids_k1/protocol/__init__.py index ea3ba82..65e5b71 100644 --- a/src/k1link/protocol/__init__.py +++ b/src/k1link/device_plugins/xgrids_k1/protocol/__init__.py @@ -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, diff --git a/src/k1link/protocol/normalizer.py b/src/k1link/device_plugins/xgrids_k1/protocol/normalizer.py similarity index 98% rename from src/k1link/protocol/normalizer.py rename to src/k1link/device_plugins/xgrids_k1/protocol/normalizer.py index 0c99005..30bb44a 100644 --- a/src/k1link/protocol/normalizer.py +++ b/src/k1link/device_plugins/xgrids_k1/protocol/normalizer.py @@ -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, diff --git a/src/k1link/protocol/protobuf_wire.py b/src/k1link/device_plugins/xgrids_k1/protocol/protobuf_wire.py similarity index 100% rename from src/k1link/protocol/protobuf_wire.py rename to src/k1link/device_plugins/xgrids_k1/protocol/protobuf_wire.py diff --git a/src/k1link/protocol/streams.py b/src/k1link/device_plugins/xgrids_k1/protocol/streams.py similarity index 99% rename from src/k1link/protocol/streams.py rename to src/k1link/device_plugins/xgrids_k1/protocol/streams.py index ee1b235..55662d7 100644 --- a/src/k1link/protocol/streams.py +++ b/src/k1link/device_plugins/xgrids_k1/protocol/streams.py @@ -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, diff --git a/src/k1link/viewer/rrd_export.py b/src/k1link/device_plugins/xgrids_k1/rrd_export.py similarity index 98% rename from src/k1link/viewer/rrd_export.py rename to src/k1link/device_plugins/xgrids_k1/rrd_export.py index 1380fa7..1c38892 100644 --- a/src/k1link/viewer/rrd_export.py +++ b/src/k1link/device_plugins/xgrids_k1/rrd_export.py @@ -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, diff --git a/src/k1link/usb/__init__.py b/src/k1link/device_plugins/xgrids_k1/usb/__init__.py similarity index 100% rename from src/k1link/usb/__init__.py rename to src/k1link/device_plugins/xgrids_k1/usb/__init__.py diff --git a/src/k1link/usb/snapshot.py b/src/k1link/device_plugins/xgrids_k1/usb/snapshot.py similarity index 100% rename from src/k1link/usb/snapshot.py rename to src/k1link/device_plugins/xgrids_k1/usb/snapshot.py diff --git a/src/k1link/device_plugins/xgrids_k1/viewer/__init__.py b/src/k1link/device_plugins/xgrids_k1/viewer/__init__.py new file mode 100644 index 0000000..d942d1e --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/viewer/__init__.py @@ -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", +] diff --git a/src/k1link/viewer/foxglove_bridge.py b/src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py similarity index 98% rename from src/k1link/viewer/foxglove_bridge.py rename to src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py index 20a02d8..e7a3138 100644 --- a/src/k1link/viewer/foxglove_bridge.py +++ b/src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py @@ -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(" 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( diff --git a/src/k1link/sessions/models.py b/src/k1link/sessions/models.py index 18e4379..3b28572 100644 --- a/src/k1link/sessions/models.py +++ b/src/k1link/sessions/models.py @@ -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) diff --git a/src/k1link/sessions/plugin_contract.py b/src/k1link/sessions/plugin_contract.py new file mode 100644 index 0000000..a0f961d --- /dev/null +++ b/src/k1link/sessions/plugin_contract.py @@ -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 diff --git a/src/k1link/sessions/preparation.py b/src/k1link/sessions/preparation.py index 97c5fde..6bb4298 100644 --- a/src/k1link/sessions/preparation.py +++ b/src/k1link/sessions/preparation.py @@ -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: diff --git a/src/k1link/sessions/recording.py b/src/k1link/sessions/recording.py index a351d72..47fff44 100644 --- a/src/k1link/sessions/recording.py +++ b/src/k1link/sessions/recording.py @@ -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 diff --git a/src/k1link/sessions/store.py b/src/k1link/sessions/store.py index baedcd0..721fdf4 100644 --- a/src/k1link/sessions/store.py +++ b/src/k1link/sessions/store.py @@ -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: diff --git a/src/k1link/viewer/__init__.py b/src/k1link/viewer/__init__.py index 6a96a8d..7352098 100644 --- a/src/k1link/viewer/__init__.py +++ b/src/k1link/viewer/__init__.py @@ -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", ] diff --git a/src/k1link/viewer/recorded.py b/src/k1link/viewer/recorded.py new file mode 100644 index 0000000..9b6b7c5 --- /dev/null +++ b/src/k1link/viewer/recorded.py @@ -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 diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 4c9f566..ec01232 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -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. diff --git a/src/k1link/web/device_plugin_composition.py b/src/k1link/web/device_plugin_composition.py index 7b7233e..8b18adc 100644 --- a/src/k1link/web/device_plugin_composition.py +++ b/src/k1link/web/device_plugin_composition.py @@ -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 diff --git a/src/k1link/web/plugin_runtime.py b/src/k1link/web/plugin_runtime.py index 8ace6de..b8113ad 100644 --- a/src/k1link/web/plugin_runtime.py +++ b/src/k1link/web/plugin_runtime.py @@ -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) diff --git a/src/k1link/web/session_api.py b/src/k1link/web/session_api.py index aa52b6f..f755820 100644 --- a/src/k1link/web/session_api.py +++ b/src/k1link/web/session_api.py @@ -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="Не удалось подготовить настройки визуализатора.", diff --git a/tests/test_active_session_lease.py b/tests/test_active_session_lease.py index f2bb55f..bbaa8da 100644 --- a/tests/test_active_session_lease.py +++ b/tests/test_active_session_lease.py @@ -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: diff --git a/tests/test_ble_scanner.py b/tests/test_ble_scanner.py index 2a2b3cf..2c746db 100644 --- a/tests/test_ble_scanner.py +++ b/tests/test_ble_scanner.py @@ -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: diff --git a/tests/test_camera_archive.py b/tests/test_camera_archive.py index 5e1b444..98b126a 100644 --- a/tests/test_camera_archive.py +++ b/tests/test_camera_archive.py @@ -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, diff --git a/tests/test_canonical_pipeline.py b/tests/test_canonical_pipeline.py index 590e90c..d787f6f 100644 --- a/tests/test_canonical_pipeline.py +++ b/tests/test_canonical_pipeline.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index d05f699..2e3a885 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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, diff --git a/tests/test_device_plugin_observation_contract.py b/tests/test_device_plugin_observation_contract.py new file mode 100644 index 0000000..2d95d81 --- /dev/null +++ b/tests/test_device_plugin_observation_contract.py @@ -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, + } diff --git a/tests/test_foxglove_bridge.py b/tests/test_foxglove_bridge.py index acfa51e..e9c37b2 100644 --- a/tests/test_foxglove_bridge.py +++ b/tests/test_foxglove_bridge.py @@ -2,14 +2,14 @@ from __future__ import annotations import struct -from k1link.protocol.streams import ( +from k1link.device_plugins.xgrids_k1.protocol.streams import ( LegacyPoint, LegacyPointCloudFrame, LioPoint, LioPointCloudFrame, MqttHeader, ) -from k1link.viewer.foxglove_bridge import ( +from k1link.device_plugins.xgrids_k1.viewer.foxglove_bridge import ( POINT_STRIDE, BridgeMetrics, pack_legacy_point_cloud, diff --git a/tests/test_frontend_plugin_boundaries.py b/tests/test_frontend_plugin_boundaries.py index 7940da1..d47f915 100644 --- a/tests/test_frontend_plugin_boundaries.py +++ b/tests/test_frontend_plugin_boundaries.py @@ -7,7 +7,7 @@ def test_mission_core_frontend_has_single_plugin_composition_root() -> None: repository_root = Path(__file__).resolve().parents[1] source_root = repository_root / "apps" / "control-station" / "src" composition = source_root / "composition" / "devicePlugins.ts" - forbidden_import = "device-plugins/xgrids-k1" + forbidden_import = "@xgrids-k1/frontend" imports = [ path.relative_to(source_root).as_posix() @@ -50,9 +50,7 @@ def test_mission_core_shell_does_not_know_xgrids_wire_fields() -> None: def test_xgrids_client_uses_manifest_identity_and_plugin_scoped_events() -> None: repository_root = Path(__file__).resolve().parents[1] - plugin_root = ( - repository_root / "apps" / "control-station" / "src" / "device-plugins" / "xgrids-k1" - ) + plugin_root = repository_root / "plugins" / "xgrids-k1" / "frontend" / "src" api_source = (plugin_root / "api.ts").read_text("utf-8") manifest_source = (plugin_root / "manifest.ts").read_text("utf-8") @@ -74,12 +72,11 @@ def test_backend_host_has_no_concrete_xgrids_imports() -> None: def test_model_switch_is_guarded_by_plugin_deactivation() -> None: repository_root = Path(__file__).resolve().parents[1] frontend_root = repository_root / "apps" / "control-station" / "src" + plugin_root = repository_root / "plugins" / "xgrids-k1" / "frontend" / "src" host_source = (frontend_root / "core" / "device-plugins" / "DevicePluginHost.tsx").read_text( "utf-8" ) - xgrids_runtime = ( - frontend_root / "device-plugins" / "xgrids-k1" / "runtimeContext.tsx" - ).read_text("utf-8") + xgrids_runtime = (plugin_root / "runtimeContext.tsx").read_text("utf-8") deactivation_guard = "if (!(await deactivate()))" assert "if (current && !deactivate)" in host_source @@ -94,9 +91,7 @@ def test_model_switch_is_guarded_by_plugin_deactivation() -> None: def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity() -> None: repository_root = Path(__file__).resolve().parents[1] - plugin_root = ( - repository_root / "apps" / "control-station" / "src" / "device-plugins" / "xgrids-k1" - ) + plugin_root = repository_root / "plugins" / "xgrids-k1" / "frontend" / "src" manifest_source = (plugin_root / "manifest.ts").read_text("utf-8") api_source = (plugin_root / "api.ts").read_text("utf-8") hook_source = (plugin_root / "useXgridsK1Runtime.ts").read_text("utf-8") @@ -121,12 +116,12 @@ def test_xgrids_live_copy_does_not_claim_software_controls_the_physical_scanner( repository_root = Path(__file__).resolve().parents[1] connection_source = ( repository_root - / "apps" - / "control-station" - / "src" - / "device-plugins" + / "plugins" / "xgrids-k1" - / "XgridsK1Connection.tsx" + / "frontend" + / "src" + / "components" + / "K1AcquisitionPipeline.tsx" ).read_text("utf-8") assert "Подготовить приём данных" in connection_source diff --git a/tests/test_mqtt_capture.py b/tests/test_mqtt_capture.py index b4c36d5..deed7f2 100644 --- a/tests/test_mqtt_capture.py +++ b/tests/test_mqtt_capture.py @@ -10,8 +10,8 @@ import pytest from paho.mqtt.packettypes import PacketTypes from paho.mqtt.reasoncodes import ReasonCode -import k1link.mqtt.capture as capture_module -from k1link.mqtt.capture import ( +import k1link.device_plugins.xgrids_k1.mqtt.capture as capture_module +from k1link.device_plugins.xgrids_k1.mqtt.capture import ( FRAME_HEADER, RAW_MAGIC, REPORT_TOPICS, diff --git a/tests/test_network_snapshot.py b/tests/test_network_snapshot.py index 1952d7e..2523d7a 100644 --- a/tests/test_network_snapshot.py +++ b/tests/test_network_snapshot.py @@ -1,4 +1,4 @@ -from k1link.net.snapshot import command_record, snapshot +from k1link.device_plugins.xgrids_k1.net.snapshot import command_record, snapshot def test_command_record_captures_success() -> None: diff --git a/tests/test_plugin_runtime.py b/tests/test_plugin_runtime.py index 2a7b789..45a27ee 100644 --- a/tests/test_plugin_runtime.py +++ b/tests/test_plugin_runtime.py @@ -3,27 +3,15 @@ from __future__ import annotations import asyncio import json import threading -from collections.abc import Mapping from pathlib import Path from typing import Any import pytest +from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation from pydantic import ValidationError import k1link.web.device_plugin_composition as plugin_composition -from k1link.web.device_plugin_composition import ( - DevicePluginCompositionError, - InstalledDevicePluginEnvironment, - load_installed_device_plugins, -) -from k1link.web.plugin_catalog import DevicePluginCatalog -from k1link.web.plugin_runtime import ( - DevicePluginDispatcher, - DevicePluginRuntimeContribution, - PluginActionNotFoundError, - PluginNotFoundError, -) -from k1link.web.xgrids_k1_facade import ( +from k1link.device_plugins.xgrids_k1.facade import ( ACTION_DISCOVERY_SCAN, ACTION_NETWORK_PROVISION, ACTION_STREAM_START_LIVE, @@ -36,6 +24,18 @@ from k1link.web.xgrids_k1_facade import ( ViewerSettingsRequest, XgridsK1PluginFacade, ) +from k1link.web.device_plugin_composition import ( + DevicePluginCompositionError, + InstalledDevicePluginEnvironment, + load_installed_device_plugins, +) +from k1link.web.plugin_catalog import DevicePluginCatalog +from k1link.web.plugin_runtime import ( + DevicePluginDispatcher, + DevicePluginRuntimeContribution, + PluginActionNotFoundError, + PluginNotFoundError, +) class FakeXgridsService: @@ -139,11 +139,11 @@ def test_composition_loads_and_dispatches_two_synthetic_plugins( async def invoke( self, - action_id: str, - payload: Mapping[str, Any], + invocation: RuntimeActionInvocation, ) -> dict[str, Any]: - assert action_id == "state.read" - assert payload == {} + assert invocation.plugin_id == self.plugin_id + assert invocation.action_id == "state.read" + assert invocation.parameters == {} return {"plugin_id": self.plugin_id} closed: list[str] = [] diff --git a/tests/test_plugin_sdk_v0alpha2_contracts.py b/tests/test_plugin_sdk_v0alpha2_contracts.py index d361e6a..01bae1a 100644 --- a/tests/test_plugin_sdk_v0alpha2_contracts.py +++ b/tests/test_plugin_sdk_v0alpha2_contracts.py @@ -346,6 +346,8 @@ def test_plugin_sdk_v0alpha2_exports_closed_json_schemas_and_vocabulary() -> Non "DeviceInstanceRef", "DeviceSessionSnapshot", "OperationEvent", + "RuntimeActionInvocation", + "RuntimeActionResult", "CanonicalStreamEnvelope", "EvidenceRecord", "CompatibilityAssessment", diff --git a/tests/test_rerun_bridge.py b/tests/test_rerun_bridge.py index e607ae6..88a8d75 100644 --- a/tests/test_rerun_bridge.py +++ b/tests/test_rerun_bridge.py @@ -10,16 +10,16 @@ import numpy as np import pytest from k1link.data_plane import DecodedDataPlaneView, 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, RerunSceneSettings, _live_time_panel, _point_colors, ) -from k1link.viewer.runtime import VisualizationRuntime class FakeRecording: diff --git a/tests/test_rrd_export.py b/tests/test_rrd_export.py index 5c51a68..d40aa7c 100644 --- a/tests/test_rrd_export.py +++ b/tests/test_rrd_export.py @@ -10,10 +10,9 @@ from pathlib import Path import pytest -import k1link.viewer.rrd_export as export_module -from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC -from k1link.viewer.rerun_bridge import RerunSceneSettings -from k1link.viewer.rrd_export import ( +import k1link.device_plugins.xgrids_k1.rrd_export as export_module +from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC +from k1link.device_plugins.xgrids_k1.rrd_export import ( RECORDED_POINTS_VISUALIZER_ID, RECORDED_ROOT_CONTAINER_ID, RECORDED_SPATIAL_VIEW_ID, @@ -24,6 +23,7 @@ from k1link.viewer.rrd_export import ( export_k1mqtt_to_rrd, recorded_blueprint_rrd, ) +from k1link.viewer.rerun_bridge import RerunSceneSettings def _point_payload(x: float) -> bytes: diff --git a/tests/test_session_api.py b/tests/test_session_api.py index 855b0f2..120225d 100644 --- a/tests/test_session_api.py +++ b/tests/test_session_api.py @@ -16,7 +16,8 @@ from fastapi import APIRouter, HTTPException, Response from fastapi.routing import APIRoute import k1link.sessions.media as recorded_media_module -from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC +from k1link.device_plugins.xgrids_k1 import xgrids_k1_archive_source +from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC from k1link.sessions import ( MaterializedRecording, RecordedMediaInspector, @@ -27,8 +28,8 @@ from k1link.sessions import ( SessionStore, ) from k1link.sessions.media import _manifest_generation_sha256 +from k1link.viewer.recorded import recorded_blueprint_rrd from k1link.viewer.rerun_bridge import RerunSceneSettings -from k1link.viewer.rrd_export import recorded_blueprint_rrd from k1link.web.camera_archive import CameraArchiveWriter from k1link.web.session_api import ( LayoutPutRequest, @@ -195,7 +196,7 @@ def test_session_router_lists_details_and_dispatches_opaque_replay(tmp_path: Pat sessions = repository / "sessions" session = make_legacy_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) observed: list[ReplayCommand] = [] async def launch(command: ReplayCommand) -> dict[str, Any]: @@ -238,7 +239,7 @@ def test_session_router_lists_details_and_dispatches_opaque_replay(tmp_path: Pat "dispatched": True, } assert replay["runtime_state"]["source_mode"] == "replay" - assert observed[0].source_path.name == "mqtt.raw.k1mqtt" + assert observed[0].primary_artifact.path.name == "mqtt.raw.k1mqtt" for value in (listing, detail, replay): assert_no_local_paths(value, repository) @@ -250,7 +251,7 @@ def test_session_router_returns_seekable_recording_and_serves_byte_ranges( sessions = repository / "sessions" session = make_legacy_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) payload = b"RRD-seekable-recording" export_calls = 0 @@ -354,7 +355,7 @@ def test_cold_replay_returns_quick_202_then_status_returns_ready_launch( sessions = repository / "sessions" session = make_legacy_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) started = threading.Event() release = threading.Event() calls = 0 @@ -579,7 +580,7 @@ def test_recording_response_releases_pin_once_when_asgi_send_fails( sessions = repository / "sessions" session = make_legacy_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) recording_path = tmp_path / "pinned.rrd" payload = b"RRD-pinned-response" recording_path.write_bytes(payload) @@ -663,7 +664,7 @@ def test_preparation_status_and_cancel_are_bound_to_exact_job_etag(tmp_path: Pat sessions = repository / "sessions" session = make_legacy_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) started = threading.Event() release = threading.Event() @@ -740,7 +741,7 @@ def test_stale_status_poll_does_not_create_a_replacement_job(tmp_path: Path) -> sessions = repository / "sessions" session = make_legacy_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) calls = 0 def exporter(_source: Path, _destination: Path) -> dict[str, object]: @@ -782,7 +783,7 @@ def test_ready_post_uses_each_callers_playback_without_mutating_shared_job( sessions = repository / "sessions" session = make_legacy_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) calls = 0 def exporter(source: Path, destination: Path) -> dict[str, object]: @@ -849,7 +850,7 @@ def test_production_router_never_materializes_recording_inline(tmp_path: Path) - sessions = repository / "sessions" session = make_legacy_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) calls = 0 def exporter(_source: Path, _destination: Path) -> dict[str, object]: @@ -887,7 +888,7 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped( sessions = repository / "sessions" session = make_legacy_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) observed_settings: list[RerunSceneSettings] = [] def capture_settings(settings: RerunSceneSettings, **kwargs: Any) -> bytes: @@ -977,7 +978,7 @@ def test_session_router_exposes_opaque_recorded_media_manifest_and_ranges( writer.close() store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) payload = b"recording" def export_recording(source: Path, destination: Path) -> dict[str, object]: @@ -1189,7 +1190,7 @@ def test_ready_launch_uses_background_prepared_camera_manifest_without_rescan( ) writer.close() store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) def exporter(source: Path, destination: Path) -> dict[str, object]: payload = b"prepared-recording" @@ -1300,7 +1301,7 @@ def test_recorded_media_epoch_ends_are_monotonic_generation_bound_and_not_rrd_pa ) second.close() store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) command = store.prepare_replay(session.name) artifact = store.list_recorded_media(session.name)[0] manifest = RecordedMediaInspector().inspect(artifact, command) @@ -1353,7 +1354,7 @@ def test_recorded_media_rejects_non_monotonic_segments_and_overlapping_epochs( ) writer.close() store = SessionStore(non_monotonic_root, data_dir=tmp_path / "non-monotonic-data") - store.import_legacy_viewer_live(non_monotonic_root / "sessions") + store.reconcile_archive(xgrids_k1_archive_source(non_monotonic_root / "sessions")) with pytest.raises(SessionIntegrityError, match="strictly monotonic"): RecordedMediaInspector().inspect( store.list_recorded_media(non_monotonic_session.name)[0], @@ -1384,7 +1385,7 @@ def test_recorded_media_rejects_non_monotonic_segments_and_overlapping_epochs( ) second.close() store = SessionStore(overlap_root, data_dir=tmp_path / "overlap-data") - store.import_legacy_viewer_live(overlap_root / "sessions") + store.reconcile_archive(xgrids_k1_archive_source(overlap_root / "sessions")) with pytest.raises(SessionIntegrityError, match="overlap"): RecordedMediaInspector().inspect( store.list_recorded_media(overlap_session.name)[0], @@ -1403,7 +1404,7 @@ def test_background_preparation_fails_closed_on_unparseable_recorded_media( writer.append("media", b"not-an-iso-bmff-fragment") writer.close() store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) def exporter(source: Path, destination: Path) -> dict[str, object]: return { @@ -1463,7 +1464,7 @@ def test_background_preparation_rejects_camera_outside_rrd_timeline( ) writer.close() store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) def exporter(source: Path, destination: Path) -> dict[str, object]: payload = b"short-spatial-recording" @@ -1526,7 +1527,7 @@ def test_recorded_media_preparation_sidecar_reuses_and_rebuilds_generation( ) writer.close() store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) command = store.prepare_replay(session.name) artifact = store.list_recorded_media(session.name)[0] cache_root = tmp_path / "prepared-media" diff --git a/tests/test_session_catalog_lifecycle.py b/tests/test_session_catalog_lifecycle.py index f1c3709..b5a9432 100644 --- a/tests/test_session_catalog_lifecycle.py +++ b/tests/test_session_catalog_lifecycle.py @@ -13,7 +13,8 @@ import pytest from fastapi import APIRouter, HTTPException from fastapi.routing import APIRoute -from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC +from k1link.device_plugins.xgrids_k1 import xgrids_k1_archive_source +from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC from k1link.sessions import ( SessionRecordingMaterializer, SessionRecordingPreparationManager, @@ -99,7 +100,7 @@ def test_list_refresh_discovers_new_completed_session_without_server_restart( def refresh() -> tuple[str, ...]: nonlocal refresh_calls refresh_calls += 1 - return store.import_legacy_viewer_live(sessions) + return store.reconcile_archive(xgrids_k1_archive_source(sessions)) router = build_session_router(store, catalog_refresher=refresh) list_route = _endpoint(router, "/api/v1/observation-sessions", "GET") @@ -122,7 +123,7 @@ def test_detail_and_replay_refresh_catalog_before_lookup(tmp_path: Path) -> None def refresh() -> tuple[str, ...]: nonlocal refresh_calls refresh_calls += 1 - return store.import_legacy_viewer_live(sessions) + return store.reconcile_archive(xgrids_k1_archive_source(sessions)) router = build_session_router(store, catalog_refresher=refresh) detail_route = _endpoint( @@ -167,7 +168,7 @@ def test_startup_warmup_enqueues_every_finalized_replayable_session( sessions = repository / "sessions" session = _make_completed_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) def exporter(source: Path, destination: Path) -> dict[str, object]: payload = b"startup-prepared-recording" @@ -211,7 +212,7 @@ def test_reconciliation_skips_one_stale_session_and_prepares_later_valid_session good = _make_completed_session(sessions, "20260716T205632Z_viewer_live") stale = _make_completed_session(sessions, "20260716T205633Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) (stale / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt").unlink() def exporter(source: Path, destination: Path) -> dict[str, object]: @@ -254,7 +255,7 @@ def test_reconciler_requeues_only_a_restart_interrupted_job( sessions = repository / "sessions" session = _make_completed_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) started = threading.Event() release = threading.Event() calls = 0 @@ -321,7 +322,7 @@ def test_reconciler_does_not_loop_retry_a_genuine_failed_job( sessions = repository / "sessions" session = _make_completed_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) calls = 0 def exporter(_source: Path, _destination: Path) -> dict[str, object]: diff --git a/tests/test_session_preparation.py b/tests/test_session_preparation.py index 1e05ab1..80480bb 100644 --- a/tests/test_session_preparation.py +++ b/tests/test_session_preparation.py @@ -9,7 +9,7 @@ from pathlib import Path import pytest -from k1link.sessions.models import ReplayCommand +from k1link.sessions.models import ReplayArtifact, ReplayCommand from k1link.sessions.preparation import ( RecordingPreparationSnapshot, SessionRecordingPreparationManager, @@ -25,12 +25,30 @@ def _command(root: Path, session_id: str = "20260717T100000Z_viewer_live") -> Re metadata.write_text('{"record_type":"message","sequence":1}\n', encoding="utf-8") return ReplayCommand( session_id=session_id, - source_path=source, + plugin_id="test.recording-plugin", allowed_root=root, session_root=root, - replay_byte_length=source.stat().st_size, - metadata_byte_length=metadata.stat().st_size, - expected_source_sha256=None, + primary_artifact_id="primary", + artifacts=( + ReplayArtifact( + artifact_id="primary", + path=source, + media_type="application/x-test-recording", + file_byte_length=source.stat().st_size, + replay_byte_length=source.stat().st_size, + expected_sha256=None, + ), + ReplayArtifact( + artifact_id="index", + path=metadata, + media_type="application/x-ndjson", + file_byte_length=metadata.stat().st_size, + replay_byte_length=metadata.stat().st_size, + expected_sha256=None, + ), + ), + timeline_origin_epoch_ns=0, + timeline_origin_monotonic_ns=0, speed=1.0, loop=False, ) diff --git a/tests/test_session_recording.py b/tests/test_session_recording.py index 26fcfe5..bec7d7b 100644 --- a/tests/test_session_recording.py +++ b/tests/test_session_recording.py @@ -13,7 +13,7 @@ from types import SimpleNamespace import pytest import k1link.sessions.recording as recording_module -from k1link.sessions.models import ReplayCommand +from k1link.sessions.models import ReplayArtifact, ReplayCommand from k1link.sessions.recording import ( CACHE_SCHEMA, RERUN_RECORDING_MEDIA_TYPE, @@ -57,17 +57,46 @@ def _command(tmp_path: Path) -> ReplayCommand: metadata.write_text('{"record_type":"message","sequence":1}\n', encoding="utf-8") return ReplayCommand( session_id="20260716T205632Z_viewer_live", - source_path=source, + plugin_id="test.recording-plugin", allowed_root=tmp_path, session_root=tmp_path, - replay_byte_length=source.stat().st_size, - metadata_byte_length=metadata.stat().st_size, - expected_source_sha256=None, + primary_artifact_id="primary", + artifacts=( + ReplayArtifact( + artifact_id="primary", + path=source, + media_type="application/x-test-recording", + file_byte_length=source.stat().st_size, + replay_byte_length=source.stat().st_size, + expected_sha256=None, + ), + ReplayArtifact( + artifact_id="index", + path=metadata, + media_type="application/x-ndjson", + file_byte_length=metadata.stat().st_size, + replay_byte_length=metadata.stat().st_size, + expected_sha256=None, + ), + ), + timeline_origin_epoch_ns=0, + timeline_origin_monotonic_ns=0, speed=1.0, loop=False, ) +def _replace_primary(command: ReplayCommand, **changes: object) -> ReplayCommand: + replacement = replace(command.primary_artifact, **changes) + return replace( + command, + artifacts=tuple( + replacement if artifact.artifact_id == command.primary_artifact_id else artifact + for artifact in command.artifacts + ), + ) + + def test_materializer_reuses_only_a_digest_validated_private_cache(tmp_path: Path) -> None: command = _command(tmp_path) exporter = FakeExporter() @@ -137,8 +166,12 @@ def test_failed_rebuild_preserves_previously_published_cache(tmp_path: Path) -> published_bytes = first.path.read_bytes() sidecar = first.path.with_name("scene.rrd.cache.json") published_sidecar = sidecar.read_bytes() - command.source_path.write_bytes(b"new-source-that-requires-rebuild") - changed = replace(command, replay_byte_length=command.source_path.stat().st_size) + command.primary_artifact.path.write_bytes(b"new-source-that-requires-rebuild") + changed = _replace_primary( + command, + file_byte_length=command.primary_artifact.path.stat().st_size, + replay_byte_length=command.primary_artifact.path.stat().st_size, + ) def fail_export(_source: Path, destination: Path) -> dict[str, object]: destination.write_bytes(b"incomplete-candidate") @@ -173,11 +206,15 @@ def test_materializer_rebuilds_when_source_or_recording_changes(tmp_path: Path) assert repaired.sha256 == _sha256(repaired.path) assert repaired.sha256 != hashlib.sha256(corrupted).hexdigest() - command.source_path.write_bytes(b"new-native-source") - command = replace(command, replay_byte_length=command.source_path.stat().st_size) + command.primary_artifact.path.write_bytes(b"new-native-source") + command = _replace_primary( + command, + file_byte_length=command.primary_artifact.path.stat().st_size, + replay_byte_length=command.primary_artifact.path.stat().st_size, + ) updated = materializer.materialize(command) assert exporter.calls == 3 - assert updated.source_sha256 == _sha256(command.source_path) + assert updated.source_sha256 == _sha256(command.primary_artifact.path) def test_concurrent_materialization_exports_one_recording(tmp_path: Path) -> None: @@ -227,9 +264,9 @@ def test_materializer_revalidates_prepared_source_without_following_replaced_sym exporter = FakeExporter() materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter) outside = tmp_path / "outside.k1mqtt" - outside.write_bytes(command.source_path.read_bytes()) - command.source_path.unlink() - command.source_path.symlink_to(outside) + outside.write_bytes(command.primary_artifact.path.read_bytes()) + command.primary_artifact.path.unlink() + command.primary_artifact.path.symlink_to(outside) with pytest.raises(RecordingMaterializationError, match="missing or unsafe"): materializer.materialize(command) @@ -242,11 +279,11 @@ def test_materializer_exports_only_validated_prefix_before_crash_tail( tmp_path: Path, ) -> None: command = _command(tmp_path / "session") - committed_raw_bytes = command.replay_byte_length - committed_metadata_bytes = command.metadata_byte_length - with command.source_path.open("ab") as stream: + committed_raw_bytes = command.primary_artifact.replay_byte_length + committed_metadata_bytes = command.artifacts[1].replay_byte_length + with command.primary_artifact.path.open("ab") as stream: stream.write(b"uncommitted-raw-tail") - metadata = command.source_path.with_name("mqtt.metadata.jsonl") + metadata = command.primary_artifact.path.with_name("mqtt.metadata.jsonl") with metadata.open("ab") as stream: stream.write(b'{"record_type":"message"') observed: list[tuple[int, int]] = [] @@ -265,7 +302,7 @@ def test_materializer_exports_only_validated_prefix_before_crash_tail( assert observed == [(committed_raw_bytes, committed_metadata_bytes)] assert recording.source_sha256 == hashlib.sha256(b"native-source-recording").hexdigest() - assert command.source_path.read_bytes().endswith(b"uncommitted-raw-tail") + assert command.primary_artifact.path.read_bytes().endswith(b"uncommitted-raw-tail") def test_global_singleflight_bounds_exports_across_different_sessions(tmp_path: Path) -> None: @@ -447,14 +484,14 @@ def test_cache_quota_evicts_lru_derived_recording_without_deleting_raw( free_space_reserve_bytes=0, ) first_recording = materializer.materialize(first) - first_source_bytes = first.source_path.read_bytes() + first_source_bytes = first.primary_artifact.path.read_bytes() second_recording = materializer.materialize(second) assert not first_recording.path.exists() assert second_recording.path.exists() - assert first.source_path.read_bytes() == first_source_bytes - assert second.source_path.exists() + assert first.primary_artifact.path.read_bytes() == first_source_bytes + assert second.primary_artifact.path.exists() def test_pinned_cache_entry_survives_eviction_until_release(tmp_path: Path) -> None: @@ -503,7 +540,7 @@ def test_cache_free_space_reserve_refuses_export_without_touching_raw( monkeypatch: pytest.MonkeyPatch, ) -> None: command = _command(tmp_path / "session") - source_bytes = command.source_path.read_bytes() + source_bytes = command.primary_artifact.path.read_bytes() materializer = SessionRecordingMaterializer( tmp_path / "private", exporter=FakeExporter(), @@ -519,4 +556,4 @@ def test_cache_free_space_reserve_refuses_export_without_touching_raw( with pytest.raises(RecordingMaterializationError, match="free-space reserve"): materializer.materialize(command) - assert command.source_path.read_bytes() == source_bytes + assert command.primary_artifact.path.read_bytes() == source_bytes diff --git a/tests/test_session_store.py b/tests/test_session_store.py index b9f7252..86d17b2 100644 --- a/tests/test_session_store.py +++ b/tests/test_session_store.py @@ -8,7 +8,12 @@ from pathlib import Path import pytest -from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC, iter_capture_frames +from k1link.device_plugins.xgrids_k1 import xgrids_k1_archive_source +from k1link.device_plugins.xgrids_k1.mqtt.capture import ( + FRAME_HEADER, + RAW_MAGIC, + iter_capture_frames, +) from k1link.sessions import ( LayoutConflictError, SessionIntegrityError, @@ -115,10 +120,10 @@ def test_catalog_reconciles_sessions_removed_from_one_evidence_root(tmp_path: Pa session = make_legacy_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - assert store.import_legacy_viewer_live(sessions) == (session.name,) + assert store.reconcile_archive(xgrids_k1_archive_source(sessions)) == (session.name,) shutil.rmtree(session) - assert store.import_legacy_viewer_live(sessions) == () + assert store.reconcile_archive(xgrids_k1_archive_source(sessions)) == () assert store.list_recent().items == () @@ -207,8 +212,8 @@ def test_store_uses_private_wal_database_and_idempotently_imports_legacy_session make_legacy_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - first = store.import_legacy_viewer_live(sessions) - second = store.import_legacy_viewer_live(sessions) + first = store.reconcile_archive(xgrids_k1_archive_source(sessions)) + second = store.reconcile_archive(xgrids_k1_archive_source(sessions)) assert first == second == ("20260716T205632Z_viewer_live",) page = store.list_recent() @@ -229,7 +234,7 @@ def test_store_uses_private_wal_database_and_idempotently_imports_legacy_session assert all(source.seekable for source in detail.sources) assert str(repository) not in serialized(detail.as_dict()) command = store.prepare_replay(summary.session_id, speed=2.0, loop=True) - assert command.source_path.name == "mqtt.raw.k1mqtt" + assert command.primary_artifact.path.name == "mqtt.raw.k1mqtt" assert command.speed == 2.0 assert command.loop is True @@ -238,6 +243,35 @@ def test_store_uses_private_wal_database_and_idempotently_imports_legacy_session assert store.database_path.stat().st_mode & 0o777 == 0o600 +def test_reconcile_claims_pre_plugin_catalog_row_without_changing_session_identity( + tmp_path: Path, +) -> None: + repository = tmp_path / "repo" + sessions = repository / "sessions" + session = make_legacy_session(sessions, "20260716T205632Z_viewer_live") + store = SessionStore(repository, data_dir=tmp_path / "data") + source = xgrids_k1_archive_source(sessions) + store.reconcile_archive(source) + with sqlite3.connect(store.database_path) as connection: + connection.execute( + "UPDATE observation_sessions SET plugin_id = '', archive_id = '', " + "primary_replay_artifact_id = NULL, timeline_origin_epoch_ns = NULL, " + "timeline_origin_monotonic_ns = NULL WHERE session_id = ?", + (session.name,), + ) + connection.execute( + "UPDATE observation_session_artifacts SET replay_byte_length = 0 " + "WHERE session_id = ?", + (session.name,), + ) + connection.commit() + + assert store.reconcile_archive(source) == (session.name,) + command = store.prepare_replay(session.name) + assert command.plugin_id == source.plugin_id + assert command.primary_artifact.replay_byte_length > 0 + + def test_recent_sessions_are_sorted_and_cursor_paginated(tmp_path: Path) -> None: repository = tmp_path / "repo" sessions = repository / "sessions" @@ -252,7 +286,7 @@ def test_recent_sessions_are_sorted_and_cursor_paginated(tmp_path: Path) -> None created_at="2026-07-16T20:56:32.699Z", ) store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) first = store.list_recent(limit=1) assert first.items[0].session_id == "20260716T205632Z_viewer_live" @@ -271,7 +305,7 @@ def test_legacy_import_adds_video_only_for_validated_recording_tree(tmp_path: Pa make_recorded_camera_source(incomplete, complete=False) store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) complete_detail = store.get_session(complete.name) assert complete_detail.summary.modalities == ("point-cloud", "trajectory", "video") @@ -305,7 +339,7 @@ def test_interrupted_capture_is_recovered_from_aligned_raw_and_metadata_prefix( replace_summary_with_recovery_metadata(session, corrupt_trailing_line=True) store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) detail = store.get_session(session.name) assert detail.summary.status == "interrupted" @@ -315,7 +349,7 @@ def test_interrupted_capture_is_recovered_from_aligned_raw_and_metadata_prefix( assert detail.summary.completed_at_utc == "2026-07-16T20:56:35.199Z" assert detail.summary.duration_seconds == 2.5 assert detail.artifacts[0].integrity_status == "validated-prefix" - assert store.prepare_replay(session.name).source_path.name == "mqtt.raw.k1mqtt" + assert store.prepare_replay(session.name).primary_artifact.path.name == "mqtt.raw.k1mqtt" def test_catalog_upsert_promotes_recovered_session_after_summary_is_completed( @@ -331,12 +365,12 @@ def test_catalog_upsert_promotes_recovered_session_after_summary_is_completed( replace_summary_with_recovery_metadata(session) store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) assert store.get_session(session.name).summary.status == "interrupted" summary_path.write_text(completed_summary, encoding="utf-8") metadata_path.write_text(completed_metadata, encoding="utf-8") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) promoted = store.get_session(session.name).summary assert promoted.status == "ready" @@ -356,7 +390,7 @@ def test_interrupted_capture_does_not_trust_metadata_outside_session(tmp_path: P metadata.symlink_to(outside) store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) detail = store.get_session(session.name) assert detail.summary.status == "failed" @@ -376,7 +410,7 @@ def test_interrupted_capture_rejects_newline_terminated_metadata_corruption( stream.write(b"{corrupt\n") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) detail = store.get_session(session.name) assert detail.summary.status == "failed" @@ -388,7 +422,7 @@ def test_replay_resolution_rejects_artifact_symlink_escape(tmp_path: Path) -> No sessions = repository / "sessions" session = make_legacy_session(sessions, "20260716T205632Z_viewer_live") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt" outside = tmp_path / "outside.k1mqtt" outside.write_bytes(raw.read_bytes()) @@ -411,7 +445,7 @@ def test_completed_capture_is_not_replayable_when_declared_integrity_fails( raw.write_bytes(corrupted) store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) detail = store.get_session(session.name) assert detail.summary.status == "failed" @@ -431,7 +465,7 @@ def test_completed_capture_is_not_replayable_when_declared_count_is_wrong( summary_path.write_text(json.dumps(summary), encoding="utf-8") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) assert store.get_session(session.name).summary.replayable is False @@ -446,13 +480,13 @@ def test_current_session_marker_keeps_active_capture_out_of_replay(tmp_path: Pat ) store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) with pytest.raises(SessionNotFoundError): store.get_session(session.name) (sessions / ".current_session").unlink() - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) assert store.get_session(session.name).summary.replayable is True @@ -469,14 +503,17 @@ def test_interrupted_capture_replays_only_committed_prefix_before_partial_raw_ta stream.write(FRAME_HEADER.pack(12, 100)[:7]) store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) detail = store.get_session(session.name) command = store.prepare_replay(session.name) assert detail.summary.status == "interrupted" assert detail.summary.replayable is True - assert command.replay_byte_length == committed_bytes - assert command.replay_byte_length < command.source_path.stat().st_size + assert command.primary_artifact.replay_byte_length == committed_bytes + assert ( + command.primary_artifact.replay_byte_length + < command.primary_artifact.path.stat().st_size + ) def test_interrupted_capture_rejects_non_frame_raw_tail(tmp_path: Path) -> None: @@ -489,7 +526,7 @@ def test_interrupted_capture_rejects_non_frame_raw_tail(tmp_path: Path) -> None: stream.write(FRAME_HEADER.pack(0, 0)) store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) assert store.get_session(session.name).summary.replayable is False @@ -511,11 +548,11 @@ def test_interrupted_capture_tolerates_bounded_group_commit_raw_tail( stream.write(payload) store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) command = store.prepare_replay(session.name) assert store.get_session(session.name).summary.status == "interrupted" - assert command.replay_byte_length == committed_bytes + assert command.primary_artifact.replay_byte_length == committed_bytes def test_failed_final_summary_falls_back_to_last_committed_prefix(tmp_path: Path) -> None: @@ -532,7 +569,7 @@ def test_failed_final_summary_falls_back_to_last_committed_prefix(tmp_path: Path summary_path.write_text(json.dumps(summary), encoding="utf-8") store = SessionStore(repository, data_dir=tmp_path / "data") - store.import_legacy_viewer_live(sessions) + store.reconcile_archive(xgrids_k1_archive_source(sessions)) detail = store.get_session(session.name) assert detail.summary.status == "interrupted" diff --git a/tests/test_stream_decoders.py b/tests/test_stream_decoders.py index bd13179..b1837e7 100644 --- a/tests/test_stream_decoders.py +++ b/tests/test_stream_decoders.py @@ -6,8 +6,12 @@ import struct import lz4.block import pytest -from k1link.protocol.protobuf_wire import ProtobufWireError, decode_zigzag64, iter_fields -from k1link.protocol.streams import ( +from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import ( + ProtobufWireError, + decode_zigzag64, + iter_fields, +) +from k1link.device_plugins.xgrids_k1.protocol.streams import ( DecodeLimits, StreamDecodeError, UnsupportedCompressionError, diff --git a/tests/test_stream_summary.py b/tests/test_stream_summary.py index b6faadf..d3809fc 100644 --- a/tests/test_stream_summary.py +++ b/tests/test_stream_summary.py @@ -10,9 +10,9 @@ import lz4.block import pytest from typer.testing import CliRunner -from k1link.analyze.stream_summary import summarize_mqtt_streams -from k1link.cli import app -from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC, CaptureFormatError +from k1link.device_plugins.xgrids_k1.analyze.stream_summary import summarize_mqtt_streams +from k1link.device_plugins.xgrids_k1.cli import app +from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC, CaptureFormatError runner = CliRunner() diff --git a/tests/test_usb_snapshot.py b/tests/test_usb_snapshot.py index cffe26d..e6cd8eb 100644 --- a/tests/test_usb_snapshot.py +++ b/tests/test_usb_snapshot.py @@ -7,8 +7,8 @@ from pathlib import Path import pytest from typer.testing import CliRunner -from k1link.cli import app -from k1link.usb.snapshot import ( +from k1link.device_plugins.xgrids_k1.cli import app +from k1link.device_plugins.xgrids_k1.usb.snapshot import ( DISKUTIL_COMMAND, SERIAL_IOREG_COMMAND, USB_IOREG_COMMAND, @@ -242,7 +242,7 @@ def test_usb_snapshot_cli_writes_json( "device_writes_performed": False, }, } - monkeypatch.setattr("k1link.cli.usb_snapshot", lambda: payload) + monkeypatch.setattr("k1link.device_plugins.xgrids_k1.cli.usb_snapshot", lambda: payload) result = runner.invoke(app, ["usb", "snapshot", "--out", str(output)]) diff --git a/tests/test_viewer_replay.py b/tests/test_viewer_replay.py index a93bc07..c55aea1 100644 --- a/tests/test_viewer_replay.py +++ b/tests/test_viewer_replay.py @@ -5,8 +5,12 @@ from pathlib import Path import pytest -from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC -from k1link.viewer.replay import ReplayFormatError, detect_replay_format, iter_replay_messages +from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC +from k1link.device_plugins.xgrids_k1.viewer.replay import ( + ReplayFormatError, + detect_replay_format, + iter_replay_messages, +) def _write_native(path: Path, topic: str, payload: bytes) -> None: diff --git a/tests/test_web_console.py b/tests/test_web_console.py index f0c1417..25cf513 100644 --- a/tests/test_web_console.py +++ b/tests/test_web_console.py @@ -6,7 +6,7 @@ from typing import Any from pytest import MonkeyPatch -import k1link.web.xgrids_k1_facade as xgrids_backend +import k1link.device_plugins.xgrids_k1.facade as xgrids_backend def test_ble_scan_exposes_every_device_and_only_labels_likely_k1( diff --git a/tests/test_web_validation_security.py b/tests/test_web_validation_security.py index f8b6bc9..156613d 100644 --- a/tests/test_web_validation_security.py +++ b/tests/test_web_validation_security.py @@ -6,8 +6,8 @@ from typing import Any import pytest +from k1link.device_plugins.xgrids_k1.facade import XGRIDS_K1_PLUGIN_ID from k1link.web.app import INVALID_REQUEST_DETAIL, app -from k1link.web.xgrids_k1_facade import XGRIDS_K1_PLUGIN_ID async def _post_json(path: str, payload: dict[str, Any]) -> tuple[int, str]: diff --git a/tests/test_wifi_provisioning.py b/tests/test_wifi_provisioning.py index fb3b0b7..f802f74 100644 --- a/tests/test_wifi_provisioning.py +++ b/tests/test_wifi_provisioning.py @@ -1,6 +1,6 @@ import pytest -from k1link.ble.wifi_provisioning import ( +from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import ( FRAME_LENGTH, build_wifi_provisioning_frame, parse_wifi_status, diff --git a/tests/test_xgrids_acquisition_lifecycle.py b/tests/test_xgrids_acquisition_lifecycle.py index ae5566c..7e37566 100644 --- a/tests/test_xgrids_acquisition_lifecycle.py +++ b/tests/test_xgrids_acquisition_lifecycle.py @@ -7,8 +7,8 @@ from typing import Any import pytest from pydantic import SecretStr -import k1link.web.xgrids_k1_facade as facade_module -from k1link.web.xgrids_k1_facade import ( +import k1link.device_plugins.xgrids_k1.facade as facade_module +from k1link.device_plugins.xgrids_k1.facade import ( DEFAULT_LIVE_STREAMS, XGRIDS_K1_COMPATIBILITY_PROFILE_ID, AbortAcquisitionRequest, diff --git a/tests/test_xgrids_camera_gateway.py b/tests/test_xgrids_camera_gateway.py index 720572e..e7e0bc0 100644 --- a/tests/test_xgrids_camera_gateway.py +++ b/tests/test_xgrids_camera_gateway.py @@ -9,14 +9,14 @@ from pathlib import Path import pytest -import k1link.web.xgrids_k1_camera as camera_module -from k1link.web.xgrids_k1_camera import ( +import k1link.device_plugins.xgrids_k1.camera as camera_module +from k1link.device_plugins.xgrids_k1.camera import ( CAMERA_MEDIA_TYPE, XgridsK1CameraGateway, _build_ffmpeg_argv, _read_mp4_box, ) -from k1link.web.xgrids_k1_facade import ( +from k1link.device_plugins.xgrids_k1.facade import ( XGRIDS_K1_COMPATIBILITY_PROFILE_ID, XGRIDS_K1_PLUGIN_ID, CameraPreviewSelectRequest,