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 (
-
- Этот путь уже работает физически, но остаётся изолированным адаптером. Парковая и
- операторская модель от конкретного устройства не зависят.
-
-
-
- {connectionPhaseLabel}
- {localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ПОДКЛЮЧЕНИЕ · ШАГИ 01–03
-
Подключите устройство к сети
-
- {connectionPhaseLabel}
-
-
-
-
-
-
- Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия.
-
- {
- setPowerConfirmed(checked);
- if (!checked) {
- setProfileConfirmed(false);
- provisioningIntentRef.current = null;
- }
- }}
- />
-
-
-
-
-
- Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата
- основана только на имени и не подтверждает модель или прошивку; окончательный выбор
- и аттестацию всегда делает оператор.
-
- Устройства пока не найдены. Проверьте питание и состояние индикатора, затем
- повторите поиск.
-
- )}
-
-
-
-
-
-
-
- Mission Core не определяет прошивку автоматически. Сверьте её на устройстве
- или в официальном приложении и подтвердите только точное соответствие профилю.
-
- {
- setProfileConfirmed(checked);
- provisioningIntentRef.current = null;
- }}
- />
-
- {!profileConfirmed
- ? "Сначала вручную подтвердите точную прошивку 3.0.2 и direct-LAN топологию. Интерфейс не аттестует устройство автоматически."
- : liveTargetReady
- ? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами."
- : "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
-
- );
-}
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) || "Ожидаем состояние локального контура."}
+
+ {!profileConfirmed
+ ? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически."
+ : liveTargetReady
+ ? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами."
+ : "Сначала подключите устройство к Wi‑Fi или укажите локальный адрес."}
+
+ Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия.
+ { setPowerConfirmed(checked); if (!checked) resetProfile(); }}
+ />
+
+
+
+
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; модель и прошивку подтверждает оператор.