feat(k1): complete primary acquisition lifecycle
This commit is contained in:
parent
9d51080d2e
commit
aa3680948f
60
README.md
60
README.md
|
|
@ -23,6 +23,13 @@ archive sources, recovery hooks and recording exporters only through the plugin
|
||||||
runtime contribution. The verified wire protocol and raw evidence format are
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 и
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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(" Mission 01 "), {
|
||||||
|
value: "Mission 01",
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
assert.match(projectName.validateProjectName("line\nbreak").error, /управляющие/);
|
||||||
|
assert.match(projectName.validateProjectName("\ud800").error, /управляющие/);
|
||||||
|
assert.match(projectName.validateProjectName("x".repeat(97)).error, /не длиннее 96/);
|
||||||
|
assert.match(projectName.validateProjectName(" \t ").error, /Введите название/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("automatic spatial source replaces the old scene only after a successful start", async () => {
|
||||||
|
const failedEvents = [];
|
||||||
|
assert.equal(await automaticSourceStart.runAutomaticSpatialSourceStart(
|
||||||
|
async () => {
|
||||||
|
failedEvents.push("start");
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
() => failedEvents.push("activate"),
|
||||||
|
() => failedEvents.push("open"),
|
||||||
|
), false);
|
||||||
|
assert.deepEqual(failedEvents, ["start"]);
|
||||||
|
|
||||||
|
const successfulEvents = [];
|
||||||
|
assert.equal(await automaticSourceStart.runAutomaticSpatialSourceStart(
|
||||||
|
async () => {
|
||||||
|
successfulEvents.push("start");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
() => successfulEvents.push("activate"),
|
||||||
|
() => successfulEvents.push("open"),
|
||||||
|
), true);
|
||||||
|
assert.deepEqual(successfulEvents, ["start", "activate", "open"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vendor commands fail closed unless the profile and acquisition both enable them", () => {
|
||||||
|
const capability = {
|
||||||
|
compatibility: {
|
||||||
|
vendor_writes_enabled: true,
|
||||||
|
permitted_mode: "active-control",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.equal(lifecycle.isVendorWriteCapable(capability), true);
|
||||||
|
assert.equal(lifecycle.isVendorWriteCapable({
|
||||||
|
compatibility: { vendor_writes_enabled: true, permitted_mode: "read-only" },
|
||||||
|
}), false);
|
||||||
|
assert.equal(lifecycle.isSoftwareCommandedAcquisition({
|
||||||
|
...capability,
|
||||||
|
acquisition: { control_mode: "operator-manual" },
|
||||||
|
}), false);
|
||||||
|
assert.equal(lifecycle.isSoftwareCommandedAcquisition({
|
||||||
|
...capability,
|
||||||
|
acquisition: { control_mode: "plugin-commanded" },
|
||||||
|
}), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("device modeling telemetry maps only finite non-negative values", () => {
|
||||||
|
assert.deepEqual(presentation.deviceTelemetry({
|
||||||
|
device_elapsed_seconds: 12.5,
|
||||||
|
device_route_distance_meters: 8.25,
|
||||||
|
device_speed_meters_per_second: 0.75,
|
||||||
|
}), {
|
||||||
|
elapsedSeconds: 12.5,
|
||||||
|
routeDistanceMeters: 8.25,
|
||||||
|
speedMetersPerSecond: 0.75,
|
||||||
|
});
|
||||||
|
assert.deepEqual(presentation.deviceTelemetry({
|
||||||
|
device_elapsed_seconds: -1,
|
||||||
|
device_route_distance_meters: Number.NaN,
|
||||||
|
device_speed_meters_per_second: Number.POSITIVE_INFINITY,
|
||||||
|
}), {
|
||||||
|
elapsedSeconds: null,
|
||||||
|
routeDistanceMeters: null,
|
||||||
|
speedMetersPerSecond: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("spatial K1 action failures have an explicit retry-safe presentation", () => {
|
||||||
|
assert.equal(presentation.spatialActionFailure(null), null);
|
||||||
|
assert.equal(presentation.spatialActionFailure(" "), null);
|
||||||
|
assert.deepEqual(presentation.spatialActionFailure(" stop failed "), {
|
||||||
|
title: "Действие K1 не выполнено",
|
||||||
|
detail: "stop failed",
|
||||||
|
});
|
||||||
|
assert.equal(lifecycle.shouldRenderSpatialControls({
|
||||||
|
source_mode: "live",
|
||||||
|
acquisition: { state: "failed", cleanup_pending: true },
|
||||||
|
}), true);
|
||||||
|
assert.equal(lifecycle.shouldRenderSpatialControls({
|
||||||
|
source_mode: "idle",
|
||||||
|
acquisition: { state: "failed", cleanup_pending: false },
|
||||||
|
}), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("replay ignores a stale failed live acquisition", () => {
|
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");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
||||||
|
});
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 76–2,294-second spans; the remaining older run contains a
|
||||||
|
counter/distance reset and is treated as a new scan generation. In two moving
|
||||||
|
runs, `ModelingReport.MoveDistance` matches the independent
|
||||||
|
`lio_pose.distance` values at 41.521 m and 26.631 m, while reported peak speeds
|
||||||
|
of 1.382 m/s and 1.291 m/s are physically plausible. This evidence does not turn
|
||||||
|
the counters into a cross-firmware contract.
|
||||||
|
|
||||||
For live MQTT only, `pipeline_ms` / `mqtt_to_publish_ms` uses the Mac monotonic
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span>
|
<span className="section-eyebrow">{effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРОЕКТ И ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</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 || "Сначала подключите устройство к Wi‑Fi"}
|
placeholder={state?.k1_ip || preparedAcquisition?.target_host || "Сначала подключите устройство к Wi‑Fi"}
|
||||||
/>
|
/>
|
||||||
<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 не отправляется; поток подтверждается только реальными кадрами."
|
||||||
: "Сначала подключите устройство к Wi‑Fi или укажите локальный адрес."}
|
: "Сначала подключите устройство к Wi‑Fi или укажите локальный адрес."}
|
||||||
</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()}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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", {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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": "Повтор записи" }
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"))
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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();")
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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": "Повтор записи"},
|
||||||
|
|
|
||||||
|
|
@ -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, {}))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"] = " K1 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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue