From aa3680948f8e0eb0d9b0497474278515ff62f68b Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 17 Jul 2026 23:03:59 +0300 Subject: [PATCH] feat(k1): complete primary acquisition lifecycle --- README.md | 60 +- apps/control-station/README.md | 58 +- apps/control-station/src/App.tsx | 48 +- .../components/ObservationSessionSelect.tsx | 7 +- .../src/core/device-plugins/contracts.ts | 3 + .../src/core/device-plugins/registry.ts | 9 +- .../observation/useObservationSessions.ts | 44 +- .../src/core/runtime/acquisitionGuard.ts | 26 + .../src/core/runtime/contracts.ts | 4 + .../src/styles/observation.css | 9 + .../control-station/src/styles/responsive.css | 5 + .../src/workspaces/DeviceWorkspace.tsx | 13 +- .../src/workspaces/Workspaces.tsx | 31 +- .../test/devicePluginContracts.test.mjs | 108 +++ .../devicePluginFrontendBoundary.test.mjs | 50 +- .../test/observationSessions.test.mjs | 19 + .../test/runtimeAcquisitionGuard.test.mjs | 95 ++ docs/01_IMPLEMENTATION_PLAN.md | 61 +- docs/05_K1_MQTT_STREAM_PROFILE.md | 41 +- docs/06_K1_LIVE_VIEWER.md | 114 ++- docs/07_MISSION_CORE_MONOREPO.md | 77 +- docs/09_OBSERVATION_SESSIONS.md | 165 +++- ...n-observation-runtime-and-k1-extraction.md | 35 +- ...-plugin-owned-frontend-device-workflows.md | 51 +- .../frontend/src/XgridsK1Connection.tsx | 1 + plugins/xgrids-k1/frontend/src/api.ts | 12 + .../frontend/src/automaticSourceStart.ts | 11 + .../src/components/K1AcquisitionPipeline.tsx | 95 +- .../src/components/K1SpatialControls.tsx | 164 ++++ plugins/xgrids-k1/frontend/src/lifecycle.ts | 26 + plugins/xgrids-k1/frontend/src/plugin.ts | 2 + .../xgrids-k1/frontend/src/presentation.ts | 46 + plugins/xgrids-k1/frontend/src/projectName.ts | 32 + .../xgrids-k1/frontend/src/runtimeContext.tsx | 7 +- plugins/xgrids-k1/frontend/src/styles.css | 112 +++ .../frontend/src/useXgridsK1Runtime.ts | 9 +- plugins/xgrids-k1/plugin.manifest.json | 2 + plugins/xgrids-k1/profile_loader.py | 127 ++- plugins/xgrids-k1/profiles/README.md | 21 +- .../profiles/fw-3.0.2/direct-lan.v1.json | 69 +- .../device_plugins/xgrids_k1/archive.py | 259 +++++- src/k1link/device_plugins/xgrids_k1/facade.py | 436 ++++++++- .../device_plugins/xgrids_k1/mqtt/capture.py | 449 ++++++++- .../device_plugins/xgrids_k1/observation.py | 82 +- .../xgrids_k1/protocol/modeling.py | 168 ++++ .../xgrids_k1/protocol/modeling_control.py | 568 ++++++++++++ .../xgrids_k1/protocol/modeling_state.py | 126 +++ .../device_plugins/xgrids_k1/rrd_export.py | 223 ++++- .../xgrids_k1/viewer/runtime.py | 59 +- src/k1link/sessions/media.py | 141 +-- src/k1link/sessions/recording.py | 127 +-- src/k1link/viewer/metrics.py | 45 + tests/test_canonical_pipeline.py | 92 +- tests/test_frontend_plugin_boundaries.py | 34 +- tests/test_mqtt_capture.py | 88 +- tests/test_plugin_catalog.py | 2 + tests/test_plugin_runtime.py | 22 +- tests/test_rerun_bridge.py | 3 +- tests/test_rrd_export.py | 185 +++- tests/test_session_api.py | 62 +- tests/test_session_recording.py | 54 +- tests/test_session_store.py | 198 +++- tests/test_stream_decoders.py | 80 ++ tests/test_xgrids_acquisition_lifecycle.py | 872 +++++++++++++++++- tests/test_xgrids_compatibility_profile.py | 66 +- .../test_xgrids_modeling_control_protocol.py | 327 +++++++ 66 files changed, 6093 insertions(+), 544 deletions(-) create mode 100644 apps/control-station/src/core/runtime/acquisitionGuard.ts create mode 100644 apps/control-station/test/runtimeAcquisitionGuard.test.mjs create mode 100644 plugins/xgrids-k1/frontend/src/automaticSourceStart.ts create mode 100644 plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx create mode 100644 plugins/xgrids-k1/frontend/src/projectName.ts create mode 100644 src/k1link/device_plugins/xgrids_k1/protocol/modeling.py create mode 100644 src/k1link/device_plugins/xgrids_k1/protocol/modeling_control.py create mode 100644 src/k1link/device_plugins/xgrids_k1/protocol/modeling_state.py create mode 100644 tests/test_xgrids_modeling_control_protocol.py diff --git a/README.md b/README.md index 9569907..1d3d891 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,13 @@ archive sources, recovery hooks and recording exporters only through the plugin runtime contribution. The verified wire protocol and raw evidence format are unchanged. +The K1 acquisition now also requires an operator project name. Frontend and +backend normalize it with NFKC plus surrounding-whitespace trimming, reject +control/surrogate characters and values above 96 Unicode characters, and +preserve it as local session/catalog display metadata. It is never used as a +filesystem path. Because K1 command publishing is disabled, the current runtime +does not claim that this value has been transmitted to the scanner. + Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session, operation, runtime-action, stream, evidence and compatibility contracts. Every backend runtime must now pass a versioned descriptor/handshake against its @@ -123,10 +130,20 @@ Open `http://127.0.0.1:8000`. The static application, REST/WebSocket control plane and credential endpoint bind to loopback only. The current K1 adapter still provides real CoreBluetooth discovery, one operator-triggered reviewed BLE Wi-Fi provisioning write, read-only MQTT live capture, native `.k1mqtt` and -reviewed-TSV replay, raw-first evidence storage and measured preview metrics. -Physical K1 scanning is still started and stopped by the verified double-click; -the connector publishes no modeling command. The observed LixelGO action mapping -remains descriptive and write-disabled. +reviewed-TSV replay, raw-first evidence storage, device-reported scan +time/distance/speed and measured preview metrics. Its frontend contribution now +also owns an optional spatial-scene control block, including acquisition phase, +telemetry and the stop action. On the active laboratory profile that stop action +finalizes local reception only; physical K1 scanning is still started and +stopped by the verified double-click. + +The exact recovered `ModelingRequest` start/stop encoder, response correlator +and device-status state machine are implemented as inert protocol components. +The command header derives `${device_id}:ModelingRequest` from an explicit ASCII +device ID and requires an explicit ASCII OpenAPI key, but the key's legitimate +provenance/provisioning and durable post-stop save evidence remain unresolved. +There is no MQTT publisher in this path, `vendor_writes_enabled` remains false, +and no modeling command is sent. This locked bootstrap is repeatable in the current workspace, not yet a standalone release install. The frontend consumes sibling `file:` packages from @@ -149,15 +166,21 @@ The first K1 live session or adapter file-replay (`.k1mqtt`/reviewed TSV) in a gRPC/proxy server on TCP 9876 and publishes the resulting URL through control-plane state. Later live/file-replay sessions reset their session-local scene and metrics and reuse that process-wide stream. Saved observation sessions -do not reuse this listener: they open a private digest-bound RRD generation over -same-origin HTTP through Rerun's native incremental receiver. Unless an operator -has entered a manual source, the React application assigns the applicable source -to the embedded viewer. The complete live runtime path is K1 MQTT → raw-first -evidence capture → bounded latest-wins preview queue → explicitly injected K1 +do not reuse this listener: they open a private digest-bound cache-v9 RRD +generation over same-origin HTTP through Rerun's native incremental receiver. +Unless an operator has entered a manual source, the React application assigns +the applicable source to the embedded viewer. A generic host action clears a +previous manual/archive selection only after a plugin-owned live or file-replay +start succeeds; a failed start leaves the current scene mounted. A fail-closed +guard blocks operator source switches for every nonterminal acquisition. The +complete visual live runtime path is K1 MQTT → raw-first evidence capture → +bounded four-message latest-wins preview queue → explicitly injected K1 protobuf/LZ4 normalizer → transport-neutral decoded local views → Rerun `Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer. Rerun does not inspect K1 topics or raw payloads. These local decoded views are not yet the -portable Plugin SDK wire envelopes. +portable Plugin SDK wire envelopes. K1 `ModelingReport` status is consumed by an +explicitly injected pre-preview observer, so scan telemetry cannot evict point +or pose frames from that four-message queue. The default Rerun blueprint shows a 12-second sliding accumulation of real point frames. Product controls are connected for point size, intensity/height/distance @@ -166,17 +189,24 @@ point and trajectory visibility, and the scene grid. The first disk action saves and restores the versioned spatial layout without mutating sensor evidence. Saved native point/pose sessions are materialized losslessly into private, digest-bound RRD recordings and can be played, paused and scrubbed on a -zero-based `session_time` timeline. No synthetic point cloud, trajectory, -camera frame or latency value is generated. +zero-based `session_time` timeline. New captures durably publish a clock origin +before camera production, retain a transport-scoped provisional envelope and +atomically point their summary to a content-addressed session envelope after all +producers stop. That session envelope becomes the real RRD origin/end range; +archived `ModelingReport` distance, speed and scan time are logged as scalar +time series. No synthetic point cloud, trajectory, camera frame or latency +value is generated. A powered-device checkpoint passed 80 real MQTT messages through the current Rerun runtime: 38 point-cloud frames, 42 pose frames, 2,775 points in the last cloud and zero decode errors. The later RTSP camera preview is available through the generic floating observation windows and new acquisitions archive its fMP4 segments independently of browser delivery. Historical sessions recorded before -that archive contract contain no video. Rerun `capture_time` and the camera -index use Mac receive/arrival timestamps, not proven K1 sensor timestamps or a -photon-to-screen measurement. +that archive contract contain no video. A real archived session containing one +camera plus point cloud has not yet passed physical shared-timeline playback; +the implementation and test contract do not close that hardware gate. Rerun +`capture_time` and the camera index use Mac receive/arrival timestamps, not +proven K1 sensor timestamps or a photon-to-screen measurement. The old Foxglove implementation is retained only in `src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py` and its regression tests. The current diff --git a/apps/control-station/README.md b/apps/control-station/README.md index 72a5463..774fd16 100644 --- a/apps/control-station/README.md +++ b/apps/control-station/README.md @@ -18,15 +18,15 @@ device adapter, но структура интерфейса от него не | --- | --- | --- | | Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. | | Device plugin registry | Реализован, v1alpha1 + v1alpha2 | До выбора модели provider остаётся inert и не делает I/O. v1alpha1 сохраняет одну модель; v1alpha2 допускает одну или несколько моделей и требует profile coverage каждой. Custom `device.connection` UI key и backend factory подключаются одним reviewed import в composition root. | -| Plugin-owned connection UI | Реализован | XGRIDS provisioning, acquisition/replay, diagnostics, metrics и scoped styles физически находятся в `plugins/xgrids-k1/frontend`; generic Control Station предоставляет только frontend SDK, model catalog и host slot. | +| Plugin-owned device UI | Реализован | XGRIDS provisioning, project/acquisition/replay, diagnostics, metrics, optional spatial controls и scoped styles физически находятся в `plugins/xgrids-k1/frontend`; generic Control Station предоставляет frontend SDK, model catalog и host slots. | | Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. | | K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. | -| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. | +| K1 live/replay MQTT | Реализован | Read-only приём, обязательное локальное имя проекта, raw-first сохранение, декодирование облака/позы и device-reported scan time/distance/speed. | | Автоматический MQTT → Rerun | Реализован | Первый live/adapter file-replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие такие сессии переиспользуют его. Saved observation replay использует отдельный immutable HTTP RRD path. | | Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. | | Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. | -| Сохранённые observation sessions | Реализованы для point/pose | Три последние сессии, background preparation, cache v7, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. | -| K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но реальная архивная K1 camera-session ещё не прошла physical acceptance. | +| Сохранённые observation sessions | Реализованы для point/pose/device metrics | Три последние сессии, background preparation, capture-clock-bound cache v9, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. | +| K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но point-cloud + one-camera archived playback ещё не прошёл physical acceptance. | | Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. | | Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. | @@ -38,9 +38,10 @@ device adapter, но структура интерфейса от него не ```text K1 MQTT :1883, read-only -└── raw .k1mqtt + metadata + SHA-256 сохраняются первыми - └── bounded latest-wins preview queue (32 сообщения) - └── явно внедрённый K1 protobuf/LZ4 normalizer +└── raw .k1mqtt + metadata + durable clock origin сохраняются первыми + ├── injected ModelingReport observer -> device scan time/distance/speed + └── bounded latest-wins visual preview queue (4 сообщения) + └── явно внедрённый K1 protobuf/LZ4 point/pose normalizer └── transport-neutral DecodedPointCloudView / DecodedPoseView └── Rerun Points3D + Transform3D + LineStrips3D └── gRPC/proxy TCP 9876 @@ -55,7 +56,7 @@ Mission Core Control Station ←→ REST /api/v1/device-plugins/* sealed/recovered observation session └── SQLite catalog + bounded background preparation - └── atomic RRD cache v7 + recorded-media manifest v2 + └── capture-clock-bound atomic RRD cache v9 + recorded-media manifest v2 └── generation-bound same-origin HTTP └── aggregate admission └── Rerun native receiver + recorded fMP4 player @@ -177,15 +178,21 @@ production-сборки запускается командой `npm run preview оператор. 6. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна reviewed provisioning-запись без автоматических повторов. -7. Запустить live-приём по определённому адресу K1 либо replay локального - `.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и - останавливается подтверждённым двойным нажатием кнопки устройства. -8. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в +7. Ввести обязательное название проекта. Оно NFKC-нормализуется, trim-ится, + проверяется на control/surrogate characters и лимит 96 Unicode characters, + сохраняется как display metadata и не используется в filesystem path. +8. Запустить локальный live-приём по определённому адресу K1 либо replay + локального `.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается + и останавливается подтверждённым двойным нажатием кнопки устройства. +9. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в state. Ручной source вводить не требуется. -9. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория, - частота, число точек, задержка и пропуски preview появятся после прихода - сообщений K1. -10. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться +10. Открыть **Наблюдение → Пространственная сцена**. Plugin-owned spatial block + показывает local acquisition phase, stop local reception и полученные от K1 + scan time/distance/speed; облако, траектория и preview metrics появляются + только после реальных сообщений. +11. Физически остановить K1, дождаться steady green, затем остановить локальный + приём из device workflow или spatial block, чтобы запечатать evidence. +12. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться состояния **Готово**, выбрать запись и использовать host timeline. Evidence сохраняется автоматически; disk action сохраняет только workspace layout. @@ -212,6 +219,11 @@ https://example.internal/recording.rrd - версия RRD должна быть совместима с версией Web Viewer; - ручной URL хранится только в состоянии текущей страницы и имеет приоритет над автоматическим source; +- nonterminal acquisition fail-closed блокирует manual source apply/reset, + saved session switch и persisted replay reattach до завершения текущего + приёма; automatic-source action очищает старый source и открывает сцену только + после successful start, failed start сохраняет текущую сцену, а guard не + останавливает устройство; - ошибка запуска показывается в viewport, без подстановки фиктивных данных. После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в @@ -282,10 +294,10 @@ facts и старые presentation-поля `phase`, `message`, `devices`, | `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. | | `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. | | `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle, plugin host и public frontend SDK surface. | -| `src/core/runtime/` | Нормализованное состояние активного устройства и spatial source. | +| `src/core/runtime/` | Нормализованное состояние активного устройства, spatial source и fail-closed source-switch guard. | | `src/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. | | `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. | -| `../../plugins/xgrids-k1/frontend/` | Plugin-owned K1 BLE/Wi-Fi, acquisition/replay UI, API client, runtime mapper и scoped styles. | +| `../../plugins/xgrids-k1/frontend/` | Plugin-owned K1 BLE/Wi-Fi, project/acquisition/replay UI, optional spatial controls, API client, runtime mapper и scoped styles. | | `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. | | `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. | | `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. | @@ -312,16 +324,26 @@ facts и старые presentation-поля `phase`, `message`, `devices`, случайные GATT writes и автоматические повторы запрещены. - MQTT live/replay не публикует команды устройству. Запуск и остановка физического сканирования остаются за кнопкой K1. +- Exact start/stop codec, response correlator и device-status state machine + остаются inert: они не импортируют MQTT transport. Header derivation известен, + но legitimate OpenAPI key provisioning и durable post-stop save gate не + доказаны, поэтому `vendor_writes_enabled=false`. - Live-сессии сначала сохраняют сырые сообщения и camera segments, затем формируют disposable preview. При перегрузке preview может быть отброшен, native evidence сохраняется. MQTT durability имеет bounded group-commit RPO, camera durability — текущий незавершённый fragment RPO; это не zero-loss claim. +- Новая session summary hash-bind-ит durable clock origin и атомарно переключает + pointer с provisional `mqtt.timeline.json` на content-addressed + `mqtt.timeline.session-.json`: только owner-sealed `session` scope + может рекламировать combined media replay. Cache v9 создаёт реальные + origin/end RRD rows; provisional `transport` scope fails closed. - Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно накопления viewer использует session-local `stream_time`. `capture_time` — не доказанный timestamp сенсора K1 и не photon-to-screen latency. - Панорамный камерный поток в MQTT report topics отсутствует; отдельные left/right RTSP preview доступны через generic camera windows. LiDAR и camera сейчас синхронизированы только по host arrival, не по доказанным sensor clocks. + Физический archived playback point cloud + одна выбранная камера ещё не принят. - `.runtime/`, canonical evidence roots и legacy `sessions/` игнорируются Git и могут содержать адреса, изображения, идентификаторы, траекторию и карту помещения. В репозиторий попадают только код, тесты, redacted manifests и diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index a087e61..e18b68c 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -30,6 +30,10 @@ import { ObservationSessionSelect } from "./components/ObservationSessionSelect" import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost"; import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext"; import type { ViewerSettings } from "./core/runtime/contracts"; +import { + SPATIAL_SOURCE_SWITCH_BLOCKED_REASON, + isSpatialSourceSwitchBlocked, +} from "./core/runtime/acquisitionGuard"; import { createLatestAsyncCommitter, type LatestAsyncCommitter, @@ -144,6 +148,12 @@ export default function App() { const [layoutSaveNotice, setLayoutSaveNotice] = useState(null); const [sceneSettings, setSceneSettings] = useState(defaultSceneSettings); const [displayDraft, setDisplayDraft] = useState(defaultSceneSettings); + const sourceSwitchBlocked = isSpatialSourceSwitchBlocked(runtime.state); + const sourceSwitchBlockedReason = sourceSwitchBlocked + ? SPATIAL_SOURCE_SWITCH_BLOCKED_REASON + : null; + const sourceSwitchBlockedRef = useRef(sourceSwitchBlocked); + sourceSwitchBlockedRef.current = sourceSwitchBlocked; const appliedProfileKeyRef = useRef(null); const sceneSettingsRef = useRef(defaultSceneSettings); const displayDraftRef = useRef(defaultSceneSettings); @@ -423,6 +433,9 @@ export default function App() { }; const beginRecordedReplaySwitch = useCallback(async () => { + if (sourceSwitchBlockedRef.current) { + throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON); + } // useObservationSessions calls this only after backend preparation has // produced a validated launch descriptor. Keep the old scene mounted // before this point; now perform one controlled receiver teardown before @@ -437,6 +450,9 @@ export default function App() { }, []); const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => { + if (sourceSwitchBlockedRef.current) { + throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON); + } setRecordedReplay(launch); setSourceUrl(launch.sourceUrl); setSourceDraft(launch.sourceUrl); @@ -447,6 +463,17 @@ export default function App() { if (outcome !== "accepted") setReplayTransitioning(false); }, []); + const activateAutomaticSpatialSource = useCallback(() => { + // A plugin-owned live/file-replay start must release any host-selected + // archive or manual URL before the new acquisition becomes non-terminal. + // This transition is an internal start boundary, not an operator source + // switch, so it intentionally does not consult the acquisition guard. + setReplayTransitioning(false); + setRecordedReplay(null); + setSourceUrl(""); + setSourceDraft(""); + }, []); + useEffect(() => { // ObservationSessionSelect owns the cancellable request and unmounts when // the operator leaves the spatial workspace. Its unmount cannot safely @@ -637,7 +664,8 @@ export default function App() {
acceptRecordedReplay(launch)} onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)} @@ -667,6 +695,7 @@ export default function App() { {activeDefinition.kind === "device" ? ( openView("spatial-scene")} + onActivateAutomaticSpatialSource={activateAutomaticSpatialSource} /> ) : ( )} @@ -713,7 +749,10 @@ export default function App() {
), diff --git a/apps/control-station/src/components/ObservationSessionSelect.tsx b/apps/control-station/src/components/ObservationSessionSelect.tsx index dad78f9..307638a 100644 --- a/apps/control-station/src/components/ObservationSessionSelect.tsx +++ b/apps/control-station/src/components/ObservationSessionSelect.tsx @@ -93,12 +93,14 @@ function sessionDescription(session: ObservationSessionSummary): string { export function ObservationSessionSelect({ limit = 3, disabled = false, + blockedReason = null, onReplayBegin, onReplayAccepted, onReplaySettled, }: { limit?: number; disabled?: boolean; + blockedReason?: string | null; onReplayBegin?: ( session: ObservationSessionSummary, launch: ObservationSessionReplayLaunch, @@ -114,6 +116,7 @@ export function ObservationSessionSelect({ }) { const sessions = useObservationSessions({ limit, + replayEnabled: blockedReason === null, onReplayBegin, onReplayAccepted, onReplaySettled, @@ -123,6 +126,7 @@ export function ObservationSessionSelect({ : sessions.state === "loading" ? "Загружаем сессии…" : "Сохранённые сессии"; + const presentedTriggerCopy = blockedReason ?? triggerCopy; return ( - {triggerCopy} + {presentedTriggerCopy} {sessions.state === "ready" ? {sessions.items.length} : null} diff --git a/apps/control-station/src/core/device-plugins/contracts.ts b/apps/control-station/src/core/device-plugins/contracts.ts index 63989dc..3f98df1 100644 --- a/apps/control-station/src/core/device-plugins/contracts.ts +++ b/apps/control-station/src/core/device-plugins/contracts.ts @@ -94,6 +94,7 @@ export function isDevicePluginManifestV1Alpha2( export interface DevicePluginHostActions { openSpatialScene: () => void; + activateAutomaticSpatialSource: () => void; } export interface DevicePluginConnectionProps { @@ -109,10 +110,12 @@ export interface DeviceUiPlugin { children: ReactNode; }>; connectionViews: Readonly>>; + SpatialControlsView?: ComponentType; } export interface RegisteredDeviceModel { plugin: DeviceUiPlugin; model: DeviceModelDefinition; ConnectionView: ComponentType; + SpatialControlsView?: ComponentType; } diff --git a/apps/control-station/src/core/device-plugins/registry.ts b/apps/control-station/src/core/device-plugins/registry.ts index c5aa16c..1429d2d 100644 --- a/apps/control-station/src/core/device-plugins/registry.ts +++ b/apps/control-station/src/core/device-plugins/registry.ts @@ -90,7 +90,14 @@ export function createDevicePluginRegistry( `Плагин ${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)) { diff --git a/apps/control-station/src/core/observation/useObservationSessions.ts b/apps/control-station/src/core/observation/useObservationSessions.ts index dacbcd8..9b795a4 100644 --- a/apps/control-station/src/core/observation/useObservationSessions.ts +++ b/apps/control-station/src/core/observation/useObservationSessions.ts @@ -101,9 +101,11 @@ export function createObservationReplayCoordinator(): ObservationReplayCoordinat }; }, cancel() { - sequence += 1; 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({ limit = 3, + replayEnabled = true, onReplayBegin, onReplayAccepted, onReplaySettled, }: { limit?: number; + replayEnabled?: boolean; /** Called only after the archive is ready, immediately before replacing the old viewer. */ onReplayBegin?: ( session: ObservationSessionSummary, @@ -388,11 +392,23 @@ export function useObservationSessions({ const mounted = useRef(true); const catalogSequence = useRef(0); const reattachStarted = useRef(false); + const replayEnabledRef = useRef(replayEnabled); + replayEnabledRef.current = replayEnabled; const preparationPollSequence = useRef(0); const replayCoordinator = useRef(null); if (replayCoordinator.current === null) { 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 refresh = useCallback(async () => { @@ -466,6 +482,7 @@ export function useObservationSessions({ session: ObservationSessionSummary, resumedPreparation?: ObservationSessionPreparation, ) => { + if (!replayEnabledRef.current) return false; const attempt = replayCoordinator.current!.begin(); setReplayingSessionId(session.id); setFailedSessionId(null); @@ -502,14 +519,26 @@ export function useObservationSessions({ signal: attempt.signal, 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 launch descriptor exists do we release the previous viewer. await onReplayBegin?.(session, launch); - if (!mounted.current || !attempt.isCurrent()) return false; + if ( + !mounted.current || + !attempt.isCurrent() || + !replayEnabledRef.current + ) return false; await onReplayAccepted?.(session, launch); - if (!mounted.current || !attempt.isCurrent()) return false; + if ( + !mounted.current || + !attempt.isCurrent() || + !replayEnabledRef.current + ) return false; outcome = "accepted"; try { clearObservationReplayPreparation(); @@ -543,6 +572,7 @@ export function useObservationSessions({ }, [onReplayAccepted, onReplayBegin, onReplaySettled]); const replay = useCallback(async (sessionId: string) => { + if (!replayEnabledRef.current) return false; const session = items.find((candidate) => candidate.id === sessionId); if (!session || !session.replayable) return false; reattachStarted.current = true; @@ -550,7 +580,7 @@ export function useObservationSessions({ }, [executeReplay, items]); useEffect(() => { - if (state !== "ready" || reattachStarted.current) return; + if (!replayEnabled || state !== "ready" || reattachStarted.current) return; reattachStarted.current = true; let stored: ObservationSessionPreparation | null = null; try { @@ -569,7 +599,7 @@ export function useObservationSessions({ return; } void executeReplay(session, stored); - }, [executeReplay, items, state]); + }, [executeReplay, items, replayEnabled, state]); const retry = useCallback(async () => { if (!failedSessionId) return false; diff --git a/apps/control-station/src/core/runtime/acquisitionGuard.ts b/apps/control-station/src/core/runtime/acquisitionGuard.ts new file mode 100644 index 0000000..ab477db --- /dev/null +++ b/apps/control-station/src/core/runtime/acquisitionGuard.ts @@ -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)), + ); +} diff --git a/apps/control-station/src/core/runtime/contracts.ts b/apps/control-station/src/core/runtime/contracts.ts index bb481b0..797ee38 100644 --- a/apps/control-station/src/core/runtime/contracts.ts +++ b/apps/control-station/src/core/runtime/contracts.ts @@ -29,6 +29,9 @@ export interface StreamMetrics { frameRateHz?: number | null; pointCount?: number | null; droppedPreviewFrames?: number | null; + elapsedSeconds?: number | null; + routeDistanceMeters?: number | null; + speedMetersPerSecond?: number | null; } export interface ActiveDeviceSnapshot { @@ -55,6 +58,7 @@ export interface RuntimeAcquisitionSnapshot { state: string; stateRevision: number; operatorInstructions: readonly string[]; + cleanupPending?: boolean; } export interface RuntimeOperationSnapshot { diff --git a/apps/control-station/src/styles/observation.css b/apps/control-station/src/styles/observation.css index a783115..58f9e88 100644 --- a/apps/control-station/src/styles/observation.css +++ b/apps/control-station/src/styles/observation.css @@ -8,6 +8,15 @@ 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-control, .scene-focus-exit { diff --git a/apps/control-station/src/styles/responsive.css b/apps/control-station/src/styles/responsive.css index ab4bcad..d5dc8a9 100644 --- a/apps/control-station/src/styles/responsive.css +++ b/apps/control-station/src/styles/responsive.css @@ -168,6 +168,11 @@ left: 0.6rem; } + .scene-device-controls { + top: 0.6rem; + max-width: calc(100% - 8rem); + } + .scene-adapter-note { right: 0.75rem; left: 0.75rem; diff --git a/apps/control-station/src/workspaces/DeviceWorkspace.tsx b/apps/control-station/src/workspaces/DeviceWorkspace.tsx index 634b2a4..dc1411d 100644 --- a/apps/control-station/src/workspaces/DeviceWorkspace.tsx +++ b/apps/control-station/src/workspaces/DeviceWorkspace.tsx @@ -40,7 +40,13 @@ function ModelCard({ ); } -export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: () => void }) { +export function DeviceWorkspace({ + onOpenSpatialScene, + onActivateAutomaticSpatialSource, +}: { + onOpenSpatialScene: () => void; + onActivateAutomaticSpatialSource: () => void; +}) { const { registry, selection, @@ -115,7 +121,10 @@ export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: () ); diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 8122bf6..ab33a1c 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentType, +} from "react"; import { Button, GlassSurface, @@ -21,6 +28,10 @@ import type { RecordedCameraAdmissionState, } from "../core/observation/recordedSessionAdmission"; import type { ObservationLayoutController } from "../core/observation/useObservationLayout"; +import type { + DeviceModelDefinition, + DevicePluginConnectionProps, +} from "../core/device-plugins/contracts"; import type { BackendStatus, MissionRuntimeState, @@ -98,6 +109,7 @@ export interface WorkspaceNavigation { openSource: () => void; openDisplay: () => void; openLayers: () => void; + activateAutomaticSpatialSource: () => void; } export interface WorkspaceRendererProps { @@ -113,6 +125,10 @@ export interface WorkspaceRendererProps { onAccumulationCommit: () => void; observationLayout: ObservationLayoutController; navigation: WorkspaceNavigation; + spatialControls: { + View: ComponentType; + model: DeviceModelDefinition; + } | null; } function OverviewWorkspace({ @@ -271,6 +287,7 @@ function SpatialWorkspace({ onAccumulationCommit, observationLayout, navigation, + spatialControls, }: WorkspaceRendererProps) { const [viewerStatus, setViewerStatus] = useState(sourceUrl ? "loading" : "idle"); const [viewerMessage, setViewerMessage] = useState(""); @@ -435,6 +452,18 @@ function SpatialWorkspace({ )} + {spatialControls && !recordedSource ? ( +
+ navigation.openView("spatial-scene"), + activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource, + }} + /> +
+ ) : null} + {pointCloudFocused ? (

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

@@ -163,7 +214,9 @@ export function K1AcquisitionPipeline({ {state?.source_mode === "replay" ? "Остановка завершит фактически запущенный повтор записи." : activeAcquisition || state?.source_mode === "live" - ? "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным." + ? vendorWriteCapable && activeAcquisition?.control_mode === "plugin-commanded" + ? "Остановка отправит профилированную команду K1 и дождётся завершения локального сохранения." + : "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным." : "Активного источника сейчас нет."}

+ + ); +} diff --git a/plugins/xgrids-k1/frontend/src/lifecycle.ts b/plugins/xgrids-k1/frontend/src/lifecycle.ts index 41c6d29..163bb21 100644 --- a/plugins/xgrids-k1/frontend/src/lifecycle.ts +++ b/plugins/xgrids-k1/frontend/src/lifecycle.ts @@ -29,6 +29,17 @@ export function isTerminalAcquisitionState( 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( state: XgridsK1State | null | undefined, ): XgridsAcquisition | null { @@ -44,6 +55,21 @@ export function isSourceRuntimeBusy(state: XgridsK1State | null | undefined): bo 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( state: XgridsK1State | null | undefined, ): RuntimeSourceMode { diff --git a/plugins/xgrids-k1/frontend/src/plugin.ts b/plugins/xgrids-k1/frontend/src/plugin.ts index af08926..ba8a77e 100644 --- a/plugins/xgrids-k1/frontend/src/plugin.ts +++ b/plugins/xgrids-k1/frontend/src/plugin.ts @@ -1,5 +1,6 @@ import type { DeviceUiPlugin } from "@mission-core/plugin-sdk"; import { XgridsK1Connection } from "./XgridsK1Connection"; +import { K1SpatialControls } from "./components/K1SpatialControls"; import { xgridsK1Manifest } from "./manifest"; import { XgridsK1RuntimeProvider } from "./runtimeContext"; import "./styles.css"; @@ -7,6 +8,7 @@ import "./styles.css"; export const xgridsK1Plugin: DeviceUiPlugin = { manifest: xgridsK1Manifest, RuntimeProvider: XgridsK1RuntimeProvider, + SpatialControlsView: K1SpatialControls, connectionViews: Object.freeze({ "xgrids-k1.connection": XgridsK1Connection, }), diff --git a/plugins/xgrids-k1/frontend/src/presentation.ts b/plugins/xgrids-k1/frontend/src/presentation.ts index 00a5ee9..a16b495 100644 --- a/plugins/xgrids-k1/frontend/src/presentation.ts +++ b/plugins/xgrids-k1/frontend/src/presentation.ts @@ -69,6 +69,52 @@ export function pipelineLatency(metrics: XgridsK1Metrics | undefined): number | 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 { if (value === null) return "—"; return value.toLocaleString("ru-RU", { diff --git a/plugins/xgrids-k1/frontend/src/projectName.ts b/plugins/xgrids-k1/frontend/src/projectName.ts new file mode 100644 index 0000000..e1b3f16 --- /dev/null +++ b/plugins/xgrids-k1/frontend/src/projectName.ts @@ -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 }; +} diff --git a/plugins/xgrids-k1/frontend/src/runtimeContext.tsx b/plugins/xgrids-k1/frontend/src/runtimeContext.tsx index 0fa21b9..cc1168d 100644 --- a/plugins/xgrids-k1/frontend/src/runtimeContext.tsx +++ b/plugins/xgrids-k1/frontend/src/runtimeContext.tsx @@ -15,7 +15,7 @@ import { } from "./lifecycle"; import { localizeRuntimeMessage } from "./messages"; import { xgridsK1Manifest } from "./manifest"; -import { finiteMetric, pipelineLatency } from "./presentation"; +import { deviceTelemetry, finiteMetric, pipelineLatency } from "./presentation"; import { xgridsK1ObservationSources } from "./observationSources"; import { useXgridsK1Runtime } from "./useXgridsK1Runtime"; @@ -30,6 +30,7 @@ function normalizeState( const state = controller.state; if (!state) return null; const metrics = state.metrics; + const telemetry = deviceTelemetry(metrics); const deviceRef = state.device_ref; const deviceSession = state.device_session; const acquisition = effectiveAcquisition(state); @@ -69,6 +70,7 @@ function normalizeState( state: acquisition.state, stateRevision: acquisition.state_revision, operatorInstructions: acquisition.operator_instructions ?? [], + cleanupPending: acquisition.cleanup_pending === true, } : null, operations: (state.operations ?? []).map((operation) => ({ @@ -106,6 +108,9 @@ function normalizeState( frameRateHz: finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz), pointCount: finiteMetric(metrics?.point_count), droppedPreviewFrames: finiteMetric(metrics?.dropped_preview_frames), + elapsedSeconds: telemetry.elapsedSeconds, + routeDistanceMeters: telemetry.routeDistanceMeters, + speedMetersPerSecond: telemetry.speedMetersPerSecond, }, }; } diff --git a/plugins/xgrids-k1/frontend/src/styles.css b/plugins/xgrids-k1/frontend/src/styles.css index b463dce..a8e2c6a 100644 --- a/plugins/xgrids-k1/frontend/src/styles.css +++ b/plugins/xgrids-k1/frontend/src/styles.css @@ -464,3 +464,115 @@ 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; + } +} diff --git a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts index 8296619..e7d4467 100644 --- a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts +++ b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts @@ -17,6 +17,7 @@ import { liveStartPlan, operationByIdempotencyKey, operationNeedsReconciliation, + isSoftwareCommandedAcquisition, } from "./lifecycle"; import { localizeRuntimeMessage } from "./messages"; import { selectMonotonicXgridsState } from "./stateOrdering"; @@ -65,6 +66,7 @@ export function useXgridsK1Runtime(enabled: boolean) { const [error, setError] = useState(null); const [latencyHistory, setLatencyHistory] = useState([]); const mounted = useRef(true); + const actionInFlight = useRef(false); const acceptState = useCallback((nextState: XgridsK1State) => { setState((currentState) => selectMonotonicXgridsState(currentState, nextState)); @@ -101,6 +103,8 @@ export function useXgridsK1Runtime(enabled: boolean) { const run = useCallback( async (action: PendingAction, operation: () => Promise) => { if (!enabled) return false; + if (actionInFlight.current) return false; + actionInFlight.current = true; setPendingAction(action); setError(null); @@ -117,6 +121,7 @@ export function useXgridsK1Runtime(enabled: boolean) { } return false; } finally { + actionInFlight.current = false; if (mounted.current) setPendingAction(null); } }, @@ -202,13 +207,13 @@ export function useXgridsK1Runtime(enabled: boolean) { if (acquisition && !acquisitionTerminal) { return xgridsK1Api.stopAcquisition({ acquisition_id: acquisition.acquisition_id, - mode: "capture-only", + mode: isSoftwareCommandedAcquisition(state) ? "graceful" : "capture-only", }); } // Replay and pre-v1alpha2 sessions remain a compatibility-only path. return xgridsK1Api.stopSessionCompatibility(); }), - [run, state?.acquisition], + [run, state], ); const abort = useCallback(() => { diff --git a/plugins/xgrids-k1/plugin.manifest.json b/plugins/xgrids-k1/plugin.manifest.json index 3ccca32..adffd63 100644 --- a/plugins/xgrids-k1/plugin.manifest.json +++ b/plugins/xgrids-k1/plugin.manifest.json @@ -60,6 +60,8 @@ { "id": "device.provisioning.wifi-over-ble", "label": "Wi-Fi через BLE" }, { "id": "spatial.point-cloud.live", "label": "Облако точек" }, { "id": "spatial.pose.live", "label": "Траектория" }, + { "id": "device.modeling.live", "label": "Метрики маршрута" }, + { "id": "device.status.live", "label": "Состояние сканирования" }, { "id": "camera.preview.live", "label": "Видеокамеры" }, { "id": "evidence.raw-capture", "label": "Исходная запись" }, { "id": "evidence.replay", "label": "Повтор записи" } diff --git a/plugins/xgrids-k1/profile_loader.py b/plugins/xgrids-k1/profile_loader.py index a23d142..b1cfc01 100644 --- a/plugins/xgrids-k1/profile_loader.py +++ b/plugins/xgrids-k1/profile_loader.py @@ -62,12 +62,15 @@ def _validate_evidence(value: Any, path: str) -> dict[str, bool]: if set(evidence) != set(EVIDENCE_FLAGS): expected = ", ".join(EVIDENCE_FLAGS) raise CompatibilityProfileError(f"{path} must contain exactly: {expected}") + validated: dict[str, bool] = {} 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") + validated[flag] = flag_value if evidence["write_enabled"]: 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: @@ -240,6 +243,7 @@ def _validate_channels(profile: dict[str, Any]) -> None: expected_ids = { "spatial.point-cloud.live", "spatial.pose.live", + "device.modeling.live", "device.status.live", "device.heartbeat.live", "camera.preview.live", @@ -271,21 +275,66 @@ def _validate_channels(profile: dict[str, Any]) -> None: physical_verified=True, ) - for channel_id, topic in ( - ("device.status.live", "lixel/application/report/device_status"), - ("device.heartbeat.live", "lixel/application/report/heartbeat"), - ): - channel = channels[channel_id] - 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( - channel, - f"$.channels[{channel_id}]", - observed=True, - decoded=False, - replay_verified=False, - physical_verified=True, + modeling = channels["device.modeling.live"] + if ( + 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, + } + ): + raise CompatibilityProfileError("modeling telemetry channel differs from evidence") + _expect_evidence( + modeling, + "$.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, + decoded=False, + replay_verified=False, + physical_verified=True, + ) camera = channels["camera.preview.live"] if camera.get("discovery_status") != "observed": @@ -353,14 +402,53 @@ def _validate_acquisition_control(profile: dict[str, Any]) -> None: ) if mapping.get("evidence_kind") != "owner-controlled-wire-observation": 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": raise CompatibilityProfileError( 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( 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( mapping.get("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") 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") if firmware != {"match": "exact", "version": "3.0.2"}: 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.""" validated = validate_compatibility_profile(profile) 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: diff --git a/plugins/xgrids-k1/profiles/README.md b/plugins/xgrids-k1/profiles/README.md index aadd3b9..b0de120 100644 --- a/plugins/xgrids-k1/profiles/README.md +++ b/plugins/xgrids-k1/profiles/README.md @@ -29,15 +29,22 @@ boolean flags: | `physical_verified` | Correlated with a controlled physical state/action | | `write_enabled` | The profile grants emission of a state-changing request | -`decoded` does not mean that MQTT framing alone was parsed. Status and heartbeat -are therefore observed raw channels, not decoded status models. Camera preview -transport is observed, but its media decoder/replay flags remain independent. +`decoded` does not mean that MQTT framing alone was parsed. Modeling telemetry +and the acquisition subset of DeviceStatus have bounded profile-scoped +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 -capture verifies the `ModelingRequest` topic and action values, but complete -device/session/OpenAPI header construction, settings, save completion, timeout -and rollback contracts remain unresolved. Acquisition therefore stays -`operator-manual` through the verified physical double-click. +capture verifies the `ModelingRequest` topic, action values, field layout, +literal `{device_id}:ModelingRequest` session relation, retained start settings +and numeric success code. The bounded codec accepts an OpenAPI value only as an +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 operator confirmation. Merely loading this compatibility profile neither calls diff --git a/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json b/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json index feb5794..3d9e6dc 100644 --- a/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json +++ b/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json @@ -209,20 +209,49 @@ "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", "kind": "device-status", "direction": "device-report", "topic": "lixel/application/report/device_status", - "wire_format": "opaque bytes", - "semantic_payload": null, + "wire_format": "protobuf DeviceStatusReport acquisition lifecycle subset", + "semantic_payload": "bounded modeling-state base-offset mapping, init-ready flag, project presence and redacted identity fields", "limitations": [ - "The report was physically observed, but no bounded semantic device-status decoder is implemented.", - "Raw capture is evidence; field names or meanings must not be inferred from payload shape." + "Nested system and RTK status payloads are presence-checked but not semantically decoded.", + "ScanOver and Ready observations do not prove durable artifact save completion." ], "evidence": { "observed": true, - "decoded": false, + "decoded": true, "replay_verified": false, "physical_verified": true, "write_enabled": false @@ -270,7 +299,7 @@ "limitations": [ "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.", - "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": { "observed": true, @@ -313,11 +342,25 @@ "transport": "MQTT 3.1.1", "topic": "lixel/application/request/modeling", "qos": 2, + "retain": false, "message_type": "ModelingRequest", "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": [ - "complete device/session/OpenAPI header construction", - "project/record/scan/mount setting semantics and safe defaults", + "OpenAPI credential provenance and secure provisioning", + "authorization policy for any setting outside the retained request", "timeout, rejection and rollback contract" ], "evidence": { @@ -352,10 +395,18 @@ "transport": "MQTT 3.1.1", "topic": "lixel/application/request/modeling", "qos": 2, + "retain": false, "message_type": "ModelingRequest", "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": [ - "complete device/session/OpenAPI header construction", + "OpenAPI credential provenance and secure provisioning", "save-completion and final-standby state mapping", "timeout and rollback contract" ], diff --git a/src/k1link/device_plugins/xgrids_k1/archive.py b/src/k1link/device_plugins/xgrids_k1/archive.py index 8a8b7a1..1773667 100644 --- a/src/k1link/device_plugins/xgrids_k1/archive.py +++ b/src/k1link/device_plugins/xgrids_k1/archive.py @@ -6,20 +6,29 @@ import math import os import re import stat +import unicodedata from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import UTC, datetime from functools import lru_cache from pathlib import Path from typing import IO, Any from k1link.device_plugins.xgrids_k1.mqtt.capture import ( + CAPTURE_CLOCK_FILENAME, + CAPTURE_CLOCK_ORIGIN_FILENAME, FRAME_HEADER, GROUP_COMMIT_MAX_BYTES, GROUP_COMMIT_MAX_MESSAGES, MAX_CONFIGURABLE_MESSAGE_BYTES, MAX_TOPIC_BYTES, RAW_MAGIC, + CaptureClockEnvelope, + CaptureClockOrigin, + CaptureFormatError, + is_capture_clock_filename, + read_capture_clock_envelope, + read_capture_clock_origin, ) from k1link.sessions.models import ( LegacyMediaSourceCandidate, @@ -58,8 +67,17 @@ class LegacySessionCandidate: raw_byte_length: int replay_raw_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 + capture_clock_origin_sha256: str | None + capture_clock_sha256: str | None + capture_clock_path: Path | None 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, ...] @@ -72,6 +90,14 @@ class _RecoveredCapture: duration_seconds: float raw_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, ...]: @@ -137,13 +163,17 @@ def _describe_session( message_count = ( _non_negative_int(summary.get("message_count")) 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") topic_counts = ( _normalized_topic_counts(summary_topic_counts) 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) modalities = list(_modalities(topic_counts)) @@ -151,11 +181,25 @@ def _describe_session( modalities.append("video") replayable = bool( not active - and - raw_magic_ok + and raw_magic_ok and raw_bytes > len(RAW_MAGIC) and message_count > 0 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: status = ( @@ -171,7 +215,9 @@ def _describe_session( completed_at = _safe_timestamp(summary.get("completed_at_utc")) or _safe_timestamp( manifest.get("completed_at_utc") ) - duration = _duration(summary.get("capture_elapsed_seconds")) + duration = _duration(summary.get("session_elapsed_seconds")) + if duration is None: + duration = _duration(summary.get("capture_elapsed_seconds")) declared_hash = _declared_raw_hash(summary) integrity_status = "verified" if declared_hash is not None else "validated-structure" else: @@ -190,17 +236,62 @@ def _describe_session( replay_raw_bytes = ( completed.raw_committed_bytes 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 = ( completed.metadata_committed_bytes 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( session_id=session_root.name, - display_name=session_root.name, + display_name=_project_display_name(manifest, fallback=session_root.name), status=status, started_at_utc=started_at, completed_at_utc=completed_at, @@ -214,12 +305,41 @@ def _describe_session( raw_byte_length=raw_bytes, replay_raw_byte_length=replay_raw_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, + 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, + 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, ) +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: return ( "message_count" in summary @@ -254,7 +374,7 @@ def _validate_completed_capture( sort_keys=True, separators=(",", ":"), ) - return _validate_completed_capture_cached( + validated = _validate_completed_capture_cached( str(capture_root), str(session_root), str(raw_path), @@ -262,6 +382,81 @@ def _validate_completed_capture( _stat_identity(metadata_stat), 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) @@ -312,13 +507,29 @@ def _recover_interrupted_capture( session_root: Path, raw_path: Path, ) -> _RecoveredCapture | None: - return _scan_capture_prefix( + recovered = _scan_capture_prefix( capture_root, session_root, raw_path, tolerate_incomplete_metadata_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( @@ -399,8 +610,7 @@ def _scan_capture_prefix( return None raw_committed_bytes = raw_stream.tell() if raw_committed_bytes != raw_size and not ( - tolerate_raw_crash_tail - and _tolerable_raw_crash_tail(raw_stream, raw_size=raw_size) + tolerate_raw_crash_tail and _tolerable_raw_crash_tail(raw_stream, raw_size=raw_size) ): return None except OSError: @@ -423,6 +633,10 @@ def _scan_capture_prefix( duration_seconds=(last_monotonic_ns - first_monotonic_ns) / 1_000_000_000, raw_committed_bytes=raw_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 +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: try: current = path.lstat() @@ -815,8 +1045,7 @@ def _validated_media_epoch(epoch: Path, expected_source_id: str) -> bool: except (OSError, UnicodeDecodeError, json.JSONDecodeError): return False if len(index_records) != segment_count or not all( - isinstance(record, dict) - and _non_negative_int(record.get("sequence")) > 0 + isinstance(record, dict) and _non_negative_int(record.get("sequence")) > 0 for record in index_records ): return False diff --git a/src/k1link/device_plugins/xgrids_k1/facade.py b/src/k1link/device_plugins/xgrids_k1/facade.py index 61ddfc9..67b71db 100644 --- a/src/k1link/device_plugins/xgrids_k1/facade.py +++ b/src/k1link/device_plugins/xgrids_k1/facade.py @@ -8,18 +8,26 @@ import json import secrets import threading import time -from collections.abc import Mapping -from contextlib import suppress +import unicodedata +from collections.abc import Callable, Mapping from datetime import UTC, datetime +from functools import wraps 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 missioncore_plugin_sdk.v0alpha2 import ( RuntimeActionInvocation, 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.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, ) 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.viewer.runtime import ( VisualizationRuntime, @@ -74,6 +84,33 @@ ACTION_ACQUISITION_PREPARE = "acquisition.prepare" ACTION_ACQUISITION_START = "acquisition.start" ACTION_ACQUISITION_STOP = "acquisition.stop" 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_STREAM_START_LIVE = "stream.start-live" ACTION_STREAM_START_REPLAY = "stream.start-replay" @@ -85,12 +122,14 @@ ACTION_VIEWER_SETTINGS_UPDATE = "viewer.settings.update" RequestedStreamId = Literal[ "spatial.point-cloud.live", "spatial.pose.live", + "device.modeling.live", "device.status.live", "device.heartbeat.live", ] DEFAULT_LIVE_STREAMS: tuple[RequestedStreamId, ...] = ( "spatial.point-cloud.live", "spatial.pose.live", + "device.modeling.live", "device.status.live", "device.heartbeat.live", ) @@ -130,10 +169,16 @@ class ConnectRequest(StrictRequest): class LiveRequest(StrictRequest): + project_name: str = Field(min_length=1, max_length=96) host: str | None = Field(default=None, max_length=15) duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0) compatibility_attestation: CompatibilityAttestationRequest + @field_validator("project_name") + @classmethod + def validate_project_name(cls, value: str) -> str: + return normalize_project_name(value) + class OperationContextRequest(StrictRequest): operation_id: str | None = Field(default=None, min_length=1, max_length=128) @@ -142,12 +187,18 @@ class OperationContextRequest(StrictRequest): class PrepareAcquisitionRequest(OperationContextRequest): + project_name: str = Field(min_length=1, max_length=96) host: str | None = Field(default=None, max_length=15) duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0) requested_streams: tuple[RequestedStreamId, ...] = DEFAULT_LIVE_STREAMS evidence_policy: Literal["required", "best-effort", "disabled"] = "required" compatibility_attestation: CompatibilityAttestationRequest + @field_validator("project_name") + @classmethod + def validate_project_name(cls, value: str) -> str: + return normalize_project_name(value) + class StartAcquisitionRequest(OperationContextRequest): 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.evidence_root = resolve_missioncore_evidence_dir(self.repository_root) self._lock = threading.Lock() + self._acquisition_lifecycle_gate = threading.RLock() self._provisioning_gate = threading.Lock() self._provisioning_active = False self._fingerprint_key = secrets.token_bytes(32) @@ -219,18 +271,23 @@ class XgridsK1CompatibilityService: self._operation_message: str | None = None self._operations = OperationJournal() self._acquisition: AcquisitionRecord | None = None + self._acquisition_project_name: str | None = None self._acquisition_out_dir: Path | None = None self._acquisition_start_operation_id: str | None = None self._acquisition_stop_operation_id: str | None = None self._acquisition_session_lease: ActiveSessionLease | None = None # The host-owned visual runtime receives the vendor normalizer # 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.repository_root, XGRIDS_K1_PLUGIN_ID, ) + @_serialized_acquisition_access def state(self) -> dict[str, Any]: runtime = self.runtime.snapshot() camera_preview = self.camera_preview.snapshot() @@ -253,6 +310,12 @@ class XgridsK1CompatibilityService: 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 { "starting_live", @@ -599,6 +662,7 @@ class XgridsK1CompatibilityService: } return self.state() + @_serialized_acquisition_access def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]: requested_streams = _validated_requested_streams(request) target = request.host or self.state()["k1_ip"] @@ -611,6 +675,15 @@ class XgridsK1CompatibilityService: raise ValueError( "адрес точки доступа устройства нельзя использовать как 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() request_fingerprint = self._request_fingerprint( ACTION_ACQUISITION_PREPARE, @@ -619,6 +692,7 @@ class XgridsK1CompatibilityService: "duration_seconds": request.duration_seconds, "requested_streams": requested_streams, "evidence_policy": request.evidence_policy, + "project_name": request.project_name, "compatibility_attestation": request.compatibility_attestation.model_dump( mode="json" ), @@ -649,6 +723,10 @@ class XgridsK1CompatibilityService: raise RuntimeError( "нельзя готовить acquisition во время настройки Wi-Fi устройства" ) + if self._acquisition_session_lease is not None: + raise RuntimeError( + "предыдущая evidence-сессия ещё не остановлена и не запечатана" + ) current = self._acquisition if current is not None and current.state not in TERMINAL_ACQUISITION_STATES: raise RuntimeError( @@ -674,6 +752,7 @@ class XgridsK1CompatibilityService: }, ) self._acquisition = acquisition + self._acquisition_project_name = request.project_name self._acquisition_out_dir = new_live_session_dir(self.evidence_root) self._acquisition_start_operation_id = None self._acquisition_stop_operation_id = None @@ -698,6 +777,7 @@ class XgridsK1CompatibilityService: raise return self.state() + @_serialized_acquisition_access def start_acquisition(self, request: StartAcquisitionRequest) -> dict[str, Any]: acquisition = self._require_acquisition(request.acquisition_id) if ( @@ -731,20 +811,31 @@ class XgridsK1CompatibilityService: message_code="acquisition.start.arming_receiver", ) + owns_start = False + cleanup_failed = False try: + runtime_state = self.runtime.snapshot() with self._lock: if acquisition.state != "prepared": raise RuntimeError( f"acquisition нельзя запустить из состояния {acquisition.state}" ) + if runtime_state.get("source_mode") != "idle": + raise RuntimeError( + "нельзя запускать acquisition поверх активного live/replay источника" + ) out_dir = self._acquisition_out_dir + project_name = self._acquisition_project_name if out_dir is None: raise RuntimeError("для acquisition не выделена evidence-сессия") + if project_name is None: + raise RuntimeError("для acquisition не задано название проекта") acquisition.transition( "starting", message_code="acquisition.start.waiting_receiver_ready", ) self._acquisition_start_operation_id = operation.operation_id + owns_start = True lease = ActiveSessionLease.acquire(out_dir.parent, out_dir) with self._lock: if self._acquisition_session_lease is not None: @@ -755,35 +846,51 @@ class XgridsK1CompatibilityService: acquisition.target_host, out_dir, duration_seconds=acquisition.duration_seconds, + project_name=project_name, ) self._arm_camera_recording(out_dir) except Exception as exc: - with suppress(Exception): - self.camera_preview.stop_recording( - status="failed", - failure_code="acquisition-start-failed", - ) - with suppress(Exception): - self.runtime.stop() - self._release_acquisition_session_lease() - with self._lock: - if acquisition.state not in TERMINAL_ACQUISITION_STATES: - acquisition.transition("failed", message_code="acquisition.start.failed") + if owns_start: + with self._lock: + if acquisition.state not in TERMINAL_ACQUISITION_STATES: + 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 self._lock: + if acquisition.state not in TERMINAL_ACQUISITION_STATES: + acquisition.transition("failed", message_code="acquisition.start.failed") self._operations.transition( operation.operation_id, "failed", stage_code="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 return self.state() + @_serialized_acquisition_access def stop_acquisition(self, request: StopAcquisitionRequest) -> dict[str, Any]: acquisition = self._require_acquisition(request.acquisition_id) with self._lock: acquisition_state = acquisition.state expected_stop_operation_id = self._acquisition_stop_operation_id + lease_retained = self._acquisition_session_lease is not None if request.operator_confirmed: if request.mode != "graceful": @@ -794,10 +901,15 @@ class XgridsK1CompatibilityService: raise ValueError( "подтверждение остановки должно ссылаться на исходную stop-operation" ) - elif request.mode == "graceful" and acquisition_state not in { - "acquiring", - "awaiting_external_stop", - }: + elif ( + request.mode == "graceful" + and acquisition_state + not in { + "acquiring", + "awaiting_external_stop", + } + and not (acquisition_state in TERMINAL_ACQUISITION_STATES and lease_retained) + ): raise ValueError( "graceful stop допустим только после подтверждённого потока point cloud" ) @@ -843,13 +955,51 @@ class XgridsK1CompatibilityService: if not created and not request.operator_confirmed: return self.state() if acquisition.state in TERMINAL_ACQUISITION_STATES: - self._operations.transition_if_pending( - operation.operation_id, - "succeeded", - stage_code="already-terminal", - message_code="acquisition.stop.already_terminal", - result={"acquisition_id": acquisition.acquisition_id}, - ) + 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( + operation.operation_id, + "succeeded", + stage_code="already-terminal", + message_code="acquisition.stop.already_terminal", + result={"acquisition_id": acquisition.acquisition_id}, + ) return self.state() if request.mode == "graceful" and not request.operator_confirmed: @@ -933,6 +1083,7 @@ class XgridsK1CompatibilityService: raise return self.state() + @_serialized_acquisition_access def abort_acquisition(self, request: AbortAcquisitionRequest) -> dict[str, Any]: acquisition = self._require_acquisition(request.acquisition_id) request_fingerprint = self._request_fingerprint( @@ -951,7 +1102,14 @@ class XgridsK1CompatibilityService: if not created: return self.state() 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( camera_status="interrupted", camera_failure_code="acquisition-aborted", @@ -997,6 +1155,7 @@ class XgridsK1CompatibilityService: def start_live( self, + project_name: str, host: str | None, duration_seconds: float, compatibility_attestation: CompatibilityAttestationRequest, @@ -1005,6 +1164,7 @@ class XgridsK1CompatibilityService: prepared = self.prepare_acquisition( PrepareAcquisitionRequest( + project_name=project_name, host=host, duration_seconds=duration_seconds, compatibility_attestation=compatibility_attestation, @@ -1019,28 +1179,44 @@ class XgridsK1CompatibilityService: StartAcquisitionRequest(acquisition_id=acquisition["acquisition_id"]) ) + @_serialized_acquisition_access def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]: with self._lock: 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: raise RuntimeError( "нельзя запускать replay во время активной acquisition-сессии; " "сначала остановите или отмените её" ) + if lease_retained: + raise RuntimeError( + "нельзя запускать replay: предыдущая evidence-сессия ещё не " + "остановлена и не запечатана" + ) replay_path = Path(path).expanduser().resolve() if not replay_path.is_relative_to(self.repository_root): raise ValueError("файл записи должен находиться внутри репозитория") self.runtime.start_replay(replay_path, speed=speed, loop=loop) return self.state() + @_serialized_acquisition_access def stop(self) -> dict[str, Any]: """Deprecated capture-only shim; it never claims that K1 stopped scanning.""" with self._lock: acquisition = self._acquisition + lease_retained = self._acquisition_session_lease is not None if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES: - self.camera_preview.stop_current() - self.runtime.stop() + 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.runtime.stop() return self.state() return self.stop_acquisition( StopAcquisitionRequest( @@ -1049,6 +1225,7 @@ class XgridsK1CompatibilityService: ) ) + @_serialized_acquisition_access def select_camera_preview(self, request: CameraPreviewSelectRequest) -> dict[str, Any]: target = self._camera_target_for_session(request.device_session_id) with self._lock: @@ -1064,13 +1241,28 @@ class XgridsK1CompatibilityService: self.camera_preview.select(request.source_id, target) return self.state() + @_serialized_acquisition_access def stop_camera_preview(self, request: CameraPreviewStopRequest) -> dict[str, Any]: self._camera_target_for_session(request.device_session_id) self.camera_preview.stop(request.generation) return self.state() + @_serialized_acquisition_access 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 + runtime_error: Exception | None = None + terminal_error: Exception | None = None try: self.camera_preview.close() except Exception as exc: @@ -1078,16 +1270,44 @@ class XgridsK1CompatibilityService: try: self.runtime.close() except Exception as exc: + runtime_error = exc if camera_error is not None: exc.add_note( "camera gateway cleanup also failed: " 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: - self._release_acquisition_session_lease() - if camera_error is not None: - raise camera_error + if terminal_error is None: + self._release_acquisition_session_lease() + if terminal_error is not None: + raise terminal_error def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]: self.runtime.update_scene_settings( @@ -1141,6 +1361,7 @@ class XgridsK1CompatibilityService: camera_failure_code: str | None, ) -> None: camera_error: Exception | None = None + runtime_error: Exception | None = None try: self.camera_preview.stop_recording( status=camera_status, @@ -1151,16 +1372,33 @@ class XgridsK1CompatibilityService: try: self.runtime.stop() except Exception as exc: + runtime_error = exc if camera_error is not None: exc.add_note( "camera archive cleanup also failed: " f"{type(camera_error).__name__}: {camera_error}" ) - raise + cleanup_complete = False + try: + if runtime_error is not None: + raise runtime_error + if camera_error is not None: + raise camera_error + self._seal_acquisition_capture_clock() + cleanup_complete = True finally: - self._release_acquisition_session_lease() - if camera_error is not None: - raise camera_error + 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: with self._lock: @@ -1169,6 +1407,39 @@ class XgridsK1CompatibilityService: if lease is not None: 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: with self._lock: self._operation_phase = phase @@ -1257,6 +1528,7 @@ class XgridsK1CompatibilityService: camera_terminal_status: Literal["complete", "failed"] | None = None camera_failure_code: str | None = None stop_runtime_for_camera_failure = False + complete_after_seal = False with self._lock: acquisition = self._acquisition 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" failed_operation_id = self._acquisition_start_operation_id 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( - "completed", - message_code="acquisition.receiver_completed", - result={"receiver_stopped": True, "device_state": "unknown"}, + "finalizing", + message_code="acquisition.finalizing_capture_clock", ) camera_terminal_status = "complete" + complete_after_seal = True elif acquisition.state == "awaiting_external_stop" and source_mode == "idle": acquisition.transition( "failed", @@ -1361,16 +1636,53 @@ class XgridsK1CompatibilityService: camera_failure_code = "receiver-completed-before-stop-confirmation" unconfirmed_stop_operation_id = self._acquisition_stop_operation_id + reconciliation_error: Exception | None = None if camera_terminal_status is not None: + terminal_error: Exception | None = None try: self.camera_preview.stop_recording( status=camera_terminal_status, failure_code=camera_failure_code, ) + except Exception as exc: + terminal_error = exc + if stop_runtime_for_camera_failure: + try: + 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: - self._release_acquisition_session_lease() - if stop_runtime_for_camera_failure: - self.runtime.stop() + if reconciliation_error is None: + self._release_acquisition_session_lease() if receiver_ready_operation_id is not None: self._operations.transition_if_pending( @@ -1440,6 +1752,8 @@ class XgridsK1CompatibilityService: self._acquisition_stop_operation_id = None if self._acquisition_stop_operation_id == unconfirmed_stop_operation_id: self._acquisition_stop_operation_id = None + if reconciliation_error is not None: + raise reconciliation_error class XgridsK1ServicePort(Protocol): @@ -1463,6 +1777,7 @@ class XgridsK1ServicePort(Protocol): def start_live( self, + project_name: str, host: str | None, duration_seconds: float, compatibility_attestation: CompatibilityAttestationRequest, @@ -1565,6 +1880,7 @@ class XgridsK1PluginFacade: live_request = LiveRequest.model_validate(payload) return await asyncio.to_thread( self.service.start_live, + live_request.project_name, live_request.host, live_request.duration_seconds, live_request.compatibility_attestation, @@ -1605,6 +1921,25 @@ def _utc_now_iso() -> str: 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( exc: Exception, *, @@ -1728,12 +2063,21 @@ def _sensor_catalog( "frame_id": "map", "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", "sensor_kind": "status", "modality": "device-status", "availability": "observed", - "decode_status": "raw-only", + "decode_status": "profile-decoded-physical-verified", "frame_id": None, "coordinate_convention": None, }, diff --git a/src/k1link/device_plugins/xgrids_k1/mqtt/capture.py b/src/k1link/device_plugins/xgrids_k1/mqtt/capture.py index 03af9ea..d503b8c 100644 --- a/src/k1link/device_plugins/xgrids_k1/mqtt/capture.py +++ b/src/k1link/device_plugins/xgrids_k1/mqtt/capture.py @@ -5,6 +5,8 @@ import ipaddress import json import math import os +import re +import stat import struct import time from collections.abc import Callable, Iterator @@ -36,6 +38,13 @@ LOOP_INTERVAL_SECONDS = 0.25 GROUP_COMMIT_INTERVAL_SECONDS = 0.5 GROUP_COMMIT_MAX_BYTES = 4 * 1024 * 1024 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. RAW_MAGIC = b"K1MQTT\x00\x01" @@ -60,12 +69,16 @@ StopReason = Literal[ class ArtifactPaths(TypedDict): raw: str metadata_jsonl: str + capture_clock_origin: str + capture_clock: str summary: str class ArtifactHashes(TypedDict): raw_sha256: str metadata_jsonl_sha256: str + capture_clock_origin_sha256: str + capture_clock_sha256: str class RawFormat(TypedDict): @@ -78,6 +91,7 @@ class CaptureSummary(TypedDict): schema_version: int created_at_utc: str completed_at_utc: str + capture_clock_scope: Literal["transport", "session"] sensitivity: str target_ipv4: str target_port: int @@ -89,6 +103,7 @@ class CaptureSummary(TypedDict): subscriptions: list[str] requested_duration_seconds: float capture_elapsed_seconds: float + session_elapsed_seconds: float operation_elapsed_seconds: float max_message_bytes: int connected: bool @@ -105,6 +120,32 @@ class CaptureSummary(TypedDict): 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): """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.raw_path = self.out_dir / "mqtt.raw.k1mqtt" 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.max_message_bytes = max_message_bytes self.message_count = 0 @@ -172,10 +215,18 @@ class _CaptureWriter: self._pending_metadata: list[str] = [] self._pending_raw_bytes = 0 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: 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()] if existing: names = ", ".join(existing) @@ -186,6 +237,19 @@ class _CaptureWriter: self._raw.write(RAW_MAGIC) self._metadata = _open_text_exclusive(self.metadata_path) _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: with suppress(OSError): self.close() @@ -262,8 +326,7 @@ class _CaptureWriter: if ( len(self._pending_metadata) >= GROUP_COMMIT_MAX_MESSAGES or self._pending_raw_bytes >= GROUP_COMMIT_MAX_BYTES - or time.monotonic() - self._last_commit_monotonic - >= GROUP_COMMIT_INTERVAL_SECONDS + or time.monotonic() - self._last_commit_monotonic >= GROUP_COMMIT_INTERVAL_SECONDS ): self._commit_pending() return CapturedMqttMessage( @@ -304,6 +367,31 @@ class _CaptureWriter: if first_error is not None: 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: if not self._pending_metadata: return @@ -348,9 +436,7 @@ class _CaptureWriter: metadata.write(payload) metadata.flush() os.fsync(metadata.fileno()) - self._last_commit_monotonic = ( - time.monotonic() if now_monotonic is None else now_monotonic - ) + self._last_commit_monotonic = time.monotonic() if now_monotonic is None else now_monotonic 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( host: str, out_dir: Path, @@ -444,6 +755,7 @@ def capture_mqtt( port: int = 1883, duration_seconds: float = 60.0, max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES, + on_clock_established: Callable[[], None] | None = None, on_ready: Callable[[], None] | None = None, on_message_recorded: Callable[[CapturedMqttMessage], None] | None = None, should_stop: Callable[[], bool] | None = None, @@ -471,7 +783,14 @@ def capture_mqtt( ) ) writer = _CaptureWriter(out_dir, max_message_bytes) - writer.open() + try: + writer.open() + if on_clock_established is not None: + on_clock_established() + except BaseException: + with suppress(OSError): + writer.close() + raise state = _CaptureState() created_at_utc = utc_now_iso() operation_started = time.monotonic() @@ -615,7 +934,11 @@ def capture_mqtt( if state.error is None: 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 summary = _build_summary( @@ -627,6 +950,7 @@ def capture_mqtt( operation_elapsed=operation_completed - operation_started, max_message_bytes=max_message_bytes, created_at_utc=created_at_utc, + capture_clock=capture_clock, state=state, ) try: @@ -651,12 +975,15 @@ def _build_summary( operation_elapsed: float, max_message_bytes: int, created_at_utc: str, + capture_clock: CaptureClockEnvelope, state: _CaptureState, ) -> CaptureSummary: + capture_clock_origin = read_capture_clock_origin(writer.capture_clock_origin_path) return { - "schema_version": 1, + "schema_version": 2, "created_at_utc": created_at_utc, "completed_at_utc": utc_now_iso(), + "capture_clock_scope": "transport", "sensitivity": "contains raw K1 MQTT payloads and local addressing; do not commit", "target_ipv4": target_ipv4, "target_port": port, @@ -668,6 +995,7 @@ def _build_summary( "subscriptions": list(REPORT_TOPICS), "requested_duration_seconds": duration_seconds, "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), "max_message_bytes": max_message_bytes, "connected": state.connected, @@ -687,11 +1015,15 @@ def _build_summary( "artifacts": { "raw": writer.raw_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, }, "artifact_hashes": { "raw_sha256": _sha256_file(writer.raw_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) +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: descriptor = os.open(path, os.O_RDONLY) try: diff --git a/src/k1link/device_plugins/xgrids_k1/observation.py b/src/k1link/device_plugins/xgrids_k1/observation.py index d152d18..e9dd3e8 100644 --- a/src/k1link/device_plugins/xgrids_k1/observation.py +++ b/src/k1link/device_plugins/xgrids_k1/observation.py @@ -2,10 +2,9 @@ from __future__ import annotations -import json -import os import stat import threading +from collections.abc import Mapping from pathlib import Path 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 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: @@ -95,8 +93,12 @@ def _to_host_candidate(candidate: LegacySessionCandidate) -> ObservationSessionC raw_bytes = candidate.raw_byte_length replay_raw_bytes = candidate.replay_raw_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 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 = [ ObservationArtifactCandidate( @@ -123,6 +125,34 @@ def _to_host_candidate(candidate: LegacySessionCandidate) -> ObservationSessionC 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 artifacts.extend( @@ -175,7 +205,6 @@ def _to_host_candidate(candidate: LegacySessionCandidate) -> ObservationSessionC for media in media_sources ) - timeline_origin = _timeline_origin(metadata_path) if replayable else None return ObservationSessionCandidate( session_id=session_id, display_name=candidate.display_name, @@ -189,8 +218,10 @@ def _to_host_candidate(candidate: LegacySessionCandidate) -> ObservationSessionC allowed_root=candidate.allowed_root, session_root=candidate.session_root, primary_replay_artifact_id="raw-transport-primary" if replayable else None, - timeline_origin_epoch_ns=None if timeline_origin is None else timeline_origin[0], - timeline_origin_monotonic_ns=None if timeline_origin is None else timeline_origin[1], + timeline_origin_epoch_ns=(candidate.timeline_origin_epoch_ns if replayable else None), + timeline_origin_monotonic_ns=( + candidate.timeline_origin_monotonic_ns if replayable else None + ), sources=tuple(sources), artifacts=tuple(artifacts), ) @@ -206,42 +237,11 @@ def _regular_file_size(path: Path) -> int: return value.st_size -def _timeline_origin(path: Path) -> tuple[int, int]: - flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) - try: - descriptor = os.open(path, flags) - try: - payload = os.read(descriptor, MAX_TIMELINE_ORIGIN_LINE_BYTES + 1) - finally: - os.close(descriptor) - first_line = payload.splitlines(keepends=True)[0] - if len(first_line) > MAX_TIMELINE_ORIGIN_LINE_BYTES or not first_line.endswith( - (b"\n", b"\r") - ): - raise ValueError - document = json.loads(first_line) - epoch_ns = document.get("received_at_epoch_ns") - monotonic_ns = document.get("received_monotonic_ns") - if ( - document.get("record_type") != "message" - or document.get("sequence") != 1 - or not _non_negative_int(epoch_ns) - or not _non_negative_int(monotonic_ns) - ): - raise ValueError - return int(epoch_ns), int(monotonic_ns) - except (IndexError, OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: - raise SessionIntegrityError("K1 transport timeline origin is invalid") from exc - - -def _non_negative_int(value: object) -> bool: - return isinstance(value, int) and not isinstance(value, bool) and value >= 0 - - def _export_recording( source: Path, destination: Path, *, + artifacts: Mapping[str, Path] | None = None, cancel_event: threading.Event | None = None, activity_callback: object | None = None, ) -> dict[str, object]: @@ -250,6 +250,12 @@ def _export_recording( export_k1mqtt_to_rrd( source, 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, activity_callback=activity_callback if callable(activity_callback) else None, ) diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/modeling.py b/src/k1link/device_plugins/xgrids_k1/protocol/modeling.py new file mode 100644 index 0000000..d0262bb --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/protocol/modeling.py @@ -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(" 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")) diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/modeling_state.py b/src/k1link/device_plugins/xgrids_k1/protocol/modeling_state.py new file mode 100644 index 0000000..3ddd709 --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/protocol/modeling_state.py @@ -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 diff --git a/src/k1link/device_plugins/xgrids_k1/rrd_export.py b/src/k1link/device_plugins/xgrids_k1/rrd_export.py index 1c38892..a5d0ab4 100644 --- a/src/k1link/device_plugins/xgrids_k1/rrd_export.py +++ b/src/k1link/device_plugins/xgrids_k1/rrd_export.py @@ -16,6 +16,21 @@ import rerun as rr from rerun import blueprint as rrb 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.viewer.replay import ( ReplayFormatError, @@ -45,6 +60,7 @@ JS_MAX_SAFE_INTEGER = (1 << 53) - 1 RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0") RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8") RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303") +RECORDED_METRICS_VIEW_ID = UUID("f973fc11-0867-4732-ad3c-97008621fab7") class RrdExportSummary(TypedDict): @@ -58,6 +74,7 @@ class RrdExportSummary(TypedDict): decoded_messages: int point_frames: int pose_frames: int + modeling_reports: int ignored_messages: int points: int trajectory_poses: int @@ -86,6 +103,7 @@ class _ExportCounters: source_messages: int = 0 point_frames: int = 0 pose_frames: int = 0 + modeling_reports: int = 0 ignored_messages: int = 0 points: int = 0 first_decoded_time_ns: int | None = None @@ -166,15 +184,18 @@ def export_k1mqtt_to_rrd( input_path: Path, output_path: Path, *, + capture_clock_path: Path | None = None, + capture_clock_origin_path: Path | None = None, cancel_event: threading.Event | None = None, activity_callback: Callable[[], None] | None = None, ) -> RrdExportSummary: """Losslessly project every decodable K1 data-plane frame into one RRD. 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 - receive-monotonic timestamp. It never traverses the bounded live-preview - queue, so export throughput cannot drop point or pose frames. + recording-local duration timeline whose zero is the durable capture-clock + origin for v2 recordings (or the first raw message for legacy captures). + 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, flushed and fsynced. Any decode, timing, sink or rename failure therefore @@ -202,8 +223,30 @@ def export_k1mqtt_to_rrd( counters = _ExportCounters() 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 + last_source_time_ns: int | None = None + last_source_capture_ns: int | None = None try: recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id) @@ -230,12 +273,37 @@ def export_k1mqtt_to_rrd( ) if session_origin_ns is None: 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 if session_time_ns > JS_MAX_SAFE_INTEGER: raise RrdExportError( "session duration exceeds the exact JavaScript nanosecond range" ) 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: decoded = normalize_k1_message( @@ -279,6 +347,23 @@ def export_k1mqtt_to_rrd( raise RrdExportError("native capture contains no decodable point or pose frames") assert counters.first_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) recording.flush(timeout_sec=30.0) @@ -306,18 +391,17 @@ def export_k1mqtt_to_rrd( decoded_messages=counters.decoded_messages, point_frames=counters.point_frames, pose_frames=counters.pose_frames, + modeling_reports=counters.modeling_reports, ignored_messages=counters.ignored_messages, points=counters.points, trajectory_poses=len(trajectory.positions), trajectory_updates=trajectory.updates, session_origin_monotonic_ns=session_origin_ns, timeline_start_ns=0, - # Playback completeness is defined by data actually written to - # the RRD. K1 status/heartbeat packets may continue long after the - # final point or pose frame; advertising that raw tail as the RRD - # end makes a strict browser buffering gate wait forever. - timeline_end_ns=counters.last_decoded_time_ns, - timeline_span_ns=counters.last_decoded_time_ns, + # Both endpoints are real RRD rows. This keeps camera media inside + # one seekable session without advertising an unmaterialized tail. + timeline_end_ns=timeline_end_ns, + timeline_span_ns=timeline_end_ns, first_decoded_time_ns=counters.first_decoded_time_ns, last_decoded_time_ns=counters.last_decoded_time_ns, 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") +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( settings: RerunSceneSettings, *, @@ -384,9 +520,7 @@ def _recorded_blueprint( # each Points3D row. A uniform custom color is the one color mode # that can be replaced safely by a singleton blueprint override. colors=( - [_parse_hex_color(settings.custom_color)] - if settings.palette == "custom" - else None + [_parse_hex_color(settings.custom_color)] if settings.palette == "custom" else None ), ).visualizer() point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID @@ -417,7 +551,12 @@ def _recorded_blueprint( time_ranges=time_ranges, ) 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 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), 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: @@ -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( recording: rr.RecordingStream, sequence: int, @@ -522,6 +701,22 @@ def _set_frame_time( 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( recording: rr.RecordingStream, frame: DecodedPointCloudView, diff --git a/src/k1link/device_plugins/xgrids_k1/viewer/runtime.py b/src/k1link/device_plugins/xgrids_k1/viewer/runtime.py index 6ecc7b7..ad51b3f 100644 --- a/src/k1link/device_plugins/xgrids_k1/viewer/runtime.py +++ b/src/k1link/device_plugins/xgrids_k1/viewer/runtime.py @@ -5,6 +5,7 @@ import queue import threading import time from collections.abc import Callable +from contextlib import suppress from datetime import UTC, datetime from pathlib import Path from typing import Literal, Protocol, TypedDict @@ -25,6 +26,7 @@ RuntimePhase = Literal[ "stopping", "error", ] +LIVE_CAPTURE_CLOCK_READY_TIMEOUT_SECONDS = 20.0 SourceMode = Literal["idle", "live", "replay"] StateCallback = Callable[[], None] BridgeFactory = Callable[..., RerunBridge] @@ -43,6 +45,10 @@ class CanonicalNormalizer(Protocol): ) -> DecodedDataPlaneView | None: ... +class RawMessageObserver(Protocol): + def __call__(self, message: StreamMessage, metrics: BridgeMetrics) -> bool: ... + + class RuntimeSnapshot(TypedDict): phase: RuntimePhase message: str @@ -65,6 +71,7 @@ class VisualizationRuntime: grpc_port: int = DEFAULT_GRPC_PORT, bridge_factory: BridgeFactory | None = None, normalizer: CanonicalNormalizer, + message_observer: RawMessageObserver | None = None, ) -> None: self._lock = threading.Lock() self._on_state_change = on_state_change @@ -80,6 +87,7 @@ class VisualizationRuntime: self._grpc_port = grpc_port self._bridge_factory = bridge_factory or RerunBridge self._normalizer = normalizer + self._message_observer = message_observer self._bridge: RerunBridge | None = None self._closed = False self._scene_settings = RerunSceneSettings() @@ -133,9 +141,11 @@ class VisualizationRuntime: out_dir: Path, *, duration_seconds: float = 3600.0, + project_name: str, ) -> None: if not math.isfinite(duration_seconds) or duration_seconds <= 0: raise ValueError("длительность приёма должна быть больше нуля") + clock_established = threading.Event() self._start( source_mode="live", phase="starting_live", @@ -144,8 +154,21 @@ class VisualizationRuntime: host, out_dir.expanduser().resolve(), 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: notify_only = False @@ -194,6 +217,11 @@ class VisualizationRuntime: if not thread_alive: self._bridge = None self._rerun_grpc_url = None + if thread_alive: + self._notify() + raise RuntimeError( + "поток не завершился за отведённое время; runtime и evidence lease сохранены" + ) if bridge is not None: try: bridge.close() @@ -278,8 +306,16 @@ class VisualizationRuntime: self._run_pipeline(produce, running_phase="replay") - def _run_live(self, host: str, out_dir: Path, *, duration_seconds: float) -> None: - _write_live_session_preamble(out_dir, host, duration_seconds) + def _run_live( + 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 on_message(message: CapturedMqttMessage) -> None: @@ -298,6 +334,7 @@ class VisualizationRuntime: host, out_dir / "captures" / "mqtt_live", duration_seconds=duration_seconds, + on_clock_established=clock_established.set, on_ready=lambda: self._set_running( "live", "Приём запущен. Теперь дважды нажмите физическую кнопку устройства.", @@ -325,6 +362,14 @@ class VisualizationRuntime: publisher_error: list[BaseException] = [] 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: messages.put_nowait(message) return @@ -525,7 +570,12 @@ def new_live_session_dir(sessions_root: Path) -> Path: 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) started_at_utc = utc_now_iso() 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", "target": "owner-controlled K1 at redacted RFC1918 address", "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", "credential_storage": "none", }, diff --git a/src/k1link/sessions/media.py b/src/k1link/sessions/media.py index d2c6389..aa1735d 100644 --- a/src/k1link/sessions/media.py +++ b/src/k1link/sessions/media.py @@ -94,6 +94,16 @@ class _Mp4VideoTiming: 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) class _Mp4ParseBudget: boxes_remaining: int = MAX_MP4_BOXES @@ -170,10 +180,13 @@ class RecordedMediaInspector: artifact: RecordedMediaArtifact, replay: ReplayCommand, epoch_paths: tuple[Path, ...], - ) -> tuple[ - RecordedMediaManifest, - tuple[tuple[int, int, int, int], ...], - ] | None: + ) -> ( + tuple[ + RecordedMediaManifest, + tuple[tuple[int, int, int, int], ...], + ] + | None + ): root = self._cache_root if root is None: return None @@ -406,8 +419,7 @@ def _manifest_from_sidecar( ) _validate_epoch_timeline(epochs) byte_length = sum( - epoch.init_byte_length - + sum(segment.byte_length for segment in epoch.segments) + epoch.init_byte_length + sum(segment.byte_length for segment in epoch.segments) 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, ) -> tuple[tuple[int, int, int, int], ...]: value = document.get("source_identity") - if ( - not isinstance(value, list) - or expected_length < 1 - or len(value) != expected_length - ): + if not isinstance(value, list) or expected_length < 1 or len(value) != expected_length: raise SessionIntegrityError("recorded media preparation source identity is invalid") identity: list[tuple[int, int, int, int]] = [] for item in value: @@ -561,10 +569,7 @@ def _write_sidecar_atomic(root: Path, filename: str, payload: bytes) -> None: try: descriptor = os.open( temporary, - os.O_WRONLY - | os.O_CREAT - | os.O_EXCL - | getattr(os, "O_NOFOLLOW", 0), + os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600, dir_fd=directory_fd, ) @@ -637,9 +642,7 @@ def _prepared_source_identity( identity: list[tuple[int, int, int, int]] = [] for artifact in replay.artifacts: metadata = _session_artifact_stat(artifact.path, replay.session_root) - identity.append( - (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns) - ) + identity.append((metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns)) for expected_ordinal, (epoch_path, epoch) in enumerate( zip(epoch_paths, epochs, strict=True), start=1, @@ -698,8 +701,7 @@ def _read_manifest( ) _validate_epoch_timeline(epochs) byte_length = sum( - epoch.init_byte_length - + sum(segment.byte_length for segment in epoch.segments) + epoch.init_byte_length + sum(segment.byte_length for segment in epoch.segments) for epoch in epochs ) 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_end_seconds = max(epoch.timeline_end_seconds for epoch in epochs) byte_length = sum( - epoch.init_byte_length - + sum(segment.byte_length for segment in epoch.segments) + epoch.init_byte_length + sum(segment.byte_length for segment in epoch.segments) for epoch in epochs ) descriptor = { @@ -798,11 +799,15 @@ def _read_epoch( raise SessionIntegrityError("recorded media segment count is invalid") try: - raw_lines = _read_confined_file( - epoch / "index.jsonl", - epoch, - MAX_MEDIA_INDEX_BYTES, - ).decode("utf-8").splitlines() + raw_lines = ( + _read_confined_file( + epoch / "index.jsonl", + epoch, + MAX_MEDIA_INDEX_BYTES, + ) + .decode("utf-8") + .splitlines() + ) except UnicodeDecodeError as exc: raise SessionIntegrityError("recorded media index is unavailable") from exc 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_sha256 = hashlib.sha256(init_payload).hexdigest() expected_init_sha256 = summary.get("init_sha256") - if ( - not isinstance(expected_init_sha256, str) - or expected_init_sha256 != init_sha256 - ): + if not isinstance(expected_init_sha256, str) or expected_init_sha256 != init_sha256: raise SessionIntegrityError("recorded media init digest changed") media_type = _mp4_media_type(init_payload) timing = _mp4_video_timing(init_payload, _Mp4ParseBudget()) - duration_units: list[int] = [] + fragment_timings: list[_Mp4VideoFragmentTiming] = [] for segment in segments: payload = _read_confined_file( segment.path, @@ -902,13 +904,21 @@ def _read_epoch( ) if hashlib.sha256(payload).hexdigest() != segment.sha256: raise SessionIntegrityError("recorded media segment digest changed") - duration_units.append( - _mp4_video_fragment_duration_units( + fragment_timings.append( + _mp4_video_fragment_timing( payload, timing, _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( duration_units[0], timing.timescale, @@ -919,10 +929,7 @@ def _read_epoch( total_duration_seconds = total_duration_units / timing.timescale timeline_start_seconds = max(0.0, timeline_points[0] - first_duration_seconds) timeline_end_seconds = timeline_start_seconds + total_duration_seconds - if ( - not math.isfinite(timeline_end_seconds) - or timeline_end_seconds < timeline_start_seconds - ): + if not math.isfinite(timeline_end_seconds) or timeline_end_seconds < timeline_start_seconds: raise SessionIntegrityError("recorded media epoch timeline is invalid") return RecordedMediaEpoch( ordinal=ordinal, @@ -984,23 +991,22 @@ def _mp4_fragment_duration_seconds(init_payload: bytes, fragment_payload: bytes) budget = _Mp4ParseBudget() timing = _mp4_video_timing(init_payload, budget) - duration_units = _mp4_video_fragment_duration_units( + fragment_timing = _mp4_video_fragment_timing( fragment_payload, timing, 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: if duration_units <= 0: raise SessionIntegrityError("recorded media fragment has no positive duration") duration = duration_units / timescale - if ( - not math.isfinite(duration) - or duration <= 0 - or duration > MAX_MP4_FRAGMENT_DURATION_SECONDS - ): + if not math.isfinite(duration) or duration <= 0 or duration > MAX_MP4_FRAGMENT_DURATION_SECONDS: raise SessionIntegrityError("recorded media fragment duration is outside bounds") 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") children = tuple(_iter_mp4_boxes(media_boxes[0], budget)) handlers = [ - _parse_hdlr_type(box_payload) - for box_type, box_payload in children - if box_type == b"hdlr" + _parse_hdlr_type(box_payload) for box_type, box_payload in children if box_type == b"hdlr" ] if len(handlers) != 1: 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] -def _mp4_video_fragment_duration_units( +def _mp4_video_fragment_timing( payload: bytes, timing: _Mp4VideoTiming, budget: _Mp4ParseBudget, -) -> int: +) -> _Mp4VideoFragmentTiming: moof_payloads = [ box_payload 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: 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): if box_type != b"traf": continue @@ -1110,18 +1114,28 @@ def _mp4_video_fragment_duration_units( track_id, fragment_default_duration = _parse_tfhd(tfhd_payloads[0]) if track_id != timing.track_id: 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"] if not trun_payloads: raise SessionIntegrityError("recorded media video fragment has no trun box") default_duration = fragment_default_duration or timing.default_sample_duration duration = sum( - _parse_trun_duration_units(trun, default_duration, budget) - for trun in trun_payloads + _parse_trun_duration_units(trun, default_duration, budget) for trun in trun_payloads ) - matching_durations.append(duration) - if len(matching_durations) != 1: + if base_decode_time > MAX_SAFE_INTEGER - duration: + 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") - return matching_durations[0] + return matching_timings[0] 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 +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( payload: bytes, default_duration: int | None, @@ -1210,9 +1235,7 @@ def _parse_trun_duration_units( cursor = _advance_box_cursor(payload, cursor, 4, "trun data offset") if flags & 0x000004: cursor = _advance_box_cursor(payload, cursor, 4, "trun first sample flags") - per_sample_width = sum( - 4 for flag in (0x000100, 0x000200, 0x000400, 0x000800) if flags & flag - ) + per_sample_width = sum(4 for flag in (0x000100, 0x000200, 0x000400, 0x000800) if flags & flag) if per_sample_width and sample_count > (len(payload) - cursor) // per_sample_width: raise SessionIntegrityError("recorded media trun samples are truncated") if flags & 0x000100: diff --git a/src/k1link/sessions/recording.py b/src/k1link/sessions/recording.py index 47fff44..3bb4f78 100644 --- a/src/k1link/sessions/recording.py +++ b/src/k1link/sessions/recording.py @@ -23,10 +23,10 @@ from .plugin_contract import ( RecordingExporter, ) -# v6 adds a real session_time=0 row to the RRD itself. Older sidecars can -# declare a zero start while their payload begins at the first decoded sensor -# frame, so accepting them would violate the browser playback contract. -CACHE_SCHEMA = "missioncore.derived-rerun-recording-cache/v7" +# v9 derives both real RRD boundary rows from the durable capture-clock +# envelope. v8 used the first/last MQTT message and could still exclude a +# camera fragment produced between source startup/shutdown and those packets. +CACHE_SCHEMA = "missioncore.derived-rerun-recording-cache/v9" COMPATIBLE_CACHE_SCHEMAS = frozenset({CACHE_SCHEMA}) RERUN_RECORDING_MEDIA_TYPE = "application/vnd.rerun.rrd" RERUN_SESSION_TIMELINE = "session_time" @@ -84,6 +84,7 @@ class _ValidatedArtifact: self.media_type, *_stat_identity(self.file_stat), self.replay_byte_length, + self.expected_sha256, ) @@ -358,9 +359,7 @@ class SessionRecordingMaterializer: try: session_roots = tuple(self.recordings_root.iterdir()) except OSError as exc: - raise RecordingMaterializationError( - "recording cache could not be scavenged" - ) from exc + raise RecordingMaterializationError("recording cache could not be scavenged") from exc for session_root in session_roots: try: root_stat = session_root.lstat() @@ -382,18 +381,13 @@ class SessionRecordingMaterializer: and name.endswith(".tmp") ) or (name.startswith(".source.") and name.endswith(".tmp")) - or ( - name.startswith(".scene.rrd.cache.json.") - and name.endswith(".tmp") - ) + or (name.startswith(".scene.rrd.cache.json.") and name.endswith(".tmp")) ) if not stale: continue try: child_stat = child.lstat() - if stat.S_ISDIR(child_stat.st_mode) and not stat.S_ISLNK( - child_stat.st_mode - ): + if stat.S_ISDIR(child_stat.st_mode) and not stat.S_ISLNK(child_stat.st_mode): shutil.rmtree(child) else: child.unlink() @@ -497,12 +491,17 @@ class SessionRecordingMaterializer: staged_root: Path | None = None export_source = source.primary.path + export_artifacts = {artifact.artifact_id: artifact.path for artifact in source.artifacts} 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( artifact.replay_byte_length != artifact.file_stat.st_size for artifact in source.artifacts ): - staged_root, export_source = _stage_replay_prefix( + staged_root, export_source, export_artifacts = _stage_replay_prefix( resolved_session_root, source, cancel_event=cancel_event, @@ -516,6 +515,7 @@ class SessionRecordingMaterializer: source.plugin_id, export_source, candidate_path, + artifacts=export_artifacts, cancel_event=cancel_event, activity_callback=lambda: _report_progress( progress_callback, @@ -527,9 +527,7 @@ class SessionRecordingMaterializer: _report_progress(progress_callback, "finalizing", 0.9) except PluginRecordingExportCancelled as exc: candidate_path.unlink(missing_ok=True) - raise RecordingMaterializationCancelled( - "recording preparation was cancelled" - ) from exc + raise RecordingMaterializationCancelled("recording preparation was cancelled") from exc except PluginRecordingExportError as exc: candidate_path.unlink(missing_ok=True) raise RecordingMaterializationError("native capture could not be exported") from exc @@ -548,18 +546,8 @@ class SessionRecordingMaterializer: if source_after.identity != source.identity: raise RecordingMaterializationError("native capture changed during RRD export") _chmod_best_effort(candidate_path, 0o600) - source_sha256 = _sha256_prefix_stable( - 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" - ) + artifact_digests = _validated_artifact_digests(source) + source_sha256 = artifact_digests[source.primary_artifact_id] candidate = _materialized_from_export( session_id=session_id, source_sha256=source_sha256, @@ -612,6 +600,7 @@ class SessionRecordingMaterializer: source: Path, destination: Path, *, + artifacts: Mapping[str, Path], cancel_event: threading.Event | None, activity_callback: Callable[[], None], ) -> Mapping[str, object]: @@ -622,6 +611,8 @@ class SessionRecordingMaterializer: ) exporter = cast(RrdExporter, selected) kwargs: dict[str, object] = {} + if _callable_accepts_keyword(exporter, "artifacts"): + kwargs["artifacts"] = dict(artifacts) if _callable_accepts_keyword(exporter, "cancel_event"): kwargs["cancel_event"] = cancel_event if _callable_accepts_keyword(exporter, "activity_callback"): @@ -705,28 +696,23 @@ class SessionRecordingMaterializer: if document["recording_mtime_ns"] != recording_stat.st_mtime_ns: return None - source_sha256 = _sha256_prefix_stable( - 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 + source_sha256: str | None = None for cached, artifact in zip(cached_artifacts, source.artifacts, strict=True): digest = _sha256_prefix_stable( artifact.path, artifact.file_stat, artifact.replay_byte_length, ) + if artifact.expected_sha256 is not None and digest != artifact.expected_sha256: + raise RecordingMaterializationError( + "recording artifact digest no longer matches catalog" + ) if digest != cached["sha256"]: 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) if recording_sha256 != document["recording_sha256"]: return None @@ -817,9 +803,10 @@ def _validate_source(command: ReplayCommand) -> _ValidatedSource: artifacts = getattr(command, "artifacts", None) if not isinstance(plugin_id, str) or SESSION_ID_PATTERN.fullmatch(plugin_id) is None: raise RecordingMaterializationError("replay command has an invalid plugin id") - if not isinstance(primary_artifact_id, str) or SESSION_ID_PATTERN.fullmatch( - primary_artifact_id - ) is None: + if ( + not isinstance(primary_artifact_id, str) + or SESSION_ID_PATTERN.fullmatch(primary_artifact_id) is None + ): raise RecordingMaterializationError("replay command has an invalid primary artifact") if not isinstance(artifacts, tuple) or not artifacts: raise RecordingMaterializationError("replay command has no source artifacts") @@ -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( *, session_id: str, @@ -1165,11 +1168,12 @@ def _stage_replay_prefix( *, cancel_event: threading.Event | 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" try: staged_root.mkdir(mode=0o700) staged_primary: Path | None = None + staged_artifacts: dict[str, Path] = {} for artifact in source.artifacts: staged = staged_root / artifact.path.name _copy_prefix_nofollow( @@ -1180,12 +1184,13 @@ def _stage_replay_prefix( cancel_event=cancel_event, activity_callback=activity_callback, ) + staged_artifacts[artifact.artifact_id] = staged if artifact.artifact_id == source.primary_artifact_id: staged_primary = staged if staged_primary is None: raise RecordingMaterializationError("staged recording has no primary artifact") _fsync_directory(staged_root) - return staged_root, staged_primary + return staged_root, staged_primary, staged_artifacts except BaseException: shutil.rmtree(staged_root, ignore_errors=True) raise @@ -1347,8 +1352,7 @@ def _callable_accepts_keyword(callback: Callable[..., object], keyword: str) -> except (TypeError, ValueError): return False return keyword in parameters or any( - parameter.kind is inspect.Parameter.VAR_KEYWORD - for parameter in parameters.values() + parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters.values() ) @@ -1359,14 +1363,17 @@ def _exclusive_file_lock(path: Path) -> Any: try: import fcntl except ImportError as exc: # pragma: no cover - production targets are POSIX - raise RecordingMaterializationError( - "cross-process recording lock is unavailable" - ) from exc + raise RecordingMaterializationError("cross-process recording lock is unavailable") from exc - flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) | getattr( - os, - "O_NOFOLLOW", - 0, + flags = ( + os.O_RDWR + | os.O_CREAT + | getattr(os, "O_CLOEXEC", 0) + | getattr( + os, + "O_NOFOLLOW", + 0, + ) ) try: descriptor = os.open(path, flags, 0o600) @@ -1383,9 +1390,7 @@ def _exclusive_file_lock(path: Path) -> Any: fcntl.flock(descriptor, fcntl.LOCK_EX) yield except OSError as exc: - raise RecordingMaterializationError( - "cross-process recording lock failed" - ) from exc + raise RecordingMaterializationError("cross-process recording lock failed") from exc finally: try: fcntl.flock(descriptor, fcntl.LOCK_UN) diff --git a/src/k1link/viewer/metrics.py b/src/k1link/viewer/metrics.py index ae9b699..f5aece3 100644 --- a/src/k1link/viewer/metrics.py +++ b/src/k1link/viewer/metrics.py @@ -24,6 +24,12 @@ class MetricsSnapshot(TypedDict): mqtt_to_publish_p95_ms: float | None decode_publish_ms: float | None 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: @@ -44,6 +50,12 @@ class BridgeMetrics: self._pose_times: deque[int] = deque() self._latencies_ms: deque[float] = deque(maxlen=512) 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: with self._lock: @@ -81,6 +93,33 @@ class BridgeMetrics: with self._lock: 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: now_ns = time.monotonic_ns() with self._lock: @@ -106,6 +145,12 @@ class BridgeMetrics: "mqtt_to_publish_p95_ms": _rounded(p95), "decode_publish_ms": _rounded(self._decode_publish_ms), "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, } diff --git a/tests/test_canonical_pipeline.py b/tests/test_canonical_pipeline.py index d787f6f..3c61cb2 100644 --- a/tests/test_canonical_pipeline.py +++ b/tests/test_canonical_pipeline.py @@ -1,8 +1,11 @@ from __future__ import annotations import inspect +import json import struct +import threading import time +from collections.abc import Callable from pathlib import Path import lz4.block @@ -301,11 +304,13 @@ def test_live_runtime_surfaces_preamble_failure_as_terminal_error( monkeypatch.setattr(runtime_module, "_write_live_session_preamble", fail_preamble) runtime = VisualizationRuntime(normalizer=normalize_k1_message) - runtime.start_live( - "192.168.1.20", - tmp_path / "unwritable-evidence", - duration_seconds=1.0, - ) + with pytest.raises(RuntimeError, match="session clock"): + runtime.start_live( + "192.168.1.20", + tmp_path / "unwritable-evidence", + duration_seconds=1.0, + project_name="Preamble failure test", + ) deadline = time.monotonic() + 2.0 snapshot = runtime.snapshot() while snapshot["phase"] == "starting_live" and time.monotonic() < deadline: @@ -317,3 +322,80 @@ def test_live_runtime_surfaces_preamble_failure_as_terminal_error( assert snapshot["source_ready"] is False assert "synthetic evidence directory failure" in snapshot["message"] 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) diff --git a/tests/test_frontend_plugin_boundaries.py b/tests/test_frontend_plugin_boundaries.py index d47f915..44daf89 100644 --- a/tests/test_frontend_plugin_boundaries.py +++ b/tests/test_frontend_plugin_boundaries.py @@ -104,7 +104,8 @@ def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity() assert hook_source.index("xgridsK1Api.prepareAcquisition") < hook_source.index( "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 "instanceId: deviceRef.device_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" ).read_text("utf-8") - assert "Подготовить приём данных" in connection_source - assert "Программная команда запуска на K1 пока не отправляется" in connection_source + assert "Подготовить локальный приём данных" in connection_source + assert "Программная команда запуска на K1 не отправляется" 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();") + ) diff --git a/tests/test_mqtt_capture.py b/tests/test_mqtt_capture.py index deed7f2..291f240 100644 --- a/tests/test_mqtt_capture.py +++ b/tests/test_mqtt_capture.py @@ -19,6 +19,8 @@ from k1link.device_plugins.xgrids_k1.mqtt.capture import ( CaptureFormatError, capture_mqtt, iter_capture_frames, + read_capture_clock_envelope, + seal_capture_clock, 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: fake = FakeClient() 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( "192.168.1.50", tmp_path / "capture", duration_seconds=30, + on_clock_established=on_clock_established, on_message_recorded=observed.append, _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.subscribe_calls == [[(topic, 0) for topic in REPORT_TOPICS]] assert fake.disconnect_count == 1 + assert clock_ready == [True] assert summary["stop_reason"] == "keyboard_interrupt" assert summary["message_count"] == 1 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()) assert saved_summary == summary + assert saved_summary["schema_version"] == 2 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 @@ -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 +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: fake = FakeClient() capture_dir = tmp_path / "capture" diff --git a/tests/test_plugin_catalog.py b/tests/test_plugin_catalog.py index c12ee04..8faec00 100644 --- a/tests/test_plugin_catalog.py +++ b/tests/test_plugin_catalog.py @@ -137,6 +137,8 @@ def test_repository_catalog_exposes_xgrids_model() -> None: }, {"id": "spatial.point-cloud.live", "label": "Облако точек"}, {"id": "spatial.pose.live", "label": "Траектория"}, + {"id": "device.modeling.live", "label": "Метрики маршрута"}, + {"id": "device.status.live", "label": "Состояние сканирования"}, {"id": "camera.preview.live", "label": "Видеокамеры"}, {"id": "evidence.raw-capture", "label": "Исходная запись"}, {"id": "evidence.replay", "label": "Повтор записи"}, diff --git a/tests/test_plugin_runtime.py b/tests/test_plugin_runtime.py index 58c2d70..495e794 100644 --- a/tests/test_plugin_runtime.py +++ b/tests/test_plugin_runtime.py @@ -69,11 +69,14 @@ class FakeXgridsService: def start_live( self, + project_name: str, host: str | None, duration_seconds: float, compatibility_attestation: CompatibilityAttestationRequest, ) -> 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"} 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: service = FakeXgridsService() - dispatcher = DevicePluginDispatcher( - [_in_process_runtime(XgridsK1PluginFacade(service))] - ) + dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))]) state = asyncio.run( dispatcher.invoke( @@ -373,9 +374,7 @@ def test_dispatcher_rejects_unknown_plugin_and_action() -> None: def test_facade_validates_payload_before_calling_service() -> None: service = FakeXgridsService() - dispatcher = DevicePluginDispatcher( - [_in_process_runtime(XgridsK1PluginFacade(service))] - ) + dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))]) with pytest.raises(ValidationError): asyncio.run( @@ -400,6 +399,7 @@ def test_facade_validates_payload_before_calling_service() -> None: ( ACTION_STREAM_START_LIVE, { + "project_name": "Plugin runtime test", "host": "192.168.1.20", "compatibility_attestation": { "firmware_version": "3.0.2", @@ -437,9 +437,7 @@ def test_facade_preserves_existing_runtime_operations( expected_call: str, ) -> None: service = FakeXgridsService() - dispatcher = DevicePluginDispatcher( - [_in_process_runtime(XgridsK1PluginFacade(service))] - ) + dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))]) 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() service = ThreadAwareService() - dispatcher = DevicePluginDispatcher( - [_in_process_runtime(XgridsK1PluginFacade(service))] - ) + dispatcher = DevicePluginDispatcher([_in_process_runtime(XgridsK1PluginFacade(service))]) event_loop_thread = threading.get_ident() asyncio.run(dispatcher.invoke(XGRIDS_K1_PLUGIN_ID, ACTION_STREAM_STOP, {})) diff --git a/tests/test_rerun_bridge.py b/tests/test_rerun_bridge.py index 88a8d75..c4af752 100644 --- a/tests/test_rerun_bridge.py +++ b/tests/test_rerun_bridge.py @@ -389,7 +389,8 @@ def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) -> runtime.start_replay(capture, speed=0.0) assert factory_entered.wait(timeout=2.0) - runtime.close(wait_seconds=0.01) + with pytest.raises(RuntimeError, match="evidence lease сохранены"): + runtime.close(wait_seconds=0.01) release_factory.set() deadline = time.monotonic() + 2.0 while time.monotonic() < deadline and not recording.disconnected: diff --git a/tests/test_rrd_export.py b/tests/test_rrd_export.py index d40aa7c..dc1b367 100644 --- a/tests/test_rrd_export.py +++ b/tests/test_rrd_export.py @@ -11,8 +11,13 @@ from pathlib import Path import pytest 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 ( + RECORDED_METRICS_VIEW_ID, RECORDED_POINTS_VISUALIZER_ID, RECORDED_ROOT_CONTAINER_ID, RECORDED_SPATIAL_VIEW_ID, @@ -43,9 +48,41 @@ def _pose_payload(x: float) -> bytes: return struct.pack(" 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(" 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( directory: Path, frames: list[tuple[str, bytes, int]], + *, + capture_clock_offsets_ns: tuple[int, int] | None = None, ) -> Path: capture = directory / "mqtt.raw.k1mqtt" raw = bytearray(RAW_MAGIC) @@ -70,6 +107,34 @@ def _write_capture( "".join(json.dumps(item) + "\n" for item in metadata), 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 @@ -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: - blueprint = _recorded_blueprint( - RerunSceneSettings(accumulation_seconds=0.0, show_grid=True) - ) + blueprint = _recorded_blueprint(RerunSceneSettings(accumulation_seconds=0.0, show_grid=True)) spatial_view = blueprint.root_container.contents[0] 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"] assert point_behavior.visible.as_arrow_array().to_pylist() == [True] assert trajectory_behavior.visible.as_arrow_array().to_pylist() == [True] - point_components = { - str(batch.component_descriptor()) for batch in point_visualizer.overrides - } + point_components = {str(batch.component_descriptor()) for batch in point_visualizer.overrides} 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] second_view = second.root_container.contents[0] 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[ - "/world/points" - ] + first_point_behavior, first_point_visualizer = first_view.visualizer_overrides["/world/points"] second_point_behavior, second_point_visualizer = second_view.visualizer_overrides[ "/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), ("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" @@ -222,13 +284,13 @@ def test_export_preserves_every_decodable_frame_and_source_timeline(tmp_path: Pa assert summary["points"] == 2 assert summary["trajectory_poses"] == 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_start_ns"] == 0 - assert summary["timeline_end_ns"] == 1_200_000_000 - assert summary["timeline_span_ns"] == 1_200_000_000 - assert summary["first_decoded_time_ns"] == 100_000_000 - assert summary["last_decoded_time_ns"] == 1_200_000_000 + assert summary["timeline_end_ns"] == 10_500_000_000 + assert summary["timeline_span_ns"] == 10_500_000_000 + assert summary["first_decoded_time_ns"] == 600_000_000 + assert summary["last_decoded_time_ns"] == 1_700_000_000 assert summary["source_sha256"] == _sha256(capture) assert summary["rrd_bytes"] == output.stat().st_size 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 "P0D" 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( diff --git a/tests/test_session_api.py b/tests/test_session_api.py index 120225d..8b0f885 100644 --- a/tests/test_session_api.py +++ b/tests/test_session_api.py @@ -103,6 +103,7 @@ def make_recorded_h264_fixture( *, timescale: int = 1_000, sample_duration: int = 500, + base_decode_time: int = 0, ) -> tuple[bytes, bytes]: def box(box_type: bytes, payload: bytes = b"") -> bytes: 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) 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")) - 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 @@ -543,8 +545,7 @@ def test_cold_replay_returns_quick_202_then_status_returns_ready_launch( render_file_response(viewer_response, range_header=None) ) viewer_headers = { - key.decode("latin-1"): value.decode("latin-1") - for key, value in viewer_start["headers"] + key.decode("latin-1"): value.decode("latin-1") for key, value in viewer_start["headers"] } assert viewer_start["status"] == 200 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 calls == 0 + def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped( tmp_path: Path, 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["timeline_start_seconds"] == source["timeline_start_seconds"] assert manifest["timeline_end_seconds"] == source["timeline_end_seconds"] - assert manifest_response.headers["etag"] == ( - f'"sha256:{manifest["generation_sha256"]}"' - ) + assert manifest_response.headers["etag"] == (f'"sha256:{manifest["generation_sha256"]}"') init_sha256 = hashlib.sha256(init).hexdigest() segment_sha256 = hashlib.sha256(segment).hexdigest() 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_body == segment[:6] - assert headers["cache-control"] == ( - "private, max-age=31536000, immutable, no-transform" - ) + assert headers["cache-control"] == ("private, max-age=31536000, immutable, no-transform") assert headers["etag"] == f'"sha256:{segment_sha256}"' assert headers["content-length"] == "6" 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)) deadline = time.monotonic() + 2 snapshot = manager.status(session.name) - while ( - snapshot is None or snapshot.state != "ready" - ) and time.monotonic() < deadline: + while (snapshot is None or snapshot.state != "ready") and time.monotonic() < deadline: time.sleep(0.005) snapshot = manager.status(session.name) 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" session = make_legacy_session(sessions, "20260716T205632Z_viewer_live") 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.append("init", init, host_epoch_ns=1_100_000_000, host_monotonic_ns=2_100_000_000) first.append( @@ -1295,7 +1295,7 @@ def test_recorded_media_epoch_ends_are_monotonic_generation_bound_and_not_rrd_pa ) second.append( "media", - segment, + following_segment, host_epoch_ns=2_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( tmp_path: Path, ) -> None: diff --git a/tests/test_session_recording.py b/tests/test_session_recording.py index bec7d7b..f732f1a 100644 --- a/tests/test_session_recording.py +++ b/tests/test_session_recording.py @@ -129,6 +129,40 @@ def test_materializer_reuses_only_a_digest_validated_private_cache(tmp_path: Pat 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( "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") with metadata.open("ab") as stream: stream.write(b'{"record_type":"message"') - observed: list[tuple[int, int]] = [] + observed: list[tuple[int, int, set[str]]] = [] 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( - (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) exporter = PrefixExporter() @@ -300,7 +346,7 @@ def test_materializer_exports_only_validated_prefix_before_crash_tail( 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 command.primary_artifact.path.read_bytes().endswith(b"uncommitted-raw-tail") diff --git a/tests/test_session_store.py b/tests/test_session_store.py index 86d17b2..82d0297 100644 --- a/tests/test_session_store.py +++ b/tests/test_session_store.py @@ -100,14 +100,65 @@ def make_legacy_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( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: repository = tmp_path / "repo" - assert resolve_missioncore_evidence_dir(repository) == ( - repository / ".runtime" / "mission-core" / "evidence" / "sessions" - ).resolve() + assert ( + resolve_missioncore_evidence_dir(repository) + == (repository / ".runtime" / "mission-core" / "evidence" / "sessions").resolve() + ) configured = tmp_path / "external-evidence" 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 == () +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( session: Path, source_id: str = "sensor.camera.left", @@ -191,8 +271,7 @@ def replace_summary_with_recovery_metadata( } ) metadata = b"".join( - (json.dumps(record, separators=(",", ":")) + "\n").encode("utf-8") - for record in records + (json.dumps(record, separators=(",", ":")) + "\n").encode("utf-8") for record in records ) if corrupt_trailing_line: 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 +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( tmp_path: Path, ) -> None: @@ -260,8 +442,7 @@ def test_reconcile_claims_pre_plugin_catalog_row_without_changing_session_identi (session.name,), ) connection.execute( - "UPDATE observation_session_artifacts SET replay_byte_length = 0 " - "WHERE session_id = ?", + "UPDATE observation_session_artifacts SET replay_byte_length = 0 WHERE session_id = ?", (session.name,), ) 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 command.primary_artifact.replay_byte_length == committed_bytes assert ( - command.primary_artifact.replay_byte_length - < command.primary_artifact.path.stat().st_size + command.primary_artifact.replay_byte_length < command.primary_artifact.path.stat().st_size ) diff --git a/tests/test_stream_decoders.py b/tests/test_stream_decoders.py index b1837e7..02111c0 100644 --- a/tests/test_stream_decoders.py +++ b/tests/test_stream_decoders.py @@ -2,10 +2,18 @@ from __future__ import annotations import math import struct +from types import SimpleNamespace import lz4.block 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 ( ProtobufWireError, decode_zigzag64, @@ -21,6 +29,7 @@ from k1link.device_plugins.xgrids_k1.protocol.streams import ( decode_lio_pose, decode_pre_path_array, ) +from k1link.viewer.metrics import BridgeMetrics def _varint(value: int) -> bytes: @@ -136,6 +145,77 @@ def test_decode_lio_pose_rejects_nonfinite_float() -> None: 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: envelope = struct.pack(" None: - self.start_calls.append((host, out_dir, duration_seconds)) + def start_live( + self, + host: str, + out_dir: Path, + *, + duration_seconds: float, + project_name: str, + ) -> None: + self.start_calls.append((host, out_dir, duration_seconds, project_name)) self.phase = "starting_live" self.source_mode = "live" @@ -85,6 +95,9 @@ class FakeVisualizationRuntime: self.source_mode = "idle" self.source_ready = False + def close(self) -> None: + self.stop() + def service_with_fake_runtime( tmp_path: Path, @@ -102,6 +115,7 @@ def test_prepare_creates_provisional_device_session_and_profiled_acquisition( state = service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", duration_seconds=60, compatibility_attestation=ATTESTATION, @@ -112,14 +126,33 @@ def test_prepare_creates_provisional_device_session_and_profiled_acquisition( assert state["device_ref"]["identity_stability"] == "provisional" assert state["device_ref"]["device_id"] != state["device_session"]["device_session_id"] assert state["acquisition"]["state"] == "prepared" + assert state["acquisition"]["project_name"] == PROJECT_NAME assert state["acquisition"]["compatibility_profile_id"] == (XGRIDS_K1_COMPATIBILITY_PROFILE_ID) assert state["compatibility"]["vendor_writes_enabled"] is False +def test_project_name_is_normalized_and_control_characters_are_rejected() -> None: + request = PrepareAcquisitionRequest( + project_name=" K1 Lab ", + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + + assert request.project_name == "K1 Lab" + for invalid in (" ", "line\nbreak", "\ud800", "x" * 97): + with pytest.raises(ValidationError): + PrepareAcquisitionRequest( + project_name=invalid, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + + def test_operator_manual_start_is_confirmed_only_by_real_point_data(tmp_path: Path) -> None: service, runtime = service_with_fake_runtime(tmp_path) prepared = service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", duration_seconds=60, compatibility_attestation=ATTESTATION, @@ -136,6 +169,7 @@ def test_operator_manual_start_is_confirmed_only_by_real_point_data(tmp_path: Pa assert awaiting["acquisition"]["state"] == "awaiting_external_start" assert awaiting["last_operation"]["status"] == "operator_action_required" assert len(runtime.start_calls) == 1 + assert runtime.start_calls[0][3] == PROJECT_NAME runtime.pcl_frames = 1 acquiring = service.state() @@ -145,10 +179,334 @@ def test_operator_manual_start_is_confirmed_only_by_real_point_data(tmp_path: Pa assert acquiring["last_operation"]["result"]["confirmation"] == "point-frame" +def test_second_start_conflict_does_not_tear_down_start_owner(tmp_path: Path) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + + with pytest.raises(RuntimeError, match="состояния starting"): + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + + state = service.state() + start_operations = [ + item for item in state["operations"] if item["action"] == "acquisition.start" + ] + assert state["acquisition"]["state"] == "starting" + assert runtime.stop_calls == 0 + assert [item["status"] for item in start_operations] == ["running", "failed"] + assert start_operations[-1]["error"]["category"] == "conflict" + + +def test_state_waits_for_atomic_start_handoff( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + entered_start = threading.Event() + release_start = threading.Event() + state_started = threading.Event() + state_finished = threading.Event() + worker_errors: list[BaseException] = [] + snapshots: list[dict[str, Any]] = [] + original_start_live = runtime.start_live + + def blocked_start_live(*args: object, **kwargs: object) -> None: + entered_start.set() + if not release_start.wait(timeout=2): + raise TimeoutError("test did not release start handoff") + original_start_live(*args, **kwargs) # type: ignore[arg-type] + + def start_worker() -> None: + try: + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + except BaseException as exc: # pragma: no cover - asserted below + worker_errors.append(exc) + + def state_worker() -> None: + state_started.set() + try: + snapshots.append(service.state()) + except BaseException as exc: # pragma: no cover - asserted below + worker_errors.append(exc) + finally: + state_finished.set() + + monkeypatch.setattr(runtime, "start_live", blocked_start_live) + start_thread = threading.Thread(target=start_worker) + state_thread = threading.Thread(target=state_worker) + start_thread.start() + assert entered_start.wait(timeout=2) + state_thread.start() + assert state_started.wait(timeout=2) + assert not state_finished.wait(timeout=0.05) + release_start.set() + start_thread.join(timeout=2) + state_thread.join(timeout=2) + + assert not start_thread.is_alive() + assert not state_thread.is_alive() + assert worker_errors == [] + assert snapshots[0]["acquisition"]["state"] == "starting" + assert runtime.source_mode == "live" + assert service._acquisition_session_lease is not None # noqa: SLF001 + + +def test_abort_waits_for_start_handoff_then_stops_owned_producers( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + entered_start = threading.Event() + release_start = threading.Event() + abort_finished = threading.Event() + worker_errors: list[BaseException] = [] + original_start_live = runtime.start_live + + def blocked_start_live(*args: object, **kwargs: object) -> None: + entered_start.set() + if not release_start.wait(timeout=2): + raise TimeoutError("test did not release start handoff") + original_start_live(*args, **kwargs) # type: ignore[arg-type] + + def start_worker() -> None: + try: + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + except BaseException as exc: # pragma: no cover - asserted below + worker_errors.append(exc) + + def abort_worker() -> None: + try: + service.abort_acquisition(AbortAcquisitionRequest(acquisition_id=acquisition_id)) + except BaseException as exc: # pragma: no cover - asserted below + worker_errors.append(exc) + finally: + abort_finished.set() + + monkeypatch.setattr(runtime, "start_live", blocked_start_live) + start_thread = threading.Thread(target=start_worker) + abort_thread = threading.Thread(target=abort_worker) + start_thread.start() + assert entered_start.wait(timeout=2) + abort_thread.start() + assert not abort_finished.wait(timeout=0.05) + release_start.set() + start_thread.join(timeout=2) + abort_thread.join(timeout=2) + + assert worker_errors == [] + state = service.state() + assert state["acquisition"]["state"] == "aborted" + assert runtime.source_mode == "idle" + assert service._acquisition_session_lease is None # noqa: SLF001 + + +def test_camera_selection_finishes_before_serialized_acquisition_stop( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + device_session_id = prepared["device_session"]["device_session_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + service._k1_ip = "192.168.1.20" # noqa: SLF001 + entered_camera_arm = threading.Event() + release_camera_arm = threading.Event() + stop_finished = threading.Event() + worker_errors: list[BaseException] = [] + events: list[str] = [] + + def blocked_camera_arm(_out_dir: Path, *, require_session: bool = False) -> None: + assert require_session is True + events.append("arm") + entered_camera_arm.set() + if not release_camera_arm.wait(timeout=2): + raise TimeoutError("test did not release camera arm") + + def select_worker() -> None: + try: + service.select_camera_preview( + CameraPreviewSelectRequest( + source_id="sensor.camera.left", + device_session_id=device_session_id, + ) + ) + except BaseException as exc: # pragma: no cover - asserted below + worker_errors.append(exc) + + def stop_worker() -> None: + try: + service.stop_acquisition( + StopAcquisitionRequest( + acquisition_id=acquisition_id, + mode="capture-only", + ) + ) + except BaseException as exc: # pragma: no cover - asserted below + worker_errors.append(exc) + finally: + stop_finished.set() + + monkeypatch.setattr(service, "_arm_camera_recording", blocked_camera_arm) + monkeypatch.setattr( + service.camera_preview, + "select", + lambda _source_id, _target: events.append("select"), + ) + monkeypatch.setattr( + service.camera_preview, + "stop_recording", + lambda **_kwargs: events.append("camera-stop"), + ) + monkeypatch.setattr(runtime, "stop", lambda: events.append("runtime-stop")) + select_thread = threading.Thread(target=select_worker) + stop_thread = threading.Thread(target=stop_worker) + select_thread.start() + assert entered_camera_arm.wait(timeout=2) + stop_thread.start() + assert not stop_finished.wait(timeout=0.05) + release_camera_arm.set() + select_thread.join(timeout=2) + stop_thread.join(timeout=2) + + assert worker_errors == [] + assert events == ["arm", "select", "camera-stop", "runtime-stop"] + assert service.state()["acquisition"]["state"] == "completed" + assert service._acquisition_session_lease is None # noqa: SLF001 + + +def test_camera_arm_failure_seals_stopped_session_before_releasing_lease( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + events: list[str] = [] + + def start_live( + _host: str, + out_dir: Path, + *, + duration_seconds: float, + project_name: str, + ) -> None: + assert duration_seconds > 0 + assert project_name == PROJECT_NAME + events.append("start") + out_dir.mkdir(parents=True) + runtime.phase = "starting_live" + runtime.source_mode = "live" + + def stop_runtime() -> None: + events.append("runtime") + runtime.phase = "idle" + runtime.source_mode = "idle" + + monkeypatch.setattr(runtime, "start_live", start_live) + monkeypatch.setattr(runtime, "stop", stop_runtime) + monkeypatch.setattr( + service, + "_arm_camera_recording", + lambda _out_dir: (_ for _ in ()).throw(RuntimeError("synthetic camera arm failure")), + ) + monkeypatch.setattr( + service.camera_preview, + "stop_recording", + lambda **_kwargs: events.append("camera"), + ) + monkeypatch.setattr( + facade_module, + "seal_capture_clock", + lambda _capture_root: events.append("seal"), + ) + + with pytest.raises(RuntimeError, match="synthetic camera arm failure"): + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + + state = service.state() + assert state["acquisition"]["state"] == "failed" + assert state["last_operation"]["status"] == "failed" + assert events == ["start", "camera", "runtime", "seal"] + assert service._acquisition_session_lease is None # noqa: SLF001 + + +def test_start_cleanup_failure_retains_lease_and_marks_side_effect_unknown( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + + def fail_camera_arm(out_dir: Path) -> None: + out_dir.mkdir() + raise RuntimeError("synthetic camera arm failure") + + monkeypatch.setattr(service, "_arm_camera_recording", fail_camera_arm) + monkeypatch.setattr(facade_module, "seal_capture_clock", lambda _capture_root: None) + runtime.stop_error = RuntimeError("synthetic cleanup timeout") + with pytest.raises(RuntimeError, match="synthetic camera arm failure"): + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + + state = service.state() + operation = next(item for item in state["operations"] if item["action"] == "acquisition.start") + assert state["acquisition"]["state"] == "failed" + assert operation["status"] == "failed" + assert operation["error"]["side_effect_status"] == "unknown" + assert service._acquisition_session_lease is not None # noqa: SLF001 + + runtime.stop_error = None + service.stop() + assert service._acquisition_session_lease is None # noqa: SLF001 + + def test_receiver_completion_without_point_data_fails_start_operation(tmp_path: Path) -> None: service, runtime = service_with_fake_runtime(tmp_path) prepared = service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", duration_seconds=60, compatibility_attestation=ATTESTATION, @@ -170,6 +528,7 @@ def test_capture_only_stop_never_claims_that_physical_k1_stopped(tmp_path: Path) service, runtime = service_with_fake_runtime(tmp_path) prepared = service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", duration_seconds=60, compatibility_attestation=ATTESTATION, @@ -193,10 +552,256 @@ def test_capture_only_stop_never_claims_that_physical_k1_stopped(tmp_path: Path) assert operations["acquisition.stop"]["status"] == "succeeded" +def test_stop_seals_session_clock_after_camera_and_runtime_before_lease_release( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + out_dir = tmp_path / "evidence-session" + out_dir.mkdir() + service._acquisition_out_dir = out_dir # noqa: SLF001 + events: list[object] = [] + + monkeypatch.setattr( + service.camera_preview, + "stop_recording", + lambda **_kwargs: events.append("camera"), + ) + monkeypatch.setattr(runtime, "stop", lambda: events.append("runtime")) + monkeypatch.setattr( + facade_module, + "seal_capture_clock", + lambda capture_root: events.append(("seal", capture_root)), + ) + + class Lease: + def release(self) -> None: + events.append("lease") + + service._acquisition_session_lease = Lease() # type: ignore[assignment] # noqa: SLF001 + service._stop_acquisition_sources( # noqa: SLF001 + camera_status="complete", + camera_failure_code=None, + ) + + assert events == [ + "camera", + "runtime", + ("seal", out_dir / "captures" / "mqtt_live"), + "lease", + ] + + +def test_stop_timeout_retains_lease_and_blocks_replacement_acquisition( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + out_dir = service._acquisition_out_dir # noqa: SLF001 + assert out_dir is not None + out_dir.mkdir() + seal_calls: list[Path] = [] + monkeypatch.setattr( + facade_module, + "seal_capture_clock", + lambda capture_root: seal_calls.append(capture_root), + ) + runtime.stop_error = RuntimeError("synthetic stop timeout") + + with pytest.raises(RuntimeError, match="synthetic stop timeout"): + service.stop_acquisition( + StopAcquisitionRequest(acquisition_id=acquisition_id, mode="capture-only") + ) + + assert seal_calls == [] + assert service._acquisition_session_lease is not None # noqa: SLF001 + assert service.state()["acquisition"]["cleanup_pending"] is True + with pytest.raises(RuntimeError, match="evidence-сессия"): + service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name="replacement", + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + + runtime.stop_error = None + retried = service.stop() + assert retried["acquisition"]["state"] == "failed" + assert len(seal_calls) == 1 + assert service._acquisition_session_lease is None # noqa: SLF001 + assert retried["acquisition"]["cleanup_pending"] is False + replacement = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name="replacement", + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + assert replacement["acquisition"]["state"] == "prepared" + + +def test_replay_rejects_retained_failed_acquisition_lease( + tmp_path: Path, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + runtime.stop_error = RuntimeError("synthetic stop timeout") + with pytest.raises(RuntimeError, match="synthetic stop timeout"): + service.stop_acquisition( + StopAcquisitionRequest(acquisition_id=acquisition_id, mode="capture-only") + ) + + with pytest.raises(RuntimeError, match="не запечатана"): + service.start_replay("sessions/fixture.k1mqtt", speed=1.0, loop=False) + + runtime.stop_error = None + service.stop() + + +def test_new_explicit_stop_retries_retained_terminal_cleanup( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + out_dir = service._acquisition_out_dir # noqa: SLF001 + assert out_dir is not None + out_dir.mkdir() + seal_calls: list[Path] = [] + monkeypatch.setattr( + facade_module, + "seal_capture_clock", + lambda capture_root: seal_calls.append(capture_root), + ) + runtime.stop_error = RuntimeError("synthetic stop timeout") + with pytest.raises(RuntimeError, match="synthetic stop timeout"): + service.stop_acquisition( + StopAcquisitionRequest(acquisition_id=acquisition_id, mode="capture-only") + ) + + runtime.stop_error = None + recovered = service.stop_acquisition( + StopAcquisitionRequest( + acquisition_id=acquisition_id, + mode="capture-only", + idempotency_key="retry-retained-cleanup", + ) + ) + + retry_operation = recovered["last_operation"] + assert retry_operation["status"] == "succeeded" + assert retry_operation["stage_code"] == "retained-cleanup-completed" + assert retry_operation["result"]["device_stop"] == "unknown" + assert len(seal_calls) == 1 + assert service._acquisition_session_lease is None # noqa: SLF001 + + +def test_natural_receiver_completion_fails_acquisition_when_session_clock_cannot_seal( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + runtime.pcl_frames = 1 + service.state() + out_dir = service._acquisition_out_dir # noqa: SLF001 + assert out_dir is not None + out_dir.mkdir() + monkeypatch.setattr( + facade_module, + "seal_capture_clock", + lambda _capture_root: (_ for _ in ()).throw(RuntimeError("synthetic seal failure")), + ) + runtime.phase = "idle" + runtime.source_mode = "idle" + + with pytest.raises(RuntimeError, match="synthetic seal failure"): + service.state() + + assert service._acquisition is not None # noqa: SLF001 + assert service._acquisition.state == "failed" # noqa: SLF001 + + +def test_natural_receiver_completion_reserves_finalization_before_reentrant_callback( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + runtime.pcl_frames = 1 + service.state() + out_dir = service._acquisition_out_dir # noqa: SLF001 + assert out_dir is not None + out_dir.mkdir() + events: list[str] = [] + + def reentrant_camera_stop(**_kwargs: object) -> None: + events.append("camera") + nested = service.state() + assert nested["acquisition"]["state"] == "finalizing" + + monkeypatch.setattr(service.camera_preview, "stop_recording", reentrant_camera_stop) + monkeypatch.setattr( + facade_module, + "seal_capture_clock", + lambda _capture_root: events.append("seal"), + ) + runtime.phase = "idle" + runtime.source_mode = "idle" + + completed = service.state() + + assert completed["acquisition"]["state"] == "completed" + assert events == ["camera", "seal"] + + def test_graceful_stop_waits_for_explicit_operator_confirmation(tmp_path: Path) -> None: service, runtime = service_with_fake_runtime(tmp_path) prepared = service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", duration_seconds=60, compatibility_attestation=ATTESTATION, @@ -264,6 +869,7 @@ def test_graceful_stop_retry_by_idempotency_key_reuses_original_operation( service, runtime = service_with_fake_runtime(tmp_path) prepared = service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", compatibility_attestation=ATTESTATION, ) @@ -304,6 +910,7 @@ def test_unrelated_graceful_stop_is_rejected_while_confirmation_is_pending( service, runtime = service_with_fake_runtime(tmp_path) prepared = service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", compatibility_attestation=ATTESTATION, ) @@ -342,6 +949,7 @@ def test_graceful_stop_is_rejected_until_point_data_confirms_acquisition( service, runtime = service_with_fake_runtime(tmp_path) prepared = service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", compatibility_attestation=ATTESTATION, ) @@ -371,6 +979,7 @@ def test_prepare_rejects_unsupported_evidence_policies( with pytest.raises(ValueError, match="evidence_policy=required"): service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", evidence_policy=evidence_policy, # type: ignore[arg-type] compatibility_attestation=ATTESTATION, @@ -396,6 +1005,7 @@ def test_prepare_rejects_stream_subsets_and_duplicates( with pytest.raises(ValueError, match="полный проверенный набор"): service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", requested_streams=requested_streams, # type: ignore[arg-type] compatibility_attestation=ATTESTATION, @@ -422,6 +1032,7 @@ def test_exact_profile_is_inactive_until_explicit_operator_attestation( attested = service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", compatibility_attestation=ATTESTATION, ) @@ -441,6 +1052,7 @@ def test_prepare_rejects_device_ap_fallback_as_direct_lan_target(tmp_path: Path) with pytest.raises(ValueError, match="точки доступа"): service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.56.1", compatibility_attestation=ATTESTATION, ) @@ -455,6 +1067,7 @@ def test_runtime_error_terminalizes_pending_graceful_stop(tmp_path: Path) -> Non service, runtime = service_with_fake_runtime(tmp_path) prepared = service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", compatibility_attestation=ATTESTATION, ) @@ -487,6 +1100,7 @@ def test_receiver_completion_terminalizes_unconfirmed_graceful_stop( service, runtime = service_with_fake_runtime(tmp_path) prepared = service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", compatibility_attestation=ATTESTATION, ) @@ -519,10 +1133,57 @@ def test_receiver_completion_terminalizes_unconfirmed_graceful_stop( assert stop_operation["error"]["code"] == ("receiver-completed-before-device-stop-confirmation") +def test_unconfirmed_stop_operation_terminalizes_before_clock_seal_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + runtime.pcl_frames = 1 + service.state() + awaiting = service.stop_acquisition( + StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful") + ) + stop_operation_id = awaiting["last_operation"]["operation_id"] + out_dir = service._acquisition_out_dir # noqa: SLF001 + assert out_dir is not None + out_dir.mkdir() + monkeypatch.setattr( + facade_module, + "seal_capture_clock", + lambda _capture_root: (_ for _ in ()).throw(RuntimeError("synthetic seal failure")), + ) + runtime.phase = "idle" + runtime.source_mode = "idle" + + with pytest.raises(RuntimeError, match="synthetic seal failure"): + service.state() + + state = service.state() + stop_operation = next( + item for item in state["operations"] if item["operation_id"] == stop_operation_id + ) + assert state["acquisition"]["state"] == "failed" + assert stop_operation["status"] == "failed" + assert service._acquisition_session_lease is not None # noqa: SLF001 + + monkeypatch.setattr(facade_module, "seal_capture_clock", lambda _capture_root: None) + service.stop() + + def test_abort_failure_terminalizes_acquisition_and_pending_start(tmp_path: Path) -> None: service, runtime = service_with_fake_runtime(tmp_path) prepared = service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", compatibility_attestation=ATTESTATION, ) @@ -546,10 +1207,182 @@ def test_abort_failure_terminalizes_acquisition_and_pending_start(tmp_path: Path assert state["source_mode"] == "live" +def test_abort_reserves_stopping_before_reentrant_runtime_callback( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + runtime.pcl_frames = 1 + service.state() + out_dir = service._acquisition_out_dir # noqa: SLF001 + assert out_dir is not None + out_dir.mkdir() + events: list[str] = [] + + monkeypatch.setattr( + service.camera_preview, + "stop_recording", + lambda **_kwargs: events.append("camera"), + ) + + def reentrant_runtime_stop() -> None: + events.append("runtime") + runtime.phase = "idle" + runtime.source_mode = "idle" + nested = service.state() + assert nested["acquisition"]["state"] == "stopping" + + monkeypatch.setattr(runtime, "stop", reentrant_runtime_stop) + monkeypatch.setattr( + facade_module, + "seal_capture_clock", + lambda _capture_root: events.append("seal"), + ) + + aborted = service.abort_acquisition(AbortAcquisitionRequest(acquisition_id=acquisition_id)) + + assert aborted["acquisition"]["state"] == "aborted" + assert events == ["camera", "runtime", "seal"] + assert service._acquisition_session_lease is None # noqa: SLF001 + + +def test_clean_close_seals_and_cancels_pending_start_operation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + out_dir = service._acquisition_out_dir # noqa: SLF001 + assert out_dir is not None + out_dir.mkdir() + events: list[str] = [] + + monkeypatch.setattr( + service.camera_preview, + "close", + lambda: events.append("camera"), + ) + + def close_runtime() -> None: + events.append("runtime") + runtime.phase = "idle" + runtime.source_mode = "idle" + + monkeypatch.setattr(runtime, "close", close_runtime) + monkeypatch.setattr( + facade_module, + "seal_capture_clock", + lambda _capture_root: events.append("seal"), + ) + + service.close() + state = service.state() + start_operation = next( + item for item in state["operations"] if item["action"] == "acquisition.start" + ) + assert state["acquisition"]["state"] == "interrupted" + assert start_operation["status"] == "cancelled" + assert events == ["camera", "runtime", "seal"] + assert service._acquisition_session_lease is None # noqa: SLF001 + + +def test_clean_close_cancels_pending_external_stop_operation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + runtime.pcl_frames = 1 + service.state() + awaiting = service.stop_acquisition( + StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful") + ) + stop_operation_id = awaiting["last_operation"]["operation_id"] + monkeypatch.setattr(service.camera_preview, "close", lambda: None) + monkeypatch.setattr(runtime, "close", lambda: runtime.stop()) + + service.close() + state = service.state() + stop_operation = next( + item for item in state["operations"] if item["operation_id"] == stop_operation_id + ) + assert state["acquisition"]["state"] == "interrupted" + assert stop_operation["status"] == "cancelled" + assert all( + item["status"] not in {"accepted", "running", "operator_action_required"} + for item in state["operations"] + if item["action"].startswith("acquisition.") + ) + + +def test_close_failure_fails_pending_operation_and_retains_lease( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + monkeypatch.setattr(service.camera_preview, "close", lambda: None) + monkeypatch.setattr( + runtime, + "close", + lambda: (_ for _ in ()).throw(RuntimeError("synthetic close timeout")), + ) + + with pytest.raises(RuntimeError, match="synthetic close timeout"): + service.close() + + state = service.state() + start_operation = next( + item for item in state["operations"] if item["action"] == "acquisition.start" + ) + assert state["acquisition"]["state"] == "failed" + assert start_operation["status"] == "failed" + assert start_operation["error"]["side_effect_status"] == "unknown" + assert service._acquisition_session_lease is not None # noqa: SLF001 + + runtime.stop_error = None + service.stop() + assert service._acquisition_session_lease is None # noqa: SLF001 + + def test_replay_is_rejected_during_nonterminal_acquisition(tmp_path: Path) -> None: service, _ = service_with_fake_runtime(tmp_path) service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", compatibility_attestation=ATTESTATION, ) @@ -561,6 +1394,30 @@ def test_replay_is_rejected_during_nonterminal_acquisition(tmp_path: Path) -> No assert service.state()["acquisition"]["state"] == "prepared" +def test_prepare_rejects_active_replay_without_stopping_or_replacing_it( + tmp_path: Path, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + runtime.phase = "replay" + runtime.source_mode = "replay" + runtime.source_ready = True + + with pytest.raises(RuntimeError, match="активного live/replay"): + service.prepare_acquisition( + PrepareAcquisitionRequest( + project_name=PROJECT_NAME, + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + + state = service.state() + assert state["acquisition"] is None + assert state["source_mode"] == "replay" + assert runtime.stop_calls == 0 + assert service._acquisition_session_lease is None # noqa: SLF001 + + def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_boundary( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -636,6 +1493,7 @@ def test_provisioning_cannot_switch_device_during_active_acquisition( service._devices = [{"device_id": "k1-a"}] # noqa: SLF001 service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", compatibility_attestation=ATTESTATION, ) @@ -683,6 +1541,7 @@ def test_sensor_catalog_exposes_two_browser_adapter_cameras_after_profile_attest state = service.prepare_acquisition( PrepareAcquisitionRequest( + project_name=PROJECT_NAME, host="192.168.1.20", compatibility_attestation=ATTESTATION, ) @@ -696,10 +1555,7 @@ def test_sensor_catalog_exposes_two_browser_adapter_cameras_after_profile_attest assert len(cameras) == 2 assert all(camera["availability"] == "available" for camera in cameras) assert all(camera["modality"] == "encoded-video" for camera in cameras) - assert all( - camera["decode_status"] == "rtsp-h264-observed-browser-remux" - for camera in cameras - ) + assert all(camera["decode_status"] == "rtsp-h264-observed-browser-remux" for camera in cameras) assert all(camera["activation"]["max_active"] == 1 for camera in cameras) assert all(camera["delivery"] is None for camera in cameras) assert state["connection_verification"]["network_reachability"] == "unknown" diff --git a/tests/test_xgrids_compatibility_profile.py b/tests/test_xgrids_compatibility_profile.py index 16988ab..91ce2f8 100644 --- a/tests/test_xgrids_compatibility_profile.py +++ b/tests/test_xgrids_compatibility_profile.py @@ -46,6 +46,8 @@ def test_xgrids_compatibility_profile_loads_exact_firmware_and_sources() -> None profile = LOADER.load_compatibility_profile() 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"]["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, "write_enabled": False, } - raw_status = { + decoded_status = { "observed": True, - "decoded": False, + "decoded": True, "replay_verified": False, "physical_verified": True, "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.pose.live"]["evidence"] == verified_stream - assert channels["device.status.live"]["evidence"] == raw_status - assert channels["device.heartbeat.live"]["evidence"] == raw_status - assert channels["device.status.live"]["semantic_payload"] is None + assert channels["device.modeling.live"]["evidence"] == decoded_status + assert channels["device.status.live"]["evidence"] == decoded_status + 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"] 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["topic"] == "lixel/application/request/modeling" assert mapping["qos"] == 2 + assert mapping["retain"] is False assert mapping["message_type"] == "ModelingRequest" 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["evidence"]["observed"] 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) +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: profile = LOADER.load_compatibility_profile() modified = copy.deepcopy(profile) diff --git a/tests/test_xgrids_modeling_control_protocol.py b/tests/test_xgrids_modeling_control_protocol.py new file mode 100644 index 0000000..995875f --- /dev/null +++ b/tests/test_xgrids_modeling_control_protocol.py @@ -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