From aa3680948f8e0eb0d9b0497474278515ff62f68b Mon Sep 17 00:00:00 2001
From: DCCONSTRUCTIONS
Date: Fri, 17 Jul 2026 23:03:59 +0300
Subject: [PATCH] feat(k1): complete primary acquisition lifecycle
---
README.md | 60 +-
apps/control-station/README.md | 58 +-
apps/control-station/src/App.tsx | 48 +-
.../components/ObservationSessionSelect.tsx | 7 +-
.../src/core/device-plugins/contracts.ts | 3 +
.../src/core/device-plugins/registry.ts | 9 +-
.../observation/useObservationSessions.ts | 44 +-
.../src/core/runtime/acquisitionGuard.ts | 26 +
.../src/core/runtime/contracts.ts | 4 +
.../src/styles/observation.css | 9 +
.../control-station/src/styles/responsive.css | 5 +
.../src/workspaces/DeviceWorkspace.tsx | 13 +-
.../src/workspaces/Workspaces.tsx | 31 +-
.../test/devicePluginContracts.test.mjs | 108 +++
.../devicePluginFrontendBoundary.test.mjs | 50 +-
.../test/observationSessions.test.mjs | 19 +
.../test/runtimeAcquisitionGuard.test.mjs | 95 ++
docs/01_IMPLEMENTATION_PLAN.md | 61 +-
docs/05_K1_MQTT_STREAM_PROFILE.md | 41 +-
docs/06_K1_LIVE_VIEWER.md | 114 ++-
docs/07_MISSION_CORE_MONOREPO.md | 77 +-
docs/09_OBSERVATION_SESSIONS.md | 165 +++-
...n-observation-runtime-and-k1-extraction.md | 35 +-
...-plugin-owned-frontend-device-workflows.md | 51 +-
.../frontend/src/XgridsK1Connection.tsx | 1 +
plugins/xgrids-k1/frontend/src/api.ts | 12 +
.../frontend/src/automaticSourceStart.ts | 11 +
.../src/components/K1AcquisitionPipeline.tsx | 95 +-
.../src/components/K1SpatialControls.tsx | 164 ++++
plugins/xgrids-k1/frontend/src/lifecycle.ts | 26 +
plugins/xgrids-k1/frontend/src/plugin.ts | 2 +
.../xgrids-k1/frontend/src/presentation.ts | 46 +
plugins/xgrids-k1/frontend/src/projectName.ts | 32 +
.../xgrids-k1/frontend/src/runtimeContext.tsx | 7 +-
plugins/xgrids-k1/frontend/src/styles.css | 112 +++
.../frontend/src/useXgridsK1Runtime.ts | 9 +-
plugins/xgrids-k1/plugin.manifest.json | 2 +
plugins/xgrids-k1/profile_loader.py | 127 ++-
plugins/xgrids-k1/profiles/README.md | 21 +-
.../profiles/fw-3.0.2/direct-lan.v1.json | 69 +-
.../device_plugins/xgrids_k1/archive.py | 259 +++++-
src/k1link/device_plugins/xgrids_k1/facade.py | 436 ++++++++-
.../device_plugins/xgrids_k1/mqtt/capture.py | 449 ++++++++-
.../device_plugins/xgrids_k1/observation.py | 82 +-
.../xgrids_k1/protocol/modeling.py | 168 ++++
.../xgrids_k1/protocol/modeling_control.py | 568 ++++++++++++
.../xgrids_k1/protocol/modeling_state.py | 126 +++
.../device_plugins/xgrids_k1/rrd_export.py | 223 ++++-
.../xgrids_k1/viewer/runtime.py | 59 +-
src/k1link/sessions/media.py | 141 +--
src/k1link/sessions/recording.py | 127 +--
src/k1link/viewer/metrics.py | 45 +
tests/test_canonical_pipeline.py | 92 +-
tests/test_frontend_plugin_boundaries.py | 34 +-
tests/test_mqtt_capture.py | 88 +-
tests/test_plugin_catalog.py | 2 +
tests/test_plugin_runtime.py | 22 +-
tests/test_rerun_bridge.py | 3 +-
tests/test_rrd_export.py | 185 +++-
tests/test_session_api.py | 62 +-
tests/test_session_recording.py | 54 +-
tests/test_session_store.py | 198 +++-
tests/test_stream_decoders.py | 80 ++
tests/test_xgrids_acquisition_lifecycle.py | 872 +++++++++++++++++-
tests/test_xgrids_compatibility_profile.py | 66 +-
.../test_xgrids_modeling_control_protocol.py | 327 +++++++
66 files changed, 6093 insertions(+), 544 deletions(-)
create mode 100644 apps/control-station/src/core/runtime/acquisitionGuard.ts
create mode 100644 apps/control-station/test/runtimeAcquisitionGuard.test.mjs
create mode 100644 plugins/xgrids-k1/frontend/src/automaticSourceStart.ts
create mode 100644 plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx
create mode 100644 plugins/xgrids-k1/frontend/src/projectName.ts
create mode 100644 src/k1link/device_plugins/xgrids_k1/protocol/modeling.py
create mode 100644 src/k1link/device_plugins/xgrids_k1/protocol/modeling_control.py
create mode 100644 src/k1link/device_plugins/xgrids_k1/protocol/modeling_state.py
create mode 100644 tests/test_xgrids_modeling_control_protocol.py
diff --git a/README.md b/README.md
index 9569907..1d3d891 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,13 @@ archive sources, recovery hooks and recording exporters only through the plugin
runtime contribution. The verified wire protocol and raw evidence format are
unchanged.
+The K1 acquisition now also requires an operator project name. Frontend and
+backend normalize it with NFKC plus surrounding-whitespace trimming, reject
+control/surrogate characters and values above 96 Unicode characters, and
+preserve it as local session/catalog display metadata. It is never used as a
+filesystem path. Because K1 command publishing is disabled, the current runtime
+does not claim that this value has been transmitted to the scanner.
+
Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session,
operation, runtime-action, stream, evidence and compatibility contracts. Every
backend runtime must now pass a versioned descriptor/handshake against its
@@ -123,10 +130,20 @@ Open `http://127.0.0.1:8000`. The static application, REST/WebSocket control
plane and credential endpoint bind to loopback only. The current K1 adapter
still provides real CoreBluetooth discovery, one operator-triggered reviewed
BLE Wi-Fi provisioning write, read-only MQTT live capture, native `.k1mqtt` and
-reviewed-TSV replay, raw-first evidence storage and measured preview metrics.
-Physical K1 scanning is still started and stopped by the verified double-click;
-the connector publishes no modeling command. The observed LixelGO action mapping
-remains descriptive and write-disabled.
+reviewed-TSV replay, raw-first evidence storage, device-reported scan
+time/distance/speed and measured preview metrics. Its frontend contribution now
+also owns an optional spatial-scene control block, including acquisition phase,
+telemetry and the stop action. On the active laboratory profile that stop action
+finalizes local reception only; physical K1 scanning is still started and
+stopped by the verified double-click.
+
+The exact recovered `ModelingRequest` start/stop encoder, response correlator
+and device-status state machine are implemented as inert protocol components.
+The command header derives `${device_id}:ModelingRequest` from an explicit ASCII
+device ID and requires an explicit ASCII OpenAPI key, but the key's legitimate
+provenance/provisioning and durable post-stop save evidence remain unresolved.
+There is no MQTT publisher in this path, `vendor_writes_enabled` remains false,
+and no modeling command is sent.
This locked bootstrap is repeatable in the current workspace, not yet a
standalone release install. The frontend consumes sibling `file:` packages from
@@ -149,15 +166,21 @@ The first K1 live session or adapter file-replay (`.k1mqtt`/reviewed TSV) in a
gRPC/proxy server on TCP 9876 and publishes the resulting URL through
control-plane state. Later live/file-replay sessions reset their session-local
scene and metrics and reuse that process-wide stream. Saved observation sessions
-do not reuse this listener: they open a private digest-bound RRD generation over
-same-origin HTTP through Rerun's native incremental receiver. Unless an operator
-has entered a manual source, the React application assigns the applicable source
-to the embedded viewer. The complete live runtime path is K1 MQTT → raw-first
-evidence capture → bounded latest-wins preview queue → explicitly injected K1
+do not reuse this listener: they open a private digest-bound cache-v9 RRD
+generation over same-origin HTTP through Rerun's native incremental receiver.
+Unless an operator has entered a manual source, the React application assigns
+the applicable source to the embedded viewer. A generic host action clears a
+previous manual/archive selection only after a plugin-owned live or file-replay
+start succeeds; a failed start leaves the current scene mounted. A fail-closed
+guard blocks operator source switches for every nonterminal acquisition. The
+complete visual live runtime path is K1 MQTT → raw-first evidence capture →
+bounded four-message latest-wins preview queue → explicitly injected K1
protobuf/LZ4 normalizer → transport-neutral decoded local views → Rerun
`Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer. Rerun does
not inspect K1 topics or raw payloads. These local decoded views are not yet the
-portable Plugin SDK wire envelopes.
+portable Plugin SDK wire envelopes. K1 `ModelingReport` status is consumed by an
+explicitly injected pre-preview observer, so scan telemetry cannot evict point
+or pose frames from that four-message queue.
The default Rerun blueprint shows a 12-second sliding accumulation of real point
frames. Product controls are connected for point size, intensity/height/distance
@@ -166,17 +189,24 @@ point and trajectory visibility, and the scene grid. The first disk action saves
and restores the versioned spatial layout without mutating sensor evidence.
Saved native point/pose sessions are materialized losslessly into private,
digest-bound RRD recordings and can be played, paused and scrubbed on a
-zero-based `session_time` timeline. No synthetic point cloud, trajectory,
-camera frame or latency value is generated.
+zero-based `session_time` timeline. New captures durably publish a clock origin
+before camera production, retain a transport-scoped provisional envelope and
+atomically point their summary to a content-addressed session envelope after all
+producers stop. That session envelope becomes the real RRD origin/end range;
+archived `ModelingReport` distance, speed and scan time are logged as scalar
+time series. No synthetic point cloud, trajectory, camera frame or latency
+value is generated.
A powered-device checkpoint passed 80 real MQTT messages through the current
Rerun runtime: 38 point-cloud frames, 42 pose frames, 2,775 points in the last
cloud and zero decode errors. The later RTSP camera preview is available through
the generic floating observation windows and new acquisitions archive its fMP4
segments independently of browser delivery. Historical sessions recorded before
-that archive contract contain no video. Rerun `capture_time` and the camera
-index use Mac receive/arrival timestamps, not proven K1 sensor timestamps or a
-photon-to-screen measurement.
+that archive contract contain no video. A real archived session containing one
+camera plus point cloud has not yet passed physical shared-timeline playback;
+the implementation and test contract do not close that hardware gate. Rerun
+`capture_time` and the camera 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/device_plugins/xgrids_k1/viewer/foxglove_bridge.py` and its regression tests. The current
diff --git a/apps/control-station/README.md b/apps/control-station/README.md
index 72a5463..774fd16 100644
--- a/apps/control-station/README.md
+++ b/apps/control-station/README.md
@@ -18,15 +18,15 @@ 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. |
+| Plugin-owned device UI | Реализован | XGRIDS provisioning, project/acquisition/replay, diagnostics, metrics, optional spatial controls и scoped styles физически находятся в `plugins/xgrids-k1/frontend`; generic Control Station предоставляет frontend SDK, model catalog и host slots. |
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
-| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
+| K1 live/replay MQTT | Реализован | Read-only приём, обязательное локальное имя проекта, raw-first сохранение, декодирование облака/позы и device-reported scan time/distance/speed. |
| Автоматический 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 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. |
+| Сохранённые observation sessions | Реализованы для point/pose/device metrics | Три последние сессии, background preparation, capture-clock-bound cache v9, 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 подключён, но point-cloud + one-camera archived playback ещё не прошёл physical acceptance. |
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
| Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. |
@@ -38,9 +38,10 @@ device adapter, но структура интерфейса от него не
```text
K1 MQTT :1883, read-only
-└── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
- └── bounded latest-wins preview queue (32 сообщения)
- └── явно внедрённый K1 protobuf/LZ4 normalizer
+└── raw .k1mqtt + metadata + durable clock origin сохраняются первыми
+ ├── injected ModelingReport observer -> device scan time/distance/speed
+ └── bounded latest-wins visual preview queue (4 сообщения)
+ └── явно внедрённый K1 protobuf/LZ4 point/pose normalizer
└── transport-neutral DecodedPointCloudView / DecodedPoseView
└── Rerun Points3D + Transform3D + LineStrips3D
└── gRPC/proxy TCP 9876
@@ -55,7 +56,7 @@ Mission Core Control Station ←→ REST /api/v1/device-plugins/*
sealed/recovered observation session
└── SQLite catalog + bounded background preparation
- └── atomic RRD cache v7 + recorded-media manifest v2
+ └── capture-clock-bound atomic RRD cache v9 + recorded-media manifest v2
└── generation-bound same-origin HTTP
└── aggregate admission
└── Rerun native receiver + recorded fMP4 player
@@ -177,15 +178,21 @@ production-сборки запускается командой `npm run preview
оператор.
6. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
reviewed provisioning-запись без автоматических повторов.
-7. Запустить live-приём по определённому адресу K1 либо replay локального
- `.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и
- останавливается подтверждённым двойным нажатием кнопки устройства.
-8. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
+7. Ввести обязательное название проекта. Оно NFKC-нормализуется, trim-ится,
+ проверяется на control/surrogate characters и лимит 96 Unicode characters,
+ сохраняется как display metadata и не используется в filesystem path.
+8. Запустить локальный live-приём по определённому адресу K1 либо replay
+ локального `.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается
+ и останавливается подтверждённым двойным нажатием кнопки устройства.
+9. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
state. Ручной source вводить не требуется.
-9. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
- частота, число точек, задержка и пропуски preview появятся после прихода
- сообщений K1.
-10. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться
+10. Открыть **Наблюдение → Пространственная сцена**. Plugin-owned spatial block
+ показывает local acquisition phase, stop local reception и полученные от K1
+ scan time/distance/speed; облако, траектория и preview metrics появляются
+ только после реальных сообщений.
+11. Физически остановить K1, дождаться steady green, затем остановить локальный
+ приём из device workflow или spatial block, чтобы запечатать evidence.
+12. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться
состояния **Готово**, выбрать запись и использовать host timeline. Evidence
сохраняется автоматически; disk action сохраняет только workspace layout.
@@ -212,6 +219,11 @@ https://example.internal/recording.rrd
- версия RRD должна быть совместима с версией Web Viewer;
- ручной URL хранится только в состоянии текущей страницы и имеет приоритет над
автоматическим source;
+- nonterminal acquisition fail-closed блокирует manual source apply/reset,
+ saved session switch и persisted replay reattach до завершения текущего
+ приёма; automatic-source action очищает старый source и открывает сцену только
+ после successful start, failed start сохраняет текущую сцену, а guard не
+ останавливает устройство;
- ошибка запуска показывается в viewport, без подстановки фиктивных данных.
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
@@ -282,10 +294,10 @@ 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 и public frontend SDK surface. |
-| `src/core/runtime/` | Нормализованное состояние активного устройства и spatial source. |
+| `src/core/runtime/` | Нормализованное состояние активного устройства, spatial source и fail-closed source-switch guard. |
| `src/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. |
| `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. |
-| `../../plugins/xgrids-k1/frontend/` | Plugin-owned K1 BLE/Wi-Fi, acquisition/replay UI, API client, runtime mapper и scoped styles. |
+| `../../plugins/xgrids-k1/frontend/` | Plugin-owned K1 BLE/Wi-Fi, project/acquisition/replay UI, optional spatial controls, 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` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |
@@ -312,16 +324,26 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
случайные GATT writes и автоматические повторы запрещены.
- MQTT live/replay не публикует команды устройству. Запуск и остановка
физического сканирования остаются за кнопкой K1.
+- Exact start/stop codec, response correlator и device-status state machine
+ остаются inert: они не импортируют MQTT transport. Header derivation известен,
+ но legitimate OpenAPI key provisioning и durable post-stop save gate не
+ доказаны, поэтому `vendor_writes_enabled=false`.
- Live-сессии сначала сохраняют сырые сообщения и camera segments, затем
формируют disposable preview. При перегрузке preview может быть отброшен,
native evidence сохраняется. MQTT durability имеет bounded group-commit RPO,
camera durability — текущий незавершённый fragment RPO; это не zero-loss claim.
+- Новая session summary hash-bind-ит durable clock origin и атомарно переключает
+ pointer с provisional `mqtt.timeline.json` на content-addressed
+ `mqtt.timeline.session-.json`: только owner-sealed `session` scope
+ может рекламировать combined media replay. Cache v9 создаёт реальные
+ origin/end RRD rows; provisional `transport` scope fails closed.
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
накопления viewer использует session-local `stream_time`. `capture_time` — не
доказанный timestamp сенсора K1 и не photon-to-screen latency.
- Панорамный камерный поток в MQTT report topics отсутствует; отдельные
left/right RTSP preview доступны через generic camera windows. LiDAR и camera
сейчас синхронизированы только по host arrival, не по доказанным sensor clocks.
+ Физический archived playback point cloud + одна выбранная камера ещё не принят.
- `.runtime/`, canonical evidence roots и legacy `sessions/` игнорируются Git и
могут содержать адреса, изображения, идентификаторы, траекторию и карту
помещения. В репозиторий попадают только код, тесты, redacted manifests и
diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx
index a087e61..e18b68c 100644
--- a/apps/control-station/src/App.tsx
+++ b/apps/control-station/src/App.tsx
@@ -30,6 +30,10 @@ import { ObservationSessionSelect } from "./components/ObservationSessionSelect"
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
import type { ViewerSettings } from "./core/runtime/contracts";
+import {
+ SPATIAL_SOURCE_SWITCH_BLOCKED_REASON,
+ isSpatialSourceSwitchBlocked,
+} from "./core/runtime/acquisitionGuard";
import {
createLatestAsyncCommitter,
type LatestAsyncCommitter,
@@ -144,6 +148,12 @@ export default function App() {
const [layoutSaveNotice, setLayoutSaveNotice] = useState(null);
const [sceneSettings, setSceneSettings] = useState(defaultSceneSettings);
const [displayDraft, setDisplayDraft] = useState(defaultSceneSettings);
+ const sourceSwitchBlocked = isSpatialSourceSwitchBlocked(runtime.state);
+ const sourceSwitchBlockedReason = sourceSwitchBlocked
+ ? SPATIAL_SOURCE_SWITCH_BLOCKED_REASON
+ : null;
+ const sourceSwitchBlockedRef = useRef(sourceSwitchBlocked);
+ sourceSwitchBlockedRef.current = sourceSwitchBlocked;
const appliedProfileKeyRef = useRef(null);
const sceneSettingsRef = useRef(defaultSceneSettings);
const displayDraftRef = useRef(defaultSceneSettings);
@@ -423,6 +433,9 @@ export default function App() {
};
const beginRecordedReplaySwitch = useCallback(async () => {
+ if (sourceSwitchBlockedRef.current) {
+ throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
+ }
// useObservationSessions calls this only after backend preparation has
// produced a validated launch descriptor. Keep the old scene mounted
// before this point; now perform one controlled receiver teardown before
@@ -437,6 +450,9 @@ export default function App() {
}, []);
const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => {
+ if (sourceSwitchBlockedRef.current) {
+ throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
+ }
setRecordedReplay(launch);
setSourceUrl(launch.sourceUrl);
setSourceDraft(launch.sourceUrl);
@@ -447,6 +463,17 @@ export default function App() {
if (outcome !== "accepted") setReplayTransitioning(false);
}, []);
+ const activateAutomaticSpatialSource = useCallback(() => {
+ // A plugin-owned live/file-replay start must release any host-selected
+ // archive or manual URL before the new acquisition becomes non-terminal.
+ // This transition is an internal start boundary, not an operator source
+ // switch, so it intentionally does not consult the acquisition guard.
+ setReplayTransitioning(false);
+ setRecordedReplay(null);
+ setSourceUrl("");
+ setSourceDraft("");
+ }, []);
+
useEffect(() => {
// ObservationSessionSelect owns the cancellable request and unmounts when
// the operator leaves the spatial workspace. Its unmount cannot safely
@@ -637,7 +664,8 @@ export default function App() {
+ ) : null}
+
{pointCloudFocused ? (
{
server = await createServer({
@@ -29,6 +32,15 @@ before(async () => {
lifecycle = await server.ssrLoadModule(
"@xgrids-k1/frontend/lifecycle.ts",
);
+ projectName = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/projectName.ts",
+ );
+ automaticSourceStart = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/automaticSourceStart.ts",
+ );
+ presentation = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/presentation.ts",
+ );
({ xgridsK1Api } = await server.ssrLoadModule(
"@xgrids-k1/frontend/api.ts",
));
@@ -279,6 +291,100 @@ test("prepared acquisition resumes without another prepare and remains recoverab
assert.equal(lifecycle.recoverableAcquisition(prepared)?.acquisition_id, "acq-1");
});
+test("project name is canonicalized and rejected outside the bounded safe contract", () => {
+ assert.deepEqual(projectName.validateProjectName(" Mission 01 "), {
+ value: "Mission 01",
+ error: null,
+ });
+ assert.match(projectName.validateProjectName("line\nbreak").error, /управляющие/);
+ assert.match(projectName.validateProjectName("\ud800").error, /управляющие/);
+ assert.match(projectName.validateProjectName("x".repeat(97)).error, /не длиннее 96/);
+ assert.match(projectName.validateProjectName(" \t ").error, /Введите название/);
+});
+
+test("automatic spatial source replaces the old scene only after a successful start", async () => {
+ const failedEvents = [];
+ assert.equal(await automaticSourceStart.runAutomaticSpatialSourceStart(
+ async () => {
+ failedEvents.push("start");
+ return false;
+ },
+ () => failedEvents.push("activate"),
+ () => failedEvents.push("open"),
+ ), false);
+ assert.deepEqual(failedEvents, ["start"]);
+
+ const successfulEvents = [];
+ assert.equal(await automaticSourceStart.runAutomaticSpatialSourceStart(
+ async () => {
+ successfulEvents.push("start");
+ return true;
+ },
+ () => successfulEvents.push("activate"),
+ () => successfulEvents.push("open"),
+ ), true);
+ assert.deepEqual(successfulEvents, ["start", "activate", "open"]);
+});
+
+test("vendor commands fail closed unless the profile and acquisition both enable them", () => {
+ const capability = {
+ compatibility: {
+ vendor_writes_enabled: true,
+ permitted_mode: "active-control",
+ },
+ };
+ assert.equal(lifecycle.isVendorWriteCapable(capability), true);
+ assert.equal(lifecycle.isVendorWriteCapable({
+ compatibility: { vendor_writes_enabled: true, permitted_mode: "read-only" },
+ }), false);
+ assert.equal(lifecycle.isSoftwareCommandedAcquisition({
+ ...capability,
+ acquisition: { control_mode: "operator-manual" },
+ }), false);
+ assert.equal(lifecycle.isSoftwareCommandedAcquisition({
+ ...capability,
+ acquisition: { control_mode: "plugin-commanded" },
+ }), true);
+});
+
+test("device modeling telemetry maps only finite non-negative values", () => {
+ assert.deepEqual(presentation.deviceTelemetry({
+ device_elapsed_seconds: 12.5,
+ device_route_distance_meters: 8.25,
+ device_speed_meters_per_second: 0.75,
+ }), {
+ elapsedSeconds: 12.5,
+ routeDistanceMeters: 8.25,
+ speedMetersPerSecond: 0.75,
+ });
+ assert.deepEqual(presentation.deviceTelemetry({
+ device_elapsed_seconds: -1,
+ device_route_distance_meters: Number.NaN,
+ device_speed_meters_per_second: Number.POSITIVE_INFINITY,
+ }), {
+ elapsedSeconds: null,
+ routeDistanceMeters: null,
+ speedMetersPerSecond: null,
+ });
+});
+
+test("spatial K1 action failures have an explicit retry-safe presentation", () => {
+ assert.equal(presentation.spatialActionFailure(null), null);
+ assert.equal(presentation.spatialActionFailure(" "), null);
+ assert.deepEqual(presentation.spatialActionFailure(" stop failed "), {
+ title: "Действие K1 не выполнено",
+ detail: "stop failed",
+ });
+ assert.equal(lifecycle.shouldRenderSpatialControls({
+ source_mode: "live",
+ acquisition: { state: "failed", cleanup_pending: true },
+ }), true);
+ assert.equal(lifecycle.shouldRenderSpatialControls({
+ source_mode: "idle",
+ acquisition: { state: "failed", cleanup_pending: false },
+ }), false);
+});
+
test("replay ignores a stale failed live acquisition", () => {
const replay = {
source_mode: "replay",
@@ -343,6 +449,7 @@ test("device mutations send explicit nested compatibility attestation", async ()
idempotency_key: "network-provision:test",
});
await xgridsK1Api.prepareAcquisition({
+ project_name: "Mission 01",
compatibility_attestation: attestation,
});
} finally {
@@ -355,4 +462,5 @@ test("device mutations send explicit nested compatibility attestation", async ()
assert.deepEqual(provisioning.input.compatibility_attestation, attestation);
assert.equal(provisioning.input.idempotency_key, "network-provision:test");
assert.deepEqual(prepare.input.compatibility_attestation, attestation);
+ assert.equal(prepare.input.project_name, "Mission 01");
});
diff --git a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs
index b5c21e3..5303f9b 100644
--- a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs
+++ b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs
@@ -39,7 +39,13 @@ after(async () => {
await server?.close();
});
-function syntheticPlugin({ pluginId, modelId, componentKey, connectionView }) {
+function syntheticPlugin({
+ pluginId,
+ modelId,
+ componentKey,
+ connectionView,
+ spatialControlsView,
+}) {
return {
manifest: {
apiVersion: "missioncore.nodedc/v1alpha2",
@@ -69,6 +75,7 @@ function syntheticPlugin({ pluginId, modelId, componentKey, connectionView }) {
},
RuntimeProvider: ({ children }) => children,
connectionViews: { [componentKey]: connectionView },
+ ...(spatialControlsView ? { SpatialControlsView: spatialControlsView } : {}),
};
}
@@ -98,6 +105,35 @@ test("each device plugin contributes its own connection pipeline component", ()
);
});
+test("registry exposes an optional model-scoped spatial controls contribution", () => {
+ const connectionView = () => null;
+ const spatialControlsView = () => null;
+ const registry = createDevicePluginRegistry([
+ syntheticPlugin({
+ pluginId: "synthetic.spatial",
+ modelId: "synthetic.spatial.sensor",
+ componentKey: "spatial.connection",
+ connectionView,
+ spatialControlsView,
+ }),
+ syntheticPlugin({
+ pluginId: "synthetic.connection-only",
+ modelId: "synthetic.connection-only.sensor",
+ componentKey: "connection-only.connection",
+ connectionView,
+ }),
+ ]);
+
+ assert.equal(
+ registry.resolveModel("synthetic.spatial.sensor").SpatialControlsView,
+ spatialControlsView,
+ );
+ assert.equal(
+ registry.resolveModel("synthetic.connection-only.sensor").SpatialControlsView,
+ undefined,
+ );
+});
+
test("XGRIDS frontend is physically plugin-owned and split by operator pipeline", () => {
if (existsSync(legacyPluginRoot)) {
assert.deepEqual(readdirSync(legacyPluginRoot), []);
@@ -108,10 +144,22 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
"styles.css",
"components/K1ProvisioningPipeline.tsx",
"components/K1AcquisitionPipeline.tsx",
+ "components/K1SpatialControls.tsx",
"components/K1Diagnostics.tsx",
+ "projectName.ts",
]) {
assert.equal(existsSync(join(pluginFrontendRoot, relativePath)), true, relativePath);
}
+
+ const spatialControls = readFileSync(
+ join(pluginFrontendRoot, "components/K1SpatialControls.tsx"),
+ "utf8",
+ );
+ assert.match(spatialControls, /shouldRenderSpatialControls\(state\)/);
+ assert.match(spatialControls, /cleanup_pending/);
+ assert.match(spatialControls, /spatialActionFailure/);
+ assert.match(spatialControls, /role="alert"/);
+ assert.match(spatialControls, /Повторить остановку/);
});
test("generic Control Station has one composition import and no K1 implementation knowledge", () => {
diff --git a/apps/control-station/test/observationSessions.test.mjs b/apps/control-station/test/observationSessions.test.mjs
index 8d365ec..edd4b9a 100644
--- a/apps/control-station/test/observationSessions.test.mjs
+++ b/apps/control-station/test/observationSessions.test.mjs
@@ -603,6 +603,25 @@ test("replay coordinator makes rapid saved-session selection latest-request-wins
coordinator.cancel();
assert.equal(third.signal.aborted, true);
assert.equal(third.isCurrent(), false);
+ assert.equal(third.finish(), true);
+ assert.equal(third.finish(), false);
+});
+
+test("an acquisition guard cancellation keeps settlement ownership", () => {
+ const coordinator = createObservationReplayCoordinator();
+ const archiveSwitch = coordinator.begin();
+ let replacementBlanked = false;
+ let replacementSettled = false;
+
+ // Models the point after onReplayBegin has released the previous viewer.
+ replacementBlanked = true;
+ coordinator.cancel();
+
+ assert.equal(archiveSwitch.signal.aborted, true);
+ assert.equal(archiveSwitch.isCurrent(), false);
+ if (archiveSwitch.finish()) replacementSettled = true;
+ assert.equal(replacementBlanked, true);
+ assert.equal(replacementSettled, true);
});
test("switching saved sessions aborts only local polling and never cancels shared backend work", async () => {
diff --git a/apps/control-station/test/runtimeAcquisitionGuard.test.mjs b/apps/control-station/test/runtimeAcquisitionGuard.test.mjs
new file mode 100644
index 0000000..0b5eaa5
--- /dev/null
+++ b/apps/control-station/test/runtimeAcquisitionGuard.test.mjs
@@ -0,0 +1,95 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import { after, before, test } from "node:test";
+
+import { createServer } from "vite";
+
+let server;
+let guard;
+
+before(async () => {
+ server = await createServer({
+ appType: "custom",
+ logLevel: "silent",
+ server: { middlewareMode: true },
+ });
+ guard = await server.ssrLoadModule("/src/core/runtime/acquisitionGuard.ts");
+});
+
+after(async () => {
+ await server?.close();
+});
+
+function runtimeState(acquisitionState, cleanupPending = false) {
+ return {
+ phase: "idle",
+ sourceMode: "idle",
+ acquisition: acquisitionState === null
+ ? null
+ : {
+ acquisitionId: "acq-1",
+ deviceId: "device-1",
+ deviceSessionId: "device-session-1",
+ compatibilityProfileId: "profile-1",
+ controlMode: "operator-manual",
+ state: acquisitionState,
+ stateRevision: 1,
+ operatorInstructions: [],
+ cleanupPending,
+ },
+ };
+}
+
+test("every nonterminal acquisition phase blocks saved and manual source replacement", () => {
+ for (const state of [
+ "preparing",
+ "prepared",
+ "awaiting_external_start",
+ "starting",
+ "acquiring",
+ "awaiting_external_stop",
+ "stopping",
+ "finalizing",
+ ]) {
+ assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(state)), true, state);
+ }
+});
+
+test("terminal or absent acquisitions allow an explicit source selection", () => {
+ for (const state of ["completed", "failed", "aborted", "interrupted"]) {
+ assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(state)), false, state);
+ }
+ assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(null)), false);
+ assert.equal(guard.isSpatialSourceSwitchBlocked(null), false);
+});
+
+test("an unknown acquisition state fails closed", () => {
+ assert.equal(
+ guard.isSpatialSourceSwitchBlocked(runtimeState("future_vendor_phase")),
+ true,
+ );
+ assert.match(guard.SPATIAL_SOURCE_SWITCH_BLOCKED_REASON, /Завершите текущий приём/);
+});
+
+test("terminal acquisition with retained cleanup remains source-switch blocked", () => {
+ assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState("failed", true)), true);
+ assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState("failed", false)), false);
+});
+
+test("saved-session and manual source controls share the acquisition guard", async () => {
+ const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
+ const sessionSelectSource = await readFile(
+ new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
+ "utf8",
+ );
+ const observationHookSource = await readFile(
+ new URL("../src/core/observation/useObservationSessions.ts", import.meta.url),
+ "utf8",
+ );
+
+ assert.match(appSource, /blockedReason=\{sourceSwitchBlockedReason\}/);
+ assert.match(appSource, /disabled=\{sourceSwitchBlocked \|\| !sourceDraft\.trim\(\)\}/);
+ assert.match(appSource, /if \(sourceSwitchBlockedRef\.current\) return;/);
+ assert.match(sessionSelectSource, /replayEnabled: blockedReason === null/);
+ assert.match(observationHookSource, /!replayEnabledRef\.current/);
+});
diff --git a/docs/01_IMPLEMENTATION_PLAN.md b/docs/01_IMPLEMENTATION_PLAN.md
index f0fb433..0afa415 100644
--- a/docs/01_IMPLEMENTATION_PLAN.md
+++ b/docs/01_IMPLEMENTATION_PLAN.md
@@ -15,27 +15,55 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
| Stage 4 artifacts/flows | GO — bounded capture, hashes and negative control |
| Stage 5 point cloud | GO — raw-LZ4 protobuf, 1,140 live frames decoded |
| Stage 5 pose | GO — 1,215 live frames decoded and motion-correlated |
+| Stage 5 modeling telemetry | GO (read-only) — bounded `ModelingReport` decoder and device-reported scan time/distance/speed are integrated before the preview queue |
| Stage 5 camera | GO (live) — left/right RTSP/H.264 preview observed, read-only runtime adapter and physical UI acceptance completed |
-| Stage 6 live viewer | GO — React Control Station, embedded self-hosted Rerun cloud/trajectory and Mac pipeline metrics |
-| Stage 7 observation archive | GO (point/pose) — durable catalog, recovery, background RRD preparation, saved-session timeline and atomic playback verified |
-| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; no real archived K1 camera session exists yet |
-| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
+| Stage 6 live viewer | GO — React Control Station, embedded self-hosted Rerun cloud/trajectory, plugin-owned spatial controls and live device/Mac metrics |
+| Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented |
+| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet |
+| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
USB project copying remains optional ground truth rather than a blocker for the
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
-start/stop mapping and RTSP camera transport. MQTT control publishing remains
-deliberately deferred because complete request/save/rollback semantics are not
-yet modeled and the physical button remains a known-safe fallback.
+start/stop mapping and RTSP camera transport. The exact start/stop protobuf
+encoder, response correlator and device-status state machine now exist without a
+publisher. Header construction is known: an explicit ASCII device ID and ASCII
+OpenAPI key are required, while the session ID is derived exactly as
+`${device_id}:ModelingRequest`. MQTT control publishing remains deliberately
+disabled because legitimate OpenAPI credential provenance/provisioning and a
+durable post-stop save gate are not proven. The physical button remains the
+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-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
-camera/LiDAR sensor-clock alignment remain separate correlation tests.
+queue cannot discard MQTT evidence. The queue holds four preview messages;
+`ModelingReport` is consumed by a plugin-injected observer before that queue and
+does not compete with point/pose frames. A project name is mandatory at
+preparation, NFKC-normalized/trimmed on both sides, rejected for control
+or surrogate characters or more than 96 characters, and retained only as
+display metadata.
+
+Stage 7 adds an independent durable observation-session lifecycle. New native
+captures durably publish the clock origin before camera production, retain a
+transport-scoped provisional envelope and atomically switch the capture-summary
+pointer to a content-addressed session envelope after all producers stop.
+Completed or recovered captures are prepared once by a bounded backend worker
+into digest-bound RRD/cache-v9 and camera-manifest generations;
+a browser replay request never performs conversion. The RRD materializes real
+origin/end rows plus archived device-reported distance, speed and scan-time
+series. The saved scene, controller and timeline remain hidden until the
+complete RRD range and every declared camera pass admission. Sensor-to-display
+latency and camera/LiDAR sensor-clock alignment remain separate correlation
+tests.
+
+The generic host clears a prior manual or archived spatial source only after a
+plugin-owned live/file-replay start succeeds; a failed start preserves the
+current scene. Once an acquisition is nonterminal, a fail-closed guard blocks
+saved-session switching, persisted replay reattach and manual source
+input/apply/reset until that acquisition has been finalized. The successful
+automatic-source action is an internal start-result boundary and intentionally
+does not masquerade as an operator source switch. The K1 scene-level stop
+control is plugin-owned; with the active read-only profile it stops and seals
+local reception only and never claims to stop the scanner.
## Stage 0 — repository and host baseline
@@ -221,6 +249,9 @@ useful stream is decoded or structurally identified.
- automated scan-button electronics;
- OpenWrt/monitor-mode infrastructure;
- firmware or internal-Linux analysis;
-- physical end-to-end replay acceptance for newly archived left/right cameras;
+- physical end-to-end shared-timeline playback for a newly archived session
+ containing point cloud plus one selected K1 camera;
+- legitimate K1 OpenAPI credential provisioning, authorized command transport
+ and stable-artifact proof after the observed stop lifecycle;
- long-running large-session WebViewer/WASM memory telemetry;
- production retention, replication, encryption and cross-platform packaging.
diff --git a/docs/05_K1_MQTT_STREAM_PROFILE.md b/docs/05_K1_MQTT_STREAM_PROFILE.md
index 3cde9fb..e755160 100644
--- a/docs/05_K1_MQTT_STREAM_PROFILE.md
+++ b/docs/05_K1_MQTT_STREAM_PROFILE.md
@@ -129,27 +129,46 @@ orientation is exposed as `(x,y,z,w)`. Unknown tail fields are retained.
`PrePathArray` is exactly 16 little-endian float64 values.
+## Modeling telemetry
+
+`lixel/application/report/modeling` contains a bounded `ModelingReport`. Field 2
+is PGO progress; field 3 is a nested scan status containing float32 move
+distance, float32 move speed and an int64 scan-time counter. Retained physical
+captures correlate that counter at exactly two ticks per second. Mission Core
+consumes this status before the disposable preview queue and exposes the current
+device-reported scan generation as elapsed seconds, route metres and metres per
+second. When scan time and distance reset together, the old route is not added
+to the new generation.
+
## Application control boundary
Static analysis first identified the application start/stop topic as
`lixel/application/request/modeling`, QoS 2, with a protobuf
-`ModelingRequest`. Start action is 1 and stop action is 2. A valid request also
-contains a device/session/OpenAPI header and, for start, project/record/scan/mount
-settings.
+`ModelingRequest`. Start action is 1 and stop action is 2. The retained header
+contains explicit device identity and OpenAPI value; its session relation is
+literally `{device_id}:ModelingRequest`, not a caller-selected session. The
+retained start request carries an operator project name, record mode 2, scan
+mode 1 and mount mode 0; the retained stop request carries only header and
+action. QoS is 2, retain is false, and correlated success uses numeric result
+`302252033` with the same header identity and action.
Lab 002 then observed both mappings in owner-operated LixelGO traffic and
correlated them with device success responses and physical start/stop of the
high-rate streams. This promotes the mapping from a static hint to descriptive
wire evidence; it does not authorize replay or publishing.
-Publishing is deliberately not implemented. The physical double-click provides
-a verified autonomous start/stop path and avoids inventing session headers or
-changing scan settings. A future publisher requires a separate reviewed profile,
-explicit confirmation, response handling and rollback.
+The repository now contains an inert bounded encoder/response parser and a
+fail-closed device-status state machine for this exact profile. They have no
+MQTT publish dependency. Publishing remains deliberately disabled: the OpenAPI
+credential's provenance and secure provisioning are unresolved, and the
+complete `ScanOver`/ready/stable-artifact sequence has not physically proved a
+durable save result. The verified physical double-click remains the acquisition
+control until a separate reviewed write gate closes.
-## Decoder and capture bounds
+## Decoder and raw-capture bounds
-Default defensive limits are applied before allocation or iteration:
+The semantic decoder applies these default defensive limits before allocation
+or iteration:
- 2 MiB maximum MQTT payload;
- 1 MiB maximum compressed block;
@@ -158,6 +177,10 @@ Default defensive limits are applied before allocation or iteration:
- 250,000 points per frame;
- bounded protobuf field counts and exact LZ4 decoded length.
+The raw-first evidence recorder has an independent 64 MiB default per-message
+limit, configurable only up to 256 MiB. That larger transport ceiling does not
+raise any semantic decoder allocation bound.
+
Per-frame failures do not justify firmware writes or speculative recovery.
Always preserve the raw MQTT record and report the decoder error separately.
diff --git a/docs/06_K1_LIVE_VIEWER.md b/docs/06_K1_LIVE_VIEWER.md
index 5342aae..738af23 100644
--- a/docs/06_K1_LIVE_VIEWER.md
+++ b/docs/06_K1_LIVE_VIEWER.md
@@ -10,18 +10,22 @@ trajectory, camera frame or latency values.
## Active device-to-scene path
```text
-K1 lio_pcl / lio_pose
+K1 lio_pcl / lio_pose / ModelingReport
|
v
read-only MQTT subscription on TCP 1883
|
- +--> raw .k1mqtt + JSONL + SHA-256 summary (written first)
+ +--> raw .k1mqtt + JSONL + durable clock origin (before camera/preview)
+ +--> transport/session envelope + SHA-256 summary (shutdown/seal)
+ |
+ +--> injected K1 ModelingReport observer
+ | `--> device scan time / distance / speed product metrics
|
v
-bounded latest-wins preview queue (32 messages)
+bounded latest-wins visual preview queue (4 messages)
|
v
-reviewed raw-LZ4/protobuf decoders
+reviewed raw-LZ4/protobuf point/pose normalizer
|
v
Rerun Points3D + Transform3D + LineStrips3D
@@ -44,6 +48,10 @@ become a durable pair at the bounded group-commit boundary (at most 0.5 s,
4 MiB or 32 messages), not at every callback. If visualization cannot keep up,
the oldest queued preview is discarded while capture continues. Rerun work
stays on the dedicated publisher thread and cannot block that raw-first path.
+The four-message queue size is independent of the 32-message evidence group
+commit. A plugin-injected observer consumes bounded K1 `ModelingReport` messages
+after raw persistence but before visual admission, so status telemetry cannot
+evict a point-cloud or pose preview.
The current live/replay runtime instantiates only `RerunBridge`. The former
Foxglove implementation is not a parallel runtime and does not listen on TCP
@@ -107,14 +115,26 @@ directly and use their sibling metadata receive timestamps when present.
complete visible-device list.
4. Enter the existing router SSID/password and explicitly authorize the reviewed
provisioning write. The backend does not retry the write automatically.
-5. When K1 reports a non-AP private address, start live reception.
-6. Wait until the UI reports that the local Rerun bridge is ready. No manual
+5. Enter the required project name. Mission Core NFKC-normalizes and trims it,
+ rejects control/surrogate characters or more than 96 characters, and stores
+ it as local display metadata rather than a path. The read-only profile does
+ not send it to K1.
+6. When K1 reports a non-AP private address, choose **Подготовить локальный
+ приём данных**. The stronger “initiate device work” wording appears only for
+ a future profile that actually authorizes a vendor write.
+7. Wait until the UI reports that the local Rerun bridge is ready. No manual
viewer URL is needed.
-7. Open **Наблюдение → Пространственная сцена**.
-8. Double-click the physical K1 button to start scanning. Real point frames and
+8. Open **Наблюдение → Пространственная сцена**. The K1 plugin's optional scene
+ block shows the local preparation/acquisition phase and stop action.
+9. Double-click the physical K1 button to start scanning. Real point frames and
pose/trajectory updates then appear in the embedded viewport.
-9. Double-click K1 again to stop physical scanning, wait for steady green, then
- stop the local session so captures and summaries are finalized.
+10. When K1 emits `ModelingReport`, the same plugin block shows its current scan
+ time, route distance and speed. These are device reports, not values inferred
+ from a browser timer or accumulated across a device counter reset.
+11. Double-click K1 again to stop physical scanning and wait for steady green,
+ then choose **Остановить локальный приём** in the scene or device workflow
+ so capture, camera archive and summaries are finalized. That UI action does
+ not stop K1 on the active profile.
Each new live run creates a direct child below `MISSIONCORE_EVIDENCE_DIR`, or
`.runtime/mission-core/evidence/sessions/` by default, with raw MQTT frames,
@@ -123,6 +143,15 @@ per-message metadata and a hash summary. Repository-level
the current writer. The connector subscribes to the fixed report-topic
allowlist and does not publish an application request or modeling command.
+The recovered command substrate is intentionally inert. It can encode exact
+start/stop request bytes, correlate response identity/action/result and classify
+observed device states without importing MQTT or publishing anything. The header
+requires explicit ASCII `device_id` and OpenAPI key and derives the session ID
+exactly as `${device_id}:ModelingRequest`. Legitimate OpenAPI key
+provenance/provisioning and durable save completion after stop are unresolved,
+so `vendor_writes_enabled` is false and the physical-button workflow remains
+canonical.
+
## Automatic Rerun source and lifecycle
On the first live or adapter file-replay session, `RerunBridge`:
@@ -149,6 +178,16 @@ served over HTTP(S). No externally hosted viewer UI is involved:
application. FastAPI carries control state only; the browser reads the point
stream directly from the Rerun gRPC/proxy endpoint.
+`activateAutomaticSpatialSource` is a generic host action, not a scanner-control
+command. A plugin invokes it only after its live or adapter-replay start returns
+success, then opens the spatial scene; a failed start preserves the old scene
+and source. The successful action releases any host-selected archive/manual URL
+so the new backend source becomes authoritative. After an acquisition becomes
+nonterminal, a fail-closed host guard blocks saved-session switching, persisted
+replay reattach and manual RRD/Rerun source input/application/reset until the
+acquisition ends. This successful-start transition intentionally does not
+consult the operator-switch guard, and the guard never stops equipment.
+
## Rerun entities
| Entity | Rerun archetype | Meaning |
@@ -159,8 +198,11 @@ stream directly from the Rerun gRPC/proxy endpoint.
| `/world/trajectory` | `LineStrips3D` | bounded path of up to 2,000 decoded poses |
Point count, frame rate, queue drops and measured pipeline time remain product
-metrics in the outer Control Station; they are not logged as Rerun entities and
-therefore cannot create additional automatic viewer panes.
+metrics in the outer Control Station. Live K1 scan time, route distance and
+speed also remain product metrics in the plugin-owned scene block. Saved-session
+export separately logs those three device-reported values below
+`/metrics/device` as Rerun scalar time series; this does not add vendor parsing
+to the generic live bridge.
Point coordinates remain `(x/scaler, y/scaler, z/scaler)` exactly as decoded.
No axis swap, quaternion normalization, scanner-to-vehicle extrinsic or SLAM
@@ -210,28 +252,46 @@ 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-v7 RRD using the zero-based `session_time`
-duration timeline. A SHA sidecar binds the generation to native evidence.
+atomically publishes a private cache-v9 RRD using the zero-based `session_time`
+duration timeline. New sessions bind that zero/end to the durable capture-clock
+envelope; older valid evidence retains the first/last-message compatibility
+fallback. 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
generation through Rerun's native incremental HTTP receiver. The bottom Control
Station timeline then controls play/pause and seek directly; it does not
approximate time with a React timer.
-The native `.k1mqtt` remains the evidence master. RRD generation does not use
-the bounded live-preview queue, so it retains every frame accepted by the
-reviewed normalizer. See
+The native `.k1mqtt`, aligned metadata and capture-clock envelope remain the
+spatial evidence master. RRD generation does not use the bounded live-preview
+queue, so it retains every point/pose frame accepted by the reviewed normalizer
+and logs every valid `ModelingReport` as distance, speed and elapsed-time scalar
+rows. See
[`09_OBSERVATION_SESSIONS.md`](09_OBSERVATION_SESSIONS.md) for storage,
recovery and HTTP Range details.
## Time and latency semantics
-The Rerun `capture_time` timeline uses
-`StreamMessage.received_at_epoch_ns`: Unix time when the Mac received the MQTT
-message. The K1 header timestamp epoch is not proven, so `capture_time` is not
-a sensor acquisition timestamp. The viewer accumulation window uses
-`stream_time`, assigned when the current live/replay run publishes a message;
-therefore replayed historical timestamps do not mix two sequential sessions.
+The Rerun `capture_time` timeline uses Mac host-clock epoch values. Data rows use
+`StreamMessage.received_at_epoch_ns`, while new archived sessions also contain a
+real origin row at the capture-envelope start and a real end row at the sealed
+session completion. Their zero-based `session_time` is derived from the same
+monotonic envelope; it can therefore cover a camera fragment before the first
+MQTT packet or after the last spatial packet without inventing an RRD range.
+Legacy evidence without an envelope falls back to first/last message bounds.
+The K1 header timestamp epoch is not proven, so `capture_time` is not a sensor
+acquisition timestamp. The live viewer accumulation window uses `stream_time`,
+assigned when the current live/replay run publishes a message; therefore
+replayed historical timestamps do not mix two sequential sessions.
+
+Retained read-only physical captures support the profile-scoped telemetry units:
+in five of six runs, `ScanTime / 2` tracks host monotonic duration within about
+0.17 seconds across 76–2,294-second spans; the remaining older run contains a
+counter/distance reset and is treated as a new scan generation. In two moving
+runs, `ModelingReport.MoveDistance` matches the independent
+`lio_pose.distance` values at 41.521 m and 26.631 m, while reported peak speeds
+of 1.382 m/s and 1.291 m/s are physically plausible. This evidence does not turn
+the counters into a cross-firmware contract.
For live MQTT only, `pipeline_ms` / `mqtt_to_publish_ms` uses the Mac monotonic
clock from MQTT callback receipt through raw disk write, bounded queue wait,
@@ -272,7 +332,8 @@ listener and its process memory must be closed unconditionally.
separate generic media path. Historical sessions predating that archive have
no recoverable video.
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
- publisher needs a separately reviewed state-changing profile.
+ publisher needs a separately reviewed state-changing profile with legitimate
+ OpenAPI key provisioning and a proven durable-save completion gate.
- No terrain map, elevation model, obstacle segmentation, localization fusion,
mission planner or vehicle control is implemented by this viewer milestone.
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting
@@ -297,8 +358,9 @@ embedded Web Viewer path without a viewer-side substitute:
The 38 point frames and 42 pose frames account for all 80 observed messages.
This proves current real-device decoding, bridge publication and embedded-viewer
-delivery for point cloud plus trajectory. It does not prove a camera channel or
-sensor-to-screen latency.
+delivery for point cloud plus trajectory. It does not prove archived playback
+of one selected camera together with the point cloud or sensor-to-screen
+latency.
## Legacy Foxglove regression module
diff --git a/docs/07_MISSION_CORE_MONOREPO.md b/docs/07_MISSION_CORE_MONOREPO.md
index c34676a..83d20aa 100644
--- a/docs/07_MISSION_CORE_MONOREPO.md
+++ b/docs/07_MISSION_CORE_MONOREPO.md
@@ -15,7 +15,7 @@ and the bounded laboratory runtime seam in
| `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 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 |
+| `plugins/xgrids-k1/frontend/` | XGRIDS provisioning, project/acquisition/replay, optional spatial controls, diagnostics, metrics, runtime mapping and scoped CSS | Plugin-owned reviewed frontend contribution |
| `src/k1link/web/` | Local FastAPI host, fail-closed runtime handshake/transport seam, 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 |
@@ -32,7 +32,8 @@ The current K1 live/replay data path is:
```text
raw K1 transport
-> raw-first evidence capture
- -> bounded preview queue
+ -> injected K1 ModelingReport observer (device time/distance/speed)
+ -> bounded four-message visual preview queue
-> XGRIDS normalizer injected by the plugin facade
-> DecodedPointCloudView / DecodedPoseView
-> Rerun bridge
@@ -44,12 +45,25 @@ 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 v7 + immutable recorded-media manifest v2
+ -> capture-clock-bound atomic RRD cache v9 + immutable recorded-media manifest v2
-> generation-bound same-origin HTTP
-> aggregate RRD/camera admission
-> native Rerun HTTP receiver + recorded fMP4 player
```
+New K1 source generations durably publish
+`captures/mqtt_live/mqtt.timeline.origin.json` before camera production. MQTT
+shutdown adds provisional transport envelope `mqtt.timeline.json`; after camera
+and MQTT/runtime shutdown, the acquisition owner publishes content-addressed
+`mqtt.timeline.session-.json` and atomically switches the capture-summary
+pointer while still holding the session lease. Only that `session` scope is
+eligible to advertise combined media replay. Cache v9 materializes both session
+envelope endpoints as real RRD rows and logs archived device-reported scan time,
+route distance and speed as scalar time series. A crash-recovered origin-only
+point/pose prefix falls back to its last validated message and cannot advertise
+camera media. Older valid sessions without the clock contract keep their
+first/last-message fallback and cannot acquire camera evidence retroactively.
+
Rerun does not import K1 protocol code, inspect MQTT topics or receive raw
payloads. `VisualizationRuntime` cannot choose a vendor decoder implicitly; its
composition owner injects one. The local `Decoded*View` classes are in-process
@@ -73,7 +87,20 @@ 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.
+exact-profile confirmation, project metadata, acquisition/replay UI and the
+optional K1 spatial-scene control block are not Core components. The host owns
+only the optional slot contract and generic actions such as opening the scene or
+releasing a prior manual/archive selection after a plugin source successfully
+starts.
+
+A generic fail-closed guard blocks manual source input/apply/reset,
+saved-session switching and persisted replay reattach whenever the runtime
+reports a nonterminal acquisition. Unknown future acquisition states block by
+default. A plugin clears the previous host-selected source only after its start
+returns success, preserving the current scene on failure. That internal
+success-result transition is distinct from an operator switch. The guard does
+not stop equipment or invent a lifecycle transition; the acquisition must
+finish through its owning plugin.
## Experimental vocabulary, not Platform Ontology
@@ -115,6 +142,17 @@ transport mutation. Application-command publishing remains disabled; the
separately reviewed BLE Wi-Fi provisioning write retains its own explicit
operator gate.
+An exact but inert K1 modeling-control substrate now lives inside the vendor
+plugin. It encodes the recovered start/stop protobuf shapes, strictly correlates
+response identity/action/numeric result and maps bounded device-status values
+into an observation-only state machine. Header construction is no longer
+guessed: callers provide explicit ASCII device ID and ASCII OpenAPI key, and the
+session ID is derived exactly as `${device_id}:ModelingRequest`. No MQTT import
+or publisher exists in that substrate. Legitimate OpenAPI key
+provenance/provisioning and stable-artifact proof after the stop lifecycle are
+still unresolved, so `vendor_writes_enabled=false` and every state-machine
+snapshot remains false for durable save completion.
+
## Semantic lifecycle
The transitional facade now creates separate provisional device,
@@ -123,6 +161,20 @@ wait for receiver readiness, then wait for an external physical start, and becom
data, wait for an externally confirmed stop, finalize and complete. Capture-only
stop reports the K1 physical state as unknown.
+Preparation requires a project name. The frontend and backend both apply NFKC
+normalization plus surrounding-whitespace trimming, reject control/surrogate
+characters and names above 96 Unicode characters, and store the result as
+session/catalog display metadata. It is not a path component. Because command
+publishing is off, the current integration does not claim that the name reaches
+K1.
+
+The K1 contribution may mount a `SpatialControlsView` beside the host-owned
+viewport. It presents plugin lifecycle wording, the local stop action and live
+`ModelingReport` scan time, route distance and speed. Under the current
+capture-only profile the stop action seals local reception; it does not claim a
+device stop. `ModelingReport` is consumed before the visual preview queue through
+an injected observer, preserving the generic normalizer/Rerun boundary.
+
The operation journal is bounded and in memory. It records IDs, idempotency,
declared deadlines, progress and terminal results without action parameters or secrets.
It is not durable, distributed or recoverable after process restart.
@@ -140,8 +192,7 @@ independently of browser delivery. The generic recorded player, manifest-v2
validation and shared `session_time` controls are connected; historical sessions
created before this archive contract contain no recoverable video, so physical
recorded-camera acceptance remains open. Portable FFmpeg packaging, disk-backed
-browser buffering, fan-out and remote delivery also remain open. Device status
-and heartbeat remain raw observed channels without semantic decoders. Device
+browser buffering, fan-out and remote delivery also remain open. Device
calibration command and sensor-to-vehicle extrinsics are unavailable.
The 2026-07-17 physical acceptance gate confirmed continuously updating point
@@ -149,7 +200,10 @@ clouds, a matching live trajectory, and both camera selections in the same
Mission Core acquisition. The embedded Rerun blueprint owns live-edge following
on `stream_time`; the React shell selects the timeline once and does not drive it
with a timer. Raw captures, camera frames, device identity and network details
-remain outside Git. See [`ADR 0007`](adr/0007-k1-camera-preview-copy-remux-gateway.md)
+remain outside Git. The bounded acquisition subset of `DeviceStatusReport` and
+`ModelingReport` route telemetry are decoded for the exact profile; heartbeat
+and nested system/RTK status payloads remain raw observed evidence. See
+[`ADR 0007`](adr/0007-k1-camera-preview-copy-remux-gateway.md)
for the measured gate and remaining limits.
## Remaining extraction order
@@ -169,8 +223,9 @@ views.
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
- gate closes.
+ plane. Keep the modeling-command publisher disabled until legitimate OpenAPI
+ key provisioning and durable-save confirmation close its separate safety
+ gate.
Complex equipment will likely be assembled from separately useful component
plugins into configured hardware packs, while standalone equipment remains
@@ -209,8 +264,8 @@ single K1 vertical.
- remote Edge split, authenticated WAN relay and fleet orchestration;
- automatic K1 start/stop/calibration commands;
- device-reported camera capability discovery beyond the reviewed K1 profile;
-- physical acceptance of shared-timeline recorded-camera playback on a newly
- archived K1 session;
+- physical acceptance of shared-timeline point-cloud plus one selected-camera
+ playback on a newly archived K1 session;
- frame-accurate camera/LiDAR calibration, panoramic stitching, disk-backed
browser media cache and remote multi-consumer delivery;
- production retention, replication, encryption and long-run WebViewer/WASM
diff --git a/docs/09_OBSERVATION_SESSIONS.md b/docs/09_OBSERVATION_SESSIONS.md
index d2aa5bc..4ca3a95 100644
--- a/docs/09_OBSERVATION_SESSIONS.md
+++ b/docs/09_OBSERVATION_SESSIONS.md
@@ -1,15 +1,19 @@
# Observation sessions, playback and workspace layout
Status: implemented for native K1 point/pose evidence and host-side camera
-archival. Recorded spatial playback is exposed in the Mission Core observation
-workspace. The camera archive/player contract is implemented and covered by
-tests, but all retained real K1 sessions predate canonical camera archival.
-Physical archived-camera playback therefore remains an open acceptance gate;
-historical sessions contain no recoverable video.
+archival. Valid K1 `ModelingReport` scan time, distance and speed are also
+materialized as recorded Rerun time series. Recorded spatial playback is exposed
+in the Mission Core observation workspace. The camera archive/player contract
+is implemented and covered by tests, but all retained real K1 sessions predate
+canonical camera archival. Physical shared-timeline playback of one selected
+camera plus point cloud therefore remains an open acceptance gate; historical
+sessions contain no recoverable video.
## Operator path
-1. Start a normal acquisition from **Парк → Локальное устройство**.
+1. Enter the required project name and start a normal acquisition from **Парк →
+ Локальное устройство**. The NFKC-normalized/trimmed value is local display
+ metadata, not a filesystem path, and becomes the saved-session catalog name.
2. Open **Наблюдение → Пространственная сцена**. Live point cloud, trajectory
and the selected camera remain live-only while acquisition is running.
3. Stop acquisition normally, or allow the local service to recover an
@@ -35,6 +39,15 @@ historical sessions contain no recoverable video.
opening restores display settings, tool windows and dynamic source-window
positions. This action saves no sensor evidence.
+Mission Core does not switch an active acquisition to another spatial source.
+Every nonterminal or unknown acquisition state fail-closes saved-session replay,
+persisted replay reattach and manual RRD/Rerun input/apply/reset with
+**Завершите текущий приём перед сменой источника.** A plugin-owned automatic
+source action clears the older source and opens the scene only after its start
+returns success; failure leaves the current scene mounted. This internal
+success-result transition is not an operator switch. The guard does not stop the
+scanner or local receiver automatically.
+
Display controls have no separate Apply/Reset transaction. Boolean controls
commit immediately; sliders, colors and selects commit on release/blur or after
a short quiet period, and concurrent commits collapse to the newest value. A
@@ -75,12 +88,12 @@ A queued job may legitimately wait behind another export and therefore uses the
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 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.
+point/pose message and every valid K1 `ModelingReport` is projected into RRD.
+Later openings reuse a verified, digest-bound cache. Derived RRD cache v9 is
+intentionally incompatible with v8 and older generations. In addition to plugin
+and ordered-artifact identity, it binds the durable clock origin and active
+envelope and materializes real `session_time = 0` origin and sealed completion
+rows. 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
@@ -106,7 +119,15 @@ checkout:
│ └── sessions/
│ ├── .current_session # present only while a writer owns the root
│ └── _viewer_live/
-│ ├── captures/ # native MQTT source evidence
+│ ├── manifest.redacted.json # normalized local project display metadata
+│ ├── captures/
+│ │ └── mqtt_live/
+│ │ ├── mqtt.raw.k1mqtt
+│ │ ├── mqtt.metadata.jsonl
+│ │ ├── mqtt.timeline.origin.json
+│ │ ├── mqtt.timeline.json # provisional transport envelope
+│ │ ├── mqtt.timeline.session-.json # sealed generation
+│ │ └── mqtt.summary.json # atomic active-envelope pointer
│ └── media/ # canonical camera archives
└── recordings/
├── .export.lock # cross-process conversion/eviction lock
@@ -156,6 +177,9 @@ For the current K1 profile:
```text
MQTT callback
├─ durable native .k1mqtt + aligned metadata (source of record)
+ ├─ durable clock origin (before camera production)
+ ├─ provisional + content-addressed envelopes (transport/session bounds)
+ ├─ ModelingReport -> live product metrics (before visual preview queue)
└─ bounded latest-wins Rerun live preview (disposable)
selected RTSP producer
@@ -166,14 +190,21 @@ selected RTSP producer
The live preview is intentionally allowed to drop frames under load. Native
point/pose evidence and camera archive writes do not traverse that queue.
-Native `.k1mqtt` bytes with aligned metadata and canonical camera fMP4 archives
-are the evidence source of truth. The derived RRD contains every decodable point
-and pose frame from that source, but remains a rebuildable view rather than an
-evidence master. A cache entry is rebuilt only when native source
+Native `.k1mqtt` bytes with aligned metadata, the capture-clock artifacts and
+canonical camera fMP4 archives are the evidence source of truth. The derived RRD
+contains every decodable point and pose frame plus every valid `ModelingReport`
+distance/speed/scan-time sample from that source, but remains a rebuildable view
+rather than an evidence master. A cache entry is rebuilt only when native source
identity/digests change or an incompatible derived-data export revision is
introduced. A UI blueprint or workspace-layout revision never invalidates or
-rewrites the data RRD. Cache v4/v5 payloads are not reusable as v6 because they
-do not guarantee the real zero-time anchor.
+rewrites the data RRD. Cache v8 and older payloads are not reusable as v9 because
+they do not bind both real capture-envelope endpoints.
+
+`ModelingReport` time is the device's `ScanTime` counter at two ticks per second;
+distance and speed are the reported `MoveDistance`/`MoveSpeed` values. Live state
+keeps the current device scan generation and does not accumulate an earlier
+distance after the device resets scan time and route distance. These values are
+not reconstructed from pose integration or a browser timer.
Expensive cache misses run through the bounded preparation worker and one global
cross-process export gate to cap peak RAM, CPU and temporary-disk use. Crash
@@ -183,6 +214,52 @@ active response leases do not wait behind that gate. The derived cache has an
8 GiB default quota, preserves a 2 GiB default filesystem reserve and evicts
least-recently-used RRDs only; it never deletes native evidence.
+## Capture-clock envelope
+
+New K1 captures first publish bounded schema-1
+`captures/mqtt_live/mqtt.timeline.origin.json` with start epoch and monotonic
+nanoseconds. The writer creates and synchronizes this immutable artifact after
+its raw/metadata files are open and before camera production is armed. A crash
+therefore cannot leave retained camera evidence whose intended session zero
+existed only in memory.
+
+MQTT shutdown then publishes schema-1 `mqtt.timeline.json` with the same start
+plus transport completion. Capture-summary schema 2 initially points to it with
+`capture_clock_scope: transport`, and binds both origin/envelope names and
+SHA-256 values. That provisional file is not rewritten.
+
+After the camera archive and MQTT/runtime stop, the acquisition owner creates
+`mqtt.timeline.session-.json` with the extended session completion and
+atomically switches `mqtt.summary.json` to that content-addressed name, digest
+and `capture_clock_scope: session` while still holding the active-session lease.
+Publication order is sealed file first, summary pointer second, so a crash leaves
+the previous pointer valid and the idempotent seal can converge on retry. The
+same summary update records `session_elapsed_seconds`. Files and containing
+directories are synchronized at their publication boundaries.
+
+Discovery validates the summary-selected filename, both digests, the shared
+origin and the envelope bounds, then catalogs that exact artifact. The
+materializer passes its exact staged path to the exporter; it never chooses a
+sealed generation by glob or “latest file” ordering. Direct/legacy export keeps
+the fixed provisional-name fallback only for evidence outside the cataloged
+session contract, and orphan sealed candidates not selected by the validated
+summary are ignored.
+
+A provisional `transport` scope is never advertised as combined camera replay.
+An interrupted origin-only point/pose prefix may use the durable origin and its
+last validated message as a compatibility end, but it cannot advertise camera
+media. If the origin, active envelope, summary pointer or digest relationship is
+missing/corrupt, new-schema discovery fails closed instead of inventing a
+session boundary.
+
+For a sealed generation, `session_time = 0` is the envelope start and the RRD
+end is the envelope completion. Cache v9 logs real rows at
+`/__mission_core/session_origin` and `/__mission_core/session_end`, so a camera
+fragment accepted before the first MQTT message or after the last point/pose
+message can remain inside the declared RRD interval. Valid legacy sessions
+without this artifact keep their first/last-message compatibility fallback and
+cannot gain historical camera coverage.
+
## Camera archive contract
New acquisitions archive each selected source and codec epoch below the same
@@ -275,6 +352,11 @@ camera acceptance run retained point/pose evidence only.
process or power failure may discard the final uncommitted group. Raw bytes
from an interrupted commit window may survive beyond the durable metadata;
replay uses only the last validated metadata-aligned raw boundary.
+- New capture-summary schema 2 binds the durable origin and its active envelope
+ by name and SHA-256. A normal combined replay requires the owner-sealed,
+ content-addressed `session` scope. A crash can leave an origin-only or
+ provisional `transport` generation, but discovery will not use either to
+ advertise camera coverage. A mismatched origin/envelope/summary fails closed.
- A native capture with a missing final summary is accepted only when the raw
and metadata prefix is bounded, aligned and structurally valid. It is cataloged
as `interrupted`, never silently promoted to `ready`.
@@ -385,15 +467,17 @@ API boundary.
## Current limit
-The spatial RRD, trajectory and archived fMP4 cameras use the same operator
-scrubber now. Camera epochs are aligned to zero-based `session_time` from the
-shared host-arrival monotonic clock and rendered through MSE. The client selects
-an epoch only inside its declared inclusive interval and verifies that the
+The spatial RRD, trajectory, archived device-metric series and archived fMP4
+cameras use the same operator scrubber now. Camera epochs are aligned to
+zero-based `session_time` from the shared host-arrival monotonic clock and
+rendered through MSE. The metric tab carries device-reported route distance,
+speed and scan time at their `ModelingReport` receive times. The client selects
+a camera epoch only inside its declared inclusive interval and verifies that the
decoded MSE seekable duration covers that interval. This remains best-effort
-correlation: codec PTS and K1 sensor exposure time are not proven to share the
-LiDAR clock. A codec epoch whose init segment does not expose a browser-supported
-codec or whose timing cannot be proven remains retained evidence and fails
-closed in the UI/background preparation.
+correlation: codec PTS, K1 sensor exposure time and LiDAR firing time are not
+proven to share a device clock. A codec epoch whose init segment does not expose
+a browser-supported codec or whose timing cannot be proven remains retained
+evidence and fails closed in the UI/background preparation.
Historical sessions with no canonical camera archive honestly show no recorded
video. Recorded blueprints can change accumulation, grid visibility, point and
trajectory visibility, point radius and a uniform custom point color without
@@ -403,21 +487,20 @@ declared RRD has been received and decoded. Height, intensity, distance and RGB
palettes remain baked into current RRD rows; fully dynamic recoloring requires
exporting the corresponding scalar components in a future recording schema.
-## Verification checkpoint — 2026-07-17
+## Verification boundary — 2026-07-17
-The repository state described above passed the complete local pre-push gate:
-
-- `uv sync --frozen --group dev` completed against the locked Python graph;
-- `uv run pytest`: **284 passed**;
-- `uv run ruff check .`: clean;
-- project mypy and strict plugin-SDK mypy: clean across 51 and 12 source files;
-- the XGRIDS K1 profile loader and emitted v0alpha2 JSON schema validated;
-- clean `npm ci` ran the required Rerun 0.34.1 postinstall patch;
-- frontend unit suite: **111 passed, 0 failed**;
-- TypeScript project build and Vite production build completed;
-- the generated `dist/index.html` and non-empty Rerun WASM artifact were verified.
+The current automated gate covers project-name validation, source-switch
+fail-closure, optional plugin scene controls, bounded `ModelingReport` decode,
+inert start/stop encoding and correlation, capture-clock publication/validation,
+cache-v9 origin/end materialization and archived metric series. The standard
+repository gate remains Python tests/Ruff/mypy plus frontend unit tests,
+TypeScript checking and the Vite production build; exact pass counts belong to
+the commit's CI/pre-push result rather than this durable architecture contract.
Vite still reports its expected large-chunk warning for the embedded Rerun
viewer/WASM payload. That is a packaging optimization item, not a failed gate.
-No retained physical K1 session contains the new canonical camera archive yet,
-so real recorded-camera playback remains an explicit hardware acceptance test.
+No retained physical K1 session contains the new canonical camera archive, so a
+real point-cloud plus one-camera recorded playback remains an explicit hardware
+acceptance test. Automated protocol tests also do not authorize K1 modeling
+publishing: legitimate OpenAPI key provisioning and durable save completion
+remain separate physical/security gates.
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
index fb593f3..8d6ff21 100644
--- a/docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md
+++ b/docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md
@@ -28,23 +28,41 @@ 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.
+For new K1 sessions, the plugin also contributes confined clock artifacts. A
+durable origin is published before camera production, MQTT shutdown creates a
+transport-scoped provisional envelope, and the acquisition owner publishes a
+content-addressed session envelope plus an atomic summary-pointer switch after
+camera and MQTT/runtime shutdown. A provisional envelope cannot advertise
+combined camera replay. Valid older sessions without this contract keep their
+first/last-message compatibility fallback.
+
`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:
+Derived RRD cache v9 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.
+- durable clock origin, active-envelope start/completion, selected clock
+ artifact identity and both digests when present;
+- derived recording digest and zero-based timeline bounds whose origin and end
+ are both materialized as real RRD rows.
-Cache v6 and older sidecars are rebuilt once. The native evidence is not
+Cache v8 and older sidecars are rebuilt once. The native evidence is not
rewritten.
+The K1 exporter also recognizes bounded `ModelingReport` messages. It writes the
+reported route distance, speed and scan time as scalar time series below
+`/metrics/device`; it does not infer them from pose integration or a UI timer.
+Live observation consumes the same status through a plugin-injected raw-message
+observer before the four-message visual preview queue. The generic
+`VisualizationRuntime` still has no K1 decoder or topic knowledge.
+
The concrete implementation is physically located below:
```text
@@ -79,6 +97,11 @@ 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.
+Additional acceptance tests bind the K1 capture clock into the ordered replay
+artifacts, reject corrupt or provisional new-schema boundaries, materialize real
+origin/end rows and retain archived device-metric samples without traversing the
+lossy live-preview queue.
+
## Consequences
The semantic and physical vendor boundary is complete for the current
@@ -87,6 +110,12 @@ operator workflows remain compatible. The `k1link` distribution name and CLI
command remain transitional compatibility names; their implementation is now
plugin-owned.
+The operator project name is normalized and validated by the K1 contribution,
+stored as display/catalog metadata and never used as a path component. This ADR
+does not claim that it reaches the scanner: the inert modeling-control codec has
+no publisher, and automatic K1 writes remain disabled pending legitimate OpenAPI
+credential provisioning and durable post-stop save evidence.
+
ADR 0011 subsequently places the action control plane behind a versioned
descriptor/handshake/health transport seam. Observation discovery and export
remain host-called plugin contributions and are not yet portable process
diff --git a/docs/adr/0010-plugin-owned-frontend-device-workflows.md b/docs/adr/0010-plugin-owned-frontend-device-workflows.md
index 1c2e344..d121c0d 100644
--- a/docs/adr/0010-plugin-owned-frontend-device-workflows.md
+++ b/docs/adr/0010-plugin-owned-frontend-device-workflows.md
@@ -26,10 +26,13 @@ are physically located under:
```text
plugins/xgrids-k1/frontend/src/
plugin.ts
+ projectName.ts
+ automaticSourceStart.ts
XgridsK1Connection.tsx
components/
K1ProvisioningPipeline.tsx
K1AcquisitionPipeline.tsx
+ K1SpatialControls.tsx
K1Diagnostics.tsx
K1Metrics.tsx
```
@@ -43,9 +46,32 @@ 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.
+saved-session UX and global layout. A contribution may additionally expose one
+optional `SpatialControlsView`, which the generic scene mounts beside the
+viewport without learning its fields or lifecycle wording. The plugin owns BLE
+candidates, exact-profile attestation, credentials-in-memory form state, K1
+endpoint state, connection instructions, project-name validation, live/replay
+acquisition controls and its scene-level status/stop/telemetry block.
+
+The host surface includes `activateAutomaticSpatialSource`. The name refers to
+viewer-source ownership, not automatic equipment control. The plugin calls it
+only after live or adapter replay start returns success and then opens the scene;
+a failed start leaves the existing scene/source intact. The action clears the
+earlier manual/archive selection so backend state can supply the successful new
+source. A generic fail-closed acquisition guard blocks saved-session switching,
+persisted replay reattach and manual source input/apply/reset for every
+nonterminal state, cleanup-pending terminal state or unknown acquisition state.
+The internal successful-start action is not an operator source switch and
+performs no implicit stop.
+
+K1 project names are required, NFKC-normalized and trimmed, rejected for control
+or surrogate characters or more than 96 Unicode characters, and sent to the
+backend as local display metadata. The active profile has
+`vendor_writes_enabled=false`, so its
+buttons and scene control say that they prepare/stop local reception. Before an
+acquisition exists, device-start wording is gated by active-control capability;
+after start, calibration/device-stop wording additionally requires a
+plugin-commanded acquisition mode.
Plugin CSS is imported with the contribution and scoped below
`.xgrids-k1-plugin`. A plugin cannot add global navigation, route ownership or
@@ -65,19 +91,24 @@ the correct component for each model. It also fails when:
- 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.
+- provisioning, acquisition, optional scene controls, diagnostics or
+ scoped-style modules disappear;
+- a nonterminal acquisition can be replaced by a manual or recorded spatial
+ source, including persisted replay reattach.
-The accepted local gate is 114 frontend unit tests, TypeScript strict checking
+The accepted local gate is the frontend unit suite, TypeScript strict checking
and a Vite production build. Backend behavior and the physical K1 protocol are
-unchanged by this source/UI decomposition.
+unchanged by this source/UI decomposition; protocol command authorization is a
+separate backend/physical gate.
## 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.
+and optional scene controls 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; the
+generic host changes only when a genuinely cross-device slot/action is added,
+not for vendor fields or protocol-specific workflow steps.
The remaining isolation gap is deployment-level: frontend contributions are
still compiled into one signed application build, and backend plugins still run
diff --git a/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx b/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx
index 14876e0..aa92eab 100644
--- a/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx
+++ b/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx
@@ -87,6 +87,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
controller={controller}
profileConfirmed={profileConfirmed}
openSpatialScene={host.openSpatialScene}
+ activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
/>
diff --git a/plugins/xgrids-k1/frontend/src/api.ts b/plugins/xgrids-k1/frontend/src/api.ts
index ea0bfc5..9e103f5 100644
--- a/plugins/xgrids-k1/frontend/src/api.ts
+++ b/plugins/xgrids-k1/frontend/src/api.ts
@@ -74,6 +74,8 @@ export interface XgridsAcquisition {
device_session_id: string;
compatibility_profile_id: string;
control_mode: "operator-manual" | "plugin-commanded" | "observe-only";
+ project_name?: string | null;
+ cleanup_pending?: boolean;
requested_streams: string[];
target_host: string;
duration_seconds: number;
@@ -119,6 +121,13 @@ export interface XgridsK1Metrics {
frame_rate_hz?: number | null;
point_count?: number | null;
dropped_preview_frames?: number | null;
+ device_elapsed_seconds?: number | null;
+ device_route_distance_meters?: number | null;
+ device_speed_meters_per_second?: number | null;
+ device_speed_mps?: number | null;
+ elapsed_seconds?: number | null;
+ route_distance_meters?: number | null;
+ speed_meters_per_second?: number | null;
[key: string]: number | null | undefined;
}
@@ -216,6 +225,7 @@ export interface ConnectRequest {
}
export interface PrepareAcquisitionRequest {
+ project_name: string;
host?: string;
duration_seconds?: number;
requested_streams?: RequestedStreamId[];
@@ -229,6 +239,7 @@ export interface PrepareAcquisitionRequest {
export type RequestedStreamId =
| "spatial.point-cloud.live"
| "spatial.pose.live"
+ | "device.modeling.live"
| "device.status.live"
| "device.heartbeat.live";
@@ -257,6 +268,7 @@ export interface AbortAcquisitionRequest {
}
export interface CompatibilityLiveRequest {
+ project_name: string;
host?: string;
duration_seconds?: number;
compatibility_attestation: CompatibilityAttestation;
diff --git a/plugins/xgrids-k1/frontend/src/automaticSourceStart.ts b/plugins/xgrids-k1/frontend/src/automaticSourceStart.ts
new file mode 100644
index 0000000..3fd2f1f
--- /dev/null
+++ b/plugins/xgrids-k1/frontend/src/automaticSourceStart.ts
@@ -0,0 +1,11 @@
+export async function runAutomaticSpatialSourceStart(
+ start: () => Promise,
+ activateAutomaticSpatialSource: () => void,
+ openSpatialScene: () => void,
+): Promise {
+ const started = await start();
+ if (!started) return false;
+ activateAutomaticSpatialSource();
+ openSpatialScene();
+ return true;
+}
diff --git a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx
index 57d9548..767a61b 100644
--- a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx
+++ b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import {
Button,
Checker,
@@ -11,12 +11,15 @@ import {
} from "@nodedc/ui-react";
import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
+import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
import {
isConfirmedLiveState,
isSourceRuntimeBusy,
+ isVendorWriteCapable,
recoverableAcquisition,
sourceStatusLabel,
} from "../lifecycle";
+import { normalizeProjectName, validateProjectName } from "../projectName";
import type { XgridsK1Controller } from "../runtimeContext";
type SessionIntent = "live" | "replay";
@@ -30,10 +33,12 @@ export function K1AcquisitionPipeline({
controller,
profileConfirmed,
openSpatialScene,
+ activateAutomaticSpatialSource,
}: {
controller: XgridsK1Controller;
profileConfirmed: boolean;
openSpatialScene: () => void;
+ activateAutomaticSpatialSource: () => void;
}) {
const {
state,
@@ -44,10 +49,18 @@ export function K1AcquisitionPipeline({
abort,
} = controller;
const [sessionIntent, setSessionIntent] = useState("live");
+ const [projectName, setProjectName] = useState("");
+ const [projectNameTouched, setProjectNameTouched] = useState(false);
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
+ const hydratedAcquisitionId = useRef(null);
+
+ const activeAcquisition = recoverableAcquisition(state);
+ const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
+ const projectNameValidation = validateProjectName(projectName);
+ const vendorWriteCapable = isVendorWriteCapable(state);
useEffect(() => {
if (state?.source_mode === "live" || state?.source_mode === "replay") {
@@ -57,9 +70,15 @@ export function K1AcquisitionPipeline({
}
}, [state?.acquisition?.state, state?.source_mode]);
+ useEffect(() => {
+ const acquisitionId = preparedAcquisition?.acquisition_id ?? null;
+ if (!acquisitionId || hydratedAcquisitionId.current === acquisitionId) return;
+ hydratedAcquisitionId.current = acquisitionId;
+ setProjectName(preparedAcquisition?.project_name ?? "");
+ setProjectNameTouched(false);
+ }, [preparedAcquisition?.acquisition_id, preparedAcquisition?.project_name]);
+
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 =
@@ -85,31 +104,39 @@ export function K1AcquisitionPipeline({
);
const submitLive = async () => {
- if (!profileConfirmed || sourceRuntimeBusy) return;
+ setProjectNameTouched(true);
+ if (!profileConfirmed || sourceRuntimeBusy || projectNameValidation.error) return;
const targetHost = liveHost.trim();
- const started = await prepareAndStartAcquisition({
- ...(targetHost ? { host: targetHost } : {}),
- compatibility_attestation: EXACT_PROFILE_ATTESTATION,
- });
- if (started) openSpatialScene();
+ await runAutomaticSpatialSourceStart(
+ () => prepareAndStartAcquisition({
+ project_name: projectNameValidation.value,
+ ...(targetHost ? { host: targetHost } : {}),
+ compatibility_attestation: EXACT_PROFILE_ATTESTATION,
+ }),
+ activateAutomaticSpatialSource,
+ 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();
+ await runAutomaticSpatialSourceStart(
+ () => startReplay({
+ path: replayPath.trim(),
+ speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
+ loop: replayLoop,
+ }),
+ activateAutomaticSpatialSource,
+ openSpatialScene,
+ );
};
return (
{!profileConfirmed
? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически."
: liveTargetReady
- ? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами."
+ ? vendorWriteCapable
+ ? "Mission Core подготовит локальную запись и отправит профилированную команду запуска K1. После подтверждения запуска начнётся статическая инициализация — не перемещайте устройство до появления потока."
+ : "Лабораторный профиль подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 не отправляется; поток подтверждается только реальными кадрами."
: "Сначала подключите устройство к Wi‑Fi или укажите локальный адрес."}
@@ -163,7 +214,9 @@ export function K1AcquisitionPipeline({
{state?.source_mode === "replay"
? "Остановка завершит фактически запущенный повтор записи."
: activeAcquisition || state?.source_mode === "live"
- ? "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
+ ? vendorWriteCapable && activeAcquisition?.control_mode === "plugin-commanded"
+ ? "Остановка отправит профилированную команду K1 и дождётся завершения локального сохранения."
+ : "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
: "Активного источника сейчас нет."}