feat(k1): complete primary acquisition lifecycle

This commit is contained in:
DCCONSTRUCTIONS 2026-07-17 23:03:59 +03:00
parent 9d51080d2e
commit aa3680948f
66 changed files with 6093 additions and 544 deletions

View File

@ -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 runtime contribution. The verified wire protocol and raw evidence format are
unchanged. 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, Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session,
operation, runtime-action, stream, evidence and compatibility contracts. Every operation, runtime-action, stream, evidence and compatibility contracts. Every
backend runtime must now pass a versioned descriptor/handshake against its 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 plane and credential endpoint bind to loopback only. The current K1 adapter
still provides real CoreBluetooth discovery, one operator-triggered reviewed still provides real CoreBluetooth discovery, one operator-triggered reviewed
BLE Wi-Fi provisioning write, read-only MQTT live capture, native `.k1mqtt` and BLE Wi-Fi provisioning write, read-only MQTT live capture, native `.k1mqtt` and
reviewed-TSV replay, raw-first evidence storage and measured preview metrics. reviewed-TSV replay, raw-first evidence storage, device-reported scan
Physical K1 scanning is still started and stopped by the verified double-click; time/distance/speed and measured preview metrics. Its frontend contribution now
the connector publishes no modeling command. The observed LixelGO action mapping also owns an optional spatial-scene control block, including acquisition phase,
remains descriptive and write-disabled. 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 This locked bootstrap is repeatable in the current workspace, not yet a
standalone release install. The frontend consumes sibling `file:` packages from 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 gRPC/proxy server on TCP 9876 and publishes the resulting URL through
control-plane state. Later live/file-replay sessions reset their session-local control-plane state. Later live/file-replay sessions reset their session-local
scene and metrics and reuse that process-wide stream. Saved observation sessions 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 do not reuse this listener: they open a private digest-bound cache-v9 RRD
same-origin HTTP through Rerun's native incremental receiver. Unless an operator generation over same-origin HTTP through Rerun's native incremental receiver.
has entered a manual source, the React application assigns the applicable source Unless an operator has entered a manual source, the React application assigns
to the embedded viewer. The complete live runtime path is K1 MQTT → raw-first the applicable source to the embedded viewer. A generic host action clears a
evidence capture → bounded latest-wins preview queue → explicitly injected K1 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 protobuf/LZ4 normalizer → transport-neutral decoded local views → Rerun
`Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer. Rerun does `Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer. Rerun does
not inspect K1 topics or raw payloads. These local decoded views are not yet the 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 The default Rerun blueprint shows a 12-second sliding accumulation of real point
frames. Product controls are connected for point size, intensity/height/distance 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. and restores the versioned spatial layout without mutating sensor evidence.
Saved native point/pose sessions are materialized losslessly into private, Saved native point/pose sessions are materialized losslessly into private,
digest-bound RRD recordings and can be played, paused and scrubbed on a digest-bound RRD recordings and can be played, paused and scrubbed on a
zero-based `session_time` timeline. No synthetic point cloud, trajectory, zero-based `session_time` timeline. New captures durably publish a clock origin
camera frame or latency value is generated. 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 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 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 cloud and zero decode errors. The later RTSP camera preview is available through
the generic floating observation windows and new acquisitions archive its fMP4 the generic floating observation windows and new acquisitions archive its fMP4
segments independently of browser delivery. Historical sessions recorded before segments independently of browser delivery. Historical sessions recorded before
that archive contract contain no video. Rerun `capture_time` and the camera that archive contract contain no video. A real archived session containing one
index use Mac receive/arrival timestamps, not proven K1 sensor timestamps or a camera plus point cloud has not yet passed physical shared-timeline playback;
photon-to-screen measurement. 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 The old Foxglove implementation is retained only in
`src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py` and its regression tests. The current `src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py` and its regression tests. The current

View File

@ -18,15 +18,15 @@ device adapter, но структура интерфейса от него не
| --- | --- | --- | | --- | --- | --- |
| Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. | | Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
| Device plugin registry | Реализован, v1alpha1 + v1alpha2 | До выбора модели provider остаётся inert и не делает I/O. v1alpha1 сохраняет одну модель; v1alpha2 допускает одну или несколько моделей и требует profile coverage каждой. Custom `device.connection` UI key и backend factory подключаются одним reviewed import в composition root. | | Device plugin registry | Реализован, v1alpha1 + v1alpha2 | До выбора модели provider остаётся inert и не делает I/O. v1alpha1 сохраняет одну модель; v1alpha2 допускает одну или несколько моделей и требует profile coverage каждой. Custom `device.connection` UI key и backend factory подключаются одним reviewed import в composition root. |
| Plugin-owned connection UI | Реализован | XGRIDS provisioning, acquisition/replay, diagnostics, metrics и scoped styles физически находятся в `plugins/xgrids-k1/frontend`; generic Control Station предоставляет только frontend SDK, model catalog и host slot. | | 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. | | Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. | | K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. | | K1 live/replay MQTT | Реализован | Read-only приём, обязательное локальное имя проекта, raw-first сохранение, декодирование облака/позы и 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. | | Автоматический MQTT → Rerun | Реализован | Первый live/adapter file-replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие такие сессии переиспользуют его. Saved observation replay использует отдельный immutable HTTP RRD path. |
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. | | Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. | | Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. |
| Сохранённые observation sessions | Реализованы для point/pose | Три последние сессии, background preparation, cache v7, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. | | Сохранённые 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 подключён, но реальная архивная K1 camera-session ещё не прошла physical acceptance. | | K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но point-cloud + one-camera archived playback ещё не прошёл physical acceptance. |
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. | | Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
| Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. | | Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. |
@ -38,9 +38,10 @@ device adapter, но структура интерфейса от него не
```text ```text
K1 MQTT :1883, read-only K1 MQTT :1883, read-only
└── raw .k1mqtt + metadata + SHA-256 сохраняются первыми └── raw .k1mqtt + metadata + durable clock origin сохраняются первыми
└── bounded latest-wins preview queue (32 сообщения) ├── injected ModelingReport observer -> device scan time/distance/speed
└── явно внедрённый K1 protobuf/LZ4 normalizer └── bounded latest-wins visual preview queue (4 сообщения)
└── явно внедрённый K1 protobuf/LZ4 point/pose normalizer
└── transport-neutral DecodedPointCloudView / DecodedPoseView └── transport-neutral DecodedPointCloudView / DecodedPoseView
└── Rerun Points3D + Transform3D + LineStrips3D └── Rerun Points3D + Transform3D + LineStrips3D
└── gRPC/proxy TCP 9876 └── gRPC/proxy TCP 9876
@ -55,7 +56,7 @@ Mission Core Control Station ←→ REST /api/v1/device-plugins/*
sealed/recovered observation session sealed/recovered observation session
└── SQLite catalog + bounded background preparation └── SQLite catalog + bounded background preparation
└── atomic RRD cache v7 + recorded-media manifest v2 └── capture-clock-bound atomic RRD cache v9 + recorded-media manifest v2
└── generation-bound same-origin HTTP └── generation-bound same-origin HTTP
└── aggregate admission └── aggregate admission
└── Rerun native receiver + recorded fMP4 player └── Rerun native receiver + recorded fMP4 player
@ -177,15 +178,21 @@ production-сборки запускается командой `npm run preview
оператор. оператор.
6. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна 6. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
reviewed provisioning-запись без автоматических повторов. reviewed provisioning-запись без автоматических повторов.
7. Запустить live-приём по определённому адресу K1 либо replay локального 7. Ввести обязательное название проекта. Оно NFKC-нормализуется, trim-ится,
`.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и проверяется на control/surrogate characters и лимит 96 Unicode characters,
останавливается подтверждённым двойным нажатием кнопки устройства. сохраняется как display metadata и не используется в filesystem path.
8. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в 8. Запустить локальный live-приём по определённому адресу K1 либо replay
локального `.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается
и останавливается подтверждённым двойным нажатием кнопки устройства.
9. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
state. Ручной source вводить не требуется. state. Ручной source вводить не требуется.
9. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория, 10. Открыть **Наблюдение → Пространственная сцена**. Plugin-owned spatial block
частота, число точек, задержка и пропуски preview появятся после прихода показывает local acquisition phase, stop local reception и полученные от K1
сообщений K1. scan time/distance/speed; облако, траектория и preview metrics появляются
10. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться только после реальных сообщений.
11. Физически остановить K1, дождаться steady green, затем остановить локальный
приём из device workflow или spatial block, чтобы запечатать evidence.
12. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться
состояния **Готово**, выбрать запись и использовать host timeline. Evidence состояния **Готово**, выбрать запись и использовать host timeline. Evidence
сохраняется автоматически; disk action сохраняет только workspace layout. сохраняется автоматически; disk action сохраняет только workspace layout.
@ -212,6 +219,11 @@ https://example.internal/recording.rrd
- версия RRD должна быть совместима с версией Web Viewer; - версия RRD должна быть совместима с версией Web Viewer;
- ручной URL хранится только в состоянии текущей страницы и имеет приоритет над - ручной URL хранится только в состоянии текущей страницы и имеет приоритет над
автоматическим source; автоматическим 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, без подстановки фиктивных данных. - ошибка запуска показывается в viewport, без подстановки фиктивных данных.
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
@ -282,10 +294,10 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. | | `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. | | `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
| `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle, plugin host и public frontend SDK surface. | | `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/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. |
| `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. | | `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/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
| `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. | | `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. |
| `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. | | `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |
@ -312,16 +324,26 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
случайные GATT writes и автоматические повторы запрещены. случайные GATT writes и автоматические повторы запрещены.
- MQTT live/replay не публикует команды устройству. Запуск и остановка - MQTT live/replay не публикует команды устройству. Запуск и остановка
физического сканирования остаются за кнопкой K1. физического сканирования остаются за кнопкой 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, затем - Live-сессии сначала сохраняют сырые сообщения и camera segments, затем
формируют disposable preview. При перегрузке preview может быть отброшен, формируют disposable preview. При перегрузке preview может быть отброшен,
native evidence сохраняется. MQTT durability имеет bounded group-commit RPO, native evidence сохраняется. MQTT durability имеет bounded group-commit RPO,
camera durability — текущий незавершённый fragment RPO; это не zero-loss claim. camera durability — текущий незавершённый fragment RPO; это не zero-loss claim.
- Новая session summary hash-bind-ит durable clock origin и атомарно переключает
pointer с provisional `mqtt.timeline.json` на content-addressed
`mqtt.timeline.session-<sha256>.json`: только owner-sealed `session` scope
может рекламировать combined media replay. Cache v9 создаёт реальные
origin/end RRD rows; provisional `transport` scope fails closed.
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно - Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
накопления viewer использует session-local `stream_time`. `capture_time` — не накопления viewer использует session-local `stream_time`. `capture_time` — не
доказанный timestamp сенсора K1 и не photon-to-screen latency. доказанный timestamp сенсора K1 и не photon-to-screen latency.
- Панорамный камерный поток в MQTT report topics отсутствует; отдельные - Панорамный камерный поток в MQTT report topics отсутствует; отдельные
left/right RTSP preview доступны через generic camera windows. LiDAR и camera left/right RTSP preview доступны через generic camera windows. LiDAR и camera
сейчас синхронизированы только по host arrival, не по доказанным sensor clocks. сейчас синхронизированы только по host arrival, не по доказанным sensor clocks.
Физический archived playback point cloud + одна выбранная камера ещё не принят.
- `.runtime/`, canonical evidence roots и legacy `sessions/` игнорируются Git и - `.runtime/`, canonical evidence roots и legacy `sessions/` игнорируются Git и
могут содержать адреса, изображения, идентификаторы, траекторию и карту могут содержать адреса, изображения, идентификаторы, траекторию и карту
помещения. В репозиторий попадают только код, тесты, redacted manifests и помещения. В репозиторий попадают только код, тесты, redacted manifests и

View File

@ -30,6 +30,10 @@ import { ObservationSessionSelect } from "./components/ObservationSessionSelect"
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost"; import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext"; import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
import type { ViewerSettings } from "./core/runtime/contracts"; import type { ViewerSettings } from "./core/runtime/contracts";
import {
SPATIAL_SOURCE_SWITCH_BLOCKED_REASON,
isSpatialSourceSwitchBlocked,
} from "./core/runtime/acquisitionGuard";
import { import {
createLatestAsyncCommitter, createLatestAsyncCommitter,
type LatestAsyncCommitter, type LatestAsyncCommitter,
@ -144,6 +148,12 @@ export default function App() {
const [layoutSaveNotice, setLayoutSaveNotice] = useState<string | null>(null); const [layoutSaveNotice, setLayoutSaveNotice] = useState<string | null>(null);
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings); const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings); const [displayDraft, setDisplayDraft] = useState<SceneSettings>(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<string | null>(null); const appliedProfileKeyRef = useRef<string | null>(null);
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings); const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings); const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
@ -423,6 +433,9 @@ export default function App() {
}; };
const beginRecordedReplaySwitch = useCallback(async () => { const beginRecordedReplaySwitch = useCallback(async () => {
if (sourceSwitchBlockedRef.current) {
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
}
// useObservationSessions calls this only after backend preparation has // useObservationSessions calls this only after backend preparation has
// produced a validated launch descriptor. Keep the old scene mounted // produced a validated launch descriptor. Keep the old scene mounted
// before this point; now perform one controlled receiver teardown before // before this point; now perform one controlled receiver teardown before
@ -437,6 +450,9 @@ export default function App() {
}, []); }, []);
const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => { const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => {
if (sourceSwitchBlockedRef.current) {
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
}
setRecordedReplay(launch); setRecordedReplay(launch);
setSourceUrl(launch.sourceUrl); setSourceUrl(launch.sourceUrl);
setSourceDraft(launch.sourceUrl); setSourceDraft(launch.sourceUrl);
@ -447,6 +463,17 @@ export default function App() {
if (outcome !== "accepted") setReplayTransitioning(false); 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(() => { useEffect(() => {
// ObservationSessionSelect owns the cancellable request and unmounts when // ObservationSessionSelect owns the cancellable request and unmounts when
// the operator leaves the spatial workspace. Its unmount cannot safely // the operator leaves the spatial workspace. Its unmount cannot safely
@ -637,7 +664,8 @@ export default function App() {
<div className="observation-header-tools"> <div className="observation-header-tools">
<ObservationSessionSelect <ObservationSessionSelect
limit={3} limit={3}
disabled={runtime.pendingAction !== null} disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
blockedReason={sourceSwitchBlockedReason}
onReplayBegin={beginRecordedReplaySwitch} onReplayBegin={beginRecordedReplaySwitch}
onReplayAccepted={(_session, launch) => acceptRecordedReplay(launch)} onReplayAccepted={(_session, launch) => acceptRecordedReplay(launch)}
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)} onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
@ -667,6 +695,7 @@ export default function App() {
{activeDefinition.kind === "device" ? ( {activeDefinition.kind === "device" ? (
<DeviceWorkspace <DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene")} onOpenSpatialScene={() => openView("spatial-scene")}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/> />
) : ( ) : (
<WorkspaceRenderer <WorkspaceRenderer
@ -682,11 +711,18 @@ export default function App() {
stageDisplayPatch({ accumulationSeconds })} stageDisplayPatch({ accumulationSeconds })}
onAccumulationCommit={flushDisplaySettings} onAccumulationCommit={flushDisplaySettings}
observationLayout={observationLayout} observationLayout={observationLayout}
spatialControls={selection?.SpatialControlsView
? {
View: selection.SpatialControlsView,
model: selection.model,
}
: null}
navigation={{ navigation={{
openView, openView,
openSource, openSource,
openDisplay, openDisplay,
openLayers, openLayers,
activateAutomaticSpatialSource,
}} }}
/> />
)} )}
@ -713,7 +749,10 @@ export default function App() {
<WindowFooterActions> <WindowFooterActions>
<Button <Button
variant="ghost" variant="ghost"
disabled={sourceSwitchBlocked}
title={sourceSwitchBlockedReason ?? undefined}
onClick={() => { onClick={() => {
if (sourceSwitchBlockedRef.current) return;
setSourceDraft(""); setSourceDraft("");
setSourceUrl(""); setSourceUrl("");
setRecordedReplay(null); setRecordedReplay(null);
@ -724,8 +763,10 @@ export default function App() {
<Button <Button
variant="primary" variant="primary"
shape="pill" shape="pill"
disabled={!sourceDraft.trim()} disabled={sourceSwitchBlocked || !sourceDraft.trim()}
title={sourceSwitchBlockedReason ?? undefined}
onClick={() => { onClick={() => {
if (sourceSwitchBlockedRef.current) return;
setSourceUrl(sourceDraft.trim()); setSourceUrl(sourceDraft.trim());
setRecordedReplay(null); setRecordedReplay(null);
}} }}
@ -766,9 +807,10 @@ export default function App() {
hint="необязательно" hint="необязательно"
value={sourceDraft} value={sourceDraft}
onChange={(event) => setSourceDraft(event.target.value)} onChange={(event) => setSourceDraft(event.target.value)}
disabled={sourceSwitchBlocked}
spellCheck={false} spellCheck={false}
placeholder="rerun+http://127.0.0.1:9876/proxy" placeholder="rerun+http://127.0.0.1:9876/proxy"
description="Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD." description={sourceSwitchBlockedReason ?? "Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."}
/> />
</div> </div>
), ),

View File

@ -93,12 +93,14 @@ function sessionDescription(session: ObservationSessionSummary): string {
export function ObservationSessionSelect({ export function ObservationSessionSelect({
limit = 3, limit = 3,
disabled = false, disabled = false,
blockedReason = null,
onReplayBegin, onReplayBegin,
onReplayAccepted, onReplayAccepted,
onReplaySettled, onReplaySettled,
}: { }: {
limit?: number; limit?: number;
disabled?: boolean; disabled?: boolean;
blockedReason?: string | null;
onReplayBegin?: ( onReplayBegin?: (
session: ObservationSessionSummary, session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch, launch: ObservationSessionReplayLaunch,
@ -114,6 +116,7 @@ export function ObservationSessionSelect({
}) { }) {
const sessions = useObservationSessions({ const sessions = useObservationSessions({
limit, limit,
replayEnabled: blockedReason === null,
onReplayBegin, onReplayBegin,
onReplayAccepted, onReplayAccepted,
onReplaySettled, onReplaySettled,
@ -123,6 +126,7 @@ export function ObservationSessionSelect({
: sessions.state === "loading" : sessions.state === "loading"
? "Загружаем сессии…" ? "Загружаем сессии…"
: "Сохранённые сессии"; : "Сохранённые сессии";
const presentedTriggerCopy = blockedReason ?? triggerCopy;
return ( return (
<Dropdown <Dropdown
@ -147,10 +151,11 @@ export function ObservationSessionSelect({
aria-expanded={open} aria-expanded={open}
aria-controls={surfaceId} aria-controls={surfaceId}
disabled={disabled} disabled={disabled}
title={blockedReason ?? undefined}
onClick={toggle} onClick={toggle}
> >
<Icon name="database" size={15} /> <Icon name="database" size={15} />
<span>{triggerCopy}</span> <span>{presentedTriggerCopy}</span>
{sessions.state === "ready" ? <small>{sessions.items.length}</small> : null} {sessions.state === "ready" ? <small>{sessions.items.length}</small> : null}
<Icon name="chevron-down" size={14} /> <Icon name="chevron-down" size={14} />
</button> </button>

View File

@ -94,6 +94,7 @@ export function isDevicePluginManifestV1Alpha2(
export interface DevicePluginHostActions { export interface DevicePluginHostActions {
openSpatialScene: () => void; openSpatialScene: () => void;
activateAutomaticSpatialSource: () => void;
} }
export interface DevicePluginConnectionProps { export interface DevicePluginConnectionProps {
@ -109,10 +110,12 @@ export interface DeviceUiPlugin {
children: ReactNode; children: ReactNode;
}>; }>;
connectionViews: Readonly<Record<string, ComponentType<DevicePluginConnectionProps>>>; connectionViews: Readonly<Record<string, ComponentType<DevicePluginConnectionProps>>>;
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
} }
export interface RegisteredDeviceModel { export interface RegisteredDeviceModel {
plugin: DeviceUiPlugin; plugin: DeviceUiPlugin;
model: DeviceModelDefinition; model: DeviceModelDefinition;
ConnectionView: ComponentType<DevicePluginConnectionProps>; ConnectionView: ComponentType<DevicePluginConnectionProps>;
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
} }

View File

@ -90,7 +90,14 @@ export function createDevicePluginRegistry(
`Плагин ${manifest.metadata.id} не реализует UI ${model.ui.componentKey}.`, `Плагин ${manifest.metadata.id} не реализует UI ${model.ui.componentKey}.`,
); );
} }
models.push({ plugin, model, ConnectionView }); models.push({
plugin,
model,
ConnectionView,
...(plugin.SpatialControlsView
? { SpatialControlsView: plugin.SpatialControlsView }
: {}),
});
} }
if (isDevicePluginManifestV1Alpha2(manifest)) { if (isDevicePluginManifestV1Alpha2(manifest)) {

View File

@ -101,9 +101,11 @@ export function createObservationReplayCoordinator(): ObservationReplayCoordinat
}; };
}, },
cancel() { cancel() {
sequence += 1;
active?.abort(); active?.abort();
active = null; // Keep ownership until the cancelled attempt reaches `finish()`. This
// lets its finally block settle a replacement that already passed
// onReplayBegin, while `isCurrent()` still fails immediately because the
// signal is aborted. A later `begin()` replaces and invalidates it.
}, },
}; };
} }
@ -359,11 +361,13 @@ export function clearObservationReplayPreparation(
export function useObservationSessions({ export function useObservationSessions({
limit = 3, limit = 3,
replayEnabled = true,
onReplayBegin, onReplayBegin,
onReplayAccepted, onReplayAccepted,
onReplaySettled, onReplaySettled,
}: { }: {
limit?: number; limit?: number;
replayEnabled?: boolean;
/** Called only after the archive is ready, immediately before replacing the old viewer. */ /** Called only after the archive is ready, immediately before replacing the old viewer. */
onReplayBegin?: ( onReplayBegin?: (
session: ObservationSessionSummary, session: ObservationSessionSummary,
@ -388,11 +392,23 @@ export function useObservationSessions({
const mounted = useRef(true); const mounted = useRef(true);
const catalogSequence = useRef(0); const catalogSequence = useRef(0);
const reattachStarted = useRef(false); const reattachStarted = useRef(false);
const replayEnabledRef = useRef(replayEnabled);
replayEnabledRef.current = replayEnabled;
const preparationPollSequence = useRef(0); const preparationPollSequence = useRef(0);
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null); const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
if (replayCoordinator.current === null) { if (replayCoordinator.current === null) {
replayCoordinator.current = createObservationReplayCoordinator(); replayCoordinator.current = createObservationReplayCoordinator();
} }
useEffect(() => {
if (replayEnabled) return;
// Cancels only this browser's polling/selection attempt. The shared
// backend preparation remains untouched and can be selected again later.
reattachStarted.current = true;
replayCoordinator.current?.cancel();
setReplayingSessionId(null);
setReplayProgress(null);
}, [replayEnabled]);
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 3; const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 3;
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
@ -466,6 +482,7 @@ export function useObservationSessions({
session: ObservationSessionSummary, session: ObservationSessionSummary,
resumedPreparation?: ObservationSessionPreparation, resumedPreparation?: ObservationSessionPreparation,
) => { ) => {
if (!replayEnabledRef.current) return false;
const attempt = replayCoordinator.current!.begin(); const attempt = replayCoordinator.current!.begin();
setReplayingSessionId(session.id); setReplayingSessionId(session.id);
setFailedSessionId(null); setFailedSessionId(null);
@ -502,14 +519,26 @@ export function useObservationSessions({
signal: attempt.signal, signal: attempt.signal,
onUpdate, onUpdate,
}); });
if (!mounted.current || !attempt.isCurrent()) return false; if (
!mounted.current ||
!attempt.isCurrent() ||
!replayEnabledRef.current
) return false;
// The current scene stays mounted throughout preparation. Only now that // The current scene stays mounted throughout preparation. Only now that
// the launch descriptor exists do we release the previous viewer. // the launch descriptor exists do we release the previous viewer.
await onReplayBegin?.(session, launch); await onReplayBegin?.(session, launch);
if (!mounted.current || !attempt.isCurrent()) return false; if (
!mounted.current ||
!attempt.isCurrent() ||
!replayEnabledRef.current
) return false;
await onReplayAccepted?.(session, launch); await onReplayAccepted?.(session, launch);
if (!mounted.current || !attempt.isCurrent()) return false; if (
!mounted.current ||
!attempt.isCurrent() ||
!replayEnabledRef.current
) return false;
outcome = "accepted"; outcome = "accepted";
try { try {
clearObservationReplayPreparation(); clearObservationReplayPreparation();
@ -543,6 +572,7 @@ export function useObservationSessions({
}, [onReplayAccepted, onReplayBegin, onReplaySettled]); }, [onReplayAccepted, onReplayBegin, onReplaySettled]);
const replay = useCallback(async (sessionId: string) => { const replay = useCallback(async (sessionId: string) => {
if (!replayEnabledRef.current) return false;
const session = items.find((candidate) => candidate.id === sessionId); const session = items.find((candidate) => candidate.id === sessionId);
if (!session || !session.replayable) return false; if (!session || !session.replayable) return false;
reattachStarted.current = true; reattachStarted.current = true;
@ -550,7 +580,7 @@ export function useObservationSessions({
}, [executeReplay, items]); }, [executeReplay, items]);
useEffect(() => { useEffect(() => {
if (state !== "ready" || reattachStarted.current) return; if (!replayEnabled || state !== "ready" || reattachStarted.current) return;
reattachStarted.current = true; reattachStarted.current = true;
let stored: ObservationSessionPreparation | null = null; let stored: ObservationSessionPreparation | null = null;
try { try {
@ -569,7 +599,7 @@ export function useObservationSessions({
return; return;
} }
void executeReplay(session, stored); void executeReplay(session, stored);
}, [executeReplay, items, state]); }, [executeReplay, items, replayEnabled, state]);
const retry = useCallback(async () => { const retry = useCallback(async () => {
if (!failedSessionId) return false; if (!failedSessionId) return false;

View File

@ -0,0 +1,26 @@
import type { MissionRuntimeState } from "./contracts";
const TERMINAL_ACQUISITION_STATES = new Set([
"completed",
"failed",
"aborted",
"interrupted",
]);
export const SPATIAL_SOURCE_SWITCH_BLOCKED_REASON =
"Завершите текущий приём перед сменой источника.";
/**
* An acquisition snapshot is blocking unless its state is explicitly known to
* be terminal. Unknown future states therefore fail closed.
*/
export function isSpatialSourceSwitchBlocked(
state: MissionRuntimeState | null | undefined,
): boolean {
const acquisition = state?.acquisition;
return Boolean(
acquisition &&
(acquisition.cleanupPending === true ||
!TERMINAL_ACQUISITION_STATES.has(acquisition.state)),
);
}

View File

@ -29,6 +29,9 @@ export interface StreamMetrics {
frameRateHz?: number | null; frameRateHz?: number | null;
pointCount?: number | null; pointCount?: number | null;
droppedPreviewFrames?: number | null; droppedPreviewFrames?: number | null;
elapsedSeconds?: number | null;
routeDistanceMeters?: number | null;
speedMetersPerSecond?: number | null;
} }
export interface ActiveDeviceSnapshot { export interface ActiveDeviceSnapshot {
@ -55,6 +58,7 @@ export interface RuntimeAcquisitionSnapshot {
state: string; state: string;
stateRevision: number; stateRevision: number;
operatorInstructions: readonly string[]; operatorInstructions: readonly string[];
cleanupPending?: boolean;
} }
export interface RuntimeOperationSnapshot { export interface RuntimeOperationSnapshot {

View File

@ -8,6 +8,15 @@
gap: 0.45rem; gap: 0.45rem;
} }
.scene-device-controls {
position: absolute;
z-index: 30;
top: 0.85rem;
left: 50%;
max-width: calc(100% - 9rem);
transform: translateX(-50%);
}
.scene-source-picker__trigger, .scene-source-picker__trigger,
.scene-source-control, .scene-source-control,
.scene-focus-exit { .scene-focus-exit {

View File

@ -168,6 +168,11 @@
left: 0.6rem; left: 0.6rem;
} }
.scene-device-controls {
top: 0.6rem;
max-width: calc(100% - 8rem);
}
.scene-adapter-note { .scene-adapter-note {
right: 0.75rem; right: 0.75rem;
left: 0.75rem; left: 0.75rem;

View File

@ -40,7 +40,13 @@ function ModelCard({
); );
} }
export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: () => void }) { export function DeviceWorkspace({
onOpenSpatialScene,
onActivateAutomaticSpatialSource,
}: {
onOpenSpatialScene: () => void;
onActivateAutomaticSpatialSource: () => void;
}) {
const { const {
registry, registry,
selection, selection,
@ -115,7 +121,10 @@ export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: ()
</div> </div>
<ConnectionView <ConnectionView
model={selection.model} model={selection.model}
host={{ openSpatialScene: onOpenSpatialScene }} host={{
openSpatialScene: onOpenSpatialScene,
activateAutomaticSpatialSource: onActivateAutomaticSpatialSource,
}}
/> />
</div> </div>
); );

View File

@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
} from "react";
import { import {
Button, Button,
GlassSurface, GlassSurface,
@ -21,6 +28,10 @@ import type {
RecordedCameraAdmissionState, RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission"; } from "../core/observation/recordedSessionAdmission";
import type { ObservationLayoutController } from "../core/observation/useObservationLayout"; import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
import type {
DeviceModelDefinition,
DevicePluginConnectionProps,
} from "../core/device-plugins/contracts";
import type { import type {
BackendStatus, BackendStatus,
MissionRuntimeState, MissionRuntimeState,
@ -98,6 +109,7 @@ export interface WorkspaceNavigation {
openSource: () => void; openSource: () => void;
openDisplay: () => void; openDisplay: () => void;
openLayers: () => void; openLayers: () => void;
activateAutomaticSpatialSource: () => void;
} }
export interface WorkspaceRendererProps { export interface WorkspaceRendererProps {
@ -113,6 +125,10 @@ export interface WorkspaceRendererProps {
onAccumulationCommit: () => void; onAccumulationCommit: () => void;
observationLayout: ObservationLayoutController; observationLayout: ObservationLayoutController;
navigation: WorkspaceNavigation; navigation: WorkspaceNavigation;
spatialControls: {
View: ComponentType<DevicePluginConnectionProps>;
model: DeviceModelDefinition;
} | null;
} }
function OverviewWorkspace({ function OverviewWorkspace({
@ -271,6 +287,7 @@ function SpatialWorkspace({
onAccumulationCommit, onAccumulationCommit,
observationLayout, observationLayout,
navigation, navigation,
spatialControls,
}: WorkspaceRendererProps) { }: WorkspaceRendererProps) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle"); const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState(""); const [viewerMessage, setViewerMessage] = useState("");
@ -435,6 +452,18 @@ function SpatialWorkspace({
<EmptySpatialStage settings={sceneSettings} /> <EmptySpatialStage settings={sceneSettings} />
)} )}
{spatialControls && !recordedSource ? (
<div className="scene-device-controls">
<spatialControls.View
model={spatialControls.model}
host={{
openSpatialScene: () => navigation.openView("spatial-scene"),
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
}}
/>
</div>
) : null}
{pointCloudFocused ? ( {pointCloudFocused ? (
<button <button
type="button" type="button"

View File

@ -10,6 +10,9 @@ let xgridsK1Manifest;
let xgridsK1Actions; let xgridsK1Actions;
let xgridsK1Api; let xgridsK1Api;
let lifecycle; let lifecycle;
let projectName;
let automaticSourceStart;
let presentation;
before(async () => { before(async () => {
server = await createServer({ server = await createServer({
@ -29,6 +32,15 @@ before(async () => {
lifecycle = await server.ssrLoadModule( lifecycle = await server.ssrLoadModule(
"@xgrids-k1/frontend/lifecycle.ts", "@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( ({ xgridsK1Api } = await server.ssrLoadModule(
"@xgrids-k1/frontend/api.ts", "@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"); 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("  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", () => { test("replay ignores a stale failed live acquisition", () => {
const replay = { const replay = {
source_mode: "replay", source_mode: "replay",
@ -343,6 +449,7 @@ test("device mutations send explicit nested compatibility attestation", async ()
idempotency_key: "network-provision:test", idempotency_key: "network-provision:test",
}); });
await xgridsK1Api.prepareAcquisition({ await xgridsK1Api.prepareAcquisition({
project_name: "Mission 01",
compatibility_attestation: attestation, compatibility_attestation: attestation,
}); });
} finally { } finally {
@ -355,4 +462,5 @@ test("device mutations send explicit nested compatibility attestation", async ()
assert.deepEqual(provisioning.input.compatibility_attestation, attestation); assert.deepEqual(provisioning.input.compatibility_attestation, attestation);
assert.equal(provisioning.input.idempotency_key, "network-provision:test"); assert.equal(provisioning.input.idempotency_key, "network-provision:test");
assert.deepEqual(prepare.input.compatibility_attestation, attestation); assert.deepEqual(prepare.input.compatibility_attestation, attestation);
assert.equal(prepare.input.project_name, "Mission 01");
}); });

View File

@ -39,7 +39,13 @@ after(async () => {
await server?.close(); await server?.close();
}); });
function syntheticPlugin({ pluginId, modelId, componentKey, connectionView }) { function syntheticPlugin({
pluginId,
modelId,
componentKey,
connectionView,
spatialControlsView,
}) {
return { return {
manifest: { manifest: {
apiVersion: "missioncore.nodedc/v1alpha2", apiVersion: "missioncore.nodedc/v1alpha2",
@ -69,6 +75,7 @@ function syntheticPlugin({ pluginId, modelId, componentKey, connectionView }) {
}, },
RuntimeProvider: ({ children }) => children, RuntimeProvider: ({ children }) => children,
connectionViews: { [componentKey]: connectionView }, 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", () => { test("XGRIDS frontend is physically plugin-owned and split by operator pipeline", () => {
if (existsSync(legacyPluginRoot)) { if (existsSync(legacyPluginRoot)) {
assert.deepEqual(readdirSync(legacyPluginRoot), []); assert.deepEqual(readdirSync(legacyPluginRoot), []);
@ -108,10 +144,22 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
"styles.css", "styles.css",
"components/K1ProvisioningPipeline.tsx", "components/K1ProvisioningPipeline.tsx",
"components/K1AcquisitionPipeline.tsx", "components/K1AcquisitionPipeline.tsx",
"components/K1SpatialControls.tsx",
"components/K1Diagnostics.tsx", "components/K1Diagnostics.tsx",
"projectName.ts",
]) { ]) {
assert.equal(existsSync(join(pluginFrontendRoot, relativePath)), true, relativePath); 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", () => { test("generic Control Station has one composition import and no K1 implementation knowledge", () => {

View File

@ -603,6 +603,25 @@ test("replay coordinator makes rapid saved-session selection latest-request-wins
coordinator.cancel(); coordinator.cancel();
assert.equal(third.signal.aborted, true); assert.equal(third.signal.aborted, true);
assert.equal(third.isCurrent(), false); 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 () => { test("switching saved sessions aborts only local polling and never cancels shared backend work", async () => {

View File

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

View File

@ -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 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 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 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 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 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) — durable catalog, recovery, background RRD preparation, saved-session timeline and atomic playback verified | | 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; no real archived K1 camera session exists yet | | 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 are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process | | 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 | | 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 USB project copying remains optional ground truth rather than a blocker for the
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
start/stop mapping and RTSP camera transport. MQTT control publishing remains start/stop mapping and RTSP camera transport. The exact start/stop protobuf
deliberately deferred because complete request/save/rollback semantics are not encoder, response correlator and device-status state machine now exist without a
yet modeled and the physical button remains a known-safe fallback. 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 The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
queue cannot discard MQTT evidence. Stage 7 adds an independent durable queue cannot discard MQTT evidence. The queue holds four preview messages;
observation-session lifecycle. Completed or recovered native captures are `ModelingReport` is consumed by a plugin-injected observer before that queue and
prepared once by a bounded backend worker into digest-bound RRD/cache-v7 and does not compete with point/pose frames. A project name is mandatory at
camera-manifest generations; a browser replay request never performs conversion. preparation, NFKC-normalized/trimmed on both sides, rejected for control
The saved scene, controller and timeline remain hidden until the complete RRD or surrogate characters or more than 96 characters, and retained only as
range and every declared camera pass admission. Sensor-to-display latency and display metadata.
camera/LiDAR sensor-clock alignment remain separate correlation tests.
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 ## Stage 0 — repository and host baseline
@ -221,6 +249,9 @@ useful stream is decoded or structurally identified.
- automated scan-button electronics; - automated scan-button electronics;
- OpenWrt/monitor-mode infrastructure; - OpenWrt/monitor-mode infrastructure;
- firmware or internal-Linux analysis; - 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; - long-running large-session WebViewer/WASM memory telemetry;
- production retention, replication, encryption and cross-platform packaging. - production retention, replication, encryption and cross-platform packaging.

View File

@ -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. `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 ## Application control boundary
Static analysis first identified the application start/stop topic as Static analysis first identified the application start/stop topic as
`lixel/application/request/modeling`, QoS 2, with a protobuf `lixel/application/request/modeling`, QoS 2, with a protobuf
`ModelingRequest`. Start action is 1 and stop action is 2. A valid request also `ModelingRequest`. Start action is 1 and stop action is 2. The retained header
contains a device/session/OpenAPI header and, for start, project/record/scan/mount contains explicit device identity and OpenAPI value; its session relation is
settings. 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 Lab 002 then observed both mappings in owner-operated LixelGO traffic and
correlated them with device success responses and physical start/stop of the 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 high-rate streams. This promotes the mapping from a static hint to descriptive
wire evidence; it does not authorize replay or publishing. wire evidence; it does not authorize replay or publishing.
Publishing is deliberately not implemented. The physical double-click provides The repository now contains an inert bounded encoder/response parser and a
a verified autonomous start/stop path and avoids inventing session headers or fail-closed device-status state machine for this exact profile. They have no
changing scan settings. A future publisher requires a separate reviewed profile, MQTT publish dependency. Publishing remains deliberately disabled: the OpenAPI
explicit confirmation, response handling and rollback. 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; - 2 MiB maximum MQTT payload;
- 1 MiB maximum compressed block; - 1 MiB maximum compressed block;
@ -158,6 +177,10 @@ Default defensive limits are applied before allocation or iteration:
- 250,000 points per frame; - 250,000 points per frame;
- bounded protobuf field counts and exact LZ4 decoded length. - 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. Per-frame failures do not justify firmware writes or speculative recovery.
Always preserve the raw MQTT record and report the decoder error separately. Always preserve the raw MQTT record and report the decoder error separately.

View File

@ -10,18 +10,22 @@ trajectory, camera frame or latency values.
## Active device-to-scene path ## Active device-to-scene path
```text ```text
K1 lio_pcl / lio_pose K1 lio_pcl / lio_pose / ModelingReport
| |
v v
read-only MQTT subscription on TCP 1883 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 v
bounded latest-wins preview queue (32 messages) bounded latest-wins visual preview queue (4 messages)
| |
v v
reviewed raw-LZ4/protobuf decoders reviewed raw-LZ4/protobuf point/pose normalizer
| |
v v
Rerun Points3D + Transform3D + LineStrips3D 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, 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 the oldest queued preview is discarded while capture continues. Rerun work
stays on the dedicated publisher thread and cannot block that raw-first path. 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 The current live/replay runtime instantiates only `RerunBridge`. The former
Foxglove implementation is not a parallel runtime and does not listen on TCP 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. complete visible-device list.
4. Enter the existing router SSID/password and explicitly authorize the reviewed 4. Enter the existing router SSID/password and explicitly authorize the reviewed
provisioning write. The backend does not retry the write automatically. provisioning write. The backend does not retry the write automatically.
5. When K1 reports a non-AP private address, start live reception. 5. Enter the required project name. Mission Core NFKC-normalizes and trims it,
6. Wait until the UI reports that the local Rerun bridge is ready. No manual 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. viewer URL is needed.
7. Open **Наблюдение → Пространственная сцена**. 8. Open **Наблюдение → Пространственная сцена**. The K1 plugin's optional scene
8. Double-click the physical K1 button to start scanning. Real point frames and 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. pose/trajectory updates then appear in the embedded viewport.
9. Double-click K1 again to stop physical scanning, wait for steady green, then 10. When K1 emits `ModelingReport`, the same plugin block shows its current scan
stop the local session so captures and summaries are finalized. 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 Each new live run creates a direct child below `MISSIONCORE_EVIDENCE_DIR`, or
`.runtime/mission-core/evidence/sessions/` by default, with raw MQTT frames, `.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 the current writer. The connector subscribes to the fixed report-topic
allowlist and does not publish an application request or modeling command. 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 ## Automatic Rerun source and lifecycle
On the first live or adapter file-replay session, `RerunBridge`: 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 application. FastAPI carries control state only; the browser reads the point
stream directly from the Rerun gRPC/proxy endpoint. 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 ## Rerun entities
| Entity | Rerun archetype | Meaning | | 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 | | `/world/trajectory` | `LineStrips3D` | bounded path of up to 2,000 decoded poses |
Point count, frame rate, queue drops and measured pipeline time remain product 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 metrics in the outer Control Station. Live K1 scan time, route distance and
therefore cannot create additional automatic viewer panes. 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. Point coordinates remain `(x/scaler, y/scaler, z/scaler)` exactly as decoded.
No axis swap, quaternion normalization, scanner-to-vehicle extrinsic or SLAM 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 Sealing, recovery or legacy import makes the session eligible for the bounded
backend preparation worker. The worker reads every native message once and backend preparation worker. The worker reads every native message once and
atomically publishes a private cache-v7 RRD using the zero-based `session_time` atomically publishes a private cache-v9 RRD using the zero-based `session_time`
duration timeline. A SHA sidecar binds the generation to native evidence. 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 Replay-open never performs conversion: it returns HTTP 202 and a preparation
handle or reuses the verified artifact. The embedded viewer opens the exact handle or reuses the verified artifact. The embedded viewer opens the exact
generation through Rerun's native incremental HTTP receiver. The bottom Control generation through Rerun's native incremental HTTP receiver. The bottom Control
Station timeline then controls play/pause and seek directly; it does not Station timeline then controls play/pause and seek directly; it does not
approximate time with a React timer. approximate time with a React timer.
The native `.k1mqtt` remains the evidence master. RRD generation does not use The native `.k1mqtt`, aligned metadata and capture-clock envelope remain the
the bounded live-preview queue, so it retains every frame accepted by the spatial evidence master. RRD generation does not use the bounded live-preview
reviewed normalizer. See 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, [`09_OBSERVATION_SESSIONS.md`](09_OBSERVATION_SESSIONS.md) for storage,
recovery and HTTP Range details. recovery and HTTP Range details.
## Time and latency semantics ## Time and latency semantics
The Rerun `capture_time` timeline uses The Rerun `capture_time` timeline uses Mac host-clock epoch values. Data rows use
`StreamMessage.received_at_epoch_ns`: Unix time when the Mac received the MQTT `StreamMessage.received_at_epoch_ns`, while new archived sessions also contain a
message. The K1 header timestamp epoch is not proven, so `capture_time` is not real origin row at the capture-envelope start and a real end row at the sealed
a sensor acquisition timestamp. The viewer accumulation window uses session completion. Their zero-based `session_time` is derived from the same
`stream_time`, assigned when the current live/replay run publishes a message; monotonic envelope; it can therefore cover a camera fragment before the first
therefore replayed historical timestamps do not mix two sequential sessions. 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 762,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 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, 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 separate generic media path. Historical sessions predating that archive have
no recoverable video. no recoverable video.
- Physical double-click remains the K1 scan start/stop control. Any MQTT command - 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, - No terrain map, elevation model, obstacle segmentation, localization fusion,
mission planner or vehicle control is implemented by this viewer milestone. mission planner or vehicle control is implemented by this viewer milestone.
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting - 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. 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 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 delivery for point cloud plus trajectory. It does not prove archived playback
sensor-to-screen latency. of one selected camera together with the point cloud or sensor-to-screen
latency.
## Legacy Foxglove regression module ## Legacy Foxglove regression module

View File

@ -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 | | `apps/control-station/src/core/device-plugins/frontendSdk.ts` | Public React host surface for reviewed frontend contributions | Versioned frontend Plugin SDK package surface |
| `packages/plugin-sdk/` | Installable v0alpha2 Pydantic contracts and JSON Schema export | Portable host/plugin identity, lifecycle, stream and evidence boundary | | `packages/plugin-sdk/` | Installable v0alpha2 Pydantic contracts and JSON Schema export | Portable host/plugin identity, lifecycle, stream and evidence boundary |
| `plugins/xgrids-k1/` | v1alpha2 manifest, exact profile/loader and plugin-owned React connection/acquisition UI | Independently versioned XGRIDS device plugin | | `plugins/xgrids-k1/` | v1alpha2 manifest, exact profile/loader and plugin-owned React connection/acquisition UI | Independently versioned XGRIDS device plugin |
| `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/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/device_plugins/xgrids_k1/` | Physically isolated K1 BLE/MQTT/protobuf/LZ4/camera/replay/CLI compatibility implementation | Independently built XGRIDS device plugin process |
| `src/k1link/data_plane/` | Transport-neutral decoded in-process consumer views | Local projections hydrated from portable SDK envelopes | | `src/k1link/data_plane/` | Transport-neutral decoded in-process consumer views | Local projections hydrated from portable SDK envelopes |
@ -32,7 +32,8 @@ The current K1 live/replay data path is:
```text ```text
raw K1 transport raw K1 transport
-> raw-first evidence capture -> 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 -> XGRIDS normalizer injected by the plugin facade
-> DecodedPointCloudView / DecodedPoseView -> DecodedPointCloudView / DecodedPoseView
-> Rerun bridge -> Rerun bridge
@ -44,12 +45,25 @@ The host-owned recorded path is separate from the disposable live preview:
```text ```text
sealed or recovered native observation session sealed or recovered native observation session
-> SQLite catalog and bounded single-worker preparation -> SQLite catalog and bounded single-worker preparation
-> atomic RRD cache v7 + immutable recorded-media manifest v2 -> capture-clock-bound atomic RRD cache v9 + immutable recorded-media manifest v2
-> generation-bound same-origin HTTP -> generation-bound same-origin HTTP
-> aggregate RRD/camera admission -> aggregate RRD/camera admission
-> native Rerun HTTP receiver + recorded fMP4 player -> native Rerun HTTP receiver + recorded fMP4 player
``` ```
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-<sha256>.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 Rerun does not import K1 protocol code, inspect MQTT topics or receive raw
payloads. `VisualizationRuntime` cannot choose a vendor decoder implicitly; its payloads. `VisualizationRuntime` cannot choose a vendor decoder implicitly; its
composition owner injects one. The local `Decoded*View` classes are in-process 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 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 `@mission-core/plugin-sdk`, and is connected by the single reviewed import in
`apps/control-station/src/composition/devicePlugins.ts`. BLE/Wi-Fi provisioning, `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 ## 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 separately reviewed BLE Wi-Fi provisioning write retains its own explicit
operator gate. 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 ## Semantic lifecycle
The transitional facade now creates separate provisional device, 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 data, wait for an externally confirmed stop, finalize and complete. Capture-only
stop reports the K1 physical state as unknown. 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, The operation journal is bounded and in memory. It records IDs, idempotency,
declared deadlines, progress and terminal results without action parameters or secrets. declared deadlines, progress and terminal results without action parameters or secrets.
It is not durable, distributed or recoverable after process restart. 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 validation and shared `session_time` controls are connected; historical sessions
created before this archive contract contain no recoverable video, so physical created before this archive contract contain no recoverable video, so physical
recorded-camera acceptance remains open. Portable FFmpeg packaging, disk-backed recorded-camera acceptance remains open. Portable FFmpeg packaging, disk-backed
browser buffering, fan-out and remote delivery also remain open. Device status browser buffering, fan-out and remote delivery also remain open. Device
and heartbeat remain raw observed channels without semantic decoders. Device
calibration command and sensor-to-vehicle extrinsics are unavailable. calibration command and sensor-to-vehicle extrinsics are unavailable.
The 2026-07-17 physical acceptance gate confirmed continuously updating point 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 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 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 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. for the measured gate and remaining limits.
## Remaining extraction order ## Remaining extraction order
@ -169,8 +223,9 @@ views.
4. Physically accept a newly archived left/right K1 session, then package the 4. Physically accept a newly archived left/right K1 session, then package the
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
caching and evolve same-host MSE delivery toward an authenticated Edge media caching and evolve same-host MSE delivery toward an authenticated Edge media
plane; keep the modeling-command publisher disabled until its separate safety plane. Keep the modeling-command publisher disabled until legitimate OpenAPI
gate closes. key provisioning and durable-save confirmation close its separate safety
gate.
Complex equipment will likely be assembled from separately useful component Complex equipment will likely be assembled from separately useful component
plugins into configured hardware packs, while standalone equipment remains 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; - remote Edge split, authenticated WAN relay and fleet orchestration;
- automatic K1 start/stop/calibration commands; - automatic K1 start/stop/calibration commands;
- device-reported camera capability discovery beyond the reviewed K1 profile; - device-reported camera capability discovery beyond the reviewed K1 profile;
- physical acceptance of shared-timeline recorded-camera playback on a newly - physical acceptance of shared-timeline point-cloud plus one selected-camera
archived K1 session; playback on a newly archived K1 session;
- frame-accurate camera/LiDAR calibration, panoramic stitching, disk-backed - frame-accurate camera/LiDAR calibration, panoramic stitching, disk-backed
browser media cache and remote multi-consumer delivery; browser media cache and remote multi-consumer delivery;
- production retention, replication, encryption and long-run WebViewer/WASM - production retention, replication, encryption and long-run WebViewer/WASM

View File

@ -1,15 +1,19 @@
# Observation sessions, playback and workspace layout # Observation sessions, playback and workspace layout
Status: implemented for native K1 point/pose evidence and host-side camera Status: implemented for native K1 point/pose evidence and host-side camera
archival. Recorded spatial playback is exposed in the Mission Core observation archival. Valid K1 `ModelingReport` scan time, distance and speed are also
workspace. The camera archive/player contract is implemented and covered by materialized as recorded Rerun time series. Recorded spatial playback is exposed
tests, but all retained real K1 sessions predate canonical camera archival. in the Mission Core observation workspace. The camera archive/player contract
Physical archived-camera playback therefore remains an open acceptance gate; is implemented and covered by tests, but all retained real K1 sessions predate
historical sessions contain no recoverable video. 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 ## 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 2. Open **Наблюдение → Пространственная сцена**. Live point cloud, trajectory
and the selected camera remain live-only while acquisition is running. and the selected camera remain live-only while acquisition is running.
3. Stop acquisition normally, or allow the local service to recover an 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 opening restores display settings, tool windows and dynamic source-window
positions. This action saves no sensor evidence. 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 Display controls have no separate Apply/Reset transaction. Boolean controls
commit immediately; sliders, colors and selects commit on release/blur or after 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 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. overall preparation deadline rather than the active-export heartbeat timeout.
The first preparation of a long capture can take time because every decodable The first preparation of a long capture can take time because every decodable
point/pose message is projected into RRD. Later openings reuse a verified, point/pose message and every valid K1 `ModelingReport` is projected into RRD.
digest-bound cache. Derived RRD cache v7 is intentionally incompatible with v6 Later openings reuse a verified, digest-bound cache. Derived RRD cache v9 is
and older generations. It retains v6's real `session_time = 0` payload anchor intentionally incompatible with v8 and older generations. In addition to plugin
and additionally binds the cache to the owning plugin ID, primary artifact and and ordered-artifact identity, it binds the durable clock origin and active
ordered set of confined source artifacts. Older generations are rebuilt once in envelope and materializes real `session_time = 0` origin and sealed completion
the background. rows. Older generations are rebuilt once in the background.
The browser may use the strict `source_url` with `If-Match`, while the embedded The browser may use the strict `source_url` with `If-Match`, while the embedded
Rerun loader uses the canonical `viewer_source_url` whose lowercase SHA-256 Rerun loader uses the canonical `viewer_source_url` whose lowercase SHA-256
`generation` query is bound to the same launch descriptor. A missing or stale `generation` query is bound to the same launch descriptor. A missing or stale
@ -106,7 +119,15 @@ checkout:
│ └── sessions/ │ └── sessions/
│ ├── .current_session # present only while a writer owns the root │ ├── .current_session # present only while a writer owns the root
│ └── <UTC>_viewer_live/ │ └── <UTC>_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-<sha256>.json # sealed generation
│ │ └── mqtt.summary.json # atomic active-envelope pointer
│ └── media/ # canonical camera archives │ └── media/ # canonical camera archives
└── recordings/ └── recordings/
├── .export.lock # cross-process conversion/eviction lock ├── .export.lock # cross-process conversion/eviction lock
@ -156,6 +177,9 @@ For the current K1 profile:
```text ```text
MQTT callback MQTT callback
├─ durable native .k1mqtt + aligned metadata (source of record) ├─ 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) └─ bounded latest-wins Rerun live preview (disposable)
selected RTSP producer selected RTSP producer
@ -166,14 +190,21 @@ selected RTSP producer
The live preview is intentionally allowed to drop frames under load. Native The live preview is intentionally allowed to drop frames under load. Native
point/pose evidence and camera archive writes do not traverse that queue. point/pose evidence and camera archive writes do not traverse that queue.
Native `.k1mqtt` bytes with aligned metadata and canonical camera fMP4 archives Native `.k1mqtt` bytes with aligned metadata, the capture-clock artifacts and
are the evidence source of truth. The derived RRD contains every decodable point canonical camera fMP4 archives are the evidence source of truth. The derived RRD
and pose frame from that source, but remains a rebuildable view rather than an contains every decodable point and pose frame plus every valid `ModelingReport`
evidence master. A cache entry is rebuilt only when native source 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 identity/digests change or an incompatible derived-data export revision is
introduced. A UI blueprint or workspace-layout revision never invalidates or 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 rewrites the data RRD. Cache v8 and older payloads are not reusable as v9 because
do not guarantee the real zero-time anchor. 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 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 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 8 GiB default quota, preserves a 2 GiB default filesystem reserve and evicts
least-recently-used RRDs only; it never deletes native evidence. 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-<sha256>.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 ## Camera archive contract
New acquisitions archive each selected source and codec epoch below the same 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 process or power failure may discard the final uncommitted group. Raw bytes
from an interrupted commit window may survive beyond the durable metadata; from an interrupted commit window may survive beyond the durable metadata;
replay uses only the last validated metadata-aligned raw boundary. 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 - 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 and metadata prefix is bounded, aligned and structurally valid. It is cataloged
as `interrupted`, never silently promoted to `ready`. as `interrupted`, never silently promoted to `ready`.
@ -385,15 +467,17 @@ API boundary.
## Current limit ## Current limit
The spatial RRD, trajectory and archived fMP4 cameras use the same operator The spatial RRD, trajectory, archived device-metric series and archived fMP4
scrubber now. Camera epochs are aligned to zero-based `session_time` from the cameras use the same operator scrubber now. Camera epochs are aligned to
shared host-arrival monotonic clock and rendered through MSE. The client selects zero-based `session_time` from the shared host-arrival monotonic clock and
an epoch only inside its declared inclusive interval and verifies that the 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 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 correlation: codec PTS, K1 sensor exposure time and LiDAR firing time are not
LiDAR clock. A codec epoch whose init segment does not expose a browser-supported proven to share a device clock. A codec epoch whose init segment does not expose
codec or whose timing cannot be proven remains retained evidence and fails a browser-supported codec or whose timing cannot be proven remains retained
closed in the UI/background preparation. evidence and fails closed in the UI/background preparation.
Historical sessions with no canonical camera archive honestly show no recorded Historical sessions with no canonical camera archive honestly show no recorded
video. Recorded blueprints can change accumulation, grid visibility, point and video. Recorded blueprints can change accumulation, grid visibility, point and
trajectory visibility, point radius and a uniform custom point color without 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 palettes remain baked into current RRD rows; fully dynamic recoloring requires
exporting the corresponding scalar components in a future recording schema. 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: The current automated gate covers project-name validation, source-switch
fail-closure, optional plugin scene controls, bounded `ModelingReport` decode,
- `uv sync --frozen --group dev` completed against the locked Python graph; inert start/stop encoding and correlation, capture-clock publication/validation,
- `uv run pytest`: **284 passed**; cache-v9 origin/end materialization and archived metric series. The standard
- `uv run ruff check .`: clean; repository gate remains Python tests/Ruff/mypy plus frontend unit tests,
- project mypy and strict plugin-SDK mypy: clean across 51 and 12 source files; TypeScript checking and the Vite production build; exact pass counts belong to
- the XGRIDS K1 profile loader and emitted v0alpha2 JSON schema validated; the commit's CI/pre-push result rather than this durable architecture contract.
- 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.
Vite still reports its expected large-chunk warning for the embedded Rerun 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. 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, No retained physical K1 session contains the new canonical camera archive, so a
so real recorded-camera playback remains an explicit hardware acceptance test. 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.

View File

@ -28,23 +28,41 @@ admission and browser APIs. A plugin owns native evidence discovery, transport
format validation, recovery semantics, timeline-origin extraction and conversion format validation, recovery semantics, timeline-origin extraction and conversion
of its primary artifact into the canonical recorded Rerun artifact. 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 `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 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. timeline origin. It contains no filename suffix, MQTT topic or sidecar name.
The materializer selects the exporter by `plugin_id` and stages every validated The materializer selects the exporter by `plugin_id` and stages every validated
artifact prefix while preserving only its plugin-owned basename. 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; - plugin ID and primary artifact ID;
- ordered artifact IDs and media types; - ordered artifact IDs and media types;
- full file identity and replay boundary for every artifact; - full file identity and replay boundary for every artifact;
- per-artifact prefix digest; - 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. 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: The concrete implementation is physically located below:
```text ```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 UI contribution, not edits to the session API, preparation queue, Rerun consumer
or main application shell. ADR 0010 defines that frontend boundary. 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 ## Consequences
The semantic and physical vendor boundary is complete for the current 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 command remain transitional compatibility names; their implementation is now
plugin-owned. 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 ADR 0011 subsequently places the action control plane behind a versioned
descriptor/handshake/health transport seam. Observation discovery and export descriptor/handshake/health transport seam. Observation discovery and export
remain host-called plugin contributions and are not yet portable process remain host-called plugin contributions and are not yet portable process

View File

@ -26,10 +26,13 @@ are physically located under:
```text ```text
plugins/xgrids-k1/frontend/src/ plugins/xgrids-k1/frontend/src/
plugin.ts plugin.ts
projectName.ts
automaticSourceStart.ts
XgridsK1Connection.tsx XgridsK1Connection.tsx
components/ components/
K1ProvisioningPipeline.tsx K1ProvisioningPipeline.tsx
K1AcquisitionPipeline.tsx K1AcquisitionPipeline.tsx
K1SpatialControls.tsx
K1Diagnostics.tsx K1Diagnostics.tsx
K1Metrics.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 `apps/control-station/src/composition/devicePlugins.ts` is the only Core file
which imports the concrete XGRIDS frontend. The host owns model selection, which imports the concrete XGRIDS frontend. The host owns model selection,
safe deactivation, the `device.connection` slot, navigation, the spatial scene, safe deactivation, the `device.connection` slot, navigation, the spatial scene,
saved-session UX and global layout. The plugin owns BLE candidates, exact-profile saved-session UX and global layout. A contribution may additionally expose one
attestation, credentials-in-memory form state, K1 endpoint state, connection optional `SpatialControlsView`, which the generic scene mounts beside the
instructions and the live/replay acquisition controls. 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 Plugin CSS is imported with the contribution and scoped below
`.xgrids-k1-plugin`. A plugin cannot add global navigation, route ownership or `.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 - generic Core files other than the composition root contain XGRIDS/Lixel/K1
implementation knowledge; implementation knowledge;
- plugin source imports Control Station implementation paths; - 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 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 ## Consequences
A second device family can provide a completely different connection pipeline A second device family can provide a completely different connection pipeline
and UI while reusing the same model catalog, lifecycle transition, observation and optional scene controls while reusing the same model catalog, lifecycle
scene and saved-session host. Adding it requires its own plugin-owned frontend transition, observation scene and saved-session host. Adding it requires its own
contribution and one reviewed composition import, not edits to `App`, plugin-owned frontend contribution and one reviewed composition import; the
`DeviceWorkspace` or generic runtime state. 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 The remaining isolation gap is deployment-level: frontend contributions are
still compiled into one signed application build, and backend plugins still run still compiled into one signed application build, and backend plugins still run

View File

@ -87,6 +87,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
controller={controller} controller={controller}
profileConfirmed={profileConfirmed} profileConfirmed={profileConfirmed}
openSpatialScene={host.openSpatialScene} openSpatialScene={host.openSpatialScene}
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
/> />
<K1Diagnostics controller={controller} sourceLabel={sourceLabel} /> <K1Diagnostics controller={controller} sourceLabel={sourceLabel} />
</div> </div>

View File

@ -74,6 +74,8 @@ export interface XgridsAcquisition {
device_session_id: string; device_session_id: string;
compatibility_profile_id: string; compatibility_profile_id: string;
control_mode: "operator-manual" | "plugin-commanded" | "observe-only"; control_mode: "operator-manual" | "plugin-commanded" | "observe-only";
project_name?: string | null;
cleanup_pending?: boolean;
requested_streams: string[]; requested_streams: string[];
target_host: string; target_host: string;
duration_seconds: number; duration_seconds: number;
@ -119,6 +121,13 @@ export interface XgridsK1Metrics {
frame_rate_hz?: number | null; frame_rate_hz?: number | null;
point_count?: number | null; point_count?: number | null;
dropped_preview_frames?: 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; [key: string]: number | null | undefined;
} }
@ -216,6 +225,7 @@ export interface ConnectRequest {
} }
export interface PrepareAcquisitionRequest { export interface PrepareAcquisitionRequest {
project_name: string;
host?: string; host?: string;
duration_seconds?: number; duration_seconds?: number;
requested_streams?: RequestedStreamId[]; requested_streams?: RequestedStreamId[];
@ -229,6 +239,7 @@ export interface PrepareAcquisitionRequest {
export type RequestedStreamId = export type RequestedStreamId =
| "spatial.point-cloud.live" | "spatial.point-cloud.live"
| "spatial.pose.live" | "spatial.pose.live"
| "device.modeling.live"
| "device.status.live" | "device.status.live"
| "device.heartbeat.live"; | "device.heartbeat.live";
@ -257,6 +268,7 @@ export interface AbortAcquisitionRequest {
} }
export interface CompatibilityLiveRequest { export interface CompatibilityLiveRequest {
project_name: string;
host?: string; host?: string;
duration_seconds?: number; duration_seconds?: number;
compatibility_attestation: CompatibilityAttestation; compatibility_attestation: CompatibilityAttestation;

View File

@ -0,0 +1,11 @@
export async function runAutomaticSpatialSourceStart(
start: () => Promise<boolean>,
activateAutomaticSpatialSource: () => void,
openSpatialScene: () => void,
): Promise<boolean> {
const started = await start();
if (!started) return false;
activateAutomaticSpatialSource();
openSpatialScene();
return true;
}

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
Button, Button,
Checker, Checker,
@ -11,12 +11,15 @@ import {
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
import { EXACT_PROFILE_ATTESTATION } from "../compatibility"; import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
import { import {
isConfirmedLiveState, isConfirmedLiveState,
isSourceRuntimeBusy, isSourceRuntimeBusy,
isVendorWriteCapable,
recoverableAcquisition, recoverableAcquisition,
sourceStatusLabel, sourceStatusLabel,
} from "../lifecycle"; } from "../lifecycle";
import { normalizeProjectName, validateProjectName } from "../projectName";
import type { XgridsK1Controller } from "../runtimeContext"; import type { XgridsK1Controller } from "../runtimeContext";
type SessionIntent = "live" | "replay"; type SessionIntent = "live" | "replay";
@ -30,10 +33,12 @@ export function K1AcquisitionPipeline({
controller, controller,
profileConfirmed, profileConfirmed,
openSpatialScene, openSpatialScene,
activateAutomaticSpatialSource,
}: { }: {
controller: XgridsK1Controller; controller: XgridsK1Controller;
profileConfirmed: boolean; profileConfirmed: boolean;
openSpatialScene: () => void; openSpatialScene: () => void;
activateAutomaticSpatialSource: () => void;
}) { }) {
const { const {
state, state,
@ -44,10 +49,18 @@ export function K1AcquisitionPipeline({
abort, abort,
} = controller; } = controller;
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live"); const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
const [projectName, setProjectName] = useState("");
const [projectNameTouched, setProjectNameTouched] = useState(false);
const [liveHost, setLiveHost] = useState(""); const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState(""); const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1"); const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false); const [replayLoop, setReplayLoop] = useState(false);
const hydratedAcquisitionId = useRef<string | null>(null);
const activeAcquisition = recoverableAcquisition(state);
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
const projectNameValidation = validateProjectName(projectName);
const vendorWriteCapable = isVendorWriteCapable(state);
useEffect(() => { useEffect(() => {
if (state?.source_mode === "live" || state?.source_mode === "replay") { if (state?.source_mode === "live" || state?.source_mode === "replay") {
@ -57,9 +70,15 @@ export function K1AcquisitionPipeline({
} }
}, [state?.acquisition?.state, state?.source_mode]); }, [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 isBusy = pendingAction !== null;
const activeAcquisition = recoverableAcquisition(state);
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
const sourceRuntimeBusy = isSourceRuntimeBusy(state); const sourceRuntimeBusy = isSourceRuntimeBusy(state);
const sessionLocked = sourceRuntimeBusy || activeAcquisition !== null; const sessionLocked = sourceRuntimeBusy || activeAcquisition !== null;
const effectiveSessionIntent: SessionIntent = const effectiveSessionIntent: SessionIntent =
@ -85,31 +104,39 @@ export function K1AcquisitionPipeline({
); );
const submitLive = async () => { const submitLive = async () => {
if (!profileConfirmed || sourceRuntimeBusy) return; setProjectNameTouched(true);
if (!profileConfirmed || sourceRuntimeBusy || projectNameValidation.error) return;
const targetHost = liveHost.trim(); const targetHost = liveHost.trim();
const started = await prepareAndStartAcquisition({ await runAutomaticSpatialSourceStart(
() => prepareAndStartAcquisition({
project_name: projectNameValidation.value,
...(targetHost ? { host: targetHost } : {}), ...(targetHost ? { host: targetHost } : {}),
compatibility_attestation: EXACT_PROFILE_ATTESTATION, compatibility_attestation: EXACT_PROFILE_ATTESTATION,
}); }),
if (started) openSpatialScene(); activateAutomaticSpatialSource,
openSpatialScene,
);
}; };
const submitReplay = async () => { const submitReplay = async () => {
const speed = Number(replaySpeed); const speed = Number(replaySpeed);
const started = await startReplay({ await runAutomaticSpatialSourceStart(
() => startReplay({
path: replayPath.trim(), path: replayPath.trim(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 1, speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
loop: replayLoop, loop: replayLoop,
}); }),
if (started) openSpatialScene(); activateAutomaticSpatialSource,
openSpatialScene,
);
}; };
return ( return (
<GlassSurface className="session-panel" padding="lg"> <GlassSurface className="session-panel" padding="lg">
<header className="panel-heading"> <header className="panel-heading">
<div> <div>
<span className="section-eyebrow">{effectiveSessionIntent === "live" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span> <span className="section-eyebrow">{effectiveSessionIntent === "live" ? "ШАГИ 0405 · ПРОЕКТ И ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span>
<h2>{effectiveSessionIntent === "live" ? "Подготовьте приём" : "Повторите запись"}</h2> <h2>{effectiveSessionIntent === "live" ? "Назовите проект и запустите приём" : "Повторите запись"}</h2>
</div> </div>
<StatusBadge tone={sourceTone}>{sourceLabel}</StatusBadge> <StatusBadge tone={sourceTone}>{sourceLabel}</StatusBadge>
</header> </header>
@ -121,27 +148,51 @@ export function K1AcquisitionPipeline({
/> />
{effectiveSessionIntent === "live" ? ( {effectiveSessionIntent === "live" ? (
<div className="session-form"> <div className="session-form">
<TextField
label="Название проекта"
hint="Обязательное поле · до 96 символов"
value={projectName}
onChange={(event) => {
setProjectName(event.target.value);
setProjectNameTouched(true);
}}
onBlur={() => setProjectName((value) => normalizeProjectName(value))}
disabled={sessionLocked}
autoComplete="off"
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
description={projectNameTouched && projectNameValidation.error
? projectNameValidation.error
: "Имя сохраняется в локальной сессии Mission Core для K1; в путь к файлам не подставляется."}
placeholder="Например, Испытание маршрута 01"
/>
<TextField <TextField
label="Адрес устройства" label="Адрес устройства"
hint="Обычно определяется автоматически" hint="Обычно определяется автоматически"
value={liveHost} value={liveHost}
onChange={(event) => setLiveHost(event.target.value)} onChange={(event) => setLiveHost(event.target.value)}
disabled={sessionLocked}
spellCheck={false} spellCheck={false}
placeholder={state?.k1_ip || preparedAcquisition?.target_host || "Сначала подключите устройство к WiFi"} placeholder={state?.k1_ip || preparedAcquisition?.target_host || "Сначала подключите устройство к WiFi"}
/> />
<Button <Button
variant="primary" variant="primary"
icon={<Icon name="activity" />} icon={<Icon name="activity" />}
disabled={isBusy || !profileConfirmed || !liveTargetReady || sourceRuntimeBusy || (activeAcquisition !== null && preparedAcquisition === null)} disabled={isBusy || !profileConfirmed || !liveTargetReady || projectNameValidation.error !== null || sourceRuntimeBusy || (activeAcquisition !== null && preparedAcquisition === null)}
onClick={() => void submitLive()} onClick={() => void submitLive()}
> >
{pendingAction === "live" ? "Подготавливаем приём…" : preparedAcquisition ? "Продолжить подготовленный приём" : "Подготовить приём данных"} {pendingAction === "live"
? vendorWriteCapable ? "Инициируем работу устройства…" : "Подготавливаем локальный приём…"
: vendorWriteCapable
? "Инициировать приём данных и работу устройства"
: preparedAcquisition ? "Продолжить подготовленный приём" : "Подготовить локальный приём данных"}
</Button> </Button>
<p className="live-instruction"> <p className="live-instruction">
{!profileConfirmed {!profileConfirmed
? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически." ? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически."
: liveTargetReady : liveTargetReady
? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами." ? vendorWriteCapable
? "Mission Core подготовит локальную запись и отправит профилированную команду запуска K1. После подтверждения запуска начнётся статическая инициализация — не перемещайте устройство до появления потока."
: "Лабораторный профиль подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 не отправляется; поток подтверждается только реальными кадрами."
: "Сначала подключите устройство к WiFi или укажите локальный адрес."} : "Сначала подключите устройство к WiFi или укажите локальный адрес."}
</p> </p>
</div> </div>
@ -163,7 +214,9 @@ export function K1AcquisitionPipeline({
{state?.source_mode === "replay" {state?.source_mode === "replay"
? "Остановка завершит фактически запущенный повтор записи." ? "Остановка завершит фактически запущенный повтор записи."
: activeAcquisition || state?.source_mode === "live" : activeAcquisition || state?.source_mode === "live"
? "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным." ? vendorWriteCapable && activeAcquisition?.control_mode === "plugin-commanded"
? "Остановка отправит профилированную команду K1 и дождётся завершения локального сохранения."
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
: "Активного источника сейчас нет."} : "Активного источника сейчас нет."}
</p> </p>
<Button variant="secondary" disabled={isBusy || (!sourceRuntimeBusy && activeAcquisition === null)} onClick={() => void stop()}> <Button variant="secondary" disabled={isBusy || (!sourceRuntimeBusy && activeAcquisition === null)} onClick={() => void stop()}>

View File

@ -0,0 +1,164 @@
import { Button } from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
import type { AcquisitionState, XgridsAcquisition } from "../api";
import {
isSoftwareCommandedAcquisition,
shouldRenderSpatialControls,
} from "../lifecycle";
import {
deviceTelemetry,
formatNumber,
spatialActionFailure,
} from "../presentation";
import { useXgridsK1Controller } from "../runtimeContext";
interface PhasePresentation {
label: string;
detail: string;
busy: boolean;
}
function phasePresentation(
acquisition: XgridsAcquisition,
softwareCommanded: boolean,
): PhasePresentation {
const presentations: Record<AcquisitionState, PhasePresentation> = {
preparing: {
label: "Подготовка локального приёма",
detail: "Проверяем контур и создаём сессию записи.",
busy: true,
},
prepared: {
label: "Приём подготовлен",
detail: softwareCommanded
? "Можно инициировать работу устройства из Mission Core."
: "Программная команда K1 недоступна в текущем профиле.",
busy: false,
},
awaiting_external_start: {
label: "Ожидание запуска на устройстве",
detail: "Запустите сканирование физической кнопкой K1.",
busy: true,
},
starting: {
label: softwareCommanded
? "Калибровка оборудования"
: "Подготовка локального приёмника",
detail: softwareCommanded
? "Статическая инициализация после запуска — не перемещайте устройство."
: "Mission Core запускает запись до физического старта K1.",
busy: true,
},
acquiring: {
label: softwareCommanded ? "K1 работает · запись активна" : "Локальная запись активна",
detail: softwareCommanded
? "Состояние получено из профилированного контура управления K1."
: "Mission Core принимает данные; физическое состояние K1 не управляется программно.",
busy: false,
},
awaiting_external_stop: {
label: "Ожидание остановки на устройстве",
detail: "Mission Core ждёт подтверждения физической остановки K1.",
busy: true,
},
stopping: {
label: softwareCommanded ? "Останавливаем K1 и запись" : "Останавливаем локальный приём",
detail: softwareCommanded
? "Команда отправлена; ожидаем подтверждённое состояние устройства."
: "Физическое состояние K1 остаётся неизвестным.",
busy: true,
},
finalizing: {
label: "Сохраняем локальную запись",
detail: "Не закрывайте Mission Core до завершения финализации.",
busy: true,
},
completed: { label: "Приём завершён", detail: "", busy: false },
failed: { label: "Ошибка приёма", detail: "", busy: false },
aborted: { label: "Приём прерван", detail: "", busy: false },
interrupted: { label: "Приём прерван", detail: "", busy: false },
};
return presentations[acquisition.state];
}
function formatDuration(seconds: number): string {
const wholeSeconds = Math.max(0, Math.floor(seconds));
const hours = Math.floor(wholeSeconds / 3_600);
const minutes = Math.floor((wholeSeconds % 3_600) / 60);
const remainder = wholeSeconds % 60;
return hours > 0
? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`
: `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
}
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller();
const { state, pendingAction, stop } = controller;
const acquisition = state?.acquisition;
const cleanupPending = acquisition?.cleanup_pending === true;
if (!acquisition || !shouldRenderSpatialControls(state)) {
return null;
}
const softwareCommanded = isSoftwareCommandedAcquisition(state);
const phase = phasePresentation(acquisition, softwareCommanded);
const telemetry = deviceTelemetry(state.metrics);
const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
acquisition.state,
);
const stopDisabled = pendingAction !== null || stopping;
const actionFailure = spatialActionFailure(
controller.error ??
(cleanupPending
? "Локальный поток или архив ещё не завершён. Повторите остановку."
: null),
);
return (
<section
className="xgrids-k1-spatial-controls"
aria-label="Управление сессией XGRIDS K1"
data-busy={phase.busy ? "true" : undefined}
>
<div className="xgrids-k1-spatial-controls__phase">
{phase.busy ? <span className="xgrids-k1-spatial-controls__spinner" aria-hidden="true" /> : null}
<span>
<strong>{phase.label}</strong>
<small>{phase.detail}</small>
</span>
</div>
<div className="xgrids-k1-spatial-controls__telemetry" aria-label="Телеметрия маршрута K1">
{telemetry.elapsedSeconds !== null ? (
<span><small>Время сканирования</small><strong>{formatDuration(telemetry.elapsedSeconds)}</strong></span>
) : null}
{telemetry.routeDistanceMeters !== null ? (
<span><small>Маршрут устройства</small><strong>{formatNumber(telemetry.routeDistanceMeters, 2)} м</strong></span>
) : null}
{telemetry.speedMetersPerSecond !== null ? (
<span><small>Скорость</small><strong>{formatNumber(telemetry.speedMetersPerSecond, 2)} м/с</strong></span>
) : null}
</div>
{actionFailure ? (
<div className="xgrids-k1-spatial-controls__error" role="alert">
<strong>{actionFailure.title}</strong>
<small>{actionFailure.detail}</small>
</div>
) : null}
<Button
size="compact"
variant="secondary"
disabled={stopDisabled}
onClick={() => void stop()}
>
{pendingAction === "stop"
? softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
: stopping
? acquisition.state === "finalizing" ? "Сохраняем запись…" : "Остановка выполняется…"
: actionFailure
? "Повторить остановку"
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
</Button>
</section>
);
}

View File

@ -29,6 +29,17 @@ export function isTerminalAcquisitionState(
return state ? TERMINAL_ACQUISITION_STATES.has(state) : false; return state ? TERMINAL_ACQUISITION_STATES.has(state) : false;
} }
export function shouldRenderSpatialControls(
state: XgridsK1State | null | undefined,
): boolean {
const acquisition = state?.acquisition;
if (!acquisition || state?.source_mode === "replay") return false;
return (
!isTerminalAcquisitionState(acquisition.state) ||
acquisition.cleanup_pending === true
);
}
export function recoverableAcquisition( export function recoverableAcquisition(
state: XgridsK1State | null | undefined, state: XgridsK1State | null | undefined,
): XgridsAcquisition | null { ): XgridsAcquisition | null {
@ -44,6 +55,21 @@ export function isSourceRuntimeBusy(state: XgridsK1State | null | undefined): bo
return state?.source_mode === "live" || state?.source_mode === "replay"; return state?.source_mode === "live" || state?.source_mode === "replay";
} }
export function isVendorWriteCapable(
state: XgridsK1State | null | undefined,
): boolean {
return (
state?.compatibility?.vendor_writes_enabled === true &&
state.compatibility.permitted_mode === "active-control"
);
}
export function isSoftwareCommandedAcquisition(
state: XgridsK1State | null | undefined,
): boolean {
return isVendorWriteCapable(state) && state?.acquisition?.control_mode === "plugin-commanded";
}
export function confirmedRuntimeSourceMode( export function confirmedRuntimeSourceMode(
state: XgridsK1State | null | undefined, state: XgridsK1State | null | undefined,
): RuntimeSourceMode { ): RuntimeSourceMode {

View File

@ -1,5 +1,6 @@
import type { DeviceUiPlugin } from "@mission-core/plugin-sdk"; import type { DeviceUiPlugin } from "@mission-core/plugin-sdk";
import { XgridsK1Connection } from "./XgridsK1Connection"; import { XgridsK1Connection } from "./XgridsK1Connection";
import { K1SpatialControls } from "./components/K1SpatialControls";
import { xgridsK1Manifest } from "./manifest"; import { xgridsK1Manifest } from "./manifest";
import { XgridsK1RuntimeProvider } from "./runtimeContext"; import { XgridsK1RuntimeProvider } from "./runtimeContext";
import "./styles.css"; import "./styles.css";
@ -7,6 +8,7 @@ import "./styles.css";
export const xgridsK1Plugin: DeviceUiPlugin = { export const xgridsK1Plugin: DeviceUiPlugin = {
manifest: xgridsK1Manifest, manifest: xgridsK1Manifest,
RuntimeProvider: XgridsK1RuntimeProvider, RuntimeProvider: XgridsK1RuntimeProvider,
SpatialControlsView: K1SpatialControls,
connectionViews: Object.freeze({ connectionViews: Object.freeze({
"xgrids-k1.connection": XgridsK1Connection, "xgrids-k1.connection": XgridsK1Connection,
}), }),

View File

@ -69,6 +69,52 @@ export function pipelineLatency(metrics: XgridsK1Metrics | undefined): number |
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null; return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
} }
function nonNegativeMetric(value: number | null | undefined): number | null {
const finite = finiteMetric(value);
return finite !== null && finite >= 0 ? finite : null;
}
export interface K1DeviceTelemetry {
elapsedSeconds: number | null;
routeDistanceMeters: number | null;
speedMetersPerSecond: number | null;
}
export interface SpatialActionFailure {
title: string;
detail: string;
}
export function spatialActionFailure(
error: string | null | undefined,
): SpatialActionFailure | null {
const detail = error?.trim();
return detail
? {
title: "Действие K1 не выполнено",
detail,
}
: null;
}
export function deviceTelemetry(
metrics: XgridsK1Metrics | null | undefined,
): K1DeviceTelemetry {
return {
elapsedSeconds: nonNegativeMetric(
metrics?.device_elapsed_seconds ?? metrics?.elapsed_seconds,
),
routeDistanceMeters: nonNegativeMetric(
metrics?.device_route_distance_meters ?? metrics?.route_distance_meters,
),
speedMetersPerSecond: nonNegativeMetric(
metrics?.device_speed_meters_per_second ??
metrics?.device_speed_mps ??
metrics?.speed_meters_per_second,
),
};
}
export function formatNumber(value: number | null, digits = 1): string { export function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—"; if (value === null) return "—";
return value.toLocaleString("ru-RU", { return value.toLocaleString("ru-RU", {

View File

@ -0,0 +1,32 @@
export const K1_PROJECT_NAME_MAX_LENGTH = 96;
export interface ProjectNameValidation {
value: string;
error: string | null;
}
const CONTROL_CHARACTER_OR_SURROGATE = /[\p{Cc}\p{Cs}]/u;
export function normalizeProjectName(input: string): string {
return input.normalize("NFKC").trim();
}
export function validateProjectName(input: string): ProjectNameValidation {
const value = normalizeProjectName(input);
if (!value) {
return { value, error: "Введите название проекта." };
}
if (CONTROL_CHARACTER_OR_SURROGATE.test(value)) {
return {
value,
error: "Название проекта не должно содержать управляющие символы.",
};
}
if (Array.from(value).length > K1_PROJECT_NAME_MAX_LENGTH) {
return {
value,
error: `Название проекта должно быть не длиннее ${K1_PROJECT_NAME_MAX_LENGTH} символов.`,
};
}
return { value, error: null };
}

View File

@ -15,7 +15,7 @@ import {
} from "./lifecycle"; } from "./lifecycle";
import { localizeRuntimeMessage } from "./messages"; import { localizeRuntimeMessage } from "./messages";
import { xgridsK1Manifest } from "./manifest"; import { xgridsK1Manifest } from "./manifest";
import { finiteMetric, pipelineLatency } from "./presentation"; import { deviceTelemetry, finiteMetric, pipelineLatency } from "./presentation";
import { xgridsK1ObservationSources } from "./observationSources"; import { xgridsK1ObservationSources } from "./observationSources";
import { useXgridsK1Runtime } from "./useXgridsK1Runtime"; import { useXgridsK1Runtime } from "./useXgridsK1Runtime";
@ -30,6 +30,7 @@ function normalizeState(
const state = controller.state; const state = controller.state;
if (!state) return null; if (!state) return null;
const metrics = state.metrics; const metrics = state.metrics;
const telemetry = deviceTelemetry(metrics);
const deviceRef = state.device_ref; const deviceRef = state.device_ref;
const deviceSession = state.device_session; const deviceSession = state.device_session;
const acquisition = effectiveAcquisition(state); const acquisition = effectiveAcquisition(state);
@ -69,6 +70,7 @@ function normalizeState(
state: acquisition.state, state: acquisition.state,
stateRevision: acquisition.state_revision, stateRevision: acquisition.state_revision,
operatorInstructions: acquisition.operator_instructions ?? [], operatorInstructions: acquisition.operator_instructions ?? [],
cleanupPending: acquisition.cleanup_pending === true,
} }
: null, : null,
operations: (state.operations ?? []).map((operation) => ({ operations: (state.operations ?? []).map((operation) => ({
@ -106,6 +108,9 @@ function normalizeState(
frameRateHz: finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz), frameRateHz: finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz),
pointCount: finiteMetric(metrics?.point_count), pointCount: finiteMetric(metrics?.point_count),
droppedPreviewFrames: finiteMetric(metrics?.dropped_preview_frames), droppedPreviewFrames: finiteMetric(metrics?.dropped_preview_frames),
elapsedSeconds: telemetry.elapsedSeconds,
routeDistanceMeters: telemetry.routeDistanceMeters,
speedMetersPerSecond: telemetry.speedMetersPerSecond,
}, },
}; };
} }

View File

@ -464,3 +464,115 @@
flex-direction: column; flex-direction: column;
} }
} }
.xgrids-k1-spatial-controls {
display: flex;
min-width: min(42rem, 100%);
max-width: 100%;
align-items: center;
gap: 0.85rem;
border: 1px solid rgb(255 255 255 / 0.1);
border-radius: 1rem;
background: rgb(9 10 13 / 0.88);
padding: 0.55rem 0.65rem 0.55rem 0.75rem;
color: var(--nodedc-text-primary);
box-shadow: 0 0.9rem 2.4rem rgb(0 0 0 / 0.3);
backdrop-filter: blur(20px);
}
.xgrids-k1-spatial-controls__phase {
display: flex;
min-width: 11rem;
flex: 1 1 15rem;
align-items: center;
gap: 0.58rem;
}
.xgrids-k1-spatial-controls__phase > span:last-child {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.xgrids-k1-spatial-controls__phase strong,
.xgrids-k1-spatial-controls__phase small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xgrids-k1-spatial-controls__phase strong {
font-size: 0.66rem;
}
.xgrids-k1-spatial-controls__phase small {
color: var(--nodedc-text-muted);
font-size: 0.53rem;
}
.xgrids-k1-spatial-controls__spinner {
width: 0.82rem;
height: 0.82rem;
flex: 0 0 0.82rem;
border: 1px solid rgb(255 255 255 / 0.16);
border-top-color: var(--nodedc-text-primary);
border-radius: 50%;
animation: xgrids-k1-spin 900ms linear infinite;
}
.xgrids-k1-spatial-controls__telemetry {
display: flex;
flex: 0 1 auto;
align-items: center;
gap: 0.65rem;
}
.xgrids-k1-spatial-controls__telemetry > span {
display: grid;
gap: 0.12rem;
white-space: nowrap;
}
.xgrids-k1-spatial-controls__telemetry small {
color: var(--nodedc-text-muted);
font-size: 0.48rem;
}
.xgrids-k1-spatial-controls__telemetry strong {
font-size: 0.61rem;
}
.xgrids-k1-spatial-controls__error {
display: grid;
max-width: 17rem;
gap: 0.12rem;
color: rgb(var(--nodedc-danger-rgb));
}
.xgrids-k1-spatial-controls__error strong {
font-size: 0.61rem;
}
.xgrids-k1-spatial-controls__error small {
overflow: hidden;
color: var(--nodedc-text-secondary);
font-size: 0.51rem;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes xgrids-k1-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 960px) {
.xgrids-k1-spatial-controls {
min-width: 0;
}
.xgrids-k1-spatial-controls__phase small,
.xgrids-k1-spatial-controls__telemetry,
.xgrids-k1-spatial-controls__error small {
display: none;
}
}

View File

@ -17,6 +17,7 @@ import {
liveStartPlan, liveStartPlan,
operationByIdempotencyKey, operationByIdempotencyKey,
operationNeedsReconciliation, operationNeedsReconciliation,
isSoftwareCommandedAcquisition,
} from "./lifecycle"; } from "./lifecycle";
import { localizeRuntimeMessage } from "./messages"; import { localizeRuntimeMessage } from "./messages";
import { selectMonotonicXgridsState } from "./stateOrdering"; import { selectMonotonicXgridsState } from "./stateOrdering";
@ -65,6 +66,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [latencyHistory, setLatencyHistory] = useState<number[]>([]); const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
const mounted = useRef(true); const mounted = useRef(true);
const actionInFlight = useRef(false);
const acceptState = useCallback((nextState: XgridsK1State) => { const acceptState = useCallback((nextState: XgridsK1State) => {
setState((currentState) => selectMonotonicXgridsState(currentState, nextState)); setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
@ -101,6 +103,8 @@ export function useXgridsK1Runtime(enabled: boolean) {
const run = useCallback( const run = useCallback(
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => { async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
if (!enabled) return false; if (!enabled) return false;
if (actionInFlight.current) return false;
actionInFlight.current = true;
setPendingAction(action); setPendingAction(action);
setError(null); setError(null);
@ -117,6 +121,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
} }
return false; return false;
} finally { } finally {
actionInFlight.current = false;
if (mounted.current) setPendingAction(null); if (mounted.current) setPendingAction(null);
} }
}, },
@ -202,13 +207,13 @@ export function useXgridsK1Runtime(enabled: boolean) {
if (acquisition && !acquisitionTerminal) { if (acquisition && !acquisitionTerminal) {
return xgridsK1Api.stopAcquisition({ return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id, acquisition_id: acquisition.acquisition_id,
mode: "capture-only", mode: isSoftwareCommandedAcquisition(state) ? "graceful" : "capture-only",
}); });
} }
// Replay and pre-v1alpha2 sessions remain a compatibility-only path. // Replay and pre-v1alpha2 sessions remain a compatibility-only path.
return xgridsK1Api.stopSessionCompatibility(); return xgridsK1Api.stopSessionCompatibility();
}), }),
[run, state?.acquisition], [run, state],
); );
const abort = useCallback(() => { const abort = useCallback(() => {

View File

@ -60,6 +60,8 @@
{ "id": "device.provisioning.wifi-over-ble", "label": "Wi-Fi через BLE" }, { "id": "device.provisioning.wifi-over-ble", "label": "Wi-Fi через BLE" },
{ "id": "spatial.point-cloud.live", "label": "Облако точек" }, { "id": "spatial.point-cloud.live", "label": "Облако точек" },
{ "id": "spatial.pose.live", "label": "Траектория" }, { "id": "spatial.pose.live", "label": "Траектория" },
{ "id": "device.modeling.live", "label": "Метрики маршрута" },
{ "id": "device.status.live", "label": "Состояние сканирования" },
{ "id": "camera.preview.live", "label": "Видеокамеры" }, { "id": "camera.preview.live", "label": "Видеокамеры" },
{ "id": "evidence.raw-capture", "label": "Исходная запись" }, { "id": "evidence.raw-capture", "label": "Исходная запись" },
{ "id": "evidence.replay", "label": "Повтор записи" } { "id": "evidence.replay", "label": "Повтор записи" }

View File

@ -62,12 +62,15 @@ def _validate_evidence(value: Any, path: str) -> dict[str, bool]:
if set(evidence) != set(EVIDENCE_FLAGS): if set(evidence) != set(EVIDENCE_FLAGS):
expected = ", ".join(EVIDENCE_FLAGS) expected = ", ".join(EVIDENCE_FLAGS)
raise CompatibilityProfileError(f"{path} must contain exactly: {expected}") raise CompatibilityProfileError(f"{path} must contain exactly: {expected}")
validated: dict[str, bool] = {}
for flag in EVIDENCE_FLAGS: for flag in EVIDENCE_FLAGS:
if not isinstance(evidence[flag], bool): flag_value = evidence[flag]
if not isinstance(flag_value, bool):
raise CompatibilityProfileError(f"{path}.{flag} must be a boolean") raise CompatibilityProfileError(f"{path}.{flag} must be a boolean")
validated[flag] = flag_value
if evidence["write_enabled"]: if evidence["write_enabled"]:
raise CompatibilityProfileError(f"{path}.write_enabled must remain false in v1") raise CompatibilityProfileError(f"{path}.write_enabled must remain false in v1")
return evidence # type: ignore[return-value] return validated
def _walk_and_validate_evidence(value: Any, path: str = "$") -> None: def _walk_and_validate_evidence(value: Any, path: str = "$") -> None:
@ -240,6 +243,7 @@ def _validate_channels(profile: dict[str, Any]) -> None:
expected_ids = { expected_ids = {
"spatial.point-cloud.live", "spatial.point-cloud.live",
"spatial.pose.live", "spatial.pose.live",
"device.modeling.live",
"device.status.live", "device.status.live",
"device.heartbeat.live", "device.heartbeat.live",
"camera.preview.live", "camera.preview.live",
@ -271,16 +275,61 @@ def _validate_channels(profile: dict[str, Any]) -> None:
physical_verified=True, physical_verified=True,
) )
for channel_id, topic in ( modeling = channels["device.modeling.live"]
("device.status.live", "lixel/application/report/device_status"), if (
("device.heartbeat.live", "lixel/application/report/heartbeat"), modeling.get("topic") != "lixel/application/report/modeling"
or modeling.get("wire_format") != "protobuf ModelingReport acquisition telemetry subset"
or modeling.get("semantic_payload")
!= (
"nonnegative MoveDistance metres, MoveSpeed metres per second, "
"int64 ScanTime at two ticks per second, and int32 PgoProgress"
)
or modeling.get("bounds")
!= {
"max_mqtt_payload_bytes": 65_536,
"max_fields": 64,
"max_nested_fields": 16,
}
): ):
channel = channels[channel_id] raise CompatibilityProfileError("modeling telemetry channel differs from evidence")
if channel.get("topic") != topic or channel.get("semantic_payload") is not None:
raise CompatibilityProfileError(f"{channel_id} must remain raw-only in v1")
_expect_evidence( _expect_evidence(
channel, modeling,
f"$.channels[{channel_id}]", "$.channels[device.modeling.live]",
observed=True,
decoded=True,
replay_verified=False,
physical_verified=True,
)
status = channels["device.status.live"]
if (
status.get("topic") != "lixel/application/report/device_status"
or status.get("wire_format") != "protobuf DeviceStatusReport acquisition lifecycle subset"
or status.get("semantic_payload")
!= (
"bounded modeling-state base-offset mapping, init-ready flag, "
"project presence and redacted identity fields"
)
):
raise CompatibilityProfileError("device-status channel differs from evidence")
_expect_evidence(
status,
"$.channels[device.status.live]",
observed=True,
decoded=True,
replay_verified=False,
physical_verified=True,
)
heartbeat = channels["device.heartbeat.live"]
if (
heartbeat.get("topic") != "lixel/application/report/heartbeat"
or heartbeat.get("semantic_payload") is not None
):
raise CompatibilityProfileError("device.heartbeat.live must remain raw-only in v1")
_expect_evidence(
heartbeat,
"$.channels[device.heartbeat.live]",
observed=True, observed=True,
decoded=False, decoded=False,
replay_verified=False, replay_verified=False,
@ -353,14 +402,53 @@ def _validate_acquisition_control(profile: dict[str, Any]) -> None:
) )
if mapping.get("evidence_kind") != "owner-controlled-wire-observation": if mapping.get("evidence_kind") != "owner-controlled-wire-observation":
raise CompatibilityProfileError(f"{action_id} vendor mapping differs from evidence") raise CompatibilityProfileError(f"{action_id} vendor mapping differs from evidence")
if mapping.get("transport") != "MQTT 3.1.1":
raise CompatibilityProfileError(f"{action_id} transport differs from evidence")
if mapping.get("message_type") != "ModelingRequest":
raise CompatibilityProfileError(f"{action_id} message type differs from evidence")
if mapping.get("write_enabled") is not False:
raise CompatibilityProfileError(
f"{action_id} vendor mapping must explicitly remain write-disabled"
)
if mapping.get("topic") != "lixel/application/request/modeling": if mapping.get("topic") != "lixel/application/request/modeling":
raise CompatibilityProfileError( raise CompatibilityProfileError(
f"{action_id} vendor topic differs from static evidence" f"{action_id} vendor topic differs from static evidence"
) )
if mapping.get("qos") != 2 or mapping.get("action_field_value") != action_value: if (
mapping.get("qos") != 2
or mapping.get("retain") is not False
or mapping.get("action_field_value") != action_value
):
raise CompatibilityProfileError( raise CompatibilityProfileError(
f"{action_id} vendor mapping differs from static evidence" f"{action_id} vendor mapping differs from static evidence"
) )
if mapping.get("header_contract") != {
"device_id": "explicit-observed-identity",
"session_id": "{device_id}:ModelingRequest",
"openapi_key": "explicit-observed-value-with-unresolved-provenance",
}:
raise CompatibilityProfileError(
f"{action_id} header contract differs from retained evidence"
)
expected_request_fields: dict[str, object] = (
{
"project_name": "required-operator-value",
"record_mode": 2,
"scan_mode": 1,
"mount_type": 0,
"pre_project_id": "omitted-in-retained-request",
}
if action_id == "acquisition.start"
else {}
)
if mapping.get("request_fields") != expected_request_fields:
raise CompatibilityProfileError(
f"{action_id} request fields differ from retained evidence"
)
if mapping.get("success_result_code") != 302_252_033:
raise CompatibilityProfileError(
f"{action_id} success result differs from retained evidence"
)
if not _array( if not _array(
mapping.get("required_unresolved_context"), mapping.get("required_unresolved_context"),
f"$.acquisition_control.semantic_actions[{action_id}].required_unresolved_context", f"$.acquisition_control.semantic_actions[{action_id}].required_unresolved_context",
@ -407,6 +495,8 @@ def validate_compatibility_profile(profile: Any) -> dict[str, Any]:
raise CompatibilityProfileError("unexpected compatibility profile_id") raise CompatibilityProfileError("unexpected compatibility profile_id")
scope = _object(root.get("scope"), "$.scope") scope = _object(root.get("scope"), "$.scope")
if scope.get("vendor") != "XGRIDS" or scope.get("model") != "LixelKity K1":
raise CompatibilityProfileError("profile vendor/model must remain XGRIDS LixelKity K1")
firmware = _object(scope.get("firmware"), "$.scope.firmware") firmware = _object(scope.get("firmware"), "$.scope.firmware")
if firmware != {"match": "exact", "version": "3.0.2"}: if firmware != {"match": "exact", "version": "3.0.2"}:
raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly") raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly")
@ -470,7 +560,8 @@ def matches_target(
"""Return whether an already validated exact-match profile covers the target.""" """Return whether an already validated exact-match profile covers the target."""
validated = validate_compatibility_profile(profile) validated = validate_compatibility_profile(profile)
scope = validated["scope"] scope = validated["scope"]
return scope["firmware"]["version"] == firmware and scope["topology"] == topology firmware_scope = _object(scope.get("firmware"), "$.scope.firmware")
return firmware_scope.get("version") == firmware and scope.get("topology") == topology
def _main() -> int: def _main() -> int:

View File

@ -29,15 +29,22 @@ boolean flags:
| `physical_verified` | Correlated with a controlled physical state/action | | `physical_verified` | Correlated with a controlled physical state/action |
| `write_enabled` | The profile grants emission of a state-changing request | | `write_enabled` | The profile grants emission of a state-changing request |
`decoded` does not mean that MQTT framing alone was parsed. Status and heartbeat `decoded` does not mean that MQTT framing alone was parsed. Modeling telemetry
are therefore observed raw channels, not decoded status models. Camera preview and the acquisition subset of DeviceStatus have bounded profile-scoped
transport is observed, but its media decoder/replay flags remain independent. decoders; heartbeat remains an observed raw channel. Camera preview transport
is observed, but its media decoder/replay flags remain independent.
The v1 loader rejects every `write_enabled: true`. Owner-operated LixelGO wire The v1 loader rejects every `write_enabled: true`. Owner-operated LixelGO wire
capture verifies the `ModelingRequest` topic and action values, but complete capture verifies the `ModelingRequest` topic, action values, field layout,
device/session/OpenAPI header construction, settings, save completion, timeout literal `{device_id}:ModelingRequest` session relation, retained start settings
and rollback contracts remain unresolved. Acquisition therefore stays and numeric success code. The bounded codec accepts an OpenAPI value only as an
`operator-manual` through the verified physical double-click. explicit secret; its provenance and secure provisioning are unresolved, as are
durable save completion, timeout and rollback behavior. Acquisition therefore
stays `operator-manual` through the verified physical double-click.
The standalone encoder models the recovered wire schema, including enum values
outside the retained request. It is not an authorization policy: any future
publisher must enforce the exact profile mapping (`2/1/0`, omitted
`pre_project_id`) in a separate reviewed gate.
The existing BLE Wi-Fi provisioning workflow has its own reviewed profile and The existing BLE Wi-Fi provisioning workflow has its own reviewed profile and
operator confirmation. Merely loading this compatibility profile neither calls operator confirmation. Merely loading this compatibility profile neither calls

View File

@ -209,20 +209,49 @@
"live-viewer-profile" "live-viewer-profile"
] ]
}, },
{
"id": "device.modeling.live",
"kind": "acquisition-telemetry",
"direction": "device-report",
"topic": "lixel/application/report/modeling",
"wire_format": "protobuf ModelingReport acquisition telemetry subset",
"semantic_payload": "nonnegative MoveDistance metres, MoveSpeed metres per second, int64 ScanTime at two ticks per second, and int32 PgoProgress",
"bounds": {
"max_mqtt_payload_bytes": 65536,
"max_fields": 64,
"max_nested_fields": 16
},
"limitations": [
"ScanTime scale is exact-profile evidence and must not be generalized to other firmware.",
"Replay-to-view acceptance remains open even though retained physical runs establish units and live decoding."
],
"evidence": {
"observed": true,
"decoded": true,
"replay_verified": false,
"physical_verified": true,
"write_enabled": false
},
"source_ids": [
"mqtt-stream-profile",
"lab-001",
"live-viewer-profile"
]
},
{ {
"id": "device.status.live", "id": "device.status.live",
"kind": "device-status", "kind": "device-status",
"direction": "device-report", "direction": "device-report",
"topic": "lixel/application/report/device_status", "topic": "lixel/application/report/device_status",
"wire_format": "opaque bytes", "wire_format": "protobuf DeviceStatusReport acquisition lifecycle subset",
"semantic_payload": null, "semantic_payload": "bounded modeling-state base-offset mapping, init-ready flag, project presence and redacted identity fields",
"limitations": [ "limitations": [
"The report was physically observed, but no bounded semantic device-status decoder is implemented.", "Nested system and RTK status payloads are presence-checked but not semantically decoded.",
"Raw capture is evidence; field names or meanings must not be inferred from payload shape." "ScanOver and Ready observations do not prove durable artifact save completion."
], ],
"evidence": { "evidence": {
"observed": true, "observed": true,
"decoded": false, "decoded": true,
"replay_verified": false, "replay_verified": false,
"physical_verified": true, "physical_verified": true,
"write_enabled": false "write_enabled": false
@ -270,7 +299,7 @@
"limitations": [ "limitations": [
"No full-resolution raw frame, camera calibration or panorama-stitching contract is verified.", "No full-resolution raw frame, camera calibration or panorama-stitching contract is verified.",
"Left/right optical identity is supported by endpoint labels and operator-selected application views, not an independent image-content fixture.", "Left/right optical identity is supported by endpoint labels and operator-selected application views, not an independent image-content fixture.",
"A bounded Mission Core camera decoder and replay fixture are not yet implemented." "Mission Core implements bounded copy-remux, archival and replay admission, but no newly archived physical K1 camera session has passed shared-timeline playback acceptance."
], ],
"evidence": { "evidence": {
"observed": true, "observed": true,
@ -313,11 +342,25 @@
"transport": "MQTT 3.1.1", "transport": "MQTT 3.1.1",
"topic": "lixel/application/request/modeling", "topic": "lixel/application/request/modeling",
"qos": 2, "qos": 2,
"retain": false,
"message_type": "ModelingRequest", "message_type": "ModelingRequest",
"action_field_value": 1, "action_field_value": 1,
"header_contract": {
"device_id": "explicit-observed-identity",
"session_id": "{device_id}:ModelingRequest",
"openapi_key": "explicit-observed-value-with-unresolved-provenance"
},
"request_fields": {
"project_name": "required-operator-value",
"record_mode": 2,
"scan_mode": 1,
"mount_type": 0,
"pre_project_id": "omitted-in-retained-request"
},
"success_result_code": 302252033,
"required_unresolved_context": [ "required_unresolved_context": [
"complete device/session/OpenAPI header construction", "OpenAPI credential provenance and secure provisioning",
"project/record/scan/mount setting semantics and safe defaults", "authorization policy for any setting outside the retained request",
"timeout, rejection and rollback contract" "timeout, rejection and rollback contract"
], ],
"evidence": { "evidence": {
@ -352,10 +395,18 @@
"transport": "MQTT 3.1.1", "transport": "MQTT 3.1.1",
"topic": "lixel/application/request/modeling", "topic": "lixel/application/request/modeling",
"qos": 2, "qos": 2,
"retain": false,
"message_type": "ModelingRequest", "message_type": "ModelingRequest",
"action_field_value": 2, "action_field_value": 2,
"header_contract": {
"device_id": "explicit-observed-identity",
"session_id": "{device_id}:ModelingRequest",
"openapi_key": "explicit-observed-value-with-unresolved-provenance"
},
"request_fields": {},
"success_result_code": 302252033,
"required_unresolved_context": [ "required_unresolved_context": [
"complete device/session/OpenAPI header construction", "OpenAPI credential provenance and secure provisioning",
"save-completion and final-standby state mapping", "save-completion and final-standby state mapping",
"timeout and rollback contract" "timeout and rollback contract"
], ],

View File

@ -6,20 +6,29 @@ import math
import os import os
import re import re
import stat import stat
import unicodedata
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass, replace
from datetime import UTC, datetime from datetime import UTC, datetime
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import IO, Any from typing import IO, Any
from k1link.device_plugins.xgrids_k1.mqtt.capture import ( from k1link.device_plugins.xgrids_k1.mqtt.capture import (
CAPTURE_CLOCK_FILENAME,
CAPTURE_CLOCK_ORIGIN_FILENAME,
FRAME_HEADER, FRAME_HEADER,
GROUP_COMMIT_MAX_BYTES, GROUP_COMMIT_MAX_BYTES,
GROUP_COMMIT_MAX_MESSAGES, GROUP_COMMIT_MAX_MESSAGES,
MAX_CONFIGURABLE_MESSAGE_BYTES, MAX_CONFIGURABLE_MESSAGE_BYTES,
MAX_TOPIC_BYTES, MAX_TOPIC_BYTES,
RAW_MAGIC, RAW_MAGIC,
CaptureClockEnvelope,
CaptureClockOrigin,
CaptureFormatError,
is_capture_clock_filename,
read_capture_clock_envelope,
read_capture_clock_origin,
) )
from k1link.sessions.models import ( from k1link.sessions.models import (
LegacyMediaSourceCandidate, LegacyMediaSourceCandidate,
@ -58,8 +67,17 @@ class LegacySessionCandidate:
raw_byte_length: int raw_byte_length: int
replay_raw_byte_length: int replay_raw_byte_length: int
replay_metadata_byte_length: int replay_metadata_byte_length: int
replay_capture_clock_origin_byte_length: int
replay_capture_clock_byte_length: int
raw_sha256: str | None raw_sha256: str | None
capture_clock_origin_sha256: str | None
capture_clock_sha256: str | None
capture_clock_path: Path | None
raw_integrity_status: str raw_integrity_status: str
timeline_origin_epoch_ns: int
timeline_origin_monotonic_ns: int
timeline_completion_epoch_ns: int
timeline_completion_monotonic_ns: int
media_sources: tuple[LegacyMediaSourceCandidate, ...] media_sources: tuple[LegacyMediaSourceCandidate, ...]
@ -72,6 +90,14 @@ class _RecoveredCapture:
duration_seconds: float duration_seconds: float
raw_committed_bytes: int raw_committed_bytes: int
metadata_committed_bytes: int metadata_committed_bytes: int
first_epoch_ns: int
last_epoch_ns: int
first_monotonic_ns: int
last_monotonic_ns: int
capture_clock_origin: CaptureClockOrigin | None = None
capture_clock: CaptureClockEnvelope | None = None
capture_clock_path: Path | None = None
capture_clock_scope: str | None = None
def discover_legacy_viewer_sessions(root: Path) -> tuple[LegacySessionCandidate, ...]: def discover_legacy_viewer_sessions(root: Path) -> tuple[LegacySessionCandidate, ...]:
@ -137,13 +163,17 @@ def _describe_session(
message_count = ( message_count = (
_non_negative_int(summary.get("message_count")) _non_negative_int(summary.get("message_count"))
if completed is not None if completed is not None
else recovered.message_count if recovered is not None else 0 else recovered.message_count
if recovered is not None
else 0
) )
summary_topic_counts = summary.get("topic_counts") summary_topic_counts = summary.get("topic_counts")
topic_counts = ( topic_counts = (
_normalized_topic_counts(summary_topic_counts) _normalized_topic_counts(summary_topic_counts)
if completed is not None if completed is not None
else recovered.topic_counts if recovered is not None else {} else recovered.topic_counts
if recovered is not None
else {}
) )
media_sources = _discover_media_sources(session_root) media_sources = _discover_media_sources(session_root)
modalities = list(_modalities(topic_counts)) modalities = list(_modalities(topic_counts))
@ -151,11 +181,25 @@ def _describe_session(
modalities.append("video") modalities.append("video")
replayable = bool( replayable = bool(
not active not active
and and raw_magic_ok
raw_magic_ok
and raw_bytes > len(RAW_MAGIC) and raw_bytes > len(RAW_MAGIC)
and message_count > 0 and message_count > 0
and any(modality in {"point-cloud", "trajectory"} for modality in modalities) and any(modality in {"point-cloud", "trajectory"} for modality in modalities)
# A v2 transport-scoped completion is provisional. When camera media
# exists, only the owner-sealed session envelope can advertise a
# combined replay. Legacy captures have no scope and retain their
# reviewed first/last-message fallback.
and not (
media_sources
and (
completed.capture_clock_scope
if completed is not None
else recovered.capture_clock_scope
if recovered is not None
else None
)
in {"transport", "origin"}
)
) )
if completed is not None: if completed is not None:
status = ( status = (
@ -171,6 +215,8 @@ def _describe_session(
completed_at = _safe_timestamp(summary.get("completed_at_utc")) or _safe_timestamp( completed_at = _safe_timestamp(summary.get("completed_at_utc")) or _safe_timestamp(
manifest.get("completed_at_utc") manifest.get("completed_at_utc")
) )
duration = _duration(summary.get("session_elapsed_seconds"))
if duration is None:
duration = _duration(summary.get("capture_elapsed_seconds")) duration = _duration(summary.get("capture_elapsed_seconds"))
declared_hash = _declared_raw_hash(summary) declared_hash = _declared_raw_hash(summary)
integrity_status = "verified" if declared_hash is not None else "validated-structure" integrity_status = "verified" if declared_hash is not None else "validated-structure"
@ -190,17 +236,62 @@ def _describe_session(
replay_raw_bytes = ( replay_raw_bytes = (
completed.raw_committed_bytes completed.raw_committed_bytes
if completed is not None if completed is not None
else recovered.raw_committed_bytes if recovered is not None else 0 else recovered.raw_committed_bytes
if recovered is not None
else 0
) )
replay_metadata_bytes = ( replay_metadata_bytes = (
completed.metadata_committed_bytes completed.metadata_committed_bytes
if completed is not None if completed is not None
else recovered.metadata_committed_bytes if recovered is not None else 0 else recovered.metadata_committed_bytes
if recovered is not None
else 0
)
timing = completed if completed is not None else recovered
capture_clock = None if timing is None else timing.capture_clock
capture_clock_origin = None if timing is None else timing.capture_clock_origin
replay_capture_clock_origin_bytes = (
capture_clock_origin.artifact_byte_length if capture_clock_origin is not None else 0
)
replay_capture_clock_bytes = (
capture_clock.artifact_byte_length if capture_clock is not None else 0
)
timeline_origin_epoch_ns = (
capture_clock.started_at_epoch_ns
if capture_clock is not None
else capture_clock_origin.started_at_epoch_ns
if capture_clock_origin is not None
else timing.first_epoch_ns
if timing is not None
else 0
)
timeline_origin_monotonic_ns = (
capture_clock.started_monotonic_ns
if capture_clock is not None
else capture_clock_origin.started_monotonic_ns
if capture_clock_origin is not None
else timing.first_monotonic_ns
if timing is not None
else 0
)
timeline_completion_epoch_ns = (
capture_clock.completed_at_epoch_ns
if capture_clock is not None
else timing.last_epoch_ns
if timing is not None
else 0
)
timeline_completion_monotonic_ns = (
capture_clock.completed_monotonic_ns
if capture_clock is not None
else timing.last_monotonic_ns
if timing is not None
else 0
) )
return LegacySessionCandidate( return LegacySessionCandidate(
session_id=session_root.name, session_id=session_root.name,
display_name=session_root.name, display_name=_project_display_name(manifest, fallback=session_root.name),
status=status, status=status,
started_at_utc=started_at, started_at_utc=started_at,
completed_at_utc=completed_at, completed_at_utc=completed_at,
@ -214,12 +305,41 @@ def _describe_session(
raw_byte_length=raw_bytes, raw_byte_length=raw_bytes,
replay_raw_byte_length=replay_raw_bytes, replay_raw_byte_length=replay_raw_bytes,
replay_metadata_byte_length=replay_metadata_bytes, replay_metadata_byte_length=replay_metadata_bytes,
replay_capture_clock_origin_byte_length=replay_capture_clock_origin_bytes,
replay_capture_clock_byte_length=replay_capture_clock_bytes,
raw_sha256=declared_hash, raw_sha256=declared_hash,
capture_clock_origin_sha256=(
capture_clock_origin.artifact_sha256 if capture_clock_origin is not None else None
),
capture_clock_sha256=(capture_clock.artifact_sha256 if capture_clock is not None else None),
capture_clock_path=None if timing is None else timing.capture_clock_path,
raw_integrity_status=integrity_status, raw_integrity_status=integrity_status,
timeline_origin_epoch_ns=timeline_origin_epoch_ns,
timeline_origin_monotonic_ns=timeline_origin_monotonic_ns,
timeline_completion_epoch_ns=timeline_completion_epoch_ns,
timeline_completion_monotonic_ns=timeline_completion_monotonic_ns,
media_sources=media_sources, media_sources=media_sources,
) )
def _project_display_name(manifest: Mapping[str, Any], *, fallback: str) -> str:
value = manifest.get("project_name")
if not isinstance(value, str):
return fallback
normalized = unicodedata.normalize("NFKC", value).strip()
try:
normalized.encode("utf-8", errors="strict")
except UnicodeEncodeError:
return fallback
if (
not normalized
or len(normalized) > 96
or any(unicodedata.category(character) in {"Cc", "Cs"} for character in normalized)
):
return fallback
return normalized
def _is_completed_summary(summary: dict[str, Any]) -> bool: def _is_completed_summary(summary: dict[str, Any]) -> bool:
return ( return (
"message_count" in summary "message_count" in summary
@ -254,7 +374,7 @@ def _validate_completed_capture(
sort_keys=True, sort_keys=True,
separators=(",", ":"), separators=(",", ":"),
) )
return _validate_completed_capture_cached( validated = _validate_completed_capture_cached(
str(capture_root), str(capture_root),
str(session_root), str(session_root),
str(raw_path), str(raw_path),
@ -262,6 +382,81 @@ def _validate_completed_capture(
_stat_identity(metadata_stat), _stat_identity(metadata_stat),
fingerprint, fingerprint,
) )
if validated is None:
return None
schema_version = summary.get("schema_version", 1)
requires_capture_clock = (
isinstance(schema_version, int)
and not isinstance(schema_version, bool)
and schema_version >= 2
)
capture_clock_scope = summary.get("capture_clock_scope")
if requires_capture_clock and capture_clock_scope not in {"transport", "session"}:
return None
artifacts = summary.get("artifacts")
declared_name = artifacts.get("capture_clock") if isinstance(artifacts, dict) else None
declared_origin_name = (
artifacts.get("capture_clock_origin") if isinstance(artifacts, dict) else None
)
declared_hash = _declared_capture_clock_hash(summary)
declared_origin_hash = _declared_capture_clock_origin_hash(summary)
if declared_name is None and declared_hash is None and not requires_capture_clock:
return validated
if (
not is_capture_clock_filename(declared_name)
or declared_hash is None
or declared_origin_name != CAPTURE_CLOCK_ORIGIN_FILENAME
or declared_origin_hash is None
or (capture_clock_scope == "transport" and declared_name != CAPTURE_CLOCK_FILENAME)
or (
capture_clock_scope == "session"
and isinstance(declared_name, str)
and declared_name != f"mqtt.timeline.session-{declared_hash}.json"
)
):
return None
assert isinstance(declared_name, str)
capture_clock_path = capture_root / declared_name
capture_clock_origin_path = capture_root / CAPTURE_CLOCK_ORIGIN_FILENAME
if not _confined_file(capture_clock_path, session_root) or not _confined_file(
capture_clock_origin_path,
session_root,
):
return None
try:
capture_clock_origin = read_capture_clock_origin(
capture_clock_origin_path,
expected_sha256=declared_origin_hash,
)
capture_clock = read_capture_clock_envelope(
capture_clock_path,
expected_sha256=declared_hash,
)
except CaptureFormatError:
return None
if not (
capture_clock.started_at_epoch_ns == capture_clock_origin.started_at_epoch_ns
and capture_clock.started_monotonic_ns == capture_clock_origin.started_monotonic_ns
and capture_clock.started_monotonic_ns
<= validated.first_monotonic_ns
<= validated.last_monotonic_ns
<= capture_clock.completed_monotonic_ns
):
return None
declared_duration = _duration(summary.get("session_elapsed_seconds"))
if requires_capture_clock and (
declared_duration is None
or abs(declared_duration - capture_clock.duration_ns / 1_000_000_000) > 1e-9
):
return None
return replace(
validated,
capture_clock_origin=capture_clock_origin,
capture_clock=capture_clock,
capture_clock_path=capture_clock_path,
capture_clock_scope=(capture_clock_scope if isinstance(capture_clock_scope, str) else None),
)
@lru_cache(maxsize=128) @lru_cache(maxsize=128)
@ -312,13 +507,29 @@ def _recover_interrupted_capture(
session_root: Path, session_root: Path,
raw_path: Path, raw_path: Path,
) -> _RecoveredCapture | None: ) -> _RecoveredCapture | None:
return _scan_capture_prefix( recovered = _scan_capture_prefix(
capture_root, capture_root,
session_root, session_root,
raw_path, raw_path,
tolerate_incomplete_metadata_tail=True, tolerate_incomplete_metadata_tail=True,
tolerate_raw_crash_tail=True, tolerate_raw_crash_tail=True,
) )
if recovered is None:
return None
origin_path = capture_root / CAPTURE_CLOCK_ORIGIN_FILENAME
if not _confined_file(origin_path, session_root):
return recovered
try:
origin = read_capture_clock_origin(origin_path)
except CaptureFormatError:
return recovered
if origin.started_monotonic_ns > recovered.first_monotonic_ns:
return recovered
return replace(
recovered,
capture_clock_origin=origin,
capture_clock_scope="origin",
)
def _scan_capture_prefix( def _scan_capture_prefix(
@ -399,8 +610,7 @@ def _scan_capture_prefix(
return None return None
raw_committed_bytes = raw_stream.tell() raw_committed_bytes = raw_stream.tell()
if raw_committed_bytes != raw_size and not ( if raw_committed_bytes != raw_size and not (
tolerate_raw_crash_tail tolerate_raw_crash_tail and _tolerable_raw_crash_tail(raw_stream, raw_size=raw_size)
and _tolerable_raw_crash_tail(raw_stream, raw_size=raw_size)
): ):
return None return None
except OSError: except OSError:
@ -423,6 +633,10 @@ def _scan_capture_prefix(
duration_seconds=(last_monotonic_ns - first_monotonic_ns) / 1_000_000_000, duration_seconds=(last_monotonic_ns - first_monotonic_ns) / 1_000_000_000,
raw_committed_bytes=raw_committed_bytes, raw_committed_bytes=raw_committed_bytes,
metadata_committed_bytes=metadata_committed_bytes, metadata_committed_bytes=metadata_committed_bytes,
first_epoch_ns=first_epoch_ns,
last_epoch_ns=last_epoch_ns,
first_monotonic_ns=first_monotonic_ns,
last_monotonic_ns=last_monotonic_ns,
) )
@ -629,6 +843,22 @@ def _declared_metadata_hash(summary: dict[str, Any]) -> str | None:
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
def _declared_capture_clock_hash(summary: dict[str, Any]) -> str | None:
hashes = summary.get("artifact_hashes")
if not isinstance(hashes, dict):
return None
value = hashes.get("capture_clock_sha256")
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
def _declared_capture_clock_origin_hash(summary: dict[str, Any]) -> str | None:
hashes = summary.get("artifact_hashes")
if not isinstance(hashes, dict):
return None
value = hashes.get("capture_clock_origin_sha256")
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
def _sha256_stable(path: Path) -> str: def _sha256_stable(path: Path) -> str:
try: try:
current = path.lstat() current = path.lstat()
@ -815,8 +1045,7 @@ def _validated_media_epoch(epoch: Path, expected_source_id: str) -> bool:
except (OSError, UnicodeDecodeError, json.JSONDecodeError): except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return False return False
if len(index_records) != segment_count or not all( if len(index_records) != segment_count or not all(
isinstance(record, dict) isinstance(record, dict) and _non_negative_int(record.get("sequence")) > 0
and _non_negative_int(record.get("sequence")) > 0
for record in index_records for record in index_records
): ):
return False return False

View File

@ -8,18 +8,26 @@ import json
import secrets import secrets
import threading import threading
import time import time
from collections.abc import Mapping import unicodedata
from contextlib import suppress from collections.abc import Callable, Mapping
from datetime import UTC, datetime from datetime import UTC, datetime
from functools import wraps
from pathlib import Path from pathlib import Path
from typing import Any, Literal, Protocol, cast from typing import Any, Concatenate, Literal, Protocol, cast
from bleak.exc import BleakError from bleak.exc import BleakError
from missioncore_plugin_sdk.v0alpha2 import ( from missioncore_plugin_sdk.v0alpha2 import (
RuntimeActionInvocation, RuntimeActionInvocation,
RuntimePluginDescriptor, RuntimePluginDescriptor,
) )
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError from pydantic import (
BaseModel,
ConfigDict,
Field,
SecretStr,
ValidationError,
field_validator,
)
from k1link.artifacts import write_json_atomic from k1link.artifacts import write_json_atomic
from k1link.device_plugins.xgrids_k1.ble.scanner import scan from k1link.device_plugins.xgrids_k1.ble.scanner import scan
@ -36,6 +44,8 @@ from k1link.device_plugins.xgrids_k1.camera import (
build_xgrids_k1_camera_router, build_xgrids_k1_camera_router,
) )
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4 from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
from k1link.device_plugins.xgrids_k1.mqtt.capture import seal_capture_clock
from k1link.device_plugins.xgrids_k1.protocol.modeling import observe_modeling_report
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
from k1link.device_plugins.xgrids_k1.viewer.runtime import ( from k1link.device_plugins.xgrids_k1.viewer.runtime import (
VisualizationRuntime, VisualizationRuntime,
@ -74,6 +84,33 @@ ACTION_ACQUISITION_PREPARE = "acquisition.prepare"
ACTION_ACQUISITION_START = "acquisition.start" ACTION_ACQUISITION_START = "acquisition.start"
ACTION_ACQUISITION_STOP = "acquisition.stop" ACTION_ACQUISITION_STOP = "acquisition.stop"
ACTION_ACQUISITION_ABORT = "acquisition.abort" ACTION_ACQUISITION_ABORT = "acquisition.abort"
def _serialized_acquisition_access[**P, R](
method: Callable[Concatenate[XgridsK1CompatibilityService, P], R],
) -> Callable[Concatenate[XgridsK1CompatibilityService, P], R]:
"""Serialize acquisition snapshots and producer lifecycle mutations.
Runtime/camera calls may synchronously cause a state read, so the gate is
deliberately re-entrant for the owning thread while excluding concurrent
request handlers from observing a half-completed producer handoff.
"""
@wraps(method)
def serialized(
service: XgridsK1CompatibilityService,
*args: P.args,
**kwargs: P.kwargs,
) -> R:
with service._acquisition_lifecycle_gate: # noqa: SLF001
return method(service, *args, **kwargs)
return cast(
"Callable[Concatenate[XgridsK1CompatibilityService, P], R]",
serialized,
)
ACTION_ACQUISITION_STATE_READ = "acquisition.state.read" ACTION_ACQUISITION_STATE_READ = "acquisition.state.read"
ACTION_STREAM_START_LIVE = "stream.start-live" ACTION_STREAM_START_LIVE = "stream.start-live"
ACTION_STREAM_START_REPLAY = "stream.start-replay" ACTION_STREAM_START_REPLAY = "stream.start-replay"
@ -85,12 +122,14 @@ ACTION_VIEWER_SETTINGS_UPDATE = "viewer.settings.update"
RequestedStreamId = Literal[ RequestedStreamId = Literal[
"spatial.point-cloud.live", "spatial.point-cloud.live",
"spatial.pose.live", "spatial.pose.live",
"device.modeling.live",
"device.status.live", "device.status.live",
"device.heartbeat.live", "device.heartbeat.live",
] ]
DEFAULT_LIVE_STREAMS: tuple[RequestedStreamId, ...] = ( DEFAULT_LIVE_STREAMS: tuple[RequestedStreamId, ...] = (
"spatial.point-cloud.live", "spatial.point-cloud.live",
"spatial.pose.live", "spatial.pose.live",
"device.modeling.live",
"device.status.live", "device.status.live",
"device.heartbeat.live", "device.heartbeat.live",
) )
@ -130,10 +169,16 @@ class ConnectRequest(StrictRequest):
class LiveRequest(StrictRequest): class LiveRequest(StrictRequest):
project_name: str = Field(min_length=1, max_length=96)
host: str | None = Field(default=None, max_length=15) host: str | None = Field(default=None, max_length=15)
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0) duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
compatibility_attestation: CompatibilityAttestationRequest compatibility_attestation: CompatibilityAttestationRequest
@field_validator("project_name")
@classmethod
def validate_project_name(cls, value: str) -> str:
return normalize_project_name(value)
class OperationContextRequest(StrictRequest): class OperationContextRequest(StrictRequest):
operation_id: str | None = Field(default=None, min_length=1, max_length=128) operation_id: str | None = Field(default=None, min_length=1, max_length=128)
@ -142,12 +187,18 @@ class OperationContextRequest(StrictRequest):
class PrepareAcquisitionRequest(OperationContextRequest): class PrepareAcquisitionRequest(OperationContextRequest):
project_name: str = Field(min_length=1, max_length=96)
host: str | None = Field(default=None, max_length=15) host: str | None = Field(default=None, max_length=15)
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0) duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
requested_streams: tuple[RequestedStreamId, ...] = DEFAULT_LIVE_STREAMS requested_streams: tuple[RequestedStreamId, ...] = DEFAULT_LIVE_STREAMS
evidence_policy: Literal["required", "best-effort", "disabled"] = "required" evidence_policy: Literal["required", "best-effort", "disabled"] = "required"
compatibility_attestation: CompatibilityAttestationRequest compatibility_attestation: CompatibilityAttestationRequest
@field_validator("project_name")
@classmethod
def validate_project_name(cls, value: str) -> str:
return normalize_project_name(value)
class StartAcquisitionRequest(OperationContextRequest): class StartAcquisitionRequest(OperationContextRequest):
acquisition_id: str | None = Field(default=None, min_length=1, max_length=128) acquisition_id: str | None = Field(default=None, min_length=1, max_length=128)
@ -198,6 +249,7 @@ class XgridsK1CompatibilityService:
self.repository_root = repository_root.resolve() self.repository_root = repository_root.resolve()
self.evidence_root = resolve_missioncore_evidence_dir(self.repository_root) self.evidence_root = resolve_missioncore_evidence_dir(self.repository_root)
self._lock = threading.Lock() self._lock = threading.Lock()
self._acquisition_lifecycle_gate = threading.RLock()
self._provisioning_gate = threading.Lock() self._provisioning_gate = threading.Lock()
self._provisioning_active = False self._provisioning_active = False
self._fingerprint_key = secrets.token_bytes(32) self._fingerprint_key = secrets.token_bytes(32)
@ -219,18 +271,23 @@ class XgridsK1CompatibilityService:
self._operation_message: str | None = None self._operation_message: str | None = None
self._operations = OperationJournal() self._operations = OperationJournal()
self._acquisition: AcquisitionRecord | None = None self._acquisition: AcquisitionRecord | None = None
self._acquisition_project_name: str | None = None
self._acquisition_out_dir: Path | None = None self._acquisition_out_dir: Path | None = None
self._acquisition_start_operation_id: str | None = None self._acquisition_start_operation_id: str | None = None
self._acquisition_stop_operation_id: str | None = None self._acquisition_stop_operation_id: str | None = None
self._acquisition_session_lease: ActiveSessionLease | None = None self._acquisition_session_lease: ActiveSessionLease | None = None
# The host-owned visual runtime receives the vendor normalizer # The host-owned visual runtime receives the vendor normalizer
# explicitly. There is no implicit K1 decoder in the visual layer. # explicitly. There is no implicit K1 decoder in the visual layer.
self.runtime = VisualizationRuntime(normalizer=normalize_k1_message) self.runtime = VisualizationRuntime(
normalizer=normalize_k1_message,
message_observer=observe_modeling_report,
)
self.camera_preview = XgridsK1CameraGateway( self.camera_preview = XgridsK1CameraGateway(
self.repository_root, self.repository_root,
XGRIDS_K1_PLUGIN_ID, XGRIDS_K1_PLUGIN_ID,
) )
@_serialized_acquisition_access
def state(self) -> dict[str, Any]: def state(self) -> dict[str, Any]:
runtime = self.runtime.snapshot() runtime = self.runtime.snapshot()
camera_preview = self.camera_preview.snapshot() camera_preview = self.camera_preview.snapshot()
@ -253,6 +310,12 @@ class XgridsK1CompatibilityService:
else None else None
) )
acquisition = self._acquisition.as_dict() if self._acquisition is not None else None acquisition = self._acquisition.as_dict() if self._acquisition is not None else None
if acquisition is not None:
acquisition["project_name"] = self._acquisition_project_name
acquisition["cleanup_pending"] = (
self._acquisition_session_lease is not None
and acquisition["state"] in TERMINAL_ACQUISITION_STATES
)
runtime_active = runtime["source_mode"] != "idle" or runtime["phase"] in { runtime_active = runtime["source_mode"] != "idle" or runtime["phase"] in {
"starting_live", "starting_live",
@ -599,6 +662,7 @@ class XgridsK1CompatibilityService:
} }
return self.state() return self.state()
@_serialized_acquisition_access
def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]: def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]:
requested_streams = _validated_requested_streams(request) requested_streams = _validated_requested_streams(request)
target = request.host or self.state()["k1_ip"] target = request.host or self.state()["k1_ip"]
@ -611,6 +675,15 @@ class XgridsK1CompatibilityService:
raise ValueError( raise ValueError(
"адрес точки доступа устройства нельзя использовать как direct-LAN target" "адрес точки доступа устройства нельзя использовать как direct-LAN target"
) )
with self._lock:
if self._acquisition_session_lease is not None:
raise RuntimeError("предыдущая evidence-сессия ещё не остановлена и не запечатана")
runtime_state = self.runtime.snapshot()
if runtime_state.get("source_mode") != "idle":
raise RuntimeError(
"нельзя готовить acquisition во время активного live/replay источника; "
"сначала остановите его"
)
device_id, device_session_id = self._ensure_device_context() device_id, device_session_id = self._ensure_device_context()
request_fingerprint = self._request_fingerprint( request_fingerprint = self._request_fingerprint(
ACTION_ACQUISITION_PREPARE, ACTION_ACQUISITION_PREPARE,
@ -619,6 +692,7 @@ class XgridsK1CompatibilityService:
"duration_seconds": request.duration_seconds, "duration_seconds": request.duration_seconds,
"requested_streams": requested_streams, "requested_streams": requested_streams,
"evidence_policy": request.evidence_policy, "evidence_policy": request.evidence_policy,
"project_name": request.project_name,
"compatibility_attestation": request.compatibility_attestation.model_dump( "compatibility_attestation": request.compatibility_attestation.model_dump(
mode="json" mode="json"
), ),
@ -649,6 +723,10 @@ class XgridsK1CompatibilityService:
raise RuntimeError( raise RuntimeError(
"нельзя готовить acquisition во время настройки Wi-Fi устройства" "нельзя готовить acquisition во время настройки Wi-Fi устройства"
) )
if self._acquisition_session_lease is not None:
raise RuntimeError(
"предыдущая evidence-сессия ещё не остановлена и не запечатана"
)
current = self._acquisition current = self._acquisition
if current is not None and current.state not in TERMINAL_ACQUISITION_STATES: if current is not None and current.state not in TERMINAL_ACQUISITION_STATES:
raise RuntimeError( raise RuntimeError(
@ -674,6 +752,7 @@ class XgridsK1CompatibilityService:
}, },
) )
self._acquisition = acquisition self._acquisition = acquisition
self._acquisition_project_name = request.project_name
self._acquisition_out_dir = new_live_session_dir(self.evidence_root) self._acquisition_out_dir = new_live_session_dir(self.evidence_root)
self._acquisition_start_operation_id = None self._acquisition_start_operation_id = None
self._acquisition_stop_operation_id = None self._acquisition_stop_operation_id = None
@ -698,6 +777,7 @@ class XgridsK1CompatibilityService:
raise raise
return self.state() return self.state()
@_serialized_acquisition_access
def start_acquisition(self, request: StartAcquisitionRequest) -> dict[str, Any]: def start_acquisition(self, request: StartAcquisitionRequest) -> dict[str, Any]:
acquisition = self._require_acquisition(request.acquisition_id) acquisition = self._require_acquisition(request.acquisition_id)
if ( if (
@ -731,20 +811,31 @@ class XgridsK1CompatibilityService:
message_code="acquisition.start.arming_receiver", message_code="acquisition.start.arming_receiver",
) )
owns_start = False
cleanup_failed = False
try: try:
runtime_state = self.runtime.snapshot()
with self._lock: with self._lock:
if acquisition.state != "prepared": if acquisition.state != "prepared":
raise RuntimeError( raise RuntimeError(
f"acquisition нельзя запустить из состояния {acquisition.state}" f"acquisition нельзя запустить из состояния {acquisition.state}"
) )
if runtime_state.get("source_mode") != "idle":
raise RuntimeError(
"нельзя запускать acquisition поверх активного live/replay источника"
)
out_dir = self._acquisition_out_dir out_dir = self._acquisition_out_dir
project_name = self._acquisition_project_name
if out_dir is None: if out_dir is None:
raise RuntimeError("для acquisition не выделена evidence-сессия") raise RuntimeError("для acquisition не выделена evidence-сессия")
if project_name is None:
raise RuntimeError("для acquisition не задано название проекта")
acquisition.transition( acquisition.transition(
"starting", "starting",
message_code="acquisition.start.waiting_receiver_ready", message_code="acquisition.start.waiting_receiver_ready",
) )
self._acquisition_start_operation_id = operation.operation_id self._acquisition_start_operation_id = operation.operation_id
owns_start = True
lease = ActiveSessionLease.acquire(out_dir.parent, out_dir) lease = ActiveSessionLease.acquire(out_dir.parent, out_dir)
with self._lock: with self._lock:
if self._acquisition_session_lease is not None: if self._acquisition_session_lease is not None:
@ -755,17 +846,27 @@ class XgridsK1CompatibilityService:
acquisition.target_host, acquisition.target_host,
out_dir, out_dir,
duration_seconds=acquisition.duration_seconds, duration_seconds=acquisition.duration_seconds,
project_name=project_name,
) )
self._arm_camera_recording(out_dir) self._arm_camera_recording(out_dir)
except Exception as exc: except Exception as exc:
with suppress(Exception): if owns_start:
self.camera_preview.stop_recording( with self._lock:
status="failed", if acquisition.state not in TERMINAL_ACQUISITION_STATES:
failure_code="acquisition-start-failed", acquisition.transition(
"stopping",
message_code="acquisition.start.cleanup",
)
try:
self._stop_acquisition_sources(
camera_status="failed",
camera_failure_code="acquisition-start-failed",
)
except Exception as cleanup_error:
cleanup_failed = True
exc.add_note(
f"acquisition start cleanup also failed: {type(cleanup_error).__name__}"
) )
with suppress(Exception):
self.runtime.stop()
self._release_acquisition_session_lease()
with self._lock: with self._lock:
if acquisition.state not in TERMINAL_ACQUISITION_STATES: if acquisition.state not in TERMINAL_ACQUISITION_STATES:
acquisition.transition("failed", message_code="acquisition.start.failed") acquisition.transition("failed", message_code="acquisition.start.failed")
@ -774,16 +875,22 @@ class XgridsK1CompatibilityService:
"failed", "failed",
stage_code="failed", stage_code="failed",
message_code="acquisition.start.failed", message_code="acquisition.start.failed",
error=_operation_error(exc, category="stream", side_effect_status="none"), error=_operation_error(
exc,
category="stream" if owns_start else "conflict",
side_effect_status="unknown" if cleanup_failed else "none",
),
) )
raise raise
return self.state() return self.state()
@_serialized_acquisition_access
def stop_acquisition(self, request: StopAcquisitionRequest) -> dict[str, Any]: def stop_acquisition(self, request: StopAcquisitionRequest) -> dict[str, Any]:
acquisition = self._require_acquisition(request.acquisition_id) acquisition = self._require_acquisition(request.acquisition_id)
with self._lock: with self._lock:
acquisition_state = acquisition.state acquisition_state = acquisition.state
expected_stop_operation_id = self._acquisition_stop_operation_id expected_stop_operation_id = self._acquisition_stop_operation_id
lease_retained = self._acquisition_session_lease is not None
if request.operator_confirmed: if request.operator_confirmed:
if request.mode != "graceful": if request.mode != "graceful":
@ -794,10 +901,15 @@ class XgridsK1CompatibilityService:
raise ValueError( raise ValueError(
"подтверждение остановки должно ссылаться на исходную stop-operation" "подтверждение остановки должно ссылаться на исходную stop-operation"
) )
elif request.mode == "graceful" and acquisition_state not in { elif (
request.mode == "graceful"
and acquisition_state
not in {
"acquiring", "acquiring",
"awaiting_external_stop", "awaiting_external_stop",
}: }
and not (acquisition_state in TERMINAL_ACQUISITION_STATES and lease_retained)
):
raise ValueError( raise ValueError(
"graceful stop допустим только после подтверждённого потока point cloud" "graceful stop допустим только после подтверждённого потока point cloud"
) )
@ -843,6 +955,44 @@ class XgridsK1CompatibilityService:
if not created and not request.operator_confirmed: if not created and not request.operator_confirmed:
return self.state() return self.state()
if acquisition.state in TERMINAL_ACQUISITION_STATES: if acquisition.state in TERMINAL_ACQUISITION_STATES:
if lease_retained:
self._operations.transition_if_pending(
operation.operation_id,
"running",
stage_code="retrying-retained-cleanup",
message_code="acquisition.stop.retrying_retained_cleanup",
)
try:
completed = acquisition.state == "completed"
self._stop_acquisition_sources(
camera_status="complete" if completed else "failed",
camera_failure_code=(None if completed else "acquisition-cleanup-retry"),
)
except Exception as exc:
self._operations.transition_if_pending(
operation.operation_id,
"failed",
stage_code="retained-cleanup-failed",
message_code="acquisition.stop.retained_cleanup_failed",
error=_operation_error(
exc,
category="stream",
side_effect_status="unknown",
),
)
raise
self._operations.transition_if_pending(
operation.operation_id,
"succeeded",
stage_code="retained-cleanup-completed",
message_code="acquisition.stop.retained_cleanup_completed",
result={
"acquisition_id": acquisition.acquisition_id,
"receiver_stopped": True,
"device_stop": "unknown",
},
)
else:
self._operations.transition_if_pending( self._operations.transition_if_pending(
operation.operation_id, operation.operation_id,
"succeeded", "succeeded",
@ -933,6 +1083,7 @@ class XgridsK1CompatibilityService:
raise raise
return self.state() return self.state()
@_serialized_acquisition_access
def abort_acquisition(self, request: AbortAcquisitionRequest) -> dict[str, Any]: def abort_acquisition(self, request: AbortAcquisitionRequest) -> dict[str, Any]:
acquisition = self._require_acquisition(request.acquisition_id) acquisition = self._require_acquisition(request.acquisition_id)
request_fingerprint = self._request_fingerprint( request_fingerprint = self._request_fingerprint(
@ -951,7 +1102,14 @@ class XgridsK1CompatibilityService:
if not created: if not created:
return self.state() return self.state()
try: try:
if acquisition.state not in TERMINAL_ACQUISITION_STATES: with self._lock:
should_abort = acquisition.state not in TERMINAL_ACQUISITION_STATES
if should_abort:
acquisition.transition(
"stopping",
message_code="acquisition.aborting",
)
if should_abort:
self._stop_acquisition_sources( self._stop_acquisition_sources(
camera_status="interrupted", camera_status="interrupted",
camera_failure_code="acquisition-aborted", camera_failure_code="acquisition-aborted",
@ -997,6 +1155,7 @@ class XgridsK1CompatibilityService:
def start_live( def start_live(
self, self,
project_name: str,
host: str | None, host: str | None,
duration_seconds: float, duration_seconds: float,
compatibility_attestation: CompatibilityAttestationRequest, compatibility_attestation: CompatibilityAttestationRequest,
@ -1005,6 +1164,7 @@ class XgridsK1CompatibilityService:
prepared = self.prepare_acquisition( prepared = self.prepare_acquisition(
PrepareAcquisitionRequest( PrepareAcquisitionRequest(
project_name=project_name,
host=host, host=host,
duration_seconds=duration_seconds, duration_seconds=duration_seconds,
compatibility_attestation=compatibility_attestation, compatibility_attestation=compatibility_attestation,
@ -1019,26 +1179,42 @@ class XgridsK1CompatibilityService:
StartAcquisitionRequest(acquisition_id=acquisition["acquisition_id"]) StartAcquisitionRequest(acquisition_id=acquisition["acquisition_id"])
) )
@_serialized_acquisition_access
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]: def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
with self._lock: with self._lock:
acquisition = self._acquisition acquisition = self._acquisition
lease_retained = self._acquisition_session_lease is not None
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES: if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
raise RuntimeError( raise RuntimeError(
"нельзя запускать replay во время активной acquisition-сессии; " "нельзя запускать replay во время активной acquisition-сессии; "
"сначала остановите или отмените её" "сначала остановите или отмените её"
) )
if lease_retained:
raise RuntimeError(
"нельзя запускать replay: предыдущая evidence-сессия ещё не "
"остановлена и не запечатана"
)
replay_path = Path(path).expanduser().resolve() replay_path = Path(path).expanduser().resolve()
if not replay_path.is_relative_to(self.repository_root): if not replay_path.is_relative_to(self.repository_root):
raise ValueError("файл записи должен находиться внутри репозитория") raise ValueError("файл записи должен находиться внутри репозитория")
self.runtime.start_replay(replay_path, speed=speed, loop=loop) self.runtime.start_replay(replay_path, speed=speed, loop=loop)
return self.state() return self.state()
@_serialized_acquisition_access
def stop(self) -> dict[str, Any]: def stop(self) -> dict[str, Any]:
"""Deprecated capture-only shim; it never claims that K1 stopped scanning.""" """Deprecated capture-only shim; it never claims that K1 stopped scanning."""
with self._lock: with self._lock:
acquisition = self._acquisition acquisition = self._acquisition
lease_retained = self._acquisition_session_lease is not None
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES: if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
if lease_retained:
completed = acquisition is not None and acquisition.state == "completed"
self._stop_acquisition_sources(
camera_status="complete" if completed else "failed",
camera_failure_code=(None if completed else "acquisition-cleanup-retry"),
)
else:
self.camera_preview.stop_current() self.camera_preview.stop_current()
self.runtime.stop() self.runtime.stop()
return self.state() return self.state()
@ -1049,6 +1225,7 @@ class XgridsK1CompatibilityService:
) )
) )
@_serialized_acquisition_access
def select_camera_preview(self, request: CameraPreviewSelectRequest) -> dict[str, Any]: def select_camera_preview(self, request: CameraPreviewSelectRequest) -> dict[str, Any]:
target = self._camera_target_for_session(request.device_session_id) target = self._camera_target_for_session(request.device_session_id)
with self._lock: with self._lock:
@ -1064,13 +1241,28 @@ class XgridsK1CompatibilityService:
self.camera_preview.select(request.source_id, target) self.camera_preview.select(request.source_id, target)
return self.state() return self.state()
@_serialized_acquisition_access
def stop_camera_preview(self, request: CameraPreviewStopRequest) -> dict[str, Any]: def stop_camera_preview(self, request: CameraPreviewStopRequest) -> dict[str, Any]:
self._camera_target_for_session(request.device_session_id) self._camera_target_for_session(request.device_session_id)
self.camera_preview.stop(request.generation) self.camera_preview.stop(request.generation)
return self.state() return self.state()
@_serialized_acquisition_access
def close(self) -> None: def close(self) -> None:
with self._lock:
acquisition = self._acquisition
close_active_acquisition = (
acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES
)
if close_active_acquisition:
assert acquisition is not None
acquisition.transition(
"stopping",
message_code="acquisition.shutdown",
)
camera_error: Exception | None = None camera_error: Exception | None = None
runtime_error: Exception | None = None
terminal_error: Exception | None = None
try: try:
self.camera_preview.close() self.camera_preview.close()
except Exception as exc: except Exception as exc:
@ -1078,16 +1270,44 @@ class XgridsK1CompatibilityService:
try: try:
self.runtime.close() self.runtime.close()
except Exception as exc: except Exception as exc:
runtime_error = exc
if camera_error is not None: if camera_error is not None:
exc.add_note( exc.add_note(
"camera gateway cleanup also failed: " "camera gateway cleanup also failed: "
f"{type(camera_error).__name__}: {camera_error}" f"{type(camera_error).__name__}: {camera_error}"
) )
raise if runtime_error is not None:
terminal_error = runtime_error
elif camera_error is not None:
terminal_error = camera_error
else:
try:
self._seal_acquisition_capture_clock()
except Exception as exc:
terminal_error = exc
try:
if close_active_acquisition:
with self._lock:
assert acquisition is not None
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
acquisition.transition(
"failed" if terminal_error is not None else "interrupted",
message_code=(
"acquisition.shutdown.failed"
if terminal_error is not None
else "acquisition.shutdown.interrupted"
),
result={
"receiver_stopped": terminal_error is None,
"device_state": "unknown",
},
)
self._terminalize_acquisition_operations_on_shutdown(terminal_error)
finally: finally:
if terminal_error is None:
self._release_acquisition_session_lease() self._release_acquisition_session_lease()
if camera_error is not None: if terminal_error is not None:
raise camera_error raise terminal_error
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]: def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
self.runtime.update_scene_settings( self.runtime.update_scene_settings(
@ -1141,6 +1361,7 @@ class XgridsK1CompatibilityService:
camera_failure_code: str | None, camera_failure_code: str | None,
) -> None: ) -> None:
camera_error: Exception | None = None camera_error: Exception | None = None
runtime_error: Exception | None = None
try: try:
self.camera_preview.stop_recording( self.camera_preview.stop_recording(
status=camera_status, status=camera_status,
@ -1151,16 +1372,33 @@ class XgridsK1CompatibilityService:
try: try:
self.runtime.stop() self.runtime.stop()
except Exception as exc: except Exception as exc:
runtime_error = exc
if camera_error is not None: if camera_error is not None:
exc.add_note( exc.add_note(
"camera archive cleanup also failed: " "camera archive cleanup also failed: "
f"{type(camera_error).__name__}: {camera_error}" f"{type(camera_error).__name__}: {camera_error}"
) )
raise cleanup_complete = False
finally: try:
self._release_acquisition_session_lease() if runtime_error is not None:
raise runtime_error
if camera_error is not None: if camera_error is not None:
raise camera_error raise camera_error
self._seal_acquisition_capture_clock()
cleanup_complete = True
finally:
if cleanup_complete:
self._release_acquisition_session_lease()
def _seal_acquisition_capture_clock(self) -> None:
with self._lock:
out_dir = self._acquisition_out_dir
# A prepared acquisition has reserved a future path but has no producer
# or clock to seal. Once the live session exists, any missing or
# inconsistent clock is a terminal evidence error.
if out_dir is None or not out_dir.exists():
return
seal_capture_clock(out_dir / "captures" / "mqtt_live")
def _release_acquisition_session_lease(self) -> None: def _release_acquisition_session_lease(self) -> None:
with self._lock: with self._lock:
@ -1169,6 +1407,39 @@ class XgridsK1CompatibilityService:
if lease is not None: if lease is not None:
lease.release() lease.release()
def _terminalize_acquisition_operations_on_shutdown(
self,
terminal_error: Exception | None,
) -> None:
with self._lock:
pending = (
("start", self._acquisition_start_operation_id),
("stop", self._acquisition_stop_operation_id),
)
for action_kind, operation_id in pending:
if terminal_error is None:
self._operations.transition_if_pending(
operation_id,
"cancelled",
stage_code="service-shutdown",
message_code=f"acquisition.{action_kind}.service_shutdown",
)
else:
self._operations.transition_if_pending(
operation_id,
"failed",
stage_code="service-shutdown-failed",
message_code=f"acquisition.{action_kind}.service_shutdown_failed",
error=_operation_error(
terminal_error,
category="stream",
side_effect_status="unknown",
),
)
with self._lock:
self._acquisition_start_operation_id = None
self._acquisition_stop_operation_id = None
def _set_operation(self, phase: str, message: str) -> None: def _set_operation(self, phase: str, message: str) -> None:
with self._lock: with self._lock:
self._operation_phase = phase self._operation_phase = phase
@ -1257,6 +1528,7 @@ class XgridsK1CompatibilityService:
camera_terminal_status: Literal["complete", "failed"] | None = None camera_terminal_status: Literal["complete", "failed"] | None = None
camera_failure_code: str | None = None camera_failure_code: str | None = None
stop_runtime_for_camera_failure = False stop_runtime_for_camera_failure = False
complete_after_seal = False
with self._lock: with self._lock:
acquisition = self._acquisition acquisition = self._acquisition
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES: if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
@ -1345,12 +1617,15 @@ class XgridsK1CompatibilityService:
camera_failure_code = "receiver-completed-without-point-data" camera_failure_code = "receiver-completed-without-point-data"
failed_operation_id = self._acquisition_start_operation_id failed_operation_id = self._acquisition_start_operation_id
elif acquisition.state == "acquiring" and source_mode == "idle": elif acquisition.state == "acquiring" and source_mode == "idle":
# Reserve terminal reconciliation before invoking camera or
# filesystem callbacks. A nested/concurrent state read now
# observes `finalizing` and cannot seal the same session twice.
acquisition.transition( acquisition.transition(
"completed", "finalizing",
message_code="acquisition.receiver_completed", message_code="acquisition.finalizing_capture_clock",
result={"receiver_stopped": True, "device_state": "unknown"},
) )
camera_terminal_status = "complete" camera_terminal_status = "complete"
complete_after_seal = True
elif acquisition.state == "awaiting_external_stop" and source_mode == "idle": elif acquisition.state == "awaiting_external_stop" and source_mode == "idle":
acquisition.transition( acquisition.transition(
"failed", "failed",
@ -1361,16 +1636,53 @@ class XgridsK1CompatibilityService:
camera_failure_code = "receiver-completed-before-stop-confirmation" camera_failure_code = "receiver-completed-before-stop-confirmation"
unconfirmed_stop_operation_id = self._acquisition_stop_operation_id unconfirmed_stop_operation_id = self._acquisition_stop_operation_id
reconciliation_error: Exception | None = None
if camera_terminal_status is not None: if camera_terminal_status is not None:
terminal_error: Exception | None = None
try: try:
self.camera_preview.stop_recording( self.camera_preview.stop_recording(
status=camera_terminal_status, status=camera_terminal_status,
failure_code=camera_failure_code, failure_code=camera_failure_code,
) )
finally: except Exception as exc:
self._release_acquisition_session_lease() terminal_error = exc
if stop_runtime_for_camera_failure: if stop_runtime_for_camera_failure:
try:
self.runtime.stop() self.runtime.stop()
except Exception as exc:
if terminal_error is not None:
exc.add_note(
"camera archive cleanup also failed: "
f"{type(terminal_error).__name__}: {terminal_error}"
)
terminal_error = exc
try:
if terminal_error is not None:
raise terminal_error
self._seal_acquisition_capture_clock()
if complete_after_seal:
with self._lock:
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
acquisition.transition(
"completed",
message_code="acquisition.receiver_completed",
result={
"receiver_stopped": True,
"device_state": "unknown",
},
)
except Exception as exc:
reconciliation_error = exc
if complete_after_seal:
with self._lock:
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
acquisition.transition(
"failed",
message_code="acquisition.capture_clock_failed",
)
finally:
if reconciliation_error is None:
self._release_acquisition_session_lease()
if receiver_ready_operation_id is not None: if receiver_ready_operation_id is not None:
self._operations.transition_if_pending( self._operations.transition_if_pending(
@ -1440,6 +1752,8 @@ class XgridsK1CompatibilityService:
self._acquisition_stop_operation_id = None self._acquisition_stop_operation_id = None
if self._acquisition_stop_operation_id == unconfirmed_stop_operation_id: if self._acquisition_stop_operation_id == unconfirmed_stop_operation_id:
self._acquisition_stop_operation_id = None self._acquisition_stop_operation_id = None
if reconciliation_error is not None:
raise reconciliation_error
class XgridsK1ServicePort(Protocol): class XgridsK1ServicePort(Protocol):
@ -1463,6 +1777,7 @@ class XgridsK1ServicePort(Protocol):
def start_live( def start_live(
self, self,
project_name: str,
host: str | None, host: str | None,
duration_seconds: float, duration_seconds: float,
compatibility_attestation: CompatibilityAttestationRequest, compatibility_attestation: CompatibilityAttestationRequest,
@ -1565,6 +1880,7 @@ class XgridsK1PluginFacade:
live_request = LiveRequest.model_validate(payload) live_request = LiveRequest.model_validate(payload)
return await asyncio.to_thread( return await asyncio.to_thread(
self.service.start_live, self.service.start_live,
live_request.project_name,
live_request.host, live_request.host,
live_request.duration_seconds, live_request.duration_seconds,
live_request.compatibility_attestation, live_request.compatibility_attestation,
@ -1605,6 +1921,25 @@ def _utc_now_iso() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z") return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def normalize_project_name(value: str) -> str:
"""Return a bounded display name that is never interpreted as a path."""
normalized = unicodedata.normalize("NFKC", value).strip()
if not normalized:
raise ValueError("название проекта должно содержать от 1 до 96 символов")
try:
normalized.encode("utf-8", errors="strict")
except UnicodeEncodeError as exc:
raise ValueError(
"название проекта не должно содержать недопустимые Unicode-символы"
) from exc
if len(normalized) > 96:
raise ValueError("название проекта должно содержать не более 96 символов")
if any(unicodedata.category(character) in {"Cc", "Cs"} for character in normalized):
raise ValueError("название проекта не должно содержать управляющие символы")
return normalized
def _operation_error( def _operation_error(
exc: Exception, exc: Exception,
*, *,
@ -1728,12 +2063,21 @@ def _sensor_catalog(
"frame_id": "map", "frame_id": "map",
"coordinate_convention": "unverified", "coordinate_convention": "unverified",
}, },
{
"stream_id": "device.modeling.live",
"sensor_kind": "status",
"modality": "acquisition-telemetry",
"availability": "observed",
"decode_status": "profile-decoded-physical-verified",
"frame_id": None,
"coordinate_convention": None,
},
{ {
"stream_id": "device.status.live", "stream_id": "device.status.live",
"sensor_kind": "status", "sensor_kind": "status",
"modality": "device-status", "modality": "device-status",
"availability": "observed", "availability": "observed",
"decode_status": "raw-only", "decode_status": "profile-decoded-physical-verified",
"frame_id": None, "frame_id": None,
"coordinate_convention": None, "coordinate_convention": None,
}, },

View File

@ -5,6 +5,8 @@ import ipaddress
import json import json
import math import math
import os import os
import re
import stat
import struct import struct
import time import time
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
@ -36,6 +38,13 @@ LOOP_INTERVAL_SECONDS = 0.25
GROUP_COMMIT_INTERVAL_SECONDS = 0.5 GROUP_COMMIT_INTERVAL_SECONDS = 0.5
GROUP_COMMIT_MAX_BYTES = 4 * 1024 * 1024 GROUP_COMMIT_MAX_BYTES = 4 * 1024 * 1024
GROUP_COMMIT_MAX_MESSAGES = 32 GROUP_COMMIT_MAX_MESSAGES = 32
CAPTURE_CLOCK_FILENAME = "mqtt.timeline.json"
CAPTURE_CLOCK_ORIGIN_FILENAME = "mqtt.timeline.origin.json"
CAPTURE_CLOCK_ORIGIN_SCHEMA_VERSION = 1
CAPTURE_CLOCK_SEALED_PATTERN = re.compile(r"^mqtt\.timeline\.session-[a-f0-9]{64}\.json$")
CAPTURE_CLOCK_SCHEMA_VERSION = 1
MAX_CAPTURE_CLOCK_BYTES = 16 * 1024
MAX_CAPTURE_CLOCK_SPAN_NS = (1 << 53) - 1
# Eight-byte file signature followed by repeated >IQ, topic UTF-8 bytes, payload bytes. # Eight-byte file signature followed by repeated >IQ, topic UTF-8 bytes, payload bytes.
RAW_MAGIC = b"K1MQTT\x00\x01" RAW_MAGIC = b"K1MQTT\x00\x01"
@ -60,12 +69,16 @@ StopReason = Literal[
class ArtifactPaths(TypedDict): class ArtifactPaths(TypedDict):
raw: str raw: str
metadata_jsonl: str metadata_jsonl: str
capture_clock_origin: str
capture_clock: str
summary: str summary: str
class ArtifactHashes(TypedDict): class ArtifactHashes(TypedDict):
raw_sha256: str raw_sha256: str
metadata_jsonl_sha256: str metadata_jsonl_sha256: str
capture_clock_origin_sha256: str
capture_clock_sha256: str
class RawFormat(TypedDict): class RawFormat(TypedDict):
@ -78,6 +91,7 @@ class CaptureSummary(TypedDict):
schema_version: int schema_version: int
created_at_utc: str created_at_utc: str
completed_at_utc: str completed_at_utc: str
capture_clock_scope: Literal["transport", "session"]
sensitivity: str sensitivity: str
target_ipv4: str target_ipv4: str
target_port: int target_port: int
@ -89,6 +103,7 @@ class CaptureSummary(TypedDict):
subscriptions: list[str] subscriptions: list[str]
requested_duration_seconds: float requested_duration_seconds: float
capture_elapsed_seconds: float capture_elapsed_seconds: float
session_elapsed_seconds: float
operation_elapsed_seconds: float operation_elapsed_seconds: float
max_message_bytes: int max_message_bytes: int
connected: bool connected: bool
@ -105,6 +120,32 @@ class CaptureSummary(TypedDict):
artifact_hashes: ArtifactHashes artifact_hashes: ArtifactHashes
@dataclass(frozen=True, slots=True)
class CaptureClockEnvelope:
"""Durable host-clock bounds shared by MQTT, camera, and derived replay."""
started_at_epoch_ns: int
started_monotonic_ns: int
completed_at_epoch_ns: int
completed_monotonic_ns: int
artifact_byte_length: int
artifact_sha256: str
@property
def duration_ns(self) -> int:
return self.completed_monotonic_ns - self.started_monotonic_ns
@dataclass(frozen=True, slots=True)
class CaptureClockOrigin:
"""Fsynced session zero published before any camera producer can start."""
started_at_epoch_ns: int
started_monotonic_ns: int
artifact_byte_length: int
artifact_sha256: str
class CaptureError(RuntimeError): class CaptureError(RuntimeError):
"""A one-shot capture failed after preserving all artifacts written so far.""" """A one-shot capture failed after preserving all artifacts written so far."""
@ -161,6 +202,8 @@ class _CaptureWriter:
self.out_dir = out_dir.expanduser().resolve() self.out_dir = out_dir.expanduser().resolve()
self.raw_path = self.out_dir / "mqtt.raw.k1mqtt" self.raw_path = self.out_dir / "mqtt.raw.k1mqtt"
self.metadata_path = self.out_dir / "mqtt.metadata.jsonl" self.metadata_path = self.out_dir / "mqtt.metadata.jsonl"
self.capture_clock_origin_path = self.out_dir / CAPTURE_CLOCK_ORIGIN_FILENAME
self.capture_clock_path = self.out_dir / CAPTURE_CLOCK_FILENAME
self.summary_path = self.out_dir / "mqtt.summary.json" self.summary_path = self.out_dir / "mqtt.summary.json"
self.max_message_bytes = max_message_bytes self.max_message_bytes = max_message_bytes
self.message_count = 0 self.message_count = 0
@ -172,10 +215,18 @@ class _CaptureWriter:
self._pending_metadata: list[str] = [] self._pending_metadata: list[str] = []
self._pending_raw_bytes = 0 self._pending_raw_bytes = 0
self._last_commit_monotonic = time.monotonic() self._last_commit_monotonic = time.monotonic()
self._started_at_epoch_ns: int | None = None
self._started_monotonic_ns: int | None = None
def open(self) -> None: def open(self) -> None:
self.out_dir.mkdir(parents=True, exist_ok=True) self.out_dir.mkdir(parents=True, exist_ok=True)
artifact_paths = (self.raw_path, self.metadata_path, self.summary_path) artifact_paths = (
self.raw_path,
self.metadata_path,
self.capture_clock_origin_path,
self.capture_clock_path,
self.summary_path,
)
existing = [path.name for path in artifact_paths if path.exists()] existing = [path.name for path in artifact_paths if path.exists()]
if existing: if existing:
names = ", ".join(existing) names = ", ".join(existing)
@ -186,6 +237,19 @@ class _CaptureWriter:
self._raw.write(RAW_MAGIC) self._raw.write(RAW_MAGIC)
self._metadata = _open_text_exclusive(self.metadata_path) self._metadata = _open_text_exclusive(self.metadata_path)
_fsync_directory(self.out_dir) _fsync_directory(self.out_dir)
# This is the earliest durable point from which the capture can
# accept evidence. The camera is armed only after this writer is
# running, so both modalities share this exact monotonic zero.
self._started_at_epoch_ns = time.time_ns()
self._started_monotonic_ns = time.monotonic_ns()
_write_json_atomic_new(
self.capture_clock_origin_path,
{
"schema_version": CAPTURE_CLOCK_ORIGIN_SCHEMA_VERSION,
"started_at_epoch_ns": self._started_at_epoch_ns,
"started_monotonic_ns": self._started_monotonic_ns,
},
)
except BaseException: except BaseException:
with suppress(OSError): with suppress(OSError):
self.close() self.close()
@ -262,8 +326,7 @@ class _CaptureWriter:
if ( if (
len(self._pending_metadata) >= GROUP_COMMIT_MAX_MESSAGES len(self._pending_metadata) >= GROUP_COMMIT_MAX_MESSAGES
or self._pending_raw_bytes >= GROUP_COMMIT_MAX_BYTES or self._pending_raw_bytes >= GROUP_COMMIT_MAX_BYTES
or time.monotonic() - self._last_commit_monotonic or time.monotonic() - self._last_commit_monotonic >= GROUP_COMMIT_INTERVAL_SECONDS
>= GROUP_COMMIT_INTERVAL_SECONDS
): ):
self._commit_pending() self._commit_pending()
return CapturedMqttMessage( return CapturedMqttMessage(
@ -304,6 +367,31 @@ class _CaptureWriter:
if first_error is not None: if first_error is not None:
raise first_error raise first_error
def finalize_capture_clock(self) -> CaptureClockEnvelope:
"""Publish the exact capture envelope after every producer is sealed."""
started_at_epoch_ns = self._started_at_epoch_ns
started_monotonic_ns = self._started_monotonic_ns
if started_at_epoch_ns is None or started_monotonic_ns is None:
raise RuntimeError("capture writer has no start clock")
origin = read_capture_clock_origin(self.capture_clock_origin_path)
if (
origin.started_at_epoch_ns != started_at_epoch_ns
or origin.started_monotonic_ns != started_monotonic_ns
):
raise CaptureFormatError("capture clock origin changed before finalization")
completed_at_epoch_ns = time.time_ns()
completed_monotonic_ns = time.monotonic_ns()
document = {
"schema_version": CAPTURE_CLOCK_SCHEMA_VERSION,
"started_at_epoch_ns": started_at_epoch_ns,
"started_monotonic_ns": started_monotonic_ns,
"completed_at_epoch_ns": completed_at_epoch_ns,
"completed_monotonic_ns": completed_monotonic_ns,
}
_write_json_atomic_new(self.capture_clock_path, document)
return read_capture_clock_envelope(self.capture_clock_path)
def maybe_commit(self, now_monotonic: float | None = None) -> None: def maybe_commit(self, now_monotonic: float | None = None) -> None:
if not self._pending_metadata: if not self._pending_metadata:
return return
@ -348,9 +436,7 @@ class _CaptureWriter:
metadata.write(payload) metadata.write(payload)
metadata.flush() metadata.flush()
os.fsync(metadata.fileno()) os.fsync(metadata.fileno())
self._last_commit_monotonic = ( self._last_commit_monotonic = time.monotonic() if now_monotonic is None else now_monotonic
time.monotonic() if now_monotonic is None else now_monotonic
)
def validate_private_ipv4(value: str) -> str: def validate_private_ipv4(value: str) -> str:
@ -437,6 +523,231 @@ def iter_capture_frames(
) )
def read_capture_clock_envelope(
path: Path,
*,
expected_sha256: str | None = None,
) -> CaptureClockEnvelope:
"""Read one bounded, no-follow capture clock artifact and prove stability."""
candidate = path.expanduser().absolute()
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(candidate, flags)
except OSError as exc:
raise CaptureFormatError("capture clock artifact is unavailable") from exc
try:
before = os.fstat(descriptor)
if not stat.S_ISREG(before.st_mode) or not 1 <= before.st_size <= MAX_CAPTURE_CLOCK_BYTES:
raise CaptureFormatError("capture clock artifact is not a bounded regular file")
payload = bytearray()
while len(payload) <= MAX_CAPTURE_CLOCK_BYTES:
chunk = os.read(descriptor, min(4096, MAX_CAPTURE_CLOCK_BYTES + 1 - len(payload)))
if not chunk:
break
payload.extend(chunk)
after = os.fstat(descriptor)
try:
current = os.lstat(candidate)
except OSError as exc:
raise CaptureFormatError("capture clock artifact changed during validation") from exc
if (
len(payload) != before.st_size
or _file_identity(before) != _file_identity(after)
or stat.S_ISLNK(current.st_mode)
or (current.st_dev, current.st_ino) != (before.st_dev, before.st_ino)
):
raise CaptureFormatError("capture clock artifact changed during validation")
finally:
os.close(descriptor)
digest = hashlib.sha256(payload).hexdigest()
if expected_sha256 is not None and digest != expected_sha256:
raise CaptureFormatError("capture clock digest does not match its summary")
try:
value = json.loads(payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CaptureFormatError("capture clock artifact is not valid JSON") from exc
expected_keys = {
"schema_version",
"started_at_epoch_ns",
"started_monotonic_ns",
"completed_at_epoch_ns",
"completed_monotonic_ns",
}
if not isinstance(value, dict) or set(value) != expected_keys:
raise CaptureFormatError("capture clock artifact has an unsupported shape")
if value["schema_version"] != CAPTURE_CLOCK_SCHEMA_VERSION:
raise CaptureFormatError("capture clock schema is unsupported")
for key in expected_keys - {"schema_version"}:
item = value[key]
if not isinstance(item, int) or isinstance(item, bool) or item < 0:
raise CaptureFormatError("capture clock contains an invalid timestamp")
started_at_epoch_ns = value["started_at_epoch_ns"]
started_monotonic_ns = value["started_monotonic_ns"]
completed_at_epoch_ns = value["completed_at_epoch_ns"]
completed_monotonic_ns = value["completed_monotonic_ns"]
duration_ns = completed_monotonic_ns - started_monotonic_ns
if duration_ns < 0 or duration_ns > MAX_CAPTURE_CLOCK_SPAN_NS:
raise CaptureFormatError("capture clock monotonic span is outside bounds")
return CaptureClockEnvelope(
started_at_epoch_ns=started_at_epoch_ns,
started_monotonic_ns=started_monotonic_ns,
completed_at_epoch_ns=completed_at_epoch_ns,
completed_monotonic_ns=completed_monotonic_ns,
artifact_byte_length=len(payload),
artifact_sha256=digest,
)
def read_capture_clock_origin(
path: Path,
*,
expected_sha256: str | None = None,
) -> CaptureClockOrigin:
candidate = path.expanduser().absolute()
try:
payload = _read_stable_payload(candidate, MAX_CAPTURE_CLOCK_BYTES)
except OSError as exc:
raise CaptureFormatError("capture clock origin is unavailable") from exc
digest = hashlib.sha256(payload).hexdigest()
if expected_sha256 is not None and digest != expected_sha256:
raise CaptureFormatError("capture clock origin digest does not match its summary")
try:
value = json.loads(payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CaptureFormatError("capture clock origin is not valid JSON") from exc
expected_keys = {
"schema_version",
"started_at_epoch_ns",
"started_monotonic_ns",
}
if not isinstance(value, dict) or set(value) != expected_keys:
raise CaptureFormatError("capture clock origin has an unsupported shape")
if value["schema_version"] != CAPTURE_CLOCK_ORIGIN_SCHEMA_VERSION:
raise CaptureFormatError("capture clock origin schema is unsupported")
epoch_ns = value["started_at_epoch_ns"]
monotonic_ns = value["started_monotonic_ns"]
if (
not isinstance(epoch_ns, int)
or isinstance(epoch_ns, bool)
or epoch_ns < 0
or not isinstance(monotonic_ns, int)
or isinstance(monotonic_ns, bool)
or monotonic_ns < 0
):
raise CaptureFormatError("capture clock origin contains an invalid timestamp")
return CaptureClockOrigin(
started_at_epoch_ns=epoch_ns,
started_monotonic_ns=monotonic_ns,
artifact_byte_length=len(payload),
artifact_sha256=digest,
)
def is_capture_clock_filename(value: object) -> bool:
return isinstance(value, str) and (
value == CAPTURE_CLOCK_FILENAME or CAPTURE_CLOCK_SEALED_PATTERN.fullmatch(value) is not None
)
def seal_capture_clock(capture_root: Path) -> CaptureClockEnvelope:
"""Extend the provisional MQTT clock after every session producer is sealed.
The acquisition owner must call this after camera shutdown and runtime
shutdown, but before releasing its active-session lease. A failure raises
``CaptureError``. The content-addressed clock is published first and the
summary pointer switches second, so a crash leaves the old pointer valid
and a later owner retry can finish sealing.
"""
try:
root = capture_root.expanduser().resolve(strict=True)
except OSError as exc:
raise CaptureError("capture clock root is unavailable") from exc
if not root.is_dir():
raise CaptureError("capture clock root is not a directory")
summary_path = root / "mqtt.summary.json"
try:
summary = _read_bounded_json_object(summary_path, MAX_CAPTURE_CLOCK_BYTES * 8)
artifacts = summary.get("artifacts")
hashes = summary.get("artifact_hashes")
declared_name = artifacts.get("capture_clock") if isinstance(artifacts, dict) else None
declared_origin_name = (
artifacts.get("capture_clock_origin") if isinstance(artifacts, dict) else None
)
if (
summary.get("schema_version") != 2
or not isinstance(artifacts, dict)
or not is_capture_clock_filename(declared_name)
or declared_origin_name != CAPTURE_CLOCK_ORIGIN_FILENAME
or not isinstance(hashes, dict)
or not isinstance(hashes.get("capture_clock_origin_sha256"), str)
or not isinstance(hashes.get("capture_clock_sha256"), str)
):
raise CaptureFormatError("capture summary has no supported clock contract")
assert isinstance(declared_name, str)
origin = read_capture_clock_origin(
root / CAPTURE_CLOCK_ORIGIN_FILENAME,
expected_sha256=hashes["capture_clock_origin_sha256"],
)
clock_path = root / declared_name
current = read_capture_clock_envelope(
clock_path,
expected_sha256=hashes["capture_clock_sha256"],
)
if (
current.started_at_epoch_ns != origin.started_at_epoch_ns
or current.started_monotonic_ns != origin.started_monotonic_ns
):
raise CaptureFormatError("capture clock does not match its durable origin")
scope = summary.get("capture_clock_scope")
if scope == "session":
if CAPTURE_CLOCK_SEALED_PATTERN.fullmatch(declared_name) is None:
raise CaptureFormatError("sealed capture summary points to a provisional clock")
if declared_name != f"mqtt.timeline.session-{current.artifact_sha256}.json":
raise CaptureFormatError(
"sealed capture clock filename does not match its content digest"
)
return current
if scope != "transport" or declared_name != CAPTURE_CLOCK_FILENAME:
raise CaptureFormatError("provisional capture summary has an invalid clock pointer")
completed_at_epoch_ns = time.time_ns()
completed_monotonic_ns = time.monotonic_ns()
if completed_monotonic_ns < current.completed_monotonic_ns:
raise CaptureFormatError("session completion precedes MQTT completion")
document = {
"schema_version": CAPTURE_CLOCK_SCHEMA_VERSION,
"started_at_epoch_ns": current.started_at_epoch_ns,
"started_monotonic_ns": current.started_monotonic_ns,
"completed_at_epoch_ns": completed_at_epoch_ns,
"completed_monotonic_ns": completed_monotonic_ns,
}
sealed_digest = hashlib.sha256(_serialize_json(document)).hexdigest()
sealed_name = f"mqtt.timeline.session-{sealed_digest}.json"
sealed_path = root / sealed_name
# A retry can converge on an already durable content-addressed
# artifact. It is still fully revalidated below.
with suppress(FileExistsError):
_write_json_atomic_new(sealed_path, document)
sealed = read_capture_clock_envelope(
sealed_path,
expected_sha256=sealed_digest,
)
artifacts["capture_clock"] = sealed_name
hashes["capture_clock_sha256"] = sealed.artifact_sha256
summary["capture_clock_scope"] = "session"
summary["session_elapsed_seconds"] = round(
sealed.duration_ns / 1_000_000_000,
9,
)
summary["completed_at_utc"] = utc_now_iso()
_write_json_atomic_replace(summary_path, summary)
return sealed
except (OSError, CaptureFormatError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CaptureError(f"could not seal session capture clock: {exc}") from exc
def capture_mqtt( def capture_mqtt(
host: str, host: str,
out_dir: Path, out_dir: Path,
@ -444,6 +755,7 @@ def capture_mqtt(
port: int = 1883, port: int = 1883,
duration_seconds: float = 60.0, duration_seconds: float = 60.0,
max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES, max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES,
on_clock_established: Callable[[], None] | None = None,
on_ready: Callable[[], None] | None = None, on_ready: Callable[[], None] | None = None,
on_message_recorded: Callable[[CapturedMqttMessage], None] | None = None, on_message_recorded: Callable[[CapturedMqttMessage], None] | None = None,
should_stop: Callable[[], bool] | None = None, should_stop: Callable[[], bool] | None = None,
@ -471,7 +783,14 @@ def capture_mqtt(
) )
) )
writer = _CaptureWriter(out_dir, max_message_bytes) writer = _CaptureWriter(out_dir, max_message_bytes)
try:
writer.open() writer.open()
if on_clock_established is not None:
on_clock_established()
except BaseException:
with suppress(OSError):
writer.close()
raise
state = _CaptureState() state = _CaptureState()
created_at_utc = utc_now_iso() created_at_utc = utc_now_iso()
operation_started = time.monotonic() operation_started = time.monotonic()
@ -615,7 +934,11 @@ def capture_mqtt(
if state.error is None: if state.error is None:
fail("capture_error", f"artifact close failed: {type(exc).__name__}: {exc}") fail("capture_error", f"artifact close failed: {type(exc).__name__}: {exc}")
operation_completed = time.monotonic() try:
capture_clock = writer.finalize_capture_clock()
except (OSError, RuntimeError, CaptureFormatError) as exc:
raise CaptureError(f"could not publish capture clock: {type(exc).__name__}: {exc}") from exc
operation_completed = capture_clock.completed_monotonic_ns / 1_000_000_000
capture_elapsed = 0.0 if capture_started is None else operation_completed - capture_started capture_elapsed = 0.0 if capture_started is None else operation_completed - capture_started
summary = _build_summary( summary = _build_summary(
@ -627,6 +950,7 @@ def capture_mqtt(
operation_elapsed=operation_completed - operation_started, operation_elapsed=operation_completed - operation_started,
max_message_bytes=max_message_bytes, max_message_bytes=max_message_bytes,
created_at_utc=created_at_utc, created_at_utc=created_at_utc,
capture_clock=capture_clock,
state=state, state=state,
) )
try: try:
@ -651,12 +975,15 @@ def _build_summary(
operation_elapsed: float, operation_elapsed: float,
max_message_bytes: int, max_message_bytes: int,
created_at_utc: str, created_at_utc: str,
capture_clock: CaptureClockEnvelope,
state: _CaptureState, state: _CaptureState,
) -> CaptureSummary: ) -> CaptureSummary:
capture_clock_origin = read_capture_clock_origin(writer.capture_clock_origin_path)
return { return {
"schema_version": 1, "schema_version": 2,
"created_at_utc": created_at_utc, "created_at_utc": created_at_utc,
"completed_at_utc": utc_now_iso(), "completed_at_utc": utc_now_iso(),
"capture_clock_scope": "transport",
"sensitivity": "contains raw K1 MQTT payloads and local addressing; do not commit", "sensitivity": "contains raw K1 MQTT payloads and local addressing; do not commit",
"target_ipv4": target_ipv4, "target_ipv4": target_ipv4,
"target_port": port, "target_port": port,
@ -668,6 +995,7 @@ def _build_summary(
"subscriptions": list(REPORT_TOPICS), "subscriptions": list(REPORT_TOPICS),
"requested_duration_seconds": duration_seconds, "requested_duration_seconds": duration_seconds,
"capture_elapsed_seconds": round(capture_elapsed, 6), "capture_elapsed_seconds": round(capture_elapsed, 6),
"session_elapsed_seconds": round(capture_clock.duration_ns / 1_000_000_000, 9),
"operation_elapsed_seconds": round(operation_elapsed, 6), "operation_elapsed_seconds": round(operation_elapsed, 6),
"max_message_bytes": max_message_bytes, "max_message_bytes": max_message_bytes,
"connected": state.connected, "connected": state.connected,
@ -687,11 +1015,15 @@ def _build_summary(
"artifacts": { "artifacts": {
"raw": writer.raw_path.name, "raw": writer.raw_path.name,
"metadata_jsonl": writer.metadata_path.name, "metadata_jsonl": writer.metadata_path.name,
"capture_clock_origin": writer.capture_clock_origin_path.name,
"capture_clock": writer.capture_clock_path.name,
"summary": writer.summary_path.name, "summary": writer.summary_path.name,
}, },
"artifact_hashes": { "artifact_hashes": {
"raw_sha256": _sha256_file(writer.raw_path), "raw_sha256": _sha256_file(writer.raw_path),
"metadata_jsonl_sha256": _sha256_file(writer.metadata_path), "metadata_jsonl_sha256": _sha256_file(writer.metadata_path),
"capture_clock_origin_sha256": capture_clock_origin.artifact_sha256,
"capture_clock_sha256": capture_clock.artifact_sha256,
}, },
} }
@ -740,6 +1072,105 @@ def _write_summary_exclusive(path: Path, summary: CaptureSummary) -> None:
_fsync_directory(path.parent) _fsync_directory(path.parent)
def _write_json_atomic_new(path: Path, value: object) -> None:
"""Publish a new JSON artifact atomically without replacing evidence."""
serialized = _serialize_json(value)
temporary = path.with_name(f".{path.name}.{os.getpid()}.{time.monotonic_ns()}.tmp")
descriptor = os.open(
temporary,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0),
0o600,
)
try:
view = memoryview(serialized)
while view:
written = os.write(descriptor, view)
view = view[written:]
os.fsync(descriptor)
finally:
os.close(descriptor)
try:
# Hard-link publication is atomic and fails if another artifact has
# appeared at the destination; unlike replace it never overwrites.
os.link(temporary, path, follow_symlinks=False)
temporary.unlink()
_fsync_directory(path.parent)
except BaseException:
temporary.unlink(missing_ok=True)
raise
def _write_json_atomic_replace(path: Path, value: object) -> None:
serialized = _serialize_json(value)
temporary = path.with_name(f".{path.name}.{os.getpid()}.{time.monotonic_ns()}.tmp")
descriptor = os.open(
temporary,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0),
0o600,
)
try:
view = memoryview(serialized)
while view:
written = os.write(descriptor, view)
view = view[written:]
os.fsync(descriptor)
finally:
os.close(descriptor)
try:
os.replace(temporary, path)
_fsync_directory(path.parent)
except BaseException:
temporary.unlink(missing_ok=True)
raise
def _read_bounded_json_object(path: Path, max_bytes: int) -> dict[str, object]:
payload = _read_stable_payload(path, max_bytes)
value = json.loads(payload.decode("utf-8"))
if not isinstance(value, dict):
raise CaptureFormatError("JSON artifact is not an object")
return value
def _read_stable_payload(path: Path, max_bytes: int) -> bytes:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(path, flags)
try:
before = os.fstat(descriptor)
if not stat.S_ISREG(before.st_mode) or not 1 <= before.st_size <= max_bytes:
raise CaptureFormatError("artifact is not a bounded regular file")
chunks: list[bytes] = []
remaining = before.st_size
while remaining:
chunk = os.read(descriptor, min(4096, remaining))
if not chunk:
break
chunks.append(chunk)
remaining -= len(chunk)
payload = b"".join(chunks)
after = os.fstat(descriptor)
current = os.lstat(path)
if (
len(payload) != before.st_size
or _file_identity(before) != _file_identity(after)
or stat.S_ISLNK(current.st_mode)
or (current.st_dev, current.st_ino) != (before.st_dev, before.st_ino)
):
raise CaptureFormatError("artifact changed during validation")
return payload
finally:
os.close(descriptor)
def _file_identity(value: os.stat_result) -> tuple[int, int, int, int, int]:
return (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns)
def _serialize_json(value: object) -> bytes:
return (json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
def _fsync_directory(path: Path) -> None: def _fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY) descriptor = os.open(path, os.O_RDONLY)
try: try:

View File

@ -2,10 +2,9 @@
from __future__ import annotations from __future__ import annotations
import json
import os
import stat import stat
import threading import threading
from collections.abc import Mapping
from pathlib import Path from pathlib import Path
from k1link.device_plugins.xgrids_k1.archive import ( from k1link.device_plugins.xgrids_k1.archive import (
@ -34,7 +33,6 @@ from k1link.sessions.store import resolve_missioncore_evidence_dir
from k1link.web.camera_archive import recover_incomplete_camera_archives from k1link.web.camera_archive import recover_incomplete_camera_archives
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1" XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
MAX_TIMELINE_ORIGIN_LINE_BYTES = 64 * 1024
def build_xgrids_k1_observation(repository_root: Path) -> ObservationRuntimeContribution: def build_xgrids_k1_observation(repository_root: Path) -> ObservationRuntimeContribution:
@ -95,8 +93,12 @@ def _to_host_candidate(candidate: LegacySessionCandidate) -> ObservationSessionC
raw_bytes = candidate.raw_byte_length raw_bytes = candidate.raw_byte_length
replay_raw_bytes = candidate.replay_raw_byte_length replay_raw_bytes = candidate.replay_raw_byte_length
replay_metadata_bytes = candidate.replay_metadata_byte_length replay_metadata_bytes = candidate.replay_metadata_byte_length
replay_capture_clock_origin_bytes = candidate.replay_capture_clock_origin_byte_length
replay_capture_clock_bytes = candidate.replay_capture_clock_byte_length
replayable = candidate.replayable replayable = candidate.replayable
metadata_path = raw_path.with_name("mqtt.metadata.jsonl") metadata_path = raw_path.with_name("mqtt.metadata.jsonl")
capture_clock_origin_path = raw_path.with_name("mqtt.timeline.origin.json")
capture_clock_path = candidate.capture_clock_path
artifacts = [ artifacts = [
ObservationArtifactCandidate( ObservationArtifactCandidate(
@ -123,6 +125,34 @@ def _to_host_candidate(candidate: LegacySessionCandidate) -> ObservationSessionC
integrity_status=candidate.raw_integrity_status, integrity_status=candidate.raw_integrity_status,
) )
) )
if replay_capture_clock_bytes > 0:
if capture_clock_path is None:
raise SessionIntegrityError("K1 capture clock locator is unavailable")
artifacts.append(
ObservationArtifactCandidate(
artifact_id="raw-transport-clock",
kind="raw-transport-clock",
media_type="application/vnd.nodedc.capture-clock+json",
locator=capture_clock_path,
byte_length=replay_capture_clock_bytes,
replay_byte_length=replay_capture_clock_bytes if replayable else 0,
sha256=candidate.capture_clock_sha256,
integrity_status="verified",
)
)
if replay_capture_clock_origin_bytes > 0:
artifacts.append(
ObservationArtifactCandidate(
artifact_id="raw-transport-clock-origin",
kind="raw-transport-clock-origin",
media_type="application/vnd.nodedc.capture-clock-origin+json",
locator=capture_clock_origin_path,
byte_length=replay_capture_clock_origin_bytes,
replay_byte_length=(replay_capture_clock_origin_bytes if replayable else 0),
sha256=candidate.capture_clock_origin_sha256,
integrity_status="verified",
)
)
media_sources = candidate.media_sources media_sources = candidate.media_sources
artifacts.extend( artifacts.extend(
@ -175,7 +205,6 @@ def _to_host_candidate(candidate: LegacySessionCandidate) -> ObservationSessionC
for media in media_sources for media in media_sources
) )
timeline_origin = _timeline_origin(metadata_path) if replayable else None
return ObservationSessionCandidate( return ObservationSessionCandidate(
session_id=session_id, session_id=session_id,
display_name=candidate.display_name, display_name=candidate.display_name,
@ -189,8 +218,10 @@ def _to_host_candidate(candidate: LegacySessionCandidate) -> ObservationSessionC
allowed_root=candidate.allowed_root, allowed_root=candidate.allowed_root,
session_root=candidate.session_root, session_root=candidate.session_root,
primary_replay_artifact_id="raw-transport-primary" if replayable else None, primary_replay_artifact_id="raw-transport-primary" if replayable else None,
timeline_origin_epoch_ns=None if timeline_origin is None else timeline_origin[0], timeline_origin_epoch_ns=(candidate.timeline_origin_epoch_ns if replayable else None),
timeline_origin_monotonic_ns=None if timeline_origin is None else timeline_origin[1], timeline_origin_monotonic_ns=(
candidate.timeline_origin_monotonic_ns if replayable else None
),
sources=tuple(sources), sources=tuple(sources),
artifacts=tuple(artifacts), artifacts=tuple(artifacts),
) )
@ -206,42 +237,11 @@ def _regular_file_size(path: Path) -> int:
return value.st_size return value.st_size
def _timeline_origin(path: Path) -> tuple[int, int]:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
try:
payload = os.read(descriptor, MAX_TIMELINE_ORIGIN_LINE_BYTES + 1)
finally:
os.close(descriptor)
first_line = payload.splitlines(keepends=True)[0]
if len(first_line) > MAX_TIMELINE_ORIGIN_LINE_BYTES or not first_line.endswith(
(b"\n", b"\r")
):
raise ValueError
document = json.loads(first_line)
epoch_ns = document.get("received_at_epoch_ns")
monotonic_ns = document.get("received_monotonic_ns")
if (
document.get("record_type") != "message"
or document.get("sequence") != 1
or not _non_negative_int(epoch_ns)
or not _non_negative_int(monotonic_ns)
):
raise ValueError
return int(epoch_ns), int(monotonic_ns)
except (IndexError, OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
raise SessionIntegrityError("K1 transport timeline origin is invalid") from exc
def _non_negative_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
def _export_recording( def _export_recording(
source: Path, source: Path,
destination: Path, destination: Path,
*, *,
artifacts: Mapping[str, Path] | None = None,
cancel_event: threading.Event | None = None, cancel_event: threading.Event | None = None,
activity_callback: object | None = None, activity_callback: object | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
@ -250,6 +250,12 @@ def _export_recording(
export_k1mqtt_to_rrd( export_k1mqtt_to_rrd(
source, source,
destination, destination,
capture_clock_path=(
None if artifacts is None else artifacts.get("raw-transport-clock")
),
capture_clock_origin_path=(
None if artifacts is None else artifacts.get("raw-transport-clock-origin")
),
cancel_event=cancel_event, cancel_event=cancel_event,
activity_callback=activity_callback if callable(activity_callback) else None, activity_callback=activity_callback if callable(activity_callback) else None,
) )

View File

@ -0,0 +1,168 @@
from __future__ import annotations
import math
import struct
from dataclasses import dataclass
from typing import Protocol
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
ProtobufWireError,
ProtoField,
iter_fields,
)
from k1link.viewer.metrics import BridgeMetrics
MODELING_REPORT_TOPIC = "lixel/application/report/modeling"
# Retained physical FW 3.0.2 captures independently correlate ScanTime with
# host-monotonic duration at two ticks per second (one tick = 0.5 s). Keeping
# the profile-scoped scale named avoids silently treating a vendor counter as
# SI seconds.
SCAN_TIME_TICKS_PER_SECOND = 2
class ModelingReportDecodeError(ValueError):
"""A K1 ModelingReport violated the profile-scoped protobuf contract."""
@dataclass(frozen=True, slots=True)
class ModelingTelemetry:
"""Profile-scoped acquisition telemetry reported by the K1 itself."""
move_distance_meters: float
move_speed_meters_per_second: float
scan_time_ticks: int
pgo_progress: int
@property
def elapsed_seconds(self) -> float:
return self.scan_time_ticks / SCAN_TIME_TICKS_PER_SECOND
class ModelingTransportMessage(Protocol):
@property
def topic(self) -> str: ...
@property
def payload(self) -> bytes: ...
def is_modeling_report_topic(topic: str) -> bool:
return topic == MODELING_REPORT_TOPIC
def observe_modeling_report(
message: ModelingTransportMessage,
metrics: BridgeMetrics,
) -> bool:
"""Consume one K1 ModelingReport before the visual preview queue."""
if not is_modeling_report_topic(message.topic):
return False
metrics.received(len(message.payload))
try:
telemetry = decode_modeling_report(message.payload)
except ModelingReportDecodeError:
metrics.modeling_decode_error()
else:
metrics.acquisition_telemetry(
elapsed_seconds=telemetry.elapsed_seconds,
route_distance_meters=telemetry.move_distance_meters,
speed_meters_per_second=telemetry.move_speed_meters_per_second,
pgo_progress=telemetry.pgo_progress,
)
return True
def decode_modeling_report(
payload: bytes, *, max_payload_bytes: int = 64 * 1024
) -> ModelingTelemetry:
"""Decode the verified FW 3.0.2 ``ModelingReport`` status message.
The nested schema recovered from the reviewed LixelGO build is:
``1 Header, 2 int32 PgoProgress, 3 ScanStatus`` and ScanStatus contains
``1 float MoveDistance, 2 float MoveSpeed, 3 int64 ScanTime``. Header data
is intentionally not retained here because it can contain device identity
and an OpenAPI credential.
"""
if len(payload) > max_payload_bytes:
raise ModelingReportDecodeError("ModelingReport exceeds configured limit")
pgo_progress = 0
scan_status: bytes | None = None
seen: set[int] = set()
try:
for field in iter_fields(payload, max_fields=64):
if field.number == 2:
_mark_once(seen, 2, "modeling.pgo_progress")
pgo_progress = _nonnegative_int(
field,
"modeling.pgo_progress",
max_value=0x7FFF_FFFF,
)
elif field.number == 3:
_mark_once(seen, 3, "modeling.scan_status")
scan_status = _bytes(field, "modeling.scan_status")
except ProtobufWireError as exc:
raise ModelingReportDecodeError(f"invalid ModelingReport: {exc}") from exc
if scan_status is None:
raise ModelingReportDecodeError("ModelingReport has no scan_status")
distance = 0.0
speed = 0.0
scan_time = 0
seen.clear()
try:
for field in iter_fields(scan_status, max_fields=16):
if field.number == 1:
_mark_once(seen, 1, "scan_status.move_distance")
distance = _nonnegative_float32(field, "scan_status.move_distance")
elif field.number == 2:
_mark_once(seen, 2, "scan_status.move_speed")
speed = _nonnegative_float32(field, "scan_status.move_speed")
elif field.number == 3:
_mark_once(seen, 3, "scan_status.scan_time")
scan_time = _nonnegative_int(
field,
"scan_status.scan_time",
max_value=0x7FFF_FFFF_FFFF_FFFF,
)
except ProtobufWireError as exc:
raise ModelingReportDecodeError(f"invalid ModelingReport.scan_status: {exc}") from exc
return ModelingTelemetry(
move_distance_meters=distance,
move_speed_meters_per_second=speed,
scan_time_ticks=scan_time,
pgo_progress=pgo_progress,
)
def _bytes(field: ProtoField, name: str) -> bytes:
if field.wire_type != 2 or not isinstance(field.value, bytes):
raise ModelingReportDecodeError(f"{name} has the wrong protobuf wire type")
return field.value
def _nonnegative_int(field: ProtoField, name: str, *, max_value: int) -> int:
if field.wire_type != 0 or not isinstance(field.value, int):
raise ModelingReportDecodeError(f"{name} has the wrong protobuf wire type")
if field.value > max_value:
raise ModelingReportDecodeError(f"{name} is outside its recovered signed range")
return field.value
def _mark_once(seen: set[int], number: int, name: str) -> None:
if number in seen:
raise ModelingReportDecodeError(f"{name} is duplicated")
seen.add(number)
def _nonnegative_float32(field: ProtoField, name: str) -> float:
if field.wire_type != 5 or not isinstance(field.value, bytes):
raise ModelingReportDecodeError(f"{name} has the wrong protobuf wire type")
value = float(struct.unpack("<f", field.value)[0])
if not math.isfinite(value) or value < 0:
raise ModelingReportDecodeError(f"{name} must be finite and nonnegative")
return value

View File

@ -0,0 +1,568 @@
from __future__ import annotations
import hmac
from dataclasses import dataclass, field
from enum import IntEnum
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
ProtobufWireError,
ProtoField,
iter_fields,
)
# The values below are scoped to the reviewed LixelGO/K1 protocol profile. This
# module deliberately has no MQTT dependency: it can build and validate bytes,
# but it cannot send them to equipment.
MAX_CONTROL_PAYLOAD_BYTES = 64 * 1024
MAX_HEADER_BYTES = 4 * 1024
MAX_TEXT_BYTES = 4 * 1024
MODELING_SESSION_SUFFIX = ":ModelingRequest"
OPENAPI_RESULT_BASE = 302_252_032
OPENAPI_SUCCESS = OPENAPI_RESULT_BASE + 1
MODELING_STATE_BASE = 302_252_032
class ModelingProtocolError(ValueError):
"""A bounded K1 modeling-control payload violated the recovered schema."""
class ModelingEncodeError(ModelingProtocolError):
"""A command could not be encoded without guessing vendor-owned input."""
class ModelingResponseCorrelationError(ModelingProtocolError):
"""A response did not match the exact command identity and action."""
class ModelingCommandRejected(ModelingProtocolError):
"""The device returned a non-success result for a correlated command."""
def __init__(self, code: int, description: str) -> None:
# Device descriptions are retained for a caller that needs structured
# diagnostics, but are not interpolated into the exception string.
super().__init__(f"K1 modeling command rejected with result code {code}")
self.code = code
self.description = description
class ModelingAction(IntEnum):
START = 1
STOP = 2
class RecordMode(IntEnum):
CALCULATE_ONLY = 0
RECORD_ONLY = 1
RECORD_AND_CALCULATE = 2
class ScanMode(IntEnum):
POINT_CLOUD = 0
LCC = 1
PORTRAIT = 2
class MountType(IntEnum):
HANDHELD = 0
VEHICLE = 1
UAV = 2
BACKPACK = 3
class SessionState(IntEnum):
OTHER_STATUS = 0
READY = 300
SCAN_STARTING = 301
SCANNING = 302
SCAN_STOPPING = 303
SCAN_OVER = 304
DISK_ERROR = 305
SAVE_ERROR = 306
ALGORITHM_ERROR = 307
NO_CONTINUE_ERROR = 308
NO_SPACE_ERROR = 309
CAMERA_ERROR = 310
CONTINUE_SUCCESS = 311
CONTINUE_FAIL = 312
USB_DISK = 313
SD_SPACE_NOT_ENOUGH = 314
MEMORY_NOT_ENOUGH = 315
LIDAR_DATA_ERROR = 316
MAPPING_ERROR = 317
@property
def is_fault(self) -> bool:
return self in _FAULT_SESSION_STATES
_FAULT_SESSION_STATES = frozenset(
{
SessionState.DISK_ERROR,
SessionState.SAVE_ERROR,
SessionState.ALGORITHM_ERROR,
SessionState.NO_CONTINUE_ERROR,
SessionState.NO_SPACE_ERROR,
SessionState.CAMERA_ERROR,
SessionState.CONTINUE_FAIL,
SessionState.SD_SPACE_NOT_ENOUGH,
SessionState.MEMORY_NOT_ENOUGH,
SessionState.LIDAR_DATA_ERROR,
SessionState.MAPPING_ERROR,
}
)
@dataclass(frozen=True, slots=True)
class CommandHeaderIdentity:
"""Exact recovered modeling identity; only the credential stays caller-provided."""
device_id: str = field(repr=False)
openapi_key: str = field(repr=False)
def __post_init__(self) -> None:
_validate_required_ascii_identity(self.device_id, "device_id", MAX_TEXT_BYTES)
_validate_required_ascii_identity(self.openapi_key, "openapi_key", MAX_TEXT_BYTES)
if len(self.session_id.encode("ascii")) > MAX_TEXT_BYTES:
raise ModelingEncodeError("derived modeling session_id exceeds configured limit")
@property
def session_id(self) -> str:
"""Return the literal relation proven in the retained wire evidence."""
return f"{self.device_id}{MODELING_SESSION_SUFFIX}"
def matches(self, observed: ObservedHeader) -> bool:
"""Compare every recovered identity field without exposing its value."""
if (
observed.device_id is None
or observed.session_id is None
or observed.openapi_key is None
):
return False
return all(
hmac.compare_digest(expected.encode("ascii"), actual.encode("utf-8"))
for expected, actual in (
(self.device_id, observed.device_id),
(self.session_id, observed.session_id),
(self.openapi_key, observed.openapi_key),
)
)
@dataclass(frozen=True, slots=True)
class ObservedHeader:
"""Identity subset decoded from an inbound header; repr stays redacted."""
device_id: str | None = field(default=None, repr=False)
session_id: str | None = field(default=None, repr=False)
openapi_key: str | None = field(default=None, repr=False)
@dataclass(frozen=True, slots=True)
class EncodedModelingCommand:
"""An inert command envelope. No transport operation exists in this type."""
action: ModelingAction
header: CommandHeaderIdentity = field(repr=False)
payload: bytes = field(repr=False)
@dataclass(frozen=True, slots=True)
class ModelingResponseError:
code: int
description: str = field(repr=False)
@dataclass(frozen=True, slots=True)
class ModelingResponse:
header: ObservedHeader = field(repr=False)
action: ModelingAction
error: ModelingResponseError
@dataclass(frozen=True, slots=True)
class DeviceStatusReport:
"""Recovered acquisition subset of ``DeviceStatusReport``.
Vendor identity and project strings are intentionally redacted from repr.
Nested system/RTK messages are bounded by the outer parser but are not
interpreted by this modeling state machine.
"""
header: ObservedHeader | None = field(repr=False)
modeling_state_code: int
session_state: SessionState | None
device_sn: str | None = field(repr=False)
project_id: str | None = field(repr=False)
time_zone: str | None = field(repr=False)
init_ready: bool
system_status_present: bool
rtk_status_present: bool
def encode_modeling_start(
header: CommandHeaderIdentity,
*,
project_name: str,
record_mode: RecordMode,
scan_mode: ScanMode,
mount_type: MountType,
pre_project_id: str | None = None,
) -> EncodedModelingCommand:
"""Encode the recovered ``ModelingRequest`` start shape.
All modes are mandatory so that a future caller cannot inherit an implicit
vendor default. Proto3 zero-valued enums are canonically omitted, matching
the reviewed application serializer.
"""
_expect_exact_enum(record_mode, RecordMode, "record_mode")
_expect_exact_enum(scan_mode, ScanMode, "scan_mode")
_expect_exact_enum(mount_type, MountType, "mount_type")
_validate_required_text(project_name, "project_name", MAX_TEXT_BYTES)
if pre_project_id is not None:
_validate_required_text(pre_project_id, "pre_project_id", MAX_TEXT_BYTES)
fields = [
_bytes_field(1, _encode_command_header(header)),
_varint_field(2, ModelingAction.START),
_text_field(3, project_name),
]
if record_mode:
fields.append(_varint_field(4, record_mode))
if scan_mode:
fields.append(_varint_field(5, scan_mode))
if mount_type:
fields.append(_varint_field(6, mount_type))
if pre_project_id is not None:
fields.append(_text_field(7, pre_project_id))
payload = b"".join(fields)
_ensure_encoded_bound(payload)
return EncodedModelingCommand(ModelingAction.START, header, payload)
def encode_modeling_stop(header: CommandHeaderIdentity) -> EncodedModelingCommand:
"""Encode the recovered stop shape: header plus action, and nothing else."""
payload = b"".join(
(
_bytes_field(1, _encode_command_header(header)),
_varint_field(2, ModelingAction.STOP),
)
)
_ensure_encoded_bound(payload)
return EncodedModelingCommand(ModelingAction.STOP, header, payload)
def decode_modeling_response(
payload: bytes, *, max_payload_bytes: int = MAX_CONTROL_PAYLOAD_BYTES
) -> ModelingResponse:
"""Decode a bounded response without treating its description as success."""
_check_payload_bound(payload, max_payload_bytes, "ModelingResponse")
header: ObservedHeader | None = None
action: ModelingAction | None = None
response_error: ModelingResponseError | None = None
seen: set[int] = set()
try:
for proto_field in iter_fields(payload, max_fields=64):
if proto_field.number == 1:
_mark_once(seen, 1, "response.header")
header = _decode_observed_header(
_bytes_value(proto_field, "response.header"), require_identity=True
)
elif proto_field.number == 2:
_mark_once(seen, 2, "response.action")
raw_action = _uint_value(proto_field, "response.action", max_value=0xFFFF_FFFF)
try:
action = ModelingAction(raw_action)
except ValueError as exc:
raise ModelingProtocolError("response.action is not start or stop") from exc
elif proto_field.number == 15:
_mark_once(seen, 15, "response.error")
response_error = _decode_response_error(_bytes_value(proto_field, "response.error"))
except ProtobufWireError as exc:
raise ModelingProtocolError(f"invalid ModelingResponse: {exc}") from exc
if header is None:
raise ModelingProtocolError("ModelingResponse has no header")
if action is None:
raise ModelingProtocolError("ModelingResponse has no action")
if response_error is None:
raise ModelingProtocolError("ModelingResponse has no error result")
return ModelingResponse(header, action, response_error)
def correlate_modeling_response(
payload: bytes,
expected: EncodedModelingCommand,
*,
max_payload_bytes: int = MAX_CONTROL_PAYLOAD_BYTES,
) -> ModelingResponse:
"""Require identity, action, and the physically observed success code."""
response = decode_modeling_response(payload, max_payload_bytes=max_payload_bytes)
if not expected.header.matches(response.header):
raise ModelingResponseCorrelationError("response header identity mismatch")
if response.action is not expected.action:
raise ModelingResponseCorrelationError("response action mismatch")
if response.error.code != OPENAPI_SUCCESS:
raise ModelingCommandRejected(response.error.code, response.error.description)
return response
def decode_device_status_report(
payload: bytes, *, max_payload_bytes: int = MAX_CONTROL_PAYLOAD_BYTES
) -> DeviceStatusReport:
"""Decode the fields that bound the K1 acquisition lifecycle."""
_check_payload_bound(payload, max_payload_bytes, "DeviceStatusReport")
header: ObservedHeader | None = None
modeling_state_code: int | None = None
device_sn: str | None = None
project_id: str | None = None
time_zone: str | None = None
init_ready = False
system_status_present = False
rtk_status_present = False
seen: set[int] = set()
try:
for proto_field in iter_fields(payload, max_fields=64):
if proto_field.number == 1:
_mark_once(seen, 1, "device_status.header")
header = _decode_observed_header(
_bytes_value(proto_field, "device_status.header"), require_identity=False
)
elif proto_field.number == 2:
_mark_once(seen, 2, "device_status.modeling_state")
modeling_state_code = _uint_value(
proto_field, "device_status.modeling_state", max_value=0xFFFF_FFFF
)
elif proto_field.number == 3:
_mark_once(seen, 3, "device_status.device_sn")
device_sn = _text_value(proto_field, "device_status.device_sn")
elif proto_field.number == 4:
_mark_once(seen, 4, "device_status.project_id")
project_id = _text_value(proto_field, "device_status.project_id")
elif proto_field.number == 5:
_mark_once(seen, 5, "device_status.time_zone")
time_zone = _text_value(proto_field, "device_status.time_zone")
elif proto_field.number == 6:
_mark_once(seen, 6, "device_status.init_ready")
raw_ready = _uint_value(proto_field, "device_status.init_ready", max_value=1)
init_ready = bool(raw_ready)
elif proto_field.number == 7:
_mark_once(seen, 7, "device_status.system_status")
_bytes_value(proto_field, "device_status.system_status")
system_status_present = True
elif proto_field.number == 8:
_mark_once(seen, 8, "device_status.rtk_status")
_bytes_value(proto_field, "device_status.rtk_status")
rtk_status_present = True
except ProtobufWireError as exc:
raise ModelingProtocolError(f"invalid DeviceStatusReport: {exc}") from exc
if modeling_state_code is None:
raise ModelingProtocolError("DeviceStatusReport has no modeling_state")
return DeviceStatusReport(
header=header,
modeling_state_code=modeling_state_code,
session_state=session_state_from_code(modeling_state_code),
device_sn=device_sn,
project_id=project_id,
time_zone=time_zone,
init_ready=init_ready,
system_status_present=system_status_present,
rtk_status_present=rtk_status_present,
)
def session_state_from_code(code: int) -> SessionState | None:
"""Map the captured base-offset status code, preserving unknown values."""
if code < MODELING_STATE_BASE:
return None
try:
return SessionState(code - MODELING_STATE_BASE)
except ValueError:
return None
def _encode_command_header(header: CommandHeaderIdentity) -> bytes:
# seq/stamp/scaler were default-valued and omitted in the reviewed command
# requests. The three identities must be supplied explicitly by a caller.
payload = b"".join(
(
_text_field(4, header.device_id),
_text_field(5, header.session_id),
_text_field(6, header.openapi_key),
)
)
if len(payload) > MAX_HEADER_BYTES:
raise ModelingEncodeError("encoded command header exceeds configured limit")
return payload
def _decode_observed_header(payload: bytes, *, require_identity: bool) -> ObservedHeader:
if len(payload) > MAX_HEADER_BYTES:
raise ModelingProtocolError("header exceeds configured limit")
identities: dict[int, str] = {}
seen: set[int] = set()
try:
for proto_field in iter_fields(payload, max_fields=64):
if proto_field.number in {4, 5, 6}:
_mark_once(seen, proto_field.number, "header identity")
identities[proto_field.number] = _identity_text_value(
proto_field,
"header identity",
)
except ProtobufWireError as exc:
raise ModelingProtocolError(f"invalid header: {exc}") from exc
header = ObservedHeader(
device_id=identities.get(4),
session_id=identities.get(5),
openapi_key=identities.get(6),
)
if require_identity and (
header.device_id is None or header.session_id is None or header.openapi_key is None
):
raise ModelingProtocolError("response header identity is incomplete")
return header
def _decode_response_error(payload: bytes) -> ModelingResponseError:
if len(payload) > MAX_HEADER_BYTES:
raise ModelingProtocolError("response error exceeds configured limit")
code: int | None = None
description = ""
seen: set[int] = set()
try:
for proto_field in iter_fields(payload, max_fields=16):
if proto_field.number == 1:
_mark_once(seen, 1, "response.error.code")
code = _uint_value(proto_field, "response.error.code", max_value=0xFFFF_FFFF)
elif proto_field.number == 2:
_mark_once(seen, 2, "response.error.description")
description = _text_value(proto_field, "response.error.description")
except ProtobufWireError as exc:
raise ModelingProtocolError(f"invalid response error: {exc}") from exc
if code is None:
raise ModelingProtocolError("response error has no code")
return ModelingResponseError(code, description)
def _expect_exact_enum(value: object, enum_type: type[IntEnum], name: str) -> None:
if type(value) is not enum_type:
raise ModelingEncodeError(f"{name} must be an explicit {enum_type.__name__}")
def _validate_required_text(value: object, name: str, max_bytes: int) -> None:
if not isinstance(value, str) or not value:
raise ModelingEncodeError(f"{name} must be a non-empty string")
try:
encoded = value.encode("utf-8")
except UnicodeEncodeError as exc:
raise ModelingEncodeError(f"{name} is not valid UTF-8 text") from exc
if len(encoded) > max_bytes:
raise ModelingEncodeError(f"{name} exceeds configured limit")
if any(ord(character) < 0x20 or ord(character) == 0x7F for character in value):
raise ModelingEncodeError(f"{name} contains a control character")
def _validate_required_ascii_identity(value: object, name: str, max_bytes: int) -> None:
if not isinstance(value, str) or not value:
raise ModelingEncodeError(f"{name} must be a non-empty string")
try:
encoded = value.encode("ascii")
except UnicodeEncodeError as exc:
raise ModelingEncodeError(f"{name} must use printable ASCII") from exc
if len(encoded) > max_bytes:
raise ModelingEncodeError(f"{name} exceeds configured limit")
if any(byte <= 0x20 or byte > 0x7E for byte in encoded):
raise ModelingEncodeError(f"{name} must use printable ASCII without spaces")
def _mark_once(seen: set[int], number: int, name: str) -> None:
if number in seen:
raise ModelingProtocolError(f"{name} is duplicated")
seen.add(number)
def _check_payload_bound(payload: bytes, max_payload_bytes: int, name: str) -> None:
if max_payload_bytes < 1:
raise ValueError("max_payload_bytes must be positive")
if len(payload) > max_payload_bytes:
raise ModelingProtocolError(f"{name} exceeds configured limit")
def _ensure_encoded_bound(payload: bytes) -> None:
if len(payload) > MAX_CONTROL_PAYLOAD_BYTES:
raise ModelingEncodeError("encoded ModelingRequest exceeds configured limit")
def _uint_value(proto_field: ProtoField, name: str, *, max_value: int) -> int:
if proto_field.wire_type != 0 or not isinstance(proto_field.value, int):
raise ModelingProtocolError(f"{name} has the wrong protobuf wire type")
if proto_field.value > max_value:
raise ModelingProtocolError(f"{name} exceeds its recovered integer range")
return proto_field.value
def _bytes_value(proto_field: ProtoField, name: str) -> bytes:
if proto_field.wire_type != 2 or not isinstance(proto_field.value, bytes):
raise ModelingProtocolError(f"{name} has the wrong protobuf wire type")
return proto_field.value
def _text_value(proto_field: ProtoField, name: str) -> str:
raw = _bytes_value(proto_field, name)
if len(raw) > MAX_TEXT_BYTES:
raise ModelingProtocolError(f"{name} exceeds configured limit")
try:
return raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise ModelingProtocolError(f"{name} is not valid UTF-8") from exc
def _identity_text_value(proto_field: ProtoField, name: str) -> str:
value = _text_value(proto_field, name)
try:
encoded = value.encode("ascii")
except UnicodeEncodeError as exc:
raise ModelingProtocolError(f"{name} must use printable ASCII") from exc
if not encoded or any(byte <= 0x20 or byte > 0x7E for byte in encoded):
raise ModelingProtocolError(f"{name} must use printable ASCII without spaces")
return value
def _varint(value: int) -> bytes:
if value < 0 or value > 0xFFFF_FFFF_FFFF_FFFF:
raise ModelingEncodeError("protobuf varint is outside uint64")
encoded = bytearray()
while value > 0x7F:
encoded.append((value & 0x7F) | 0x80)
value >>= 7
encoded.append(value)
return bytes(encoded)
def _key(number: int, wire_type: int) -> bytes:
if number < 1:
raise ModelingEncodeError("protobuf field number must be positive")
return _varint((number << 3) | wire_type)
def _varint_field(number: int, value: int) -> bytes:
return _key(number, 0) + _varint(int(value))
def _bytes_field(number: int, value: bytes) -> bytes:
return _key(number, 2) + _varint(len(value)) + value
def _text_field(number: int, value: str) -> bytes:
return _bytes_field(number, value.encode("utf-8"))

View File

@ -0,0 +1,126 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
DeviceStatusReport,
SessionState,
)
class AcquisitionPhase(StrEnum):
UNOBSERVED = "unobserved"
READY = "ready"
CALIBRATING = "calibrating"
SCANNING = "scanning"
STOPPING = "stopping"
SCAN_OVER_UNVERIFIED = "scan_over_unverified"
FAULT = "fault"
UNKNOWN = "unknown"
class SaveEvidence(StrEnum):
"""Evidence levels seen in status reports; none means durable completion."""
NONE = "none"
SCAN_OVER_OBSERVED = "scan_over_observed"
SCAN_OVER_THEN_READY_OBSERVED = "scan_over_then_ready_observed"
FAULT_BEFORE_DURABLE_CONFIRMATION = "fault_before_durable_confirmation"
@dataclass(frozen=True, slots=True)
class AcquisitionSnapshot:
phase: AcquisitionPhase
session_state: SessionState | None
modeling_state_code: int | None
init_ready: bool
project_bound: bool
save_evidence: SaveEvidence
@property
def durable_save_complete(self) -> bool:
"""Stay false until the physical save gate and artifacts are proven."""
return False
class DeviceAcquisitionStateMachine:
"""Observe K1 lifecycle reports without asserting an unproved save result.
The reviewed physical runs prove Ready -> ScanStarting -> Scanning and
Scanning -> ScanStopping. ``ScanOver`` exists in the recovered enum, but a
physical stop run has not yet demonstrated the complete ScanOver/Ready plus
stable-artifact sequence. Consequently every snapshot is fail-closed for
durable save completion.
"""
def __init__(self) -> None:
self._scan_over_observed = False
self._ready_after_scan_over_observed = False
self._snapshot = AcquisitionSnapshot(
phase=AcquisitionPhase.UNOBSERVED,
session_state=None,
modeling_state_code=None,
init_ready=False,
project_bound=False,
save_evidence=SaveEvidence.NONE,
)
@property
def snapshot(self) -> AcquisitionSnapshot:
return self._snapshot
def observe(self, report: DeviceStatusReport) -> AcquisitionSnapshot:
state = report.session_state
# A new start invalidates evidence retained from a previous acquisition.
if state is SessionState.SCAN_STARTING or (
state is SessionState.SCANNING
and self._snapshot.session_state in {None, SessionState.READY, SessionState.SCAN_OVER}
):
self._scan_over_observed = False
self._ready_after_scan_over_observed = False
if state is SessionState.SCAN_OVER:
self._scan_over_observed = True
elif state is SessionState.READY and self._scan_over_observed:
self._ready_after_scan_over_observed = True
phase = _phase_for(state)
if state is not None and state.is_fault:
evidence = SaveEvidence.FAULT_BEFORE_DURABLE_CONFIRMATION
elif self._ready_after_scan_over_observed:
evidence = SaveEvidence.SCAN_OVER_THEN_READY_OBSERVED
elif self._scan_over_observed:
evidence = SaveEvidence.SCAN_OVER_OBSERVED
else:
evidence = SaveEvidence.NONE
self._snapshot = AcquisitionSnapshot(
phase=phase,
session_state=state,
modeling_state_code=report.modeling_state_code,
init_ready=report.init_ready,
project_bound=bool(report.project_id),
save_evidence=evidence,
)
return self._snapshot
def _phase_for(state: SessionState | None) -> AcquisitionPhase:
if state is None:
return AcquisitionPhase.UNKNOWN
if state is SessionState.READY:
return AcquisitionPhase.READY
if state is SessionState.SCAN_STARTING:
return AcquisitionPhase.CALIBRATING
if state is SessionState.SCANNING:
return AcquisitionPhase.SCANNING
if state is SessionState.SCAN_STOPPING:
return AcquisitionPhase.STOPPING
if state is SessionState.SCAN_OVER:
return AcquisitionPhase.SCAN_OVER_UNVERIFIED
if state.is_fault:
return AcquisitionPhase.FAULT
return AcquisitionPhase.UNKNOWN

View File

@ -16,6 +16,21 @@ import rerun as rr
from rerun import blueprint as rrb from rerun import blueprint as rrb
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError
from k1link.device_plugins.xgrids_k1.mqtt.capture import (
CAPTURE_CLOCK_FILENAME,
CAPTURE_CLOCK_ORIGIN_FILENAME,
CaptureClockEnvelope,
CaptureClockOrigin,
CaptureFormatError,
read_capture_clock_envelope,
read_capture_clock_origin,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling import (
ModelingReportDecodeError,
ModelingTelemetry,
decode_modeling_report,
is_modeling_report_topic,
)
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
from k1link.device_plugins.xgrids_k1.viewer.replay import ( from k1link.device_plugins.xgrids_k1.viewer.replay import (
ReplayFormatError, ReplayFormatError,
@ -45,6 +60,7 @@ JS_MAX_SAFE_INTEGER = (1 << 53) - 1
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0") RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8") RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303") RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
RECORDED_METRICS_VIEW_ID = UUID("f973fc11-0867-4732-ad3c-97008621fab7")
class RrdExportSummary(TypedDict): class RrdExportSummary(TypedDict):
@ -58,6 +74,7 @@ class RrdExportSummary(TypedDict):
decoded_messages: int decoded_messages: int
point_frames: int point_frames: int
pose_frames: int pose_frames: int
modeling_reports: int
ignored_messages: int ignored_messages: int
points: int points: int
trajectory_poses: int trajectory_poses: int
@ -86,6 +103,7 @@ class _ExportCounters:
source_messages: int = 0 source_messages: int = 0
point_frames: int = 0 point_frames: int = 0
pose_frames: int = 0 pose_frames: int = 0
modeling_reports: int = 0
ignored_messages: int = 0 ignored_messages: int = 0
points: int = 0 points: int = 0
first_decoded_time_ns: int | None = None first_decoded_time_ns: int | None = None
@ -166,15 +184,18 @@ def export_k1mqtt_to_rrd(
input_path: Path, input_path: Path,
output_path: Path, output_path: Path,
*, *,
capture_clock_path: Path | None = None,
capture_clock_origin_path: Path | None = None,
cancel_event: threading.Event | None = None, cancel_event: threading.Event | None = None,
activity_callback: Callable[[], None] | None = None, activity_callback: Callable[[], None] | None = None,
) -> RrdExportSummary: ) -> RrdExportSummary:
"""Losslessly project every decodable K1 data-plane frame into one RRD. """Losslessly project every decodable K1 data-plane frame into one RRD.
The raw capture remains the source of record. The derived RRD uses a The raw capture remains the source of record. The derived RRD uses a
recording-local duration timeline whose zero is the first raw message's recording-local duration timeline whose zero is the durable capture-clock
receive-monotonic timestamp. It never traverses the bounded live-preview origin for v2 recordings (or the first raw message for legacy captures).
queue, so export throughput cannot drop point or pose frames. It never traverses the bounded live-preview queue, so export throughput
cannot drop point or pose frames.
The destination is replaced only after the temporary RRD has been closed, The destination is replaced only after the temporary RRD has been closed,
flushed and fsynced. Any decode, timing, sink or rename failure therefore flushed and fsynced. Any decode, timing, sink or rename failure therefore
@ -202,8 +223,30 @@ def export_k1mqtt_to_rrd(
counters = _ExportCounters() counters = _ExportCounters()
trajectory = _TrajectoryBuffer.empty() trajectory = _TrajectoryBuffer.empty()
session_origin_ns: int | None = None capture_clock = _optional_capture_clock(source, capture_clock_path)
capture_clock_origin = _optional_capture_clock_origin(
source,
capture_clock_origin_path,
)
if (
capture_clock is not None
and capture_clock_origin is not None
and (
capture_clock.started_at_epoch_ns != capture_clock_origin.started_at_epoch_ns
or capture_clock.started_monotonic_ns != capture_clock_origin.started_monotonic_ns
)
):
raise RrdExportError("native capture clock does not match its durable origin")
session_origin_ns: int | None = (
capture_clock.started_monotonic_ns
if capture_clock is not None
else capture_clock_origin.started_monotonic_ns
if capture_clock_origin is not None
else None
)
previous_monotonic_ns: int | None = None previous_monotonic_ns: int | None = None
last_source_time_ns: int | None = None
last_source_capture_ns: int | None = None
try: try:
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id) recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
@ -230,12 +273,37 @@ def export_k1mqtt_to_rrd(
) )
if session_origin_ns is None: if session_origin_ns is None:
session_origin_ns = monotonic_ns session_origin_ns = monotonic_ns
if monotonic_ns < session_origin_ns or (
capture_clock is not None and monotonic_ns > capture_clock.completed_monotonic_ns
):
raise RrdExportError(
f"native capture message {message.sequence} is outside its clock envelope"
)
session_time_ns = monotonic_ns - session_origin_ns session_time_ns = monotonic_ns - session_origin_ns
if session_time_ns > JS_MAX_SAFE_INTEGER: if session_time_ns > JS_MAX_SAFE_INTEGER:
raise RrdExportError( raise RrdExportError(
"session duration exceeds the exact JavaScript nanosecond range" "session duration exceeds the exact JavaScript nanosecond range"
) )
previous_monotonic_ns = monotonic_ns previous_monotonic_ns = monotonic_ns
last_source_time_ns = session_time_ns
last_source_capture_ns = message.received_at_epoch_ns
if is_modeling_report_topic(message.topic):
try:
telemetry = decode_modeling_report(message.payload)
except ModelingReportDecodeError as exc:
raise RrdExportError(
f"known K1 modeling report {message.sequence} failed validation"
) from exc
_set_frame_time(
recording,
message.sequence,
session_time_ns,
message.received_at_epoch_ns,
)
_log_modeling_telemetry(recording, telemetry)
counters.modeling_reports += 1
continue
try: try:
decoded = normalize_k1_message( decoded = normalize_k1_message(
@ -279,6 +347,23 @@ def export_k1mqtt_to_rrd(
raise RrdExportError("native capture contains no decodable point or pose frames") raise RrdExportError("native capture contains no decodable point or pose frames")
assert counters.first_decoded_time_ns is not None assert counters.first_decoded_time_ns is not None
assert counters.last_decoded_time_ns is not None assert counters.last_decoded_time_ns is not None
if capture_clock is None:
assert last_source_time_ns is not None
assert last_source_capture_ns is not None
timeline_end_ns = last_source_time_ns
timeline_end_epoch_ns = last_source_capture_ns
else:
timeline_end_ns = capture_clock.duration_ns
timeline_end_epoch_ns = capture_clock.completed_at_epoch_ns
# Materialize the durable capture completion independently of any
# opaque MQTT packet. This is the exact envelope end and remains a
# real RRD row for the strict browser buffering gate.
_set_terminal_time(
recording,
timeline_end_ns,
timeline_end_epoch_ns,
)
_log_session_end(recording)
_raise_if_cancelled(cancel_event) _raise_if_cancelled(cancel_event)
recording.flush(timeout_sec=30.0) recording.flush(timeout_sec=30.0)
@ -306,18 +391,17 @@ def export_k1mqtt_to_rrd(
decoded_messages=counters.decoded_messages, decoded_messages=counters.decoded_messages,
point_frames=counters.point_frames, point_frames=counters.point_frames,
pose_frames=counters.pose_frames, pose_frames=counters.pose_frames,
modeling_reports=counters.modeling_reports,
ignored_messages=counters.ignored_messages, ignored_messages=counters.ignored_messages,
points=counters.points, points=counters.points,
trajectory_poses=len(trajectory.positions), trajectory_poses=len(trajectory.positions),
trajectory_updates=trajectory.updates, trajectory_updates=trajectory.updates,
session_origin_monotonic_ns=session_origin_ns, session_origin_monotonic_ns=session_origin_ns,
timeline_start_ns=0, timeline_start_ns=0,
# Playback completeness is defined by data actually written to # Both endpoints are real RRD rows. This keeps camera media inside
# the RRD. K1 status/heartbeat packets may continue long after the # one seekable session without advertising an unmaterialized tail.
# final point or pose frame; advertising that raw tail as the RRD timeline_end_ns=timeline_end_ns,
# end makes a strict browser buffering gate wait forever. timeline_span_ns=timeline_end_ns,
timeline_end_ns=counters.last_decoded_time_ns,
timeline_span_ns=counters.last_decoded_time_ns,
first_decoded_time_ns=counters.first_decoded_time_ns, first_decoded_time_ns=counters.first_decoded_time_ns,
last_decoded_time_ns=counters.last_decoded_time_ns, last_decoded_time_ns=counters.last_decoded_time_ns,
source_sha256=source_sha256, source_sha256=source_sha256,
@ -356,6 +440,58 @@ def _validate_paths(source: Path, destination: Path) -> None:
raise RrdExportError("RRD export accepts only native K1MQTT captures") raise RrdExportError("RRD export accepts only native K1MQTT captures")
def _optional_capture_clock(
source: Path,
explicit_path: Path | None,
) -> CaptureClockEnvelope | None:
"""Load the exact v2 capture envelope, retaining legacy-message fallback."""
path = explicit_path if explicit_path is not None else source.with_name(CAPTURE_CLOCK_FILENAME)
path = _confined_clock_artifact(source, path)
try:
path.lstat()
except FileNotFoundError:
return None
except OSError as exc:
raise RrdExportError("native capture clock is unavailable") from exc
try:
return read_capture_clock_envelope(path)
except CaptureFormatError as exc:
raise RrdExportError(f"native capture clock is invalid: {exc}") from exc
def _optional_capture_clock_origin(
source: Path,
explicit_path: Path | None,
) -> CaptureClockOrigin | None:
path = (
explicit_path
if explicit_path is not None
else source.with_name(CAPTURE_CLOCK_ORIGIN_FILENAME)
)
path = _confined_clock_artifact(source, path)
try:
path.lstat()
except FileNotFoundError:
return None
except OSError as exc:
raise RrdExportError("native capture clock origin is unavailable") from exc
try:
return read_capture_clock_origin(path)
except CaptureFormatError as exc:
raise RrdExportError(f"native capture clock origin is invalid: {exc}") from exc
def _confined_clock_artifact(source: Path, path: Path) -> Path:
candidate = path.expanduser().absolute()
try:
if candidate.parent.resolve(strict=True) != source.parent.resolve(strict=True):
raise RrdExportError("native capture clock escapes its source directory")
except OSError as exc:
raise RrdExportError("native capture clock directory is unavailable") from exc
return candidate
def _recorded_blueprint( def _recorded_blueprint(
settings: RerunSceneSettings, settings: RerunSceneSettings,
*, *,
@ -384,9 +520,7 @@ def _recorded_blueprint(
# each Points3D row. A uniform custom color is the one color mode # each Points3D row. A uniform custom color is the one color mode
# that can be replaced safely by a singleton blueprint override. # that can be replaced safely by a singleton blueprint override.
colors=( colors=(
[_parse_hex_color(settings.custom_color)] [_parse_hex_color(settings.custom_color)] if settings.palette == "custom" else None
if settings.palette == "custom"
else None
), ),
).visualizer() ).visualizer()
point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
@ -417,7 +551,12 @@ def _recorded_blueprint(
time_ranges=time_ranges, time_ranges=time_ranges,
) )
spatial_view.id = RECORDED_SPATIAL_VIEW_ID spatial_view.id = RECORDED_SPATIAL_VIEW_ID
root_container = rrb.Tabs(spatial_view) metrics_view = rrb.TimeSeriesView(
origin="/metrics/device",
name="Маршрут и время",
)
metrics_view.id = RECORDED_METRICS_VIEW_ID
root_container = rrb.Tabs(spatial_view, metrics_view, active_tab=0)
root_container.id = RECORDED_ROOT_CONTAINER_ID root_container.id = RECORDED_ROOT_CONTAINER_ID
if include_initial_playback_state: if include_initial_playback_state:
@ -490,6 +629,21 @@ def _log_static_scene(recording: rr.RecordingStream) -> None:
rr.TransformAxes3D(axis_length=0.45, show_frame=False), rr.TransformAxes3D(axis_length=0.45, show_frame=False),
static=True, static=True,
) )
recording.log(
"/metrics/device/route_distance_meters",
rr.SeriesLines(names=["Дистанция, м"], colors=[[67, 191, 255, 255]]),
static=True,
)
recording.log(
"/metrics/device/speed_meters_per_second",
rr.SeriesLines(names=["Скорость, м/с"], colors=[[255, 193, 92, 255]]),
static=True,
)
recording.log(
"/metrics/device/scan_elapsed_seconds",
rr.SeriesLines(names=["Время сканирования, с"], colors=[[126, 224, 160, 255]]),
static=True,
)
def _log_session_origin(recording: rr.RecordingStream) -> None: def _log_session_origin(recording: rr.RecordingStream) -> None:
@ -505,6 +659,31 @@ def _log_session_origin(recording: rr.RecordingStream) -> None:
) )
def _log_session_end(recording: rr.RecordingStream) -> None:
recording.log(
"/__mission_core/session_end",
rr.AnyValues(session_end=True),
)
def _log_modeling_telemetry(
recording: rr.RecordingStream,
telemetry: ModelingTelemetry,
) -> None:
recording.log(
"/metrics/device/route_distance_meters",
rr.Scalars([telemetry.move_distance_meters]),
)
recording.log(
"/metrics/device/speed_meters_per_second",
rr.Scalars([telemetry.move_speed_meters_per_second]),
)
recording.log(
"/metrics/device/scan_elapsed_seconds",
rr.Scalars([telemetry.elapsed_seconds]),
)
def _set_frame_time( def _set_frame_time(
recording: rr.RecordingStream, recording: rr.RecordingStream,
sequence: int, sequence: int,
@ -522,6 +701,22 @@ def _set_frame_time(
recording.set_time("message_sequence", sequence=sequence) recording.set_time("message_sequence", sequence=sequence)
def _set_terminal_time(
recording: rr.RecordingStream,
session_time_ns: int,
capture_time_ns: int,
) -> None:
recording.set_time(
SESSION_TIMELINE,
duration=np.timedelta64(session_time_ns, "ns"),
)
recording.set_time(
CAPTURE_TIMELINE,
timestamp=np.datetime64(capture_time_ns, "ns"),
)
recording.disable_timeline("message_sequence")
def _log_points( def _log_points(
recording: rr.RecordingStream, recording: rr.RecordingStream,
frame: DecodedPointCloudView, frame: DecodedPointCloudView,

View File

@ -5,6 +5,7 @@ import queue
import threading import threading
import time import time
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Literal, Protocol, TypedDict from typing import Literal, Protocol, TypedDict
@ -25,6 +26,7 @@ RuntimePhase = Literal[
"stopping", "stopping",
"error", "error",
] ]
LIVE_CAPTURE_CLOCK_READY_TIMEOUT_SECONDS = 20.0
SourceMode = Literal["idle", "live", "replay"] SourceMode = Literal["idle", "live", "replay"]
StateCallback = Callable[[], None] StateCallback = Callable[[], None]
BridgeFactory = Callable[..., RerunBridge] BridgeFactory = Callable[..., RerunBridge]
@ -43,6 +45,10 @@ class CanonicalNormalizer(Protocol):
) -> DecodedDataPlaneView | None: ... ) -> DecodedDataPlaneView | None: ...
class RawMessageObserver(Protocol):
def __call__(self, message: StreamMessage, metrics: BridgeMetrics) -> bool: ...
class RuntimeSnapshot(TypedDict): class RuntimeSnapshot(TypedDict):
phase: RuntimePhase phase: RuntimePhase
message: str message: str
@ -65,6 +71,7 @@ class VisualizationRuntime:
grpc_port: int = DEFAULT_GRPC_PORT, grpc_port: int = DEFAULT_GRPC_PORT,
bridge_factory: BridgeFactory | None = None, bridge_factory: BridgeFactory | None = None,
normalizer: CanonicalNormalizer, normalizer: CanonicalNormalizer,
message_observer: RawMessageObserver | None = None,
) -> None: ) -> None:
self._lock = threading.Lock() self._lock = threading.Lock()
self._on_state_change = on_state_change self._on_state_change = on_state_change
@ -80,6 +87,7 @@ class VisualizationRuntime:
self._grpc_port = grpc_port self._grpc_port = grpc_port
self._bridge_factory = bridge_factory or RerunBridge self._bridge_factory = bridge_factory or RerunBridge
self._normalizer = normalizer self._normalizer = normalizer
self._message_observer = message_observer
self._bridge: RerunBridge | None = None self._bridge: RerunBridge | None = None
self._closed = False self._closed = False
self._scene_settings = RerunSceneSettings() self._scene_settings = RerunSceneSettings()
@ -133,9 +141,11 @@ class VisualizationRuntime:
out_dir: Path, out_dir: Path,
*, *,
duration_seconds: float = 3600.0, duration_seconds: float = 3600.0,
project_name: str,
) -> None: ) -> None:
if not math.isfinite(duration_seconds) or duration_seconds <= 0: if not math.isfinite(duration_seconds) or duration_seconds <= 0:
raise ValueError("длительность приёма должна быть больше нуля") raise ValueError("длительность приёма должна быть больше нуля")
clock_established = threading.Event()
self._start( self._start(
source_mode="live", source_mode="live",
phase="starting_live", phase="starting_live",
@ -144,8 +154,21 @@ class VisualizationRuntime:
host, host,
out_dir.expanduser().resolve(), out_dir.expanduser().resolve(),
duration_seconds=duration_seconds, duration_seconds=duration_seconds,
project_name=project_name,
clock_established=clock_established,
), ),
) )
deadline = time.monotonic() + LIVE_CAPTURE_CLOCK_READY_TIMEOUT_SECONDS
while not clock_established.wait(timeout=0.05):
snapshot = self.snapshot()
with self._lock:
thread_alive = self._thread is not None and self._thread.is_alive()
if snapshot["phase"] == "error" or not thread_alive:
raise RuntimeError("live capture завершился до фиксации общего session clock")
if time.monotonic() >= deadline:
with suppress(RuntimeError):
self.stop()
raise RuntimeError("live capture clock не был готов за отведённое время")
def stop(self, *, wait_seconds: float = 5.0) -> None: def stop(self, *, wait_seconds: float = 5.0) -> None:
notify_only = False notify_only = False
@ -194,6 +217,11 @@ class VisualizationRuntime:
if not thread_alive: if not thread_alive:
self._bridge = None self._bridge = None
self._rerun_grpc_url = None self._rerun_grpc_url = None
if thread_alive:
self._notify()
raise RuntimeError(
"поток не завершился за отведённое время; runtime и evidence lease сохранены"
)
if bridge is not None: if bridge is not None:
try: try:
bridge.close() bridge.close()
@ -278,8 +306,16 @@ class VisualizationRuntime:
self._run_pipeline(produce, running_phase="replay") self._run_pipeline(produce, running_phase="replay")
def _run_live(self, host: str, out_dir: Path, *, duration_seconds: float) -> None: def _run_live(
_write_live_session_preamble(out_dir, host, duration_seconds) self,
host: str,
out_dir: Path,
*,
duration_seconds: float,
project_name: str,
clock_established: threading.Event,
) -> None:
_write_live_session_preamble(out_dir, host, duration_seconds, project_name)
def produce(put: Callable[[StreamMessage], None]) -> str: def produce(put: Callable[[StreamMessage], None]) -> str:
def on_message(message: CapturedMqttMessage) -> None: def on_message(message: CapturedMqttMessage) -> None:
@ -298,6 +334,7 @@ class VisualizationRuntime:
host, host,
out_dir / "captures" / "mqtt_live", out_dir / "captures" / "mqtt_live",
duration_seconds=duration_seconds, duration_seconds=duration_seconds,
on_clock_established=clock_established.set,
on_ready=lambda: self._set_running( on_ready=lambda: self._set_running(
"live", "live",
"Приём запущен. Теперь дважды нажмите физическую кнопку устройства.", "Приём запущен. Теперь дважды нажмите физическую кнопку устройства.",
@ -325,6 +362,14 @@ class VisualizationRuntime:
publisher_error: list[BaseException] = [] publisher_error: list[BaseException] = []
def enqueue(message: StreamMessage) -> None: def enqueue(message: StreamMessage) -> None:
# A plugin may consume non-visual status before the bounded preview
# FIFO. This keeps control telemetry from evicting visual frames
# without embedding a vendor decoder in the visual runtime.
if self._message_observer is not None and self._message_observer(
message, self._metrics
):
self._notify()
return
try: try:
messages.put_nowait(message) messages.put_nowait(message)
return return
@ -525,7 +570,12 @@ def new_live_session_dir(sessions_root: Path) -> Path:
return candidate return candidate
def _write_live_session_preamble(out_dir: Path, host: str, duration_seconds: float) -> None: def _write_live_session_preamble(
out_dir: Path,
host: str,
duration_seconds: float,
project_name: str,
) -> None:
out_dir.mkdir(parents=True, exist_ok=False) out_dir.mkdir(parents=True, exist_ok=False)
started_at_utc = utc_now_iso() started_at_utc = utc_now_iso()
write_json_atomic( write_json_atomic(
@ -537,6 +587,9 @@ def _write_live_session_preamble(out_dir: Path, host: str, duration_seconds: flo
"operation": "k1_live_mqtt_to_rerun", "operation": "k1_live_mqtt_to_rerun",
"target": "owner-controlled K1 at redacted RFC1918 address", "target": "owner-controlled K1 at redacted RFC1918 address",
"requested_duration_seconds": duration_seconds, "requested_duration_seconds": duration_seconds,
# Operator-provided display metadata only. It is never used as a
# path component and is normalized/validated at the plugin API.
"project_name": project_name,
"raw_capture": "captures/mqtt_live/mqtt.raw.k1mqtt", "raw_capture": "captures/mqtt_live/mqtt.raw.k1mqtt",
"credential_storage": "none", "credential_storage": "none",
}, },

View File

@ -94,6 +94,16 @@ class _Mp4VideoTiming:
default_sample_duration: int | None default_sample_duration: int | None
@dataclass(frozen=True, slots=True)
class _Mp4VideoFragmentTiming:
base_decode_time: int
duration_units: int
@property
def end_decode_time(self) -> int:
return self.base_decode_time + self.duration_units
@dataclass(slots=True) @dataclass(slots=True)
class _Mp4ParseBudget: class _Mp4ParseBudget:
boxes_remaining: int = MAX_MP4_BOXES boxes_remaining: int = MAX_MP4_BOXES
@ -170,10 +180,13 @@ class RecordedMediaInspector:
artifact: RecordedMediaArtifact, artifact: RecordedMediaArtifact,
replay: ReplayCommand, replay: ReplayCommand,
epoch_paths: tuple[Path, ...], epoch_paths: tuple[Path, ...],
) -> tuple[ ) -> (
tuple[
RecordedMediaManifest, RecordedMediaManifest,
tuple[tuple[int, int, int, int], ...], tuple[tuple[int, int, int, int], ...],
] | None: ]
| None
):
root = self._cache_root root = self._cache_root
if root is None: if root is None:
return None return None
@ -406,8 +419,7 @@ def _manifest_from_sidecar(
) )
_validate_epoch_timeline(epochs) _validate_epoch_timeline(epochs)
byte_length = sum( byte_length = sum(
epoch.init_byte_length epoch.init_byte_length + sum(segment.byte_length for segment in epoch.segments)
+ sum(segment.byte_length for segment in epoch.segments)
for epoch in epochs for epoch in epochs
) )
timeline_start_seconds = min(epoch.timeline_start_seconds for epoch in epochs) timeline_start_seconds = min(epoch.timeline_start_seconds for epoch in epochs)
@ -515,11 +527,7 @@ def _sidecar_identity(
expected_length: int, expected_length: int,
) -> tuple[tuple[int, int, int, int], ...]: ) -> tuple[tuple[int, int, int, int], ...]:
value = document.get("source_identity") value = document.get("source_identity")
if ( if not isinstance(value, list) or expected_length < 1 or len(value) != expected_length:
not isinstance(value, list)
or expected_length < 1
or len(value) != expected_length
):
raise SessionIntegrityError("recorded media preparation source identity is invalid") raise SessionIntegrityError("recorded media preparation source identity is invalid")
identity: list[tuple[int, int, int, int]] = [] identity: list[tuple[int, int, int, int]] = []
for item in value: for item in value:
@ -561,10 +569,7 @@ def _write_sidecar_atomic(root: Path, filename: str, payload: bytes) -> None:
try: try:
descriptor = os.open( descriptor = os.open(
temporary, temporary,
os.O_WRONLY os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
| os.O_CREAT
| os.O_EXCL
| getattr(os, "O_NOFOLLOW", 0),
0o600, 0o600,
dir_fd=directory_fd, dir_fd=directory_fd,
) )
@ -637,9 +642,7 @@ def _prepared_source_identity(
identity: list[tuple[int, int, int, int]] = [] identity: list[tuple[int, int, int, int]] = []
for artifact in replay.artifacts: for artifact in replay.artifacts:
metadata = _session_artifact_stat(artifact.path, replay.session_root) metadata = _session_artifact_stat(artifact.path, replay.session_root)
identity.append( identity.append((metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns))
(metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns)
)
for expected_ordinal, (epoch_path, epoch) in enumerate( for expected_ordinal, (epoch_path, epoch) in enumerate(
zip(epoch_paths, epochs, strict=True), zip(epoch_paths, epochs, strict=True),
start=1, start=1,
@ -698,8 +701,7 @@ def _read_manifest(
) )
_validate_epoch_timeline(epochs) _validate_epoch_timeline(epochs)
byte_length = sum( byte_length = sum(
epoch.init_byte_length epoch.init_byte_length + sum(segment.byte_length for segment in epoch.segments)
+ sum(segment.byte_length for segment in epoch.segments)
for epoch in epochs for epoch in epochs
) )
if not 0 < byte_length <= MAX_SAFE_INTEGER: if not 0 < byte_length <= MAX_SAFE_INTEGER:
@ -737,8 +739,7 @@ def _manifest_generation_sha256(
timeline_start_seconds = min(epoch.timeline_start_seconds for epoch in epochs) timeline_start_seconds = min(epoch.timeline_start_seconds for epoch in epochs)
timeline_end_seconds = max(epoch.timeline_end_seconds for epoch in epochs) timeline_end_seconds = max(epoch.timeline_end_seconds for epoch in epochs)
byte_length = sum( byte_length = sum(
epoch.init_byte_length epoch.init_byte_length + sum(segment.byte_length for segment in epoch.segments)
+ sum(segment.byte_length for segment in epoch.segments)
for epoch in epochs for epoch in epochs
) )
descriptor = { descriptor = {
@ -798,11 +799,15 @@ def _read_epoch(
raise SessionIntegrityError("recorded media segment count is invalid") raise SessionIntegrityError("recorded media segment count is invalid")
try: try:
raw_lines = _read_confined_file( raw_lines = (
_read_confined_file(
epoch / "index.jsonl", epoch / "index.jsonl",
epoch, epoch,
MAX_MEDIA_INDEX_BYTES, MAX_MEDIA_INDEX_BYTES,
).decode("utf-8").splitlines() )
.decode("utf-8")
.splitlines()
)
except UnicodeDecodeError as exc: except UnicodeDecodeError as exc:
raise SessionIntegrityError("recorded media index is unavailable") from exc raise SessionIntegrityError("recorded media index is unavailable") from exc
if len(raw_lines) != int(segment_count): if len(raw_lines) != int(segment_count):
@ -886,14 +891,11 @@ def _read_epoch(
init_payload = _read_confined_file(init_path, epoch, MAX_INIT_BYTES) init_payload = _read_confined_file(init_path, epoch, MAX_INIT_BYTES)
init_sha256 = hashlib.sha256(init_payload).hexdigest() init_sha256 = hashlib.sha256(init_payload).hexdigest()
expected_init_sha256 = summary.get("init_sha256") expected_init_sha256 = summary.get("init_sha256")
if ( if not isinstance(expected_init_sha256, str) or expected_init_sha256 != init_sha256:
not isinstance(expected_init_sha256, str)
or expected_init_sha256 != init_sha256
):
raise SessionIntegrityError("recorded media init digest changed") raise SessionIntegrityError("recorded media init digest changed")
media_type = _mp4_media_type(init_payload) media_type = _mp4_media_type(init_payload)
timing = _mp4_video_timing(init_payload, _Mp4ParseBudget()) timing = _mp4_video_timing(init_payload, _Mp4ParseBudget())
duration_units: list[int] = [] fragment_timings: list[_Mp4VideoFragmentTiming] = []
for segment in segments: for segment in segments:
payload = _read_confined_file( payload = _read_confined_file(
segment.path, segment.path,
@ -902,13 +904,21 @@ def _read_epoch(
) )
if hashlib.sha256(payload).hexdigest() != segment.sha256: if hashlib.sha256(payload).hexdigest() != segment.sha256:
raise SessionIntegrityError("recorded media segment digest changed") raise SessionIntegrityError("recorded media segment digest changed")
duration_units.append( fragment_timings.append(
_mp4_video_fragment_duration_units( _mp4_video_fragment_timing(
payload, payload,
timing, timing,
_Mp4ParseBudget(), _Mp4ParseBudget(),
) )
) )
for previous, current in zip(
fragment_timings,
fragment_timings[1:],
strict=False,
):
if current.base_decode_time != previous.end_decode_time:
raise SessionIntegrityError("recorded media fragment decode timeline is discontinuous")
duration_units = [timing.duration_units for timing in fragment_timings]
first_duration_seconds = _checked_fragment_duration_seconds( first_duration_seconds = _checked_fragment_duration_seconds(
duration_units[0], duration_units[0],
timing.timescale, timing.timescale,
@ -919,10 +929,7 @@ def _read_epoch(
total_duration_seconds = total_duration_units / timing.timescale total_duration_seconds = total_duration_units / timing.timescale
timeline_start_seconds = max(0.0, timeline_points[0] - first_duration_seconds) timeline_start_seconds = max(0.0, timeline_points[0] - first_duration_seconds)
timeline_end_seconds = timeline_start_seconds + total_duration_seconds timeline_end_seconds = timeline_start_seconds + total_duration_seconds
if ( if not math.isfinite(timeline_end_seconds) or timeline_end_seconds < timeline_start_seconds:
not math.isfinite(timeline_end_seconds)
or timeline_end_seconds < timeline_start_seconds
):
raise SessionIntegrityError("recorded media epoch timeline is invalid") raise SessionIntegrityError("recorded media epoch timeline is invalid")
return RecordedMediaEpoch( return RecordedMediaEpoch(
ordinal=ordinal, ordinal=ordinal,
@ -984,23 +991,22 @@ def _mp4_fragment_duration_seconds(init_payload: bytes, fragment_payload: bytes)
budget = _Mp4ParseBudget() budget = _Mp4ParseBudget()
timing = _mp4_video_timing(init_payload, budget) timing = _mp4_video_timing(init_payload, budget)
duration_units = _mp4_video_fragment_duration_units( fragment_timing = _mp4_video_fragment_timing(
fragment_payload, fragment_payload,
timing, timing,
budget, budget,
) )
return _checked_fragment_duration_seconds(duration_units, timing.timescale) return _checked_fragment_duration_seconds(
fragment_timing.duration_units,
timing.timescale,
)
def _checked_fragment_duration_seconds(duration_units: int, timescale: int) -> float: def _checked_fragment_duration_seconds(duration_units: int, timescale: int) -> float:
if duration_units <= 0: if duration_units <= 0:
raise SessionIntegrityError("recorded media fragment has no positive duration") raise SessionIntegrityError("recorded media fragment has no positive duration")
duration = duration_units / timescale duration = duration_units / timescale
if ( if not math.isfinite(duration) or duration <= 0 or duration > MAX_MP4_FRAGMENT_DURATION_SECONDS:
not math.isfinite(duration)
or duration <= 0
or duration > MAX_MP4_FRAGMENT_DURATION_SECONDS
):
raise SessionIntegrityError("recorded media fragment duration is outside bounds") raise SessionIntegrityError("recorded media fragment duration is outside bounds")
return duration return duration
@ -1069,9 +1075,7 @@ def _trak_media_timing(payload: bytes, budget: _Mp4ParseBudget) -> int | None:
raise SessionIntegrityError("recorded media track has no unique mdia box") raise SessionIntegrityError("recorded media track has no unique mdia box")
children = tuple(_iter_mp4_boxes(media_boxes[0], budget)) children = tuple(_iter_mp4_boxes(media_boxes[0], budget))
handlers = [ handlers = [
_parse_hdlr_type(box_payload) _parse_hdlr_type(box_payload) for box_type, box_payload in children if box_type == b"hdlr"
for box_type, box_payload in children
if box_type == b"hdlr"
] ]
if len(handlers) != 1: if len(handlers) != 1:
raise SessionIntegrityError("recorded media track handler is ambiguous") raise SessionIntegrityError("recorded media track handler is ambiguous")
@ -1087,11 +1091,11 @@ def _trak_media_timing(payload: bytes, budget: _Mp4ParseBudget) -> int | None:
return timescales[0] return timescales[0]
def _mp4_video_fragment_duration_units( def _mp4_video_fragment_timing(
payload: bytes, payload: bytes,
timing: _Mp4VideoTiming, timing: _Mp4VideoTiming,
budget: _Mp4ParseBudget, budget: _Mp4ParseBudget,
) -> int: ) -> _Mp4VideoFragmentTiming:
moof_payloads = [ moof_payloads = [
box_payload box_payload
for box_type, box_payload in _iter_mp4_boxes(payload, budget) for box_type, box_payload in _iter_mp4_boxes(payload, budget)
@ -1099,7 +1103,7 @@ def _mp4_video_fragment_duration_units(
] ]
if len(moof_payloads) != 1: if len(moof_payloads) != 1:
raise SessionIntegrityError("recorded media fragment has no unique moof box") raise SessionIntegrityError("recorded media fragment has no unique moof box")
matching_durations: list[int] = [] matching_timings: list[_Mp4VideoFragmentTiming] = []
for box_type, traf_payload in _iter_mp4_boxes(moof_payloads[0], budget): for box_type, traf_payload in _iter_mp4_boxes(moof_payloads[0], budget):
if box_type != b"traf": if box_type != b"traf":
continue continue
@ -1110,18 +1114,28 @@ def _mp4_video_fragment_duration_units(
track_id, fragment_default_duration = _parse_tfhd(tfhd_payloads[0]) track_id, fragment_default_duration = _parse_tfhd(tfhd_payloads[0])
if track_id != timing.track_id: if track_id != timing.track_id:
continue continue
tfdt_payloads = [box for kind, box in boxes if kind == b"tfdt"]
if len(tfdt_payloads) != 1:
raise SessionIntegrityError("recorded media fragment tfdt is ambiguous")
base_decode_time = _parse_tfdt(tfdt_payloads[0])
trun_payloads = [box for kind, box in boxes if kind == b"trun"] trun_payloads = [box for kind, box in boxes if kind == b"trun"]
if not trun_payloads: if not trun_payloads:
raise SessionIntegrityError("recorded media video fragment has no trun box") raise SessionIntegrityError("recorded media video fragment has no trun box")
default_duration = fragment_default_duration or timing.default_sample_duration default_duration = fragment_default_duration or timing.default_sample_duration
duration = sum( duration = sum(
_parse_trun_duration_units(trun, default_duration, budget) _parse_trun_duration_units(trun, default_duration, budget) for trun in trun_payloads
for trun in trun_payloads
) )
matching_durations.append(duration) if base_decode_time > MAX_SAFE_INTEGER - duration:
if len(matching_durations) != 1: raise SessionIntegrityError("recorded media fragment decode time is outside bounds")
matching_timings.append(
_Mp4VideoFragmentTiming(
base_decode_time=base_decode_time,
duration_units=duration,
)
)
if len(matching_timings) != 1:
raise SessionIntegrityError("recorded media fragment video track is ambiguous") raise SessionIntegrityError("recorded media fragment video track is ambiguous")
return matching_durations[0] return matching_timings[0]
def _iter_mp4_boxes( def _iter_mp4_boxes(
@ -1195,6 +1209,17 @@ def _parse_tfhd(payload: bytes) -> tuple[int, int | None]:
return track_id, default_duration if default_duration and default_duration > 0 else None return track_id, default_duration if default_duration and default_duration > 0 else None
def _parse_tfdt(payload: bytes) -> int:
version = _full_box_version(payload)
if version == 0:
return _read_u32(payload, 4, "tfdt base decode time")
if version == 1:
if len(payload) < 12:
raise SessionIntegrityError("recorded media tfdt base decode time is truncated")
return int.from_bytes(payload[4:12], "big")
raise SessionIntegrityError("recorded media tfdt version is unsupported")
def _parse_trun_duration_units( def _parse_trun_duration_units(
payload: bytes, payload: bytes,
default_duration: int | None, default_duration: int | None,
@ -1210,9 +1235,7 @@ def _parse_trun_duration_units(
cursor = _advance_box_cursor(payload, cursor, 4, "trun data offset") cursor = _advance_box_cursor(payload, cursor, 4, "trun data offset")
if flags & 0x000004: if flags & 0x000004:
cursor = _advance_box_cursor(payload, cursor, 4, "trun first sample flags") cursor = _advance_box_cursor(payload, cursor, 4, "trun first sample flags")
per_sample_width = sum( per_sample_width = sum(4 for flag in (0x000100, 0x000200, 0x000400, 0x000800) if flags & flag)
4 for flag in (0x000100, 0x000200, 0x000400, 0x000800) if flags & flag
)
if per_sample_width and sample_count > (len(payload) - cursor) // per_sample_width: if per_sample_width and sample_count > (len(payload) - cursor) // per_sample_width:
raise SessionIntegrityError("recorded media trun samples are truncated") raise SessionIntegrityError("recorded media trun samples are truncated")
if flags & 0x000100: if flags & 0x000100:

View File

@ -23,10 +23,10 @@ from .plugin_contract import (
RecordingExporter, RecordingExporter,
) )
# v6 adds a real session_time=0 row to the RRD itself. Older sidecars can # v9 derives both real RRD boundary rows from the durable capture-clock
# declare a zero start while their payload begins at the first decoded sensor # envelope. v8 used the first/last MQTT message and could still exclude a
# frame, so accepting them would violate the browser playback contract. # camera fragment produced between source startup/shutdown and those packets.
CACHE_SCHEMA = "missioncore.derived-rerun-recording-cache/v7" CACHE_SCHEMA = "missioncore.derived-rerun-recording-cache/v9"
COMPATIBLE_CACHE_SCHEMAS = frozenset({CACHE_SCHEMA}) COMPATIBLE_CACHE_SCHEMAS = frozenset({CACHE_SCHEMA})
RERUN_RECORDING_MEDIA_TYPE = "application/vnd.rerun.rrd" RERUN_RECORDING_MEDIA_TYPE = "application/vnd.rerun.rrd"
RERUN_SESSION_TIMELINE = "session_time" RERUN_SESSION_TIMELINE = "session_time"
@ -84,6 +84,7 @@ class _ValidatedArtifact:
self.media_type, self.media_type,
*_stat_identity(self.file_stat), *_stat_identity(self.file_stat),
self.replay_byte_length, self.replay_byte_length,
self.expected_sha256,
) )
@ -358,9 +359,7 @@ class SessionRecordingMaterializer:
try: try:
session_roots = tuple(self.recordings_root.iterdir()) session_roots = tuple(self.recordings_root.iterdir())
except OSError as exc: except OSError as exc:
raise RecordingMaterializationError( raise RecordingMaterializationError("recording cache could not be scavenged") from exc
"recording cache could not be scavenged"
) from exc
for session_root in session_roots: for session_root in session_roots:
try: try:
root_stat = session_root.lstat() root_stat = session_root.lstat()
@ -382,18 +381,13 @@ class SessionRecordingMaterializer:
and name.endswith(".tmp") and name.endswith(".tmp")
) )
or (name.startswith(".source.") and name.endswith(".tmp")) or (name.startswith(".source.") and name.endswith(".tmp"))
or ( or (name.startswith(".scene.rrd.cache.json.") and name.endswith(".tmp"))
name.startswith(".scene.rrd.cache.json.")
and name.endswith(".tmp")
)
) )
if not stale: if not stale:
continue continue
try: try:
child_stat = child.lstat() child_stat = child.lstat()
if stat.S_ISDIR(child_stat.st_mode) and not stat.S_ISLNK( if stat.S_ISDIR(child_stat.st_mode) and not stat.S_ISLNK(child_stat.st_mode):
child_stat.st_mode
):
shutil.rmtree(child) shutil.rmtree(child)
else: else:
child.unlink() child.unlink()
@ -497,12 +491,17 @@ class SessionRecordingMaterializer:
staged_root: Path | None = None staged_root: Path | None = None
export_source = source.primary.path export_source = source.primary.path
export_artifacts = {artifact.artifact_id: artifact.path for artifact in source.artifacts}
try: try:
# Catalog digests bind every replay input, not only the primary
# raw stream. In particular the capture-clock artifact must be
# proven before its bounds influence an RRD export.
_validated_artifact_digests(source)
if any( if any(
artifact.replay_byte_length != artifact.file_stat.st_size artifact.replay_byte_length != artifact.file_stat.st_size
for artifact in source.artifacts for artifact in source.artifacts
): ):
staged_root, export_source = _stage_replay_prefix( staged_root, export_source, export_artifacts = _stage_replay_prefix(
resolved_session_root, resolved_session_root,
source, source,
cancel_event=cancel_event, cancel_event=cancel_event,
@ -516,6 +515,7 @@ class SessionRecordingMaterializer:
source.plugin_id, source.plugin_id,
export_source, export_source,
candidate_path, candidate_path,
artifacts=export_artifacts,
cancel_event=cancel_event, cancel_event=cancel_event,
activity_callback=lambda: _report_progress( activity_callback=lambda: _report_progress(
progress_callback, progress_callback,
@ -527,9 +527,7 @@ class SessionRecordingMaterializer:
_report_progress(progress_callback, "finalizing", 0.9) _report_progress(progress_callback, "finalizing", 0.9)
except PluginRecordingExportCancelled as exc: except PluginRecordingExportCancelled as exc:
candidate_path.unlink(missing_ok=True) candidate_path.unlink(missing_ok=True)
raise RecordingMaterializationCancelled( raise RecordingMaterializationCancelled("recording preparation was cancelled") from exc
"recording preparation was cancelled"
) from exc
except PluginRecordingExportError as exc: except PluginRecordingExportError as exc:
candidate_path.unlink(missing_ok=True) candidate_path.unlink(missing_ok=True)
raise RecordingMaterializationError("native capture could not be exported") from exc raise RecordingMaterializationError("native capture could not be exported") from exc
@ -548,18 +546,8 @@ class SessionRecordingMaterializer:
if source_after.identity != source.identity: if source_after.identity != source.identity:
raise RecordingMaterializationError("native capture changed during RRD export") raise RecordingMaterializationError("native capture changed during RRD export")
_chmod_best_effort(candidate_path, 0o600) _chmod_best_effort(candidate_path, 0o600)
source_sha256 = _sha256_prefix_stable( artifact_digests = _validated_artifact_digests(source)
source.primary.path, source_sha256 = artifact_digests[source.primary_artifact_id]
source.primary.file_stat,
source.primary.replay_byte_length,
)
if (
source.primary.expected_sha256 is not None
and source_sha256 != source.primary.expected_sha256
):
raise RecordingMaterializationError(
"native capture digest no longer matches catalog"
)
candidate = _materialized_from_export( candidate = _materialized_from_export(
session_id=session_id, session_id=session_id,
source_sha256=source_sha256, source_sha256=source_sha256,
@ -612,6 +600,7 @@ class SessionRecordingMaterializer:
source: Path, source: Path,
destination: Path, destination: Path,
*, *,
artifacts: Mapping[str, Path],
cancel_event: threading.Event | None, cancel_event: threading.Event | None,
activity_callback: Callable[[], None], activity_callback: Callable[[], None],
) -> Mapping[str, object]: ) -> Mapping[str, object]:
@ -622,6 +611,8 @@ class SessionRecordingMaterializer:
) )
exporter = cast(RrdExporter, selected) exporter = cast(RrdExporter, selected)
kwargs: dict[str, object] = {} kwargs: dict[str, object] = {}
if _callable_accepts_keyword(exporter, "artifacts"):
kwargs["artifacts"] = dict(artifacts)
if _callable_accepts_keyword(exporter, "cancel_event"): if _callable_accepts_keyword(exporter, "cancel_event"):
kwargs["cancel_event"] = cancel_event kwargs["cancel_event"] = cancel_event
if _callable_accepts_keyword(exporter, "activity_callback"): if _callable_accepts_keyword(exporter, "activity_callback"):
@ -705,28 +696,23 @@ class SessionRecordingMaterializer:
if document["recording_mtime_ns"] != recording_stat.st_mtime_ns: if document["recording_mtime_ns"] != recording_stat.st_mtime_ns:
return None return None
source_sha256 = _sha256_prefix_stable( source_sha256: str | None = None
source.primary.path,
source.primary.file_stat,
source.primary.replay_byte_length,
)
if (
source.primary.expected_sha256 is not None
and source_sha256 != source.primary.expected_sha256
):
raise RecordingMaterializationError(
"native capture digest no longer matches catalog"
)
if source_sha256 != document["source_sha256"]:
return None
for cached, artifact in zip(cached_artifacts, source.artifacts, strict=True): for cached, artifact in zip(cached_artifacts, source.artifacts, strict=True):
digest = _sha256_prefix_stable( digest = _sha256_prefix_stable(
artifact.path, artifact.path,
artifact.file_stat, artifact.file_stat,
artifact.replay_byte_length, artifact.replay_byte_length,
) )
if artifact.expected_sha256 is not None and digest != artifact.expected_sha256:
raise RecordingMaterializationError(
"recording artifact digest no longer matches catalog"
)
if digest != cached["sha256"]: if digest != cached["sha256"]:
return None return None
if artifact.artifact_id == source.primary_artifact_id:
source_sha256 = digest
if source_sha256 is None or source_sha256 != document["source_sha256"]:
return None
recording_sha256 = _sha256_stable(recording_path, recording_stat) recording_sha256 = _sha256_stable(recording_path, recording_stat)
if recording_sha256 != document["recording_sha256"]: if recording_sha256 != document["recording_sha256"]:
return None return None
@ -817,9 +803,10 @@ def _validate_source(command: ReplayCommand) -> _ValidatedSource:
artifacts = getattr(command, "artifacts", None) artifacts = getattr(command, "artifacts", None)
if not isinstance(plugin_id, str) or SESSION_ID_PATTERN.fullmatch(plugin_id) is None: if not isinstance(plugin_id, str) or SESSION_ID_PATTERN.fullmatch(plugin_id) is None:
raise RecordingMaterializationError("replay command has an invalid plugin id") raise RecordingMaterializationError("replay command has an invalid plugin id")
if not isinstance(primary_artifact_id, str) or SESSION_ID_PATTERN.fullmatch( if (
primary_artifact_id not isinstance(primary_artifact_id, str)
) is None: or SESSION_ID_PATTERN.fullmatch(primary_artifact_id) is None
):
raise RecordingMaterializationError("replay command has an invalid primary artifact") raise RecordingMaterializationError("replay command has an invalid primary artifact")
if not isinstance(artifacts, tuple) or not artifacts: if not isinstance(artifacts, tuple) or not artifacts:
raise RecordingMaterializationError("replay command has no source artifacts") raise RecordingMaterializationError("replay command has no source artifacts")
@ -910,6 +897,22 @@ def _validate_source_state(source: _ValidatedSource) -> _ValidatedSource:
) )
def _validated_artifact_digests(source: _ValidatedSource) -> dict[str, str]:
digests: dict[str, str] = {}
for artifact in source.artifacts:
digest = _sha256_prefix_stable(
artifact.path,
artifact.file_stat,
artifact.replay_byte_length,
)
if artifact.expected_sha256 is not None and digest != artifact.expected_sha256:
raise RecordingMaterializationError(
"recording artifact digest no longer matches catalog"
)
digests[artifact.artifact_id] = digest
return digests
def _materialized_from_export( def _materialized_from_export(
*, *,
session_id: str, session_id: str,
@ -1165,11 +1168,12 @@ def _stage_replay_prefix(
*, *,
cancel_event: threading.Event | None = None, cancel_event: threading.Event | None = None,
activity_callback: Callable[[], None] | None = None, activity_callback: Callable[[], None] | None = None,
) -> tuple[Path, Path]: ) -> tuple[Path, Path, dict[str, Path]]:
staged_root = session_cache_root / f".source.{uuid4().hex}.tmp" staged_root = session_cache_root / f".source.{uuid4().hex}.tmp"
try: try:
staged_root.mkdir(mode=0o700) staged_root.mkdir(mode=0o700)
staged_primary: Path | None = None staged_primary: Path | None = None
staged_artifacts: dict[str, Path] = {}
for artifact in source.artifacts: for artifact in source.artifacts:
staged = staged_root / artifact.path.name staged = staged_root / artifact.path.name
_copy_prefix_nofollow( _copy_prefix_nofollow(
@ -1180,12 +1184,13 @@ def _stage_replay_prefix(
cancel_event=cancel_event, cancel_event=cancel_event,
activity_callback=activity_callback, activity_callback=activity_callback,
) )
staged_artifacts[artifact.artifact_id] = staged
if artifact.artifact_id == source.primary_artifact_id: if artifact.artifact_id == source.primary_artifact_id:
staged_primary = staged staged_primary = staged
if staged_primary is None: if staged_primary is None:
raise RecordingMaterializationError("staged recording has no primary artifact") raise RecordingMaterializationError("staged recording has no primary artifact")
_fsync_directory(staged_root) _fsync_directory(staged_root)
return staged_root, staged_primary return staged_root, staged_primary, staged_artifacts
except BaseException: except BaseException:
shutil.rmtree(staged_root, ignore_errors=True) shutil.rmtree(staged_root, ignore_errors=True)
raise raise
@ -1347,8 +1352,7 @@ def _callable_accepts_keyword(callback: Callable[..., object], keyword: str) ->
except (TypeError, ValueError): except (TypeError, ValueError):
return False return False
return keyword in parameters or any( return keyword in parameters or any(
parameter.kind is inspect.Parameter.VAR_KEYWORD parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters.values()
for parameter in parameters.values()
) )
@ -1359,15 +1363,18 @@ def _exclusive_file_lock(path: Path) -> Any:
try: try:
import fcntl import fcntl
except ImportError as exc: # pragma: no cover - production targets are POSIX except ImportError as exc: # pragma: no cover - production targets are POSIX
raise RecordingMaterializationError( raise RecordingMaterializationError("cross-process recording lock is unavailable") from exc
"cross-process recording lock is unavailable"
) from exc
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) | getattr( flags = (
os.O_RDWR
| os.O_CREAT
| getattr(os, "O_CLOEXEC", 0)
| getattr(
os, os,
"O_NOFOLLOW", "O_NOFOLLOW",
0, 0,
) )
)
try: try:
descriptor = os.open(path, flags, 0o600) descriptor = os.open(path, flags, 0o600)
except OSError as exc: except OSError as exc:
@ -1383,9 +1390,7 @@ def _exclusive_file_lock(path: Path) -> Any:
fcntl.flock(descriptor, fcntl.LOCK_EX) fcntl.flock(descriptor, fcntl.LOCK_EX)
yield yield
except OSError as exc: except OSError as exc:
raise RecordingMaterializationError( raise RecordingMaterializationError("cross-process recording lock failed") from exc
"cross-process recording lock failed"
) from exc
finally: finally:
try: try:
fcntl.flock(descriptor, fcntl.LOCK_UN) fcntl.flock(descriptor, fcntl.LOCK_UN)

View File

@ -24,6 +24,12 @@ class MetricsSnapshot(TypedDict):
mqtt_to_publish_p95_ms: float | None mqtt_to_publish_p95_ms: float | None
decode_publish_ms: float | None decode_publish_ms: float | None
trajectory_poses: int trajectory_poses: int
device_elapsed_seconds: float | None
device_route_distance_meters: float | None
device_speed_meters_per_second: float | None
device_pgo_progress: int | None
modeling_reports: int
modeling_decode_errors: int
class BridgeMetrics: class BridgeMetrics:
@ -44,6 +50,12 @@ class BridgeMetrics:
self._pose_times: deque[int] = deque() self._pose_times: deque[int] = deque()
self._latencies_ms: deque[float] = deque(maxlen=512) self._latencies_ms: deque[float] = deque(maxlen=512)
self._decode_publish_ms: float | None = None self._decode_publish_ms: float | None = None
self._device_elapsed_seconds: float | None = None
self._device_route_distance_meters: float | None = None
self._device_speed_meters_per_second: float | None = None
self._device_pgo_progress: int | None = None
self._modeling_reports = 0
self._modeling_decode_errors = 0
def received(self, payload_bytes: int) -> None: def received(self, payload_bytes: int) -> None:
with self._lock: with self._lock:
@ -81,6 +93,33 @@ class BridgeMetrics:
with self._lock: with self._lock:
self._preview_dropped += 1 self._preview_dropped += 1
def acquisition_telemetry(
self,
*,
elapsed_seconds: float,
route_distance_meters: float,
speed_meters_per_second: float,
pgo_progress: int,
) -> None:
values = (elapsed_seconds, route_distance_meters, speed_meters_per_second)
if any(not math.isfinite(value) or value < 0 for value in values):
return
if pgo_progress < 0:
return
with self._lock:
self._device_elapsed_seconds = elapsed_seconds
# This is the device's current scan generation. FW 3.0.2 resets
# ScanTime and MoveDistance together; carrying an earlier project's
# route across that boundary would be false precision.
self._device_route_distance_meters = route_distance_meters
self._device_speed_meters_per_second = speed_meters_per_second
self._device_pgo_progress = pgo_progress
self._modeling_reports += 1
def modeling_decode_error(self) -> None:
with self._lock:
self._modeling_decode_errors += 1
def snapshot(self) -> MetricsSnapshot: def snapshot(self) -> MetricsSnapshot:
now_ns = time.monotonic_ns() now_ns = time.monotonic_ns()
with self._lock: with self._lock:
@ -106,6 +145,12 @@ class BridgeMetrics:
"mqtt_to_publish_p95_ms": _rounded(p95), "mqtt_to_publish_p95_ms": _rounded(p95),
"decode_publish_ms": _rounded(self._decode_publish_ms), "decode_publish_ms": _rounded(self._decode_publish_ms),
"trajectory_poses": self._trajectory_poses, "trajectory_poses": self._trajectory_poses,
"device_elapsed_seconds": _rounded(self._device_elapsed_seconds),
"device_route_distance_meters": _rounded(self._device_route_distance_meters),
"device_speed_meters_per_second": _rounded(self._device_speed_meters_per_second),
"device_pgo_progress": self._device_pgo_progress,
"modeling_reports": self._modeling_reports,
"modeling_decode_errors": self._modeling_decode_errors,
} }

View File

@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
import inspect import inspect
import json
import struct import struct
import threading
import time import time
from collections.abc import Callable
from pathlib import Path from pathlib import Path
import lz4.block import lz4.block
@ -301,10 +304,12 @@ def test_live_runtime_surfaces_preamble_failure_as_terminal_error(
monkeypatch.setattr(runtime_module, "_write_live_session_preamble", fail_preamble) monkeypatch.setattr(runtime_module, "_write_live_session_preamble", fail_preamble)
runtime = VisualizationRuntime(normalizer=normalize_k1_message) runtime = VisualizationRuntime(normalizer=normalize_k1_message)
with pytest.raises(RuntimeError, match="session clock"):
runtime.start_live( runtime.start_live(
"192.168.1.20", "192.168.1.20",
tmp_path / "unwritable-evidence", tmp_path / "unwritable-evidence",
duration_seconds=1.0, duration_seconds=1.0,
project_name="Preamble failure test",
) )
deadline = time.monotonic() + 2.0 deadline = time.monotonic() + 2.0
snapshot = runtime.snapshot() snapshot = runtime.snapshot()
@ -317,3 +322,80 @@ def test_live_runtime_surfaces_preamble_failure_as_terminal_error(
assert snapshot["source_ready"] is False assert snapshot["source_ready"] is False
assert "synthetic evidence directory failure" in snapshot["message"] assert "synthetic evidence directory failure" in snapshot["message"]
runtime.close() runtime.close()
def test_live_start_waits_for_capture_clock_before_returning(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
entered_capture = threading.Event()
allow_clock = threading.Event()
returned = threading.Event()
failures: list[BaseException] = []
recording = FakeRecording()
def bridge_factory(**kwargs: object) -> RerunBridge:
return RerunBridge(
recording_factory=lambda _: recording, # type: ignore[arg-type]
**kwargs, # type: ignore[arg-type]
)
def delayed_capture(
_host: str,
_out_dir: Path,
*,
on_clock_established: Callable[[], None],
should_stop: Callable[[], bool],
**_kwargs: object,
) -> dict[str, object]:
entered_capture.set()
assert allow_clock.wait(timeout=2.0)
on_clock_established()
while not should_stop():
time.sleep(0.005)
return {"message_count": 0}
monkeypatch.setattr(runtime_module, "capture_mqtt", delayed_capture)
runtime = VisualizationRuntime(
bridge_factory=bridge_factory,
normalizer=normalize_k1_message,
)
def start() -> None:
try:
runtime.start_live(
"192.168.1.20",
tmp_path / "delayed-clock",
duration_seconds=30.0,
project_name="Clock barrier test",
)
except BaseException as exc:
failures.append(exc)
finally:
returned.set()
caller = threading.Thread(target=start)
caller.start()
assert entered_capture.wait(timeout=2.0)
assert not returned.wait(timeout=0.05)
allow_clock.set()
assert returned.wait(timeout=2.0)
assert failures == []
runtime.stop()
caller.join(timeout=2.0)
runtime.close()
def test_live_session_manifest_retains_project_display_name(tmp_path: Path) -> None:
out_dir = tmp_path / "evidence-session"
runtime_module._write_live_session_preamble(
out_dir,
"192.168.1.20",
60.0,
"K1 Route Alpha",
)
manifest = json.loads((out_dir / "manifest.redacted.json").read_text(encoding="utf-8"))
assert manifest["project_name"] == "K1 Route Alpha"
assert "192.168.1.20" not in json.dumps(manifest)

View File

@ -104,7 +104,8 @@ def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity()
assert hook_source.index("xgridsK1Api.prepareAcquisition") < hook_source.index( assert hook_source.index("xgridsK1Api.prepareAcquisition") < hook_source.index(
"xgridsK1Api.startAcquisition" "xgridsK1Api.startAcquisition"
) )
assert 'mode: "capture-only"' in hook_source assert "isSoftwareCommandedAcquisition(state)" in hook_source
assert '? "graceful" : "capture-only"' in hook_source
assert "state.device_ref" in runtime_source assert "state.device_ref" in runtime_source
assert "instanceId: deviceRef.device_id" in runtime_source assert "instanceId: deviceRef.device_id" in runtime_source
assert "acquisition?.acquisition_id" in runtime_source assert "acquisition?.acquisition_id" in runtime_source
@ -124,7 +125,34 @@ def test_xgrids_live_copy_does_not_claim_software_controls_the_physical_scanner(
/ "K1AcquisitionPipeline.tsx" / "K1AcquisitionPipeline.tsx"
).read_text("utf-8") ).read_text("utf-8")
assert "Подготовить приём данных" in connection_source assert "Подготовить локальный приём данных" in connection_source
assert "Программная команда запуска на K1 пока не отправляется" in connection_source assert "Программная команда запуска на K1 не отправляется" in connection_source
assert "Остановить локальный приём" in connection_source assert "Остановить локальный приём" in connection_source
assert "Физическое состояние сканера остаётся неизвестным" in connection_source assert "Физическое состояние сканера остаётся неизвестным" in connection_source
def test_xgrids_start_uses_atomic_automatic_source_transition() -> None:
repository_root = Path(__file__).resolve().parents[1]
pipeline_source = (
repository_root
/ "plugins"
/ "xgrids-k1"
/ "frontend"
/ "src"
/ "components"
/ "K1AcquisitionPipeline.tsx"
).read_text("utf-8")
transition_source = (
repository_root / "plugins" / "xgrids-k1" / "frontend" / "src" / "automaticSourceStart.ts"
).read_text("utf-8")
assert pipeline_source.count("runAutomaticSpatialSourceStart(") == 2
assert transition_source.index("const started = await start();") < (
transition_source.index("activateAutomaticSpatialSource();")
)
assert transition_source.index("if (!started) return false;") < (
transition_source.index("activateAutomaticSpatialSource();")
)
assert transition_source.index("activateAutomaticSpatialSource();") < (
transition_source.index("openSpatialScene();")
)

View File

@ -19,6 +19,8 @@ from k1link.device_plugins.xgrids_k1.mqtt.capture import (
CaptureFormatError, CaptureFormatError,
capture_mqtt, capture_mqtt,
iter_capture_frames, iter_capture_frames,
read_capture_clock_envelope,
seal_capture_clock,
validate_private_ipv4, validate_private_ipv4,
) )
@ -97,11 +99,17 @@ def test_validate_private_ipv4_rejects_other_targets(address: str) -> None:
def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -> None: def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -> None:
fake = FakeClient() fake = FakeClient()
observed = [] observed = []
clock_ready: list[bool] = []
def on_clock_established() -> None:
origin = tmp_path / "capture" / "mqtt.timeline.origin.json"
clock_ready.append(origin.is_file() and bool(origin.read_bytes()))
summary = capture_mqtt( summary = capture_mqtt(
"192.168.1.50", "192.168.1.50",
tmp_path / "capture", tmp_path / "capture",
duration_seconds=30, duration_seconds=30,
on_clock_established=on_clock_established,
on_message_recorded=observed.append, on_message_recorded=observed.append,
_client_factory=lambda: cast(mqtt.Client, fake), _client_factory=lambda: cast(mqtt.Client, fake),
) )
@ -109,6 +117,7 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -
assert fake.connect_calls == [("192.168.1.50", 1883, 30)] assert fake.connect_calls == [("192.168.1.50", 1883, 30)]
assert fake.subscribe_calls == [[(topic, 0) for topic in REPORT_TOPICS]] assert fake.subscribe_calls == [[(topic, 0) for topic in REPORT_TOPICS]]
assert fake.disconnect_count == 1 assert fake.disconnect_count == 1
assert clock_ready == [True]
assert summary["stop_reason"] == "keyboard_interrupt" assert summary["stop_reason"] == "keyboard_interrupt"
assert summary["message_count"] == 1 assert summary["message_count"] == 1
assert summary["payload_bytes"] == len(fake.payload) assert summary["payload_bytes"] == len(fake.payload)
@ -151,8 +160,26 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -
saved_summary = json.loads((capture_dir / "mqtt.summary.json").read_text()) saved_summary = json.loads((capture_dir / "mqtt.summary.json").read_text())
assert saved_summary == summary assert saved_summary == summary
assert saved_summary["schema_version"] == 2
assert saved_summary["artifact_hashes"]["raw_sha256"] == hashlib.sha256(raw).hexdigest() assert saved_summary["artifact_hashes"]["raw_sha256"] == hashlib.sha256(raw).hexdigest()
for artifact_name in ("mqtt.raw.k1mqtt", "mqtt.metadata.jsonl", "mqtt.summary.json"): clock = read_capture_clock_envelope(
capture_dir / "mqtt.timeline.json",
expected_sha256=saved_summary["artifact_hashes"]["capture_clock_sha256"],
)
assert clock.started_monotonic_ns <= observed[0].received_monotonic_ns
assert observed[0].received_monotonic_ns <= clock.completed_monotonic_ns
assert clock.duration_ns > 0
assert saved_summary["session_elapsed_seconds"] == pytest.approx(
clock.duration_ns / 1_000_000_000
)
assert saved_summary["artifacts"]["capture_clock"] == "mqtt.timeline.json"
for artifact_name in (
"mqtt.raw.k1mqtt",
"mqtt.metadata.jsonl",
"mqtt.timeline.origin.json",
"mqtt.timeline.json",
"mqtt.summary.json",
):
assert stat.S_IMODE((capture_dir / artifact_name).stat().st_mode) == 0o600 assert stat.S_IMODE((capture_dir / artifact_name).stat().st_mode) == 0o600
@ -216,6 +243,65 @@ def test_capture_can_be_stopped_by_owner_without_losing_artifacts(tmp_path: Path
assert list(iter_capture_frames(tmp_path / "capture" / "mqtt.raw.k1mqtt"))[0].payload assert list(iter_capture_frames(tmp_path / "capture" / "mqtt.raw.k1mqtt"))[0].payload
def test_owner_seals_session_clock_after_all_producers_stop(tmp_path: Path) -> None:
capture_dir = tmp_path / "capture"
summary = capture_mqtt(
"192.168.1.50",
capture_dir,
duration_seconds=30,
_client_factory=lambda: cast(mqtt.Client, FakeClient()),
)
provisional = read_capture_clock_envelope(capture_dir / "mqtt.timeline.json")
assert summary["capture_clock_scope"] == "transport"
sealed = seal_capture_clock(capture_dir)
updated = json.loads((capture_dir / "mqtt.summary.json").read_text(encoding="utf-8"))
assert updated["capture_clock_scope"] == "session"
sealed_path = capture_dir / updated["artifacts"]["capture_clock"]
assert sealed_path.name == f"mqtt.timeline.session-{sealed.artifact_sha256}.json"
assert (capture_dir / "mqtt.timeline.json").read_bytes()
assert sealed.started_monotonic_ns == provisional.started_monotonic_ns
assert sealed.completed_monotonic_ns >= provisional.completed_monotonic_ns
assert updated["artifact_hashes"]["capture_clock_sha256"] == sealed.artifact_sha256
assert updated["session_elapsed_seconds"] == pytest.approx(sealed.duration_ns / 1_000_000_000)
repeated = seal_capture_clock(capture_dir)
repeated_summary = json.loads((capture_dir / "mqtt.summary.json").read_text(encoding="utf-8"))
assert repeated == sealed
assert repeated_summary == updated
def test_owner_seal_is_retryable_if_summary_pointer_switch_fails(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture_dir = tmp_path / "capture"
capture_mqtt(
"192.168.1.50",
capture_dir,
duration_seconds=30,
_client_factory=lambda: cast(mqtt.Client, FakeClient()),
)
provisional_summary = (capture_dir / "mqtt.summary.json").read_bytes()
with monkeypatch.context() as context:
context.setattr(
capture_module,
"_write_json_atomic_replace",
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("injected switch failure")),
)
with pytest.raises(CaptureError, match="injected switch failure"):
seal_capture_clock(capture_dir)
assert (capture_dir / "mqtt.summary.json").read_bytes() == provisional_summary
assert list(capture_dir.glob("mqtt.timeline.session-*.json"))
sealed = seal_capture_clock(capture_dir)
summary = json.loads((capture_dir / "mqtt.summary.json").read_text(encoding="utf-8"))
assert summary["capture_clock_scope"] == "session"
assert summary["artifact_hashes"]["capture_clock_sha256"] == sealed.artifact_sha256
def test_preview_failure_happens_after_raw_message_is_preserved(tmp_path: Path) -> None: def test_preview_failure_happens_after_raw_message_is_preserved(tmp_path: Path) -> None:
fake = FakeClient() fake = FakeClient()
capture_dir = tmp_path / "capture" capture_dir = tmp_path / "capture"

View File

@ -137,6 +137,8 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
}, },
{"id": "spatial.point-cloud.live", "label": "Облако точек"}, {"id": "spatial.point-cloud.live", "label": "Облако точек"},
{"id": "spatial.pose.live", "label": "Траектория"}, {"id": "spatial.pose.live", "label": "Траектория"},
{"id": "device.modeling.live", "label": "Метрики маршрута"},
{"id": "device.status.live", "label": "Состояние сканирования"},
{"id": "camera.preview.live", "label": "Видеокамеры"}, {"id": "camera.preview.live", "label": "Видеокамеры"},
{"id": "evidence.raw-capture", "label": "Исходная запись"}, {"id": "evidence.raw-capture", "label": "Исходная запись"},
{"id": "evidence.replay", "label": "Повтор записи"}, {"id": "evidence.replay", "label": "Повтор записи"},

View File

@ -69,11 +69,14 @@ class FakeXgridsService:
def start_live( def start_live(
self, self,
project_name: str,
host: str | None, host: str | None,
duration_seconds: float, duration_seconds: float,
compatibility_attestation: CompatibilityAttestationRequest, compatibility_attestation: CompatibilityAttestationRequest,
) -> dict[str, Any]: ) -> dict[str, Any]:
self.calls.append(("live", (host, duration_seconds, compatibility_attestation))) self.calls.append(
("live", (project_name, host, duration_seconds, compatibility_attestation))
)
return {"phase": "live"} return {"phase": "live"}
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]: def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
@ -344,9 +347,7 @@ def test_environment_shutdown_attempts_every_plugin_after_close_failure(
def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None: def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
service = FakeXgridsService() service = FakeXgridsService()
dispatcher = DevicePluginDispatcher( dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
[_in_process_runtime(XgridsK1PluginFacade(service))]
)
state = asyncio.run( state = asyncio.run(
dispatcher.invoke( dispatcher.invoke(
@ -373,9 +374,7 @@ def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
def test_facade_validates_payload_before_calling_service() -> None: def test_facade_validates_payload_before_calling_service() -> None:
service = FakeXgridsService() service = FakeXgridsService()
dispatcher = DevicePluginDispatcher( dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
[_in_process_runtime(XgridsK1PluginFacade(service))]
)
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
asyncio.run( asyncio.run(
@ -400,6 +399,7 @@ def test_facade_validates_payload_before_calling_service() -> None:
( (
ACTION_STREAM_START_LIVE, ACTION_STREAM_START_LIVE,
{ {
"project_name": "Plugin runtime test",
"host": "192.168.1.20", "host": "192.168.1.20",
"compatibility_attestation": { "compatibility_attestation": {
"firmware_version": "3.0.2", "firmware_version": "3.0.2",
@ -437,9 +437,7 @@ def test_facade_preserves_existing_runtime_operations(
expected_call: str, expected_call: str,
) -> None: ) -> None:
service = FakeXgridsService() service = FakeXgridsService()
dispatcher = DevicePluginDispatcher( dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
[_in_process_runtime(XgridsK1PluginFacade(service))]
)
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, action_id, payload)) asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, action_id, payload))
@ -455,9 +453,7 @@ def test_sync_runtime_actions_run_outside_the_api_event_loop() -> None:
return super().stop() return super().stop()
service = ThreadAwareService() service = ThreadAwareService()
dispatcher = DevicePluginDispatcher( dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))])
[_in_process_runtime(XgridsK1PluginFacade(service))]
)
event_loop_thread = threading.get_ident() event_loop_thread = threading.get_ident()
asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {})) asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {}))

View File

@ -389,6 +389,7 @@ def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) ->
runtime.start_replay(capture, speed=0.0) runtime.start_replay(capture, speed=0.0)
assert factory_entered.wait(timeout=2.0) assert factory_entered.wait(timeout=2.0)
with pytest.raises(RuntimeError, match="evidence lease сохранены"):
runtime.close(wait_seconds=0.01) runtime.close(wait_seconds=0.01)
release_factory.set() release_factory.set()
deadline = time.monotonic() + 2.0 deadline = time.monotonic() + 2.0

View File

@ -11,8 +11,13 @@ from pathlib import Path
import pytest import pytest
import k1link.device_plugins.xgrids_k1.rrd_export as export_module import k1link.device_plugins.xgrids_k1.rrd_export as export_module
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC from k1link.device_plugins.xgrids_k1.mqtt.capture import (
CAPTURE_CLOCK_FILENAME,
FRAME_HEADER,
RAW_MAGIC,
)
from k1link.device_plugins.xgrids_k1.rrd_export import ( from k1link.device_plugins.xgrids_k1.rrd_export import (
RECORDED_METRICS_VIEW_ID,
RECORDED_POINTS_VISUALIZER_ID, RECORDED_POINTS_VISUALIZER_ID,
RECORDED_ROOT_CONTAINER_ID, RECORDED_ROOT_CONTAINER_ID,
RECORDED_SPATIAL_VIEW_ID, RECORDED_SPATIAL_VIEW_ID,
@ -43,9 +48,41 @@ def _pose_payload(x: float) -> bytes:
return struct.pack("<ffffffff", x, 2.0, 3.0, 99.0, 1.0, 0.0, 0.0, 0.0) return struct.pack("<ffffffff", x, 2.0, 3.0, 99.0, 1.0, 0.0, 0.0, 0.0)
def _varint(value: int) -> bytes:
encoded = bytearray()
while value > 0x7F:
encoded.append((value & 0x7F) | 0x80)
value >>= 7
encoded.append(value)
return bytes(encoded)
def _proto_key(number: int, wire_type: int) -> bytes:
return _varint((number << 3) | wire_type)
def _proto_uint(number: int, value: int) -> bytes:
return _proto_key(number, 0) + _varint(value)
def _proto_bytes(number: int, value: bytes) -> bytes:
return _proto_key(number, 2) + _varint(len(value)) + value
def _proto_float32(number: int, value: float) -> bytes:
return _proto_key(number, 5) + struct.pack("<f", value)
def _modeling_payload(*, distance: float, speed: float, scan_ticks: int) -> bytes:
status = _proto_float32(1, distance) + _proto_float32(2, speed) + _proto_uint(3, scan_ticks)
return _proto_uint(2, 0) + _proto_bytes(3, status)
def _write_capture( def _write_capture(
directory: Path, directory: Path,
frames: list[tuple[str, bytes, int]], frames: list[tuple[str, bytes, int]],
*,
capture_clock_offsets_ns: tuple[int, int] | None = None,
) -> Path: ) -> Path:
capture = directory / "mqtt.raw.k1mqtt" capture = directory / "mqtt.raw.k1mqtt"
raw = bytearray(RAW_MAGIC) raw = bytearray(RAW_MAGIC)
@ -70,6 +107,34 @@ def _write_capture(
"".join(json.dumps(item) + "\n" for item in metadata), "".join(json.dumps(item) + "\n" for item in metadata),
encoding="utf-8", encoding="utf-8",
) )
if capture_clock_offsets_ns is not None:
started_offset_ns, completed_offset_ns = capture_clock_offsets_ns
(directory / "mqtt.timeline.origin.json").write_text(
json.dumps(
{
"schema_version": 1,
"started_at_epoch_ns": epoch_origin_ns + started_offset_ns,
"started_monotonic_ns": monotonic_origin_ns + started_offset_ns,
},
separators=(",", ":"),
)
+ "\n",
encoding="utf-8",
)
(directory / CAPTURE_CLOCK_FILENAME).write_text(
json.dumps(
{
"schema_version": 1,
"started_at_epoch_ns": epoch_origin_ns + started_offset_ns,
"started_monotonic_ns": monotonic_origin_ns + started_offset_ns,
"completed_at_epoch_ns": epoch_origin_ns + completed_offset_ns,
"completed_monotonic_ns": monotonic_origin_ns + completed_offset_ns,
},
separators=(",", ":"),
)
+ "\n",
encoding="utf-8",
)
return capture return capture
@ -139,9 +204,7 @@ def test_recorded_blueprint_accumulates_point_frames_on_session_timeline() -> No
def test_zero_accumulation_uses_latest_frame_instead_of_empty_time_range() -> None: def test_zero_accumulation_uses_latest_frame_instead_of_empty_time_range() -> None:
blueprint = _recorded_blueprint( blueprint = _recorded_blueprint(RerunSceneSettings(accumulation_seconds=0.0, show_grid=True))
RerunSceneSettings(accumulation_seconds=0.0, show_grid=True)
)
spatial_view = blueprint.root_container.contents[0] spatial_view = blueprint.root_container.contents[0]
assert "VisibleTimeRanges" not in spatial_view.properties assert "VisibleTimeRanges" not in spatial_view.properties
@ -149,9 +212,7 @@ def test_zero_accumulation_uses_latest_frame_instead_of_empty_time_range() -> No
trajectory_behavior = spatial_view.visualizer_overrides["/world/trajectory"] trajectory_behavior = spatial_view.visualizer_overrides["/world/trajectory"]
assert point_behavior.visible.as_arrow_array().to_pylist() == [True] assert point_behavior.visible.as_arrow_array().to_pylist() == [True]
assert trajectory_behavior.visible.as_arrow_array().to_pylist() == [True] assert trajectory_behavior.visible.as_arrow_array().to_pylist() == [True]
point_components = { point_components = {str(batch.component_descriptor()) for batch in point_visualizer.overrides}
str(batch.component_descriptor()) for batch in point_visualizer.overrides
}
assert point_components == {"Points3D:radii"} assert point_components == {"Points3D:radii"}
@ -171,10 +232,10 @@ def test_dynamic_blueprint_reuses_scene_ids_without_playback_mutation() -> None:
first_view = first.root_container.contents[0] first_view = first.root_container.contents[0]
second_view = second.root_container.contents[0] second_view = second.root_container.contents[0]
assert first_view.id == second_view.id == RECORDED_SPATIAL_VIEW_ID assert first_view.id == second_view.id == RECORDED_SPATIAL_VIEW_ID
assert first.root_container.contents[1].id == RECORDED_METRICS_VIEW_ID
assert second.root_container.contents[1].id == RECORDED_METRICS_VIEW_ID
first_point_behavior, first_point_visualizer = first_view.visualizer_overrides[ first_point_behavior, first_point_visualizer = first_view.visualizer_overrides["/world/points"]
"/world/points"
]
second_point_behavior, second_point_visualizer = second_view.visualizer_overrides[ second_point_behavior, second_point_visualizer = second_view.visualizer_overrides[
"/world/points" "/world/points"
] ]
@ -209,6 +270,7 @@ def test_export_preserves_every_decodable_frame_and_source_timeline(tmp_path: Pa
("RealtimePath", _pose_payload(1.2), 1_200_000_000), ("RealtimePath", _pose_payload(1.2), 1_200_000_000),
("lixel/application/report/heartbeat", b"opaque-tail", 9_500_000_000), ("lixel/application/report/heartbeat", b"opaque-tail", 9_500_000_000),
], ],
capture_clock_offsets_ns=(-500_000_000, 10_000_000_000),
) )
output = tmp_path / "session.rrd" output = tmp_path / "session.rrd"
@ -222,13 +284,13 @@ def test_export_preserves_every_decodable_frame_and_source_timeline(tmp_path: Pa
assert summary["points"] == 2 assert summary["points"] == 2
assert summary["trajectory_poses"] == 2 assert summary["trajectory_poses"] == 2
assert summary["trajectory_updates"] == 2 assert summary["trajectory_updates"] == 2
assert summary["session_origin_monotonic_ns"] == 9_000_000_000 assert summary["session_origin_monotonic_ns"] == 8_500_000_000
assert summary["timeline"] == "session_time" assert summary["timeline"] == "session_time"
assert summary["timeline_start_ns"] == 0 assert summary["timeline_start_ns"] == 0
assert summary["timeline_end_ns"] == 1_200_000_000 assert summary["timeline_end_ns"] == 10_500_000_000
assert summary["timeline_span_ns"] == 1_200_000_000 assert summary["timeline_span_ns"] == 10_500_000_000
assert summary["first_decoded_time_ns"] == 100_000_000 assert summary["first_decoded_time_ns"] == 600_000_000
assert summary["last_decoded_time_ns"] == 1_200_000_000 assert summary["last_decoded_time_ns"] == 1_700_000_000
assert summary["source_sha256"] == _sha256(capture) assert summary["source_sha256"] == _sha256(capture)
assert summary["rrd_bytes"] == output.stat().st_size assert summary["rrd_bytes"] == output.stat().st_size
assert summary["rrd_sha256"] == _sha256(output) assert summary["rrd_sha256"] == _sha256(output)
@ -240,6 +302,99 @@ def test_export_preserves_every_decodable_frame_and_source_timeline(tmp_path: Pa
assert "session_time" in origin_table assert "session_time" in origin_table
assert "P0D" in origin_table assert "P0D" in origin_table
assert "[true]" in origin_table assert "[true]" in origin_table
end_table = _print_rrd_entity(output, "/__mission_core/session_end")
assert "/__mission_core/session_end" in end_table
assert "PT10.5S" in end_table
def test_export_records_device_route_time_series_on_shared_timeline(tmp_path: Path) -> None:
capture = _write_capture(
tmp_path,
[
("RealtimePointcloud", _point_payload(1.0), 0),
(
"lixel/application/report/modeling",
_modeling_payload(distance=2.25, speed=0.75, scan_ticks=20),
500_000_000,
),
(
"lixel/application/report/modeling",
_modeling_payload(distance=3.5, speed=1.25, scan_ticks=22),
1_000_000_000,
),
],
)
output = tmp_path / "session.rrd"
summary = export_k1mqtt_to_rrd(capture, output)
assert summary["modeling_reports"] == 2
assert summary["decoded_messages"] == 1
assert summary["ignored_messages"] == 0
assert summary["timeline_end_ns"] == 1_000_000_000
distance_table = _print_rrd_entity(output, "/metrics/device/route_distance_meters")
elapsed_table = _print_rrd_entity(output, "/metrics/device/scan_elapsed_seconds")
assert "2.25" in distance_table and "3.5" in distance_table
assert "10.0" in elapsed_table and "11.0" in elapsed_table
def test_export_uses_explicit_catalog_bound_sealed_clock(tmp_path: Path) -> None:
capture = _write_capture(
tmp_path,
[("RealtimePointcloud", _point_payload(1.0), 500_000_000)],
)
origin = {
"schema_version": 1,
"started_at_epoch_ns": 1_784_124_314_000_000_000,
"started_monotonic_ns": 8_000_000_000,
}
origin_path = tmp_path / "mqtt.timeline.origin.json"
origin_path.write_text(json.dumps(origin, separators=(",", ":")) + "\n")
provisional = {
**origin,
"completed_at_epoch_ns": 1_784_124_316_000_000_000,
"completed_monotonic_ns": 10_000_000_000,
}
(tmp_path / "mqtt.timeline.json").write_text(
json.dumps(provisional, separators=(",", ":")) + "\n"
)
sealed = {
**origin,
"completed_at_epoch_ns": 1_784_124_321_000_000_000,
"completed_monotonic_ns": 15_000_000_000,
}
sealed_payload = (json.dumps(sealed, separators=(",", ":")) + "\n").encode()
sealed_digest = hashlib.sha256(sealed_payload).hexdigest()
sealed_path = tmp_path / f"mqtt.timeline.session-{sealed_digest}.json"
sealed_path.write_bytes(sealed_payload)
output = tmp_path / "session.rrd"
summary = export_k1mqtt_to_rrd(
capture,
output,
capture_clock_path=sealed_path,
capture_clock_origin_path=origin_path,
)
assert summary["session_origin_monotonic_ns"] == 8_000_000_000
assert summary["timeline_end_ns"] == 7_000_000_000
def test_export_rejects_malformed_known_modeling_report(tmp_path: Path) -> None:
capture = _write_capture(
tmp_path,
[
("RealtimePointcloud", _point_payload(1.0), 0),
("lixel/application/report/modeling", b"malformed", 1_000_000),
],
)
output = tmp_path / "session.rrd"
output.write_bytes(b"trusted-existing-recording")
with pytest.raises(RrdExportError, match="modeling report"):
export_k1mqtt_to_rrd(capture, output)
assert output.read_bytes() == b"trusted-existing-recording"
def test_atomic_rename_failure_preserves_existing_recording( def test_atomic_rename_failure_preserves_existing_recording(

View File

@ -103,6 +103,7 @@ def make_recorded_h264_fixture(
*, *,
timescale: int = 1_000, timescale: int = 1_000,
sample_duration: int = 500, sample_duration: int = 500,
base_decode_time: int = 0,
) -> tuple[bytes, bytes]: ) -> tuple[bytes, bytes]:
def box(box_type: bytes, payload: bytes = b"") -> bytes: def box(box_type: bytes, payload: bytes = b"") -> bytes:
return (8 + len(payload)).to_bytes(4, "big") + box_type + payload return (8 + len(payload)).to_bytes(4, "big") + box_type + payload
@ -137,8 +138,9 @@ def make_recorded_h264_fixture(
init = box(b"ftyp", b"isom") + box(b"moov", trak + box(b"mvex", trex) + avcc) init = box(b"ftyp", b"isom") + box(b"moov", trak + box(b"mvex", trex) + avcc)
tfhd = full_box(b"tfhd", track_id.to_bytes(4, "big"), flags=0x020000) tfhd = full_box(b"tfhd", track_id.to_bytes(4, "big"), flags=0x020000)
tfdt = full_box(b"tfdt", base_decode_time.to_bytes(4, "big"))
trun = full_box(b"trun", (1).to_bytes(4, "big")) trun = full_box(b"trun", (1).to_bytes(4, "big"))
fragment = box(b"moof", box(b"traf", tfhd + trun)) + box(b"mdat", b"frame") fragment = box(b"moof", box(b"traf", tfhd + tfdt + trun)) + box(b"mdat", b"frame")
return init, fragment return init, fragment
@ -543,8 +545,7 @@ def test_cold_replay_returns_quick_202_then_status_returns_ready_launch(
render_file_response(viewer_response, range_header=None) render_file_response(viewer_response, range_header=None)
) )
viewer_headers = { viewer_headers = {
key.decode("latin-1"): value.decode("latin-1") key.decode("latin-1"): value.decode("latin-1") for key, value in viewer_start["headers"]
for key, value in viewer_start["headers"]
} }
assert viewer_start["status"] == 200 assert viewer_start["status"] == 200
assert viewer_body == payload assert viewer_body == payload
@ -880,6 +881,7 @@ def test_production_router_never_materializes_recording_inline(tmp_path: Path) -
assert recording_error.value.status_code == 503 assert recording_error.value.status_code == 503
assert calls == 0 assert calls == 0
def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped( def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@ -1065,9 +1067,7 @@ def test_session_router_exposes_opaque_recorded_media_manifest_and_ranges(
assert manifest["byte_length"] == source["byte_length"] assert manifest["byte_length"] == source["byte_length"]
assert manifest["timeline_start_seconds"] == source["timeline_start_seconds"] assert manifest["timeline_start_seconds"] == source["timeline_start_seconds"]
assert manifest["timeline_end_seconds"] == source["timeline_end_seconds"] assert manifest["timeline_end_seconds"] == source["timeline_end_seconds"]
assert manifest_response.headers["etag"] == ( assert manifest_response.headers["etag"] == (f'"sha256:{manifest["generation_sha256"]}"')
f'"sha256:{manifest["generation_sha256"]}"'
)
init_sha256 = hashlib.sha256(init).hexdigest() init_sha256 = hashlib.sha256(init).hexdigest()
segment_sha256 = hashlib.sha256(segment).hexdigest() segment_sha256 = hashlib.sha256(segment).hexdigest()
assert manifest["epochs"] == [ assert manifest["epochs"] == [
@ -1143,9 +1143,7 @@ def test_session_router_exposes_opaque_recorded_media_manifest_and_ranges(
} }
assert segment_start["status"] == 206 assert segment_start["status"] == 206
assert segment_body == segment[:6] assert segment_body == segment[:6]
assert headers["cache-control"] == ( assert headers["cache-control"] == ("private, max-age=31536000, immutable, no-transform")
"private, max-age=31536000, immutable, no-transform"
)
assert headers["etag"] == f'"sha256:{segment_sha256}"' assert headers["etag"] == f'"sha256:{segment_sha256}"'
assert headers["content-length"] == "6" assert headers["content-length"] == "6"
assert headers["x-content-type-options"] == "nosniff" assert headers["x-content-type-options"] == "nosniff"
@ -1224,9 +1222,7 @@ def test_ready_launch_uses_background_prepared_camera_manifest_without_rescan(
manager.enqueue(store.prepare_replay(session.name)) manager.enqueue(store.prepare_replay(session.name))
deadline = time.monotonic() + 2 deadline = time.monotonic() + 2
snapshot = manager.status(session.name) snapshot = manager.status(session.name)
while ( while (snapshot is None or snapshot.state != "ready") and time.monotonic() < deadline:
snapshot is None or snapshot.state != "ready"
) and time.monotonic() < deadline:
time.sleep(0.005) time.sleep(0.005)
snapshot = manager.status(session.name) snapshot = manager.status(session.name)
assert snapshot is not None and snapshot.state == "ready" assert snapshot is not None and snapshot.state == "ready"
@ -1276,6 +1272,10 @@ def test_recorded_media_epoch_ends_are_monotonic_generation_bound_and_not_rrd_pa
sessions = repository / "sessions" sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live") session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
init, segment = make_recorded_h264_fixture(sample_duration=250) init, segment = make_recorded_h264_fixture(sample_duration=250)
_, following_segment = make_recorded_h264_fixture(
sample_duration=250,
base_decode_time=250,
)
first = CameraArchiveWriter(session, "sensor.camera.private-left", 1) first = CameraArchiveWriter(session, "sensor.camera.private-left", 1)
first.append("init", init, host_epoch_ns=1_100_000_000, host_monotonic_ns=2_100_000_000) first.append("init", init, host_epoch_ns=1_100_000_000, host_monotonic_ns=2_100_000_000)
first.append( first.append(
@ -1295,7 +1295,7 @@ def test_recorded_media_epoch_ends_are_monotonic_generation_bound_and_not_rrd_pa
) )
second.append( second.append(
"media", "media",
segment, following_segment,
host_epoch_ns=2_000_000_000, host_epoch_ns=2_000_000_000,
host_monotonic_ns=3_000_000_000, host_monotonic_ns=3_000_000_000,
) )
@ -1393,6 +1393,42 @@ def test_recorded_media_rejects_non_monotonic_segments_and_overlapping_epochs(
) )
def test_recorded_media_rejects_discontinuous_tfdt_decode_timeline(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
init, first_segment = make_recorded_h264_fixture(sample_duration=500)
_, discontinuous_segment = make_recorded_h264_fixture(
sample_duration=500,
base_decode_time=1_000,
)
writer = CameraArchiveWriter(session, "sensor.camera.private-left", 1)
writer.append("init", init)
writer.append(
"media",
first_segment,
host_epoch_ns=1_250_000_000,
host_monotonic_ns=2_250_000_000,
)
writer.append(
"media",
discontinuous_segment,
host_epoch_ns=1_750_000_000,
host_monotonic_ns=2_750_000_000,
)
writer.close()
store = SessionStore(repository, data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
with pytest.raises(SessionIntegrityError, match="decode timeline is discontinuous"):
RecordedMediaInspector().inspect(
store.list_recorded_media(session.name)[0],
store.prepare_replay(session.name),
)
def test_background_preparation_fails_closed_on_unparseable_recorded_media( def test_background_preparation_fails_closed_on_unparseable_recorded_media(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:

View File

@ -129,6 +129,40 @@ def test_materializer_reuses_only_a_digest_validated_private_cache(tmp_path: Pat
assert exporter.calls == 1 assert exporter.calls == 1
def test_materializer_rejects_changed_digest_bound_secondary_artifact(
tmp_path: Path,
) -> None:
command = _command(tmp_path / "source")
secondary = command.artifacts[1]
expected = _sha256(secondary.path)
command = replace(
command,
artifacts=(
command.artifacts[0],
replace(secondary, expected_sha256=expected),
),
)
secondary.path.write_text('{"record_type":"tampered","sequence":1}\n', encoding="utf-8")
command = replace(
command,
artifacts=(
command.artifacts[0],
replace(
command.artifacts[1],
file_byte_length=secondary.path.stat().st_size,
replay_byte_length=secondary.path.stat().st_size,
),
),
)
exporter = FakeExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
with pytest.raises(RecordingMaterializationError, match="digest.*catalog"):
materializer.materialize(command)
assert exporter.calls == 0
@pytest.mark.parametrize( @pytest.mark.parametrize(
"obsolete_schema", "obsolete_schema",
[ [
@ -286,13 +320,25 @@ def test_materializer_exports_only_validated_prefix_before_crash_tail(
metadata = command.primary_artifact.path.with_name("mqtt.metadata.jsonl") metadata = command.primary_artifact.path.with_name("mqtt.metadata.jsonl")
with metadata.open("ab") as stream: with metadata.open("ab") as stream:
stream.write(b'{"record_type":"message"') stream.write(b'{"record_type":"message"')
observed: list[tuple[int, int]] = [] observed: list[tuple[int, int, set[str]]] = []
class PrefixExporter(FakeExporter): class PrefixExporter(FakeExporter):
def __call__(self, source: Path, destination: Path) -> dict[str, object]: def __call__(
self,
source: Path,
destination: Path,
*,
artifacts: dict[str, Path],
) -> dict[str, object]:
observed.append( observed.append(
(source.stat().st_size, source.with_name("mqtt.metadata.jsonl").stat().st_size) (
source.stat().st_size,
artifacts["index"].stat().st_size,
set(artifacts),
) )
)
assert artifacts["primary"] == source
assert all(path.parent == source.parent for path in artifacts.values())
return super().__call__(source, destination) return super().__call__(source, destination)
exporter = PrefixExporter() exporter = PrefixExporter()
@ -300,7 +346,7 @@ def test_materializer_exports_only_validated_prefix_before_crash_tail(
recording = materializer.materialize(command) recording = materializer.materialize(command)
assert observed == [(committed_raw_bytes, committed_metadata_bytes)] assert observed == [(committed_raw_bytes, committed_metadata_bytes, {"primary", "index"})]
assert recording.source_sha256 == hashlib.sha256(b"native-source-recording").hexdigest() assert recording.source_sha256 == hashlib.sha256(b"native-source-recording").hexdigest()
assert command.primary_artifact.path.read_bytes().endswith(b"uncommitted-raw-tail") assert command.primary_artifact.path.read_bytes().endswith(b"uncommitted-raw-tail")

View File

@ -100,14 +100,65 @@ def make_legacy_session(
return session return session
def add_capture_clock(session: Path, *, scope: str = "session") -> Path:
capture = session / "captures" / "mqtt_live"
origin_document = {
"schema_version": 1,
"started_at_epoch_ns": 1_784_235_391_699_000_000,
"started_monotonic_ns": 9_000_000_000,
}
origin_path = capture / "mqtt.timeline.origin.json"
origin_path.write_text(
json.dumps(origin_document, separators=(",", ":")) + "\n",
encoding="utf-8",
)
clock_document = {
**origin_document,
"completed_at_epoch_ns": 1_784_235_394_699_000_000,
"completed_monotonic_ns": 12_000_000_000,
}
clock_payload = (json.dumps(clock_document, separators=(",", ":")) + "\n").encode()
clock_digest = hashlib.sha256(clock_payload).hexdigest()
provisional_path = capture / "mqtt.timeline.json"
provisional_path.write_bytes(clock_payload)
clock_path = (
capture / f"mqtt.timeline.session-{clock_digest}.json"
if scope == "session"
else provisional_path
)
if clock_path != provisional_path:
clock_path.write_bytes(clock_payload)
summary_path = capture / "mqtt.summary.json"
summary = json.loads(summary_path.read_text(encoding="utf-8"))
summary["schema_version"] = 2
summary["capture_clock_scope"] = scope
summary["session_elapsed_seconds"] = 3.0
summary["artifacts"] = {
"raw": "mqtt.raw.k1mqtt",
"metadata_jsonl": "mqtt.metadata.jsonl",
"capture_clock_origin": origin_path.name,
"capture_clock": clock_path.name,
"summary": "mqtt.summary.json",
}
summary["artifact_hashes"]["capture_clock_origin_sha256"] = hashlib.sha256(
origin_path.read_bytes()
).hexdigest()
summary["artifact_hashes"]["capture_clock_sha256"] = hashlib.sha256(
clock_path.read_bytes()
).hexdigest()
summary_path.write_text(json.dumps(summary), encoding="utf-8")
return clock_path
def test_evidence_root_is_private_and_configurable( def test_evidence_root_is_private_and_configurable(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
repository = tmp_path / "repo" repository = tmp_path / "repo"
assert resolve_missioncore_evidence_dir(repository) == ( assert (
repository / ".runtime" / "mission-core" / "evidence" / "sessions" resolve_missioncore_evidence_dir(repository)
).resolve() == (repository / ".runtime" / "mission-core" / "evidence" / "sessions").resolve()
)
configured = tmp_path / "external-evidence" configured = tmp_path / "external-evidence"
monkeypatch.setenv("MISSIONCORE_EVIDENCE_DIR", str(configured)) monkeypatch.setenv("MISSIONCORE_EVIDENCE_DIR", str(configured))
@ -127,6 +178,35 @@ def test_catalog_reconciles_sessions_removed_from_one_evidence_root(tmp_path: Pa
assert store.list_recent().items == () assert store.list_recent().items == ()
def test_catalog_uses_valid_project_name_as_session_display_name(tmp_path: Path) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
manifest_path = session / "manifest.redacted.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["project_name"] = " Route Alpha "
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
assert store.list_recent().items[0].display_name == "K1 Route Alpha"
def test_catalog_rejects_non_utf8_project_display_name(tmp_path: Path) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
manifest_path = session / "manifest.redacted.json"
manifest_path.write_text(
json.dumps({"project_name": "unsafe-\ud800"}),
encoding="utf-8",
)
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
assert store.list_recent().items[0].display_name == session.name
def make_recorded_camera_source( def make_recorded_camera_source(
session: Path, session: Path,
source_id: str = "sensor.camera.left", source_id: str = "sensor.camera.left",
@ -191,8 +271,7 @@ def replace_summary_with_recovery_metadata(
} }
) )
metadata = b"".join( metadata = b"".join(
(json.dumps(record, separators=(",", ":")) + "\n").encode("utf-8") (json.dumps(record, separators=(",", ":")) + "\n").encode("utf-8") for record in records
for record in records
) )
if corrupt_trailing_line: if corrupt_trailing_line:
metadata += b'{"record_type":"message"' metadata += b'{"record_type":"message"'
@ -243,6 +322,109 @@ def test_store_uses_private_wal_database_and_idempotently_imports_legacy_session
assert store.database_path.stat().st_mode & 0o777 == 0o600 assert store.database_path.stat().st_mode & 0o777 == 0o600
def test_completed_capture_clock_is_a_digest_bound_replay_artifact(tmp_path: Path) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
clock_path = add_capture_clock(session)
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
assert store.get_session(session.name).summary.duration_seconds == 3.0
command = store.prepare_replay(session.name)
clock_artifact = next(
artifact for artifact in command.artifacts if artifact.artifact_id == "raw-transport-clock"
)
assert command.timeline_origin_monotonic_ns == 9_000_000_000
assert command.timeline_origin_epoch_ns == 1_784_235_391_699_000_000
assert clock_artifact.path == clock_path
assert clock_artifact.replay_byte_length == clock_path.stat().st_size
assert clock_artifact.expected_sha256 == hashlib.sha256(clock_path.read_bytes()).hexdigest()
def test_completed_capture_rejects_corrupt_declared_clock(tmp_path: Path) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
clock_path = add_capture_clock(session)
clock_path.write_text('{"schema_version":1,"tampered":true}\n', encoding="utf-8")
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
detail = store.get_session(session.name)
assert detail.summary.status == "failed"
assert detail.summary.replayable is False
def test_completed_capture_rejects_clock_filename_with_wrong_digest_suffix(
tmp_path: Path,
) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
clock_path = add_capture_clock(session)
wrong_path = clock_path.with_name(f"mqtt.timeline.session-{'0' * 64}.json")
wrong_path.write_bytes(clock_path.read_bytes())
summary_path = session / "captures" / "mqtt_live" / "mqtt.summary.json"
summary = json.loads(summary_path.read_text(encoding="utf-8"))
summary["artifacts"]["capture_clock"] = wrong_path.name
summary_path.write_text(json.dumps(summary), encoding="utf-8")
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
detail = store.get_session(session.name)
assert detail.summary.status == "failed"
assert detail.summary.replayable is False
def test_camera_session_does_not_advertise_provisional_transport_clock(
tmp_path: Path,
) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
add_capture_clock(session, scope="transport")
make_recorded_camera_source(session)
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
detail = store.get_session(session.name)
assert detail.summary.replayable is False
def test_interrupted_camera_session_uses_durable_origin_and_fails_closed(
tmp_path: Path,
) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
add_capture_clock(session, scope="transport")
replace_summary_with_recovery_metadata(session)
make_recorded_camera_source(session)
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
detail = store.get_session(session.name)
assert detail.summary.status == "interrupted"
assert detail.summary.replayable is False
def test_interrupted_raw_replay_preserves_durable_pre_message_origin(tmp_path: Path) -> None:
sessions = tmp_path / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
add_capture_clock(session, scope="transport")
replace_summary_with_recovery_metadata(session)
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.reconcile_archive(xgrids_k1_archive_source(sessions))
command = store.prepare_replay(session.name)
assert command.timeline_origin_monotonic_ns == 9_000_000_000
assert any(
artifact.artifact_id == "raw-transport-clock-origin" for artifact in command.artifacts
)
def test_reconcile_claims_pre_plugin_catalog_row_without_changing_session_identity( def test_reconcile_claims_pre_plugin_catalog_row_without_changing_session_identity(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
@ -260,8 +442,7 @@ def test_reconcile_claims_pre_plugin_catalog_row_without_changing_session_identi
(session.name,), (session.name,),
) )
connection.execute( connection.execute(
"UPDATE observation_session_artifacts SET replay_byte_length = 0 " "UPDATE observation_session_artifacts SET replay_byte_length = 0 WHERE session_id = ?",
"WHERE session_id = ?",
(session.name,), (session.name,),
) )
connection.commit() connection.commit()
@ -511,8 +692,7 @@ def test_interrupted_capture_replays_only_committed_prefix_before_partial_raw_ta
assert detail.summary.replayable is True assert detail.summary.replayable is True
assert command.primary_artifact.replay_byte_length == committed_bytes assert command.primary_artifact.replay_byte_length == committed_bytes
assert ( assert (
command.primary_artifact.replay_byte_length command.primary_artifact.replay_byte_length < command.primary_artifact.path.stat().st_size
< command.primary_artifact.path.stat().st_size
) )

View File

@ -2,10 +2,18 @@ from __future__ import annotations
import math import math
import struct import struct
from types import SimpleNamespace
import lz4.block import lz4.block
import pytest import pytest
from k1link.device_plugins.xgrids_k1.protocol.modeling import (
MODELING_REPORT_TOPIC,
ModelingReportDecodeError,
decode_modeling_report,
is_modeling_report_topic,
observe_modeling_report,
)
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import ( from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
ProtobufWireError, ProtobufWireError,
decode_zigzag64, decode_zigzag64,
@ -21,6 +29,7 @@ from k1link.device_plugins.xgrids_k1.protocol.streams import (
decode_lio_pose, decode_lio_pose,
decode_pre_path_array, decode_pre_path_array,
) )
from k1link.viewer.metrics import BridgeMetrics
def _varint(value: int) -> bytes: def _varint(value: int) -> bytes:
@ -136,6 +145,77 @@ def test_decode_lio_pose_rejects_nonfinite_float() -> None:
decode_lio_pose(payload) decode_lio_pose(payload)
def test_decode_modeling_report_device_telemetry() -> None:
scan_status = _fixed32(1, 41.521404) + _fixed32(2, 1.375) + _uint(3, 204)
payload = _bytes(1, _header()) + _uint(2, 73) + _bytes(3, scan_status)
telemetry = decode_modeling_report(payload)
assert telemetry.move_distance_meters == pytest.approx(41.521404)
assert telemetry.move_speed_meters_per_second == pytest.approx(1.375)
assert telemetry.scan_time_ticks == 204
assert telemetry.elapsed_seconds == pytest.approx(102.0)
assert telemetry.pgo_progress == 73
assert is_modeling_report_topic(MODELING_REPORT_TOPIC)
assert not is_modeling_report_topic(f"{MODELING_REPORT_TOPIC}/extra")
metrics = BridgeMetrics()
assert observe_modeling_report(
SimpleNamespace(topic=MODELING_REPORT_TOPIC, payload=payload),
metrics,
)
snapshot = metrics.snapshot()
assert snapshot["device_elapsed_seconds"] == pytest.approx(102.0)
assert snapshot["device_route_distance_meters"] == pytest.approx(41.521)
assert snapshot["device_speed_meters_per_second"] == pytest.approx(1.375)
assert snapshot["modeling_reports"] == 1
assert snapshot["modeling_decode_errors"] == 0
def test_decode_modeling_report_fails_closed_on_invalid_status() -> None:
with pytest.raises(ModelingReportDecodeError, match="no scan_status"):
decode_modeling_report(_uint(2, 1))
with pytest.raises(ModelingReportDecodeError, match="finite and nonnegative"):
decode_modeling_report(_bytes(3, _fixed32(1, math.nan)))
with pytest.raises(ModelingReportDecodeError, match="configured limit"):
decode_modeling_report(b"x" * 5, max_payload_bytes=4)
with pytest.raises(ModelingReportDecodeError, match="pgo_progress is duplicated"):
decode_modeling_report(_uint(2, 1) + _uint(2, 2) + _bytes(3, _uint(3, 2)))
with pytest.raises(ModelingReportDecodeError, match="scan_status is duplicated"):
decode_modeling_report(_bytes(3, b"") + _bytes(3, b""))
with pytest.raises(ModelingReportDecodeError, match="move_speed is duplicated"):
decode_modeling_report(_bytes(3, _fixed32(2, 1.0) + _fixed32(2, 2.0)))
with pytest.raises(ModelingReportDecodeError, match="signed range"):
decode_modeling_report(_uint(2, 0x8000_0000) + _bytes(3, _uint(3, 2)))
def test_modeling_metrics_follow_device_scan_generation_reset() -> None:
metrics = BridgeMetrics()
before_reset = _bytes(
3,
_fixed32(1, 2.071593) + _fixed32(2, 0.2) + _uint(3, 734),
)
after_reset = _bytes(
3,
_fixed32(1, 0.0) + _fixed32(2, 0.0) + _uint(3, 2),
)
observe_modeling_report(
SimpleNamespace(topic=MODELING_REPORT_TOPIC, payload=before_reset),
metrics,
)
observe_modeling_report(
SimpleNamespace(topic=MODELING_REPORT_TOPIC, payload=after_reset),
metrics,
)
snapshot = metrics.snapshot()
assert snapshot["device_elapsed_seconds"] == 1.0
assert snapshot["device_route_distance_meters"] == 0.0
assert snapshot["device_speed_meters_per_second"] == 0.0
assert snapshot["modeling_reports"] == 2
def test_decode_legacy_pointcloud() -> None: def test_decode_legacy_pointcloud() -> None:
envelope = struct.pack("<III", 16, 123, 456) envelope = struct.pack("<III", 16, 123, 456)
body = struct.pack("<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40) body = struct.pack("<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40)

File diff suppressed because it is too large Load Diff

View File

@ -46,6 +46,8 @@ def test_xgrids_compatibility_profile_loads_exact_firmware_and_sources() -> None
profile = LOADER.load_compatibility_profile() profile = LOADER.load_compatibility_profile()
assert profile["profile_id"] == "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1" assert profile["profile_id"] == "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
assert profile["scope"]["vendor"] == "XGRIDS"
assert profile["scope"]["model"] == "LixelKity K1"
assert profile["scope"]["firmware"] == {"match": "exact", "version": "3.0.2"} assert profile["scope"]["firmware"] == {"match": "exact", "version": "3.0.2"}
assert profile["scope"]["topology"] == "direct-lan" assert profile["scope"]["topology"] == "direct-lan"
assert LOADER.matches_target(profile, firmware="3.0.2", topology="direct-lan") assert LOADER.matches_target(profile, firmware="3.0.2", topology="direct-lan")
@ -67,9 +69,9 @@ def test_xgrids_compatibility_profile_keeps_evidence_levels_independent() -> Non
"physical_verified": True, "physical_verified": True,
"write_enabled": False, "write_enabled": False,
} }
raw_status = { decoded_status = {
"observed": True, "observed": True,
"decoded": False, "decoded": True,
"replay_verified": False, "replay_verified": False,
"physical_verified": True, "physical_verified": True,
"write_enabled": False, "write_enabled": False,
@ -84,9 +86,11 @@ def test_xgrids_compatibility_profile_keeps_evidence_levels_independent() -> Non
assert channels["spatial.point-cloud.live"]["evidence"] == verified_stream assert channels["spatial.point-cloud.live"]["evidence"] == verified_stream
assert channels["spatial.pose.live"]["evidence"] == verified_stream assert channels["spatial.pose.live"]["evidence"] == verified_stream
assert channels["device.status.live"]["evidence"] == raw_status assert channels["device.modeling.live"]["evidence"] == decoded_status
assert channels["device.heartbeat.live"]["evidence"] == raw_status assert channels["device.status.live"]["evidence"] == decoded_status
assert channels["device.status.live"]["semantic_payload"] is None assert channels["device.heartbeat.live"]["evidence"] == observed_raw
assert "ScanTime" in channels["device.modeling.live"]["semantic_payload"]
assert "modeling-state" in channels["device.status.live"]["semantic_payload"]
camera = channels["camera.preview.live"] camera = channels["camera.preview.live"]
assert camera["discovery_status"] == "observed" assert camera["discovery_status"] == "observed"
@ -151,8 +155,21 @@ def test_xgrids_compatibility_profile_maps_actions_without_enabling_writes() ->
assert mapping["evidence_kind"] == "owner-controlled-wire-observation" assert mapping["evidence_kind"] == "owner-controlled-wire-observation"
assert mapping["topic"] == "lixel/application/request/modeling" assert mapping["topic"] == "lixel/application/request/modeling"
assert mapping["qos"] == 2 assert mapping["qos"] == 2
assert mapping["retain"] is False
assert mapping["message_type"] == "ModelingRequest" assert mapping["message_type"] == "ModelingRequest"
assert mapping["action_field_value"] == action_code assert mapping["action_field_value"] == action_code
assert mapping["header_contract"]["session_id"] == "{device_id}:ModelingRequest"
assert mapping["success_result_code"] == 302_252_033
if action_id == "acquisition.start":
assert mapping["request_fields"] == {
"project_name": "required-operator-value",
"record_mode": 2,
"scan_mode": 1,
"mount_type": 0,
"pre_project_id": "omitted-in-retained-request",
}
else:
assert mapping["request_fields"] == {}
assert mapping["required_unresolved_context"] assert mapping["required_unresolved_context"]
assert mapping["evidence"]["observed"] is True assert mapping["evidence"]["observed"] is True
assert mapping["evidence"]["decoded"] is True assert mapping["evidence"]["decoded"] is True
@ -178,6 +195,45 @@ def test_xgrids_compatibility_profile_rejects_vendor_write_promotion() -> None:
LOADER.validate_compatibility_profile(modified) LOADER.validate_compatibility_profile(modified)
def test_xgrids_compatibility_profile_rejects_noncanonical_modeling_header() -> None:
profile = LOADER.load_compatibility_profile()
modified = copy.deepcopy(profile)
actions = _by_id(modified["acquisition_control"]["semantic_actions"])
actions["acquisition.start"]["vendor_request_mapping"]["header_contract"]["session_id"] = (
"caller-provided"
)
with pytest.raises(LOADER.CompatibilityProfileError, match="header contract"):
LOADER.validate_compatibility_profile(modified)
def test_xgrids_compatibility_profile_pins_vendor_and_inert_request_mapping() -> None:
profile = LOADER.load_compatibility_profile()
wrong_model = copy.deepcopy(profile)
wrong_model["scope"]["vendor"] = "OTHER"
with pytest.raises(LOADER.CompatibilityProfileError, match="vendor/model"):
LOADER.validate_compatibility_profile(wrong_model)
wrong_message = copy.deepcopy(profile)
wrong_actions = _by_id(wrong_message["acquisition_control"]["semantic_actions"])
wrong_actions["acquisition.start"]["vendor_request_mapping"]["message_type"] = "OtherRequest"
with pytest.raises(LOADER.CompatibilityProfileError, match="message type"):
LOADER.validate_compatibility_profile(wrong_message)
missing_write_gate = copy.deepcopy(profile)
missing_actions = _by_id(missing_write_gate["acquisition_control"]["semantic_actions"])
del missing_actions["acquisition.stop"]["vendor_request_mapping"]["write_enabled"]
with pytest.raises(LOADER.CompatibilityProfileError, match="explicitly"):
LOADER.validate_compatibility_profile(missing_write_gate)
wrong_telemetry = copy.deepcopy(profile)
wrong_channels = _by_id(wrong_telemetry["channels"])
wrong_channels["device.modeling.live"]["bounds"]["max_mqtt_payload_bytes"] = 1
with pytest.raises(LOADER.CompatibilityProfileError, match="telemetry channel"):
LOADER.validate_compatibility_profile(wrong_telemetry)
def test_xgrids_compatibility_profile_rejects_claimed_camera_endpoint() -> None: def test_xgrids_compatibility_profile_rejects_claimed_camera_endpoint() -> None:
profile = LOADER.load_compatibility_profile() profile = LOADER.load_compatibility_profile()
modified = copy.deepcopy(profile) modified = copy.deepcopy(profile)

View File

@ -0,0 +1,327 @@
from __future__ import annotations
from dataclasses import replace
import pytest
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
MODELING_STATE_BASE,
OPENAPI_SUCCESS,
CommandHeaderIdentity,
ModelingAction,
ModelingCommandRejected,
ModelingEncodeError,
ModelingProtocolError,
ModelingResponseCorrelationError,
MountType,
ObservedHeader,
RecordMode,
ScanMode,
SessionState,
correlate_modeling_response,
decode_device_status_report,
decode_modeling_response,
encode_modeling_start,
encode_modeling_stop,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_state import (
AcquisitionPhase,
DeviceAcquisitionStateMachine,
SaveEvidence,
)
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import iter_fields
def _varint(value: int) -> bytes:
encoded = bytearray()
while value > 0x7F:
encoded.append((value & 0x7F) | 0x80)
value >>= 7
encoded.append(value)
return bytes(encoded)
def _uint(number: int, value: int) -> bytes:
return _varint(number << 3) + _varint(value)
def _bytes(number: int, value: bytes) -> bytes:
return _varint((number << 3) | 2) + _varint(len(value)) + value
def _text(number: int, value: str) -> bytes:
return _bytes(number, value.encode())
def _identity(**changes: str) -> CommandHeaderIdentity:
values = {
"device_id": "synthetic-device",
"openapi_key": "synthetic-explicit-key",
}
values.update(changes)
return CommandHeaderIdentity(**values)
def _header(
identity: CommandHeaderIdentity,
*,
session_id: str | None = None,
) -> bytes:
return b"".join(
(
_text(4, identity.device_id),
_text(5, identity.session_id if session_id is None else session_id),
_text(6, identity.openapi_key),
)
)
def _response(
identity: CommandHeaderIdentity,
action: ModelingAction,
*,
code: int = OPENAPI_SUCCESS,
description: str = "description-is-not-the-success-gate",
session_id: str | None = None,
) -> bytes:
error = _uint(1, code) + _text(2, description)
return (
_bytes(1, _header(identity, session_id=session_id)) + _uint(2, action) + _bytes(15, error)
)
def _device_status(
state: SessionState | None,
*,
raw_code: int | None = None,
init_ready: bool = False,
project_id: str | None = None,
) -> bytes:
if raw_code is None:
assert state is not None
raw_code = MODELING_STATE_BASE + state
fields = [_uint(2, raw_code)]
if project_id is not None:
fields.append(_text(4, project_id))
if init_ready:
fields.append(_uint(6, 1))
return b"".join(fields)
def test_command_identity_is_explicit_bounded_and_redacted() -> None:
identity = _identity()
assert "synthetic" not in repr(identity)
assert identity.session_id == "synthetic-device:ModelingRequest"
for field_name in ("device_id", "openapi_key"):
with pytest.raises(ModelingEncodeError, match="non-empty"):
_identity(**{field_name: ""})
with pytest.raises(ModelingEncodeError, match="printable ASCII"):
_identity(device_id="не-ascii")
with pytest.raises(ModelingEncodeError, match="without spaces"):
_identity(openapi_key="unsafe key")
assert not identity.matches(
ObservedHeader(
device_id="не-ascii",
session_id=identity.session_id,
openapi_key=identity.openapi_key,
)
)
def test_start_encoder_matches_recovered_field_shape() -> None:
identity = _identity()
command = encode_modeling_start(
identity,
project_name="synthetic-project",
record_mode=RecordMode.RECORD_AND_CALCULATE,
scan_mode=ScanMode.LCC,
mount_type=MountType.HANDHELD,
)
fields = list(iter_fields(command.payload, max_fields=16))
assert command.action is ModelingAction.START
# HANDHELD is proto3 zero and is therefore canonically absent.
assert [field.number for field in fields] == [1, 2, 3, 4, 5]
assert fields[1].value == ModelingAction.START
assert fields[2].value == b"synthetic-project"
assert fields[3].value == RecordMode.RECORD_AND_CALCULATE
assert fields[4].value == ScanMode.LCC
header_fields = list(iter_fields(fields[0].value, max_fields=8)) # type: ignore[arg-type]
assert [field.number for field in header_fields] == [4, 5, 6]
assert [field.value for field in header_fields] == [
b"synthetic-device",
b"synthetic-device:ModelingRequest",
b"synthetic-explicit-key",
]
assert "synthetic" not in repr(command)
def test_start_requires_explicit_enums_and_encodes_optional_pre_project() -> None:
identity = _identity()
with pytest.raises(ModelingEncodeError, match="explicit RecordMode"):
encode_modeling_start(
identity,
project_name="project",
record_mode=2, # type: ignore[arg-type]
scan_mode=ScanMode.LCC,
mount_type=MountType.HANDHELD,
)
command = encode_modeling_start(
identity,
project_name="project",
record_mode=RecordMode.CALCULATE_ONLY,
scan_mode=ScanMode.POINT_CLOUD,
mount_type=MountType.UAV,
pre_project_id="synthetic-pre-project",
)
fields = list(iter_fields(command.payload, max_fields=16))
assert [field.number for field in fields] == [1, 2, 3, 6, 7]
assert fields[-2].value == MountType.UAV
assert fields[-1].value == b"synthetic-pre-project"
def test_stop_encoder_contains_only_header_and_action() -> None:
command = encode_modeling_stop(_identity())
fields = list(iter_fields(command.payload, max_fields=8))
assert command.action is ModelingAction.STOP
assert [field.number for field in fields] == [1, 2]
assert fields[1].value == ModelingAction.STOP
@pytest.mark.parametrize("field_name", ["device_id", "openapi_key"])
def test_response_correlation_requires_every_exact_identity(field_name: str) -> None:
identity = _identity()
expected = encode_modeling_stop(identity)
mismatched = replace(identity, **{field_name: f"different-{field_name}"})
with pytest.raises(ModelingResponseCorrelationError, match="identity mismatch"):
correlate_modeling_response(_response(mismatched, ModelingAction.STOP), expected)
def test_response_correlation_rejects_noncanonical_modeling_session() -> None:
identity = _identity()
expected = encode_modeling_stop(identity)
with pytest.raises(ModelingResponseCorrelationError, match="identity mismatch"):
correlate_modeling_response(
_response(
identity,
ModelingAction.STOP,
session_id="synthetic-device:DifferentRequest",
),
expected,
)
with pytest.raises(ModelingProtocolError, match="printable ASCII"):
correlate_modeling_response(
_response(identity, ModelingAction.STOP, session_id="не-ascii"),
expected,
)
def test_response_correlation_requires_action_and_numeric_success() -> None:
identity = _identity()
expected = encode_modeling_stop(identity)
response = correlate_modeling_response(
_response(identity, ModelingAction.STOP, description="arbitrary text"), expected
)
assert response.error.code == OPENAPI_SUCCESS
with pytest.raises(ModelingResponseCorrelationError, match="action mismatch"):
correlate_modeling_response(_response(identity, ModelingAction.START), expected)
with pytest.raises(ModelingCommandRejected) as rejected:
correlate_modeling_response(
_response(identity, ModelingAction.STOP, code=OPENAPI_SUCCESS + 99), expected
)
assert rejected.value.code == OPENAPI_SUCCESS + 99
def test_response_parser_fails_closed_on_incomplete_or_ambiguous_payload() -> None:
identity = _identity()
error = _uint(1, OPENAPI_SUCCESS)
with pytest.raises(ModelingProtocolError, match="identity is incomplete"):
decode_modeling_response(
_bytes(1, _text(4, identity.device_id))
+ _uint(2, ModelingAction.START)
+ _bytes(15, error)
)
with pytest.raises(ModelingProtocolError, match="duplicated"):
decode_modeling_response(
_bytes(1, _header(identity))
+ _uint(2, ModelingAction.START)
+ _uint(2, ModelingAction.START)
+ _bytes(15, error)
)
with pytest.raises(ModelingProtocolError, match="not start or stop"):
decode_modeling_response(_bytes(1, _header(identity)) + _uint(2, 99) + _bytes(15, error))
def test_device_status_maps_base_offset_states_and_preserves_unknown() -> None:
report = decode_device_status_report(
_device_status(SessionState.SCANNING, init_ready=True, project_id="synthetic-project-id")
)
assert report.modeling_state_code == MODELING_STATE_BASE + SessionState.SCANNING
assert report.session_state is SessionState.SCANNING
assert report.init_ready
assert repr(report).find("synthetic-project-id") == -1
unknown = decode_device_status_report(_device_status(None, raw_code=MODELING_STATE_BASE + 999))
assert unknown.session_state is None
with pytest.raises(ModelingProtocolError, match="no modeling_state"):
decode_device_status_report(b"")
with pytest.raises(ModelingProtocolError, match="integer range"):
decode_device_status_report(_uint(6, 2) + _uint(2, MODELING_STATE_BASE))
def test_state_machine_never_promotes_status_only_evidence_to_durable_save() -> None:
machine = DeviceAcquisitionStateMachine()
assert machine.snapshot.phase is AcquisitionPhase.UNOBSERVED
assert not machine.snapshot.durable_save_complete
sequence = (
(SessionState.READY, AcquisitionPhase.READY),
(SessionState.SCAN_STARTING, AcquisitionPhase.CALIBRATING),
(SessionState.SCANNING, AcquisitionPhase.SCANNING),
(SessionState.SCAN_STOPPING, AcquisitionPhase.STOPPING),
(SessionState.SCAN_OVER, AcquisitionPhase.SCAN_OVER_UNVERIFIED),
)
for state, phase in sequence:
snapshot = machine.observe(decode_device_status_report(_device_status(state)))
assert snapshot.phase is phase
assert not snapshot.durable_save_complete
assert machine.snapshot.save_evidence is SaveEvidence.SCAN_OVER_OBSERVED
ready = machine.observe(decode_device_status_report(_device_status(SessionState.READY)))
assert ready.save_evidence is SaveEvidence.SCAN_OVER_THEN_READY_OBSERVED
assert not ready.durable_save_complete
next_start = machine.observe(
decode_device_status_report(_device_status(SessionState.SCAN_STARTING))
)
assert next_start.save_evidence is SaveEvidence.NONE
def test_state_machine_surfaces_faults_without_claiming_save() -> None:
machine = DeviceAcquisitionStateMachine()
for state in (
SessionState.DISK_ERROR,
SessionState.SAVE_ERROR,
SessionState.CAMERA_ERROR,
SessionState.MEMORY_NOT_ENOUGH,
SessionState.LIDAR_DATA_ERROR,
SessionState.MAPPING_ERROR,
):
snapshot = machine.observe(decode_device_status_report(_device_status(state)))
assert snapshot.phase is AcquisitionPhase.FAULT
assert snapshot.save_evidence is SaveEvidence.FAULT_BEFORE_DURABLE_CONFIRMATION
assert not snapshot.durable_save_complete
unknown = machine.observe(
decode_device_status_report(_device_status(None, raw_code=MODELING_STATE_BASE + 999))
)
assert unknown.phase is AcquisitionPhase.UNKNOWN
assert not unknown.durable_save_complete