Author SHA1 Message Date
DCCONSTRUCTIONS a19053dfd4 docs(simulation): accept Polygon SIM S0 2026-07-24 17:24:26 +03:00
DCCONSTRUCTIONS 2548bb5740 fix(simulation): accept ROS multi-document samples 2026-07-24 17:21:04 +03:00
DCCONSTRUCTIONS fc5cbdff8a fix(simulation): scope worker git ownership checks 2026-07-24 17:20:10 +03:00
DCCONSTRUCTIONS c9b1009259 feat(simulation): qualify S0 time and resource gates 2026-07-24 17:19:28 +03:00
DCCONSTRUCTIONS 6290fcb2ce feat(simulation): qualify D-only stock rover smoke 2026-07-24 16:51:39 +03:00
DCCONSTRUCTIONS 81761067e9 feat(simulation): establish polygon S0 qualification 2026-07-24 15:29:08 +03:00
DCCONSTRUCTIONS 7fba39a629 feat(perception): add camera ego-motion evidence 2026-07-24 13:37:39 +03:00
DCCONSTRUCTIONS 230cba4b21 feat(perception): add persistent lidar motion evidence 2026-07-24 12:49:11 +03:00
DCCONSTRUCTIONS e6dd8c2cf1 feat(perception): qualify world-frame motion tracking 2026-07-24 11:17:10 +03:00
DCCONSTRUCTIONS 23181c867b feat(perception): qualify inline temporal stability 2026-07-24 09:10:07 +03:00
DCCONSTRUCTIONS cfc7b062da feat(perception): qualify bounded temporal stability 2026-07-23 23:30:50 +03:00
DCCONSTRUCTIONS 5a29a536a2 fix(observation): admit bounded LAB chunk coverage 2026-07-23 22:34:07 +03:00
DCCONSTRUCTIONS 082b7057da fix(perception): bound LAB replay to AI window 2026-07-23 21:15:53 +03:00
DCCONSTRUCTIONS 10019a454a feat(perception): publish immutable lab session instances 2026-07-23 20:13:40 +03:00
DCCONSTRUCTIONS f1f8e21b78 feat(perception): qualify 1x realtime replay envelope 2026-07-23 19:01:12 +03:00
DCCONSTRUCTIONS ae2ce055c8 fix(viewer): preserve follow camera across layer toggles 2026-07-23 17:14:32 +03:00
DCCONSTRUCTIONS a7babf60db fix(viewer): keep recorded follow locked to rig 2026-07-23 15:11:38 +03:00
DCCONSTRUCTIONS 90450ae320 Preserve recorded camera orbit when toggling follow 2026-07-23 13:57:36 +03:00
DCCONSTRUCTIONS ecd95a22f0 feat(viewer): add recorded point colors and trajectory follow 2026-07-23 13:33:08 +03:00
DCCONSTRUCTIONS 9f712bf353 fix(viewer): gate recorded perception readiness 2026-07-23 12:27:48 +03:00
DCCONSTRUCTIONS ee15160862 fix(viewer): make recorded layers deterministic 2026-07-23 11:42:19 +03:00
DCCONSTRUCTIONS 3a64f54dea fix(viewer): preserve operator camera across AI layers 2026-07-23 10:51:18 +03:00
DCCONSTRUCTIONS a0706fd5d8 feat(perception): add ground-aware cuboid refusion 2026-07-23 08:24:37 +03:00
DCCONSTRUCTIONS b53d6d5a45 feat(perception): integrate calibrated operator pipeline
Add calibrated K1 projection, recorded and near-live perception qualification, unified Rerun operator layers, bounded replay admission, audited viewer controls, worker experiments, and lab evidence.
2026-07-23 00:23:28 +03:00
DCCONSTRUCTIONS ada2a55ee6 docs(perception): record Rerun projection acceptance 2026-07-19 17:39:28 +03:00
DCCONSTRUCTIONS 2b53168149 feat(perception): project recorded results into Rerun 2026-07-19 17:39:13 +03:00
DCCONSTRUCTIONS 31fc4f6567 docs(perception): record external worker acceptance 2026-07-19 16:46:39 +03:00
DCCONSTRUCTIONS 648d5bced4 feat(compute): add bounded camera job contract 2026-07-19 16:46:32 +03:00
DCCONSTRUCTIONS 75d2e5da4b docs(k1): record connection mode acceptance 2026-07-19 09:52:33 +03:00
DCCONSTRUCTIONS 57b2208cae feat(k1): add local connection matrix 2026-07-19 09:52:22 +03:00
DCCONSTRUCTIONS fecb5885d0 docs(milestone): record K1 control and archive acceptance 2026-07-19 01:07:33 +03:00
DCCONSTRUCTIONS 71c85e9894 feat(archive): complete recorded session lifecycle 2026-07-19 01:07:21 +03:00
DCCONSTRUCTIONS ffffee1879 feat(k1): complete canonical control lifecycle 2026-07-19 01:07:02 +03:00
DCCONSTRUCTIONS d7a2c22faf fix(k1): simplify response-gated launch UX 2026-07-18 18:17:16 +03:00
DCCONSTRUCTIONS 7b7b5d6cad feat(k1): wire canonical control session to UI 2026-07-18 17:53:55 +03:00
DCCONSTRUCTIONS 36f0c93d2a fix(k1): gate control dialogue on live state 2026-07-18 17:19:21 +03:00
DCCONSTRUCTIONS a29b38a0d7 fix(k1): record redacted acceptance failures 2026-07-18 16:11:29 +03:00
DCCONSTRUCTIONS eddc09008e feat(k1): add physical acceptance transport 2026-07-18 15:17:46 +03:00
DCCONSTRUCTIONS a4accf0fb6 feat(k1): wire dormant control lease 2026-07-18 14:37:02 +03:00
DCCONSTRUCTIONS ea811ff370 feat(k1): add offline control execution gate 2026-07-18 14:14:30 +03:00
DCCONSTRUCTIONS d7749208a7 feat(k1): recover exact application bootstrap 2026-07-18 13:54:25 +03:00
DCCONSTRUCTIONS af9319a33b feat(k1): add device-bound shadow control gate 2026-07-18 12:09:23 +03:00
DCCONSTRUCTIONS 0ac6424c46 fix(ui): scope spatial tools to scene 2026-07-18 11:31:44 +03:00
DCCONSTRUCTIONS 574a494759 fix(archive): preserve prepared sessions across restarts 2026-07-18 11:08:27 +03:00
DCCONSTRUCTIONS aa3680948f feat(k1): complete primary acquisition lifecycle 2026-07-17 23:03:59 +03:00
DCCONSTRUCTIONS 9d51080d2e feat(plugins): add runtime handshake boundary 2026-07-17 19:50:01 +03:00
DCCONSTRUCTIONS 24a47318f2 feat(plugins): isolate device integrations 2026-07-17 19:29:32 +03:00
DCCONSTRUCTIONS f9ffb7bd1c docs(observation): document durable sessions and viewer lifecycle 2026-07-17 17:55:39 +03:00
DCCONSTRUCTIONS e94c64eebd feat(control-station): add atomic recorded-session playback 2026-07-17 17:51:24 +03:00
DCCONSTRUCTIONS 007ff9fff2 feat(k1): integrate durable evidence with acquisition lifecycle 2026-07-17 17:51:05 +03:00
DCCONSTRUCTIONS 656f0c524d feat(sessions): add durable observation archive and replay API 2026-07-17 17:50:54 +03:00
DCCONSTRUCTIONS aa2df560b7 feat(ui): refine live observation workspace 2026-07-17 01:04:48 +03:00
DCCONSTRUCTIONS 2bda1986bd feat(k1): add live cameras and reliable spatial following 2026-07-17 00:26:03 +03:00
DCCONSTRUCTIONS a281faf923 feat(observation): add dynamic camera sources 2026-07-16 20:56:41 +03:00
DCCONSTRUCTIONS 05bdab24a5 docs: record Mission Core K1 architecture milestone 2026-07-16 19:44:16 +03:00
DCCONSTRUCTIONS e6f7648b84 feat(device-plugins): add profiled K1 lifecycle and canonical data plane 2026-07-16 19:44:06 +03:00
DCCONSTRUCTIONS 19ab973110 feat(lab): add isolated iPhone capture evidence 2026-07-16 19:43:41 +03:00
DCCONSTRUCTIONS 63dd8c8790 feat(plugin-sdk): add executable v0alpha2 contracts 2026-07-16 19:43:26 +03:00
DCCONSTRUCTIONS 27bf7527df feat: introduce device plugin runtime boundary 2026-07-16 13:49:10 +03:00
DCCONSTRUCTIONS 9225227421 chore: rename repository to NODEDC MISSION CORE 2026-07-16 12:10:05 +03:00
DCCONSTRUCTIONS 8459bb03fb fix: align control station with current checker API 2026-07-16 11:56:45 +03:00
478 changed files with 144598 additions and 4304 deletions
+1 -1
View File
@@ -9,4 +9,4 @@
*.xbin binary
*.las binary
*.lcc binary
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
+5
View File
@@ -12,6 +12,7 @@ __pycache__/
.coverage
htmlcov/
dist/
apps/control-station/dist-shadow/
build/
*.egg-info/
@@ -21,10 +22,14 @@ build/
!.env.example
config/local.toml
private/
.runtime/
# Real laboratory captures and decoded artifacts are sensitive and large.
captures/
sessions/
# The host runtime package shares the domain name but is source, not evidence.
!src/k1link/sessions/
!src/k1link/sessions/**/*.py
artifacts/raw/
artifacts/decoded/
*.pcap
+4 -3
View File
@@ -1,8 +1,9 @@
# Repository operating rules
This repository investigates an owner-controlled XGRIDS/LixelKity K1 as a
black-box sensor. Preserve the device, the Mac, the working LAN, and raw
evidence.
NODEDC MISSION CORE is a vendor-neutral mission-control monorepo. Its first
real device plugin investigates an owner-controlled XGRIDS/LixelKity K1 as a
black-box sensor. Preserve the device, the host, the working LAN, raw evidence,
and the boundary between Mission Core and vendor-specific integration code.
## Non-negotiable safety boundaries
+268 -53
View File
@@ -1,16 +1,52 @@
# NDC XGRIDS K1 Connector
# NODEDC MISSION CORE
Pre-production research project for connecting an owner-controlled
XGRIDS/LixelKity K1 to a Mac without LixelGO, firmware changes, device opening,
or speculative writes.
NODEDC MISSION CORE is the vendor-neutral control, observation, mission-planning,
recording, and integration platform for NODEDC autonomous systems. This
repository is the canonical early-stage monorepo: it contains the Mission Core
Control Station, the current local control plane, shared contracts as they are
extracted, and device plugins.
Current status: live proof completed on firmware 3.0.2. The Mac provisioned the
K1 onto an existing LAN without LixelGO, connected to its MQTT broker, captured
the scan-correlated point-cloud and pose streams, and decoded both successfully.
The repository also contains a local React control console, an automatic Rerun
gRPC bridge for the verified point-cloud and trajectory streams, and a
self-hosted Rerun Web Viewer embedded in that console. The former Foxglove
bridge remains only as a legacy regression module.
The first proven hardware vertical is the XGRIDS/LixelKity K1 plugin. On firmware
3.0.2 the host provisions the scanner onto an existing LAN without LixelGO,
connects to its MQTT broker, persists each raw frame before preview work, decodes
point cloud and pose, and renders the real cloud plus trajectory through an
embedded self-hosted Rerun Web Viewer. Native MQTT persistence uses bounded
group commit (at most 0.5 seconds, 4 MiB or 32 messages), and camera archives
commit complete fMP4 segments before their index rows. These are explicit
crash-RPO bounds, not a zero-loss or disk-replication claim. The former
Foxglove bridge remains only as a legacy regression module.
The backend vendor implementation is physically isolated below
`src/k1link/device_plugins/xgrids_k1/`; its reviewed manifest and compatibility
profile remain below `plugins/xgrids-k1/`. Mission Core host code discovers
archive sources, recovery hooks and recording exporters only through the plugin
runtime contribution. The verified wire protocol and raw evidence format are
unchanged.
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. The physically accepted interactive control path carries the
same validated value in its single canonical START; there is no separate
project-name write.
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
manifest before actions are admitted. Every action then crosses immutable SDK
`RuntimeActionInvocation` and `RuntimeActionResult` validation through a
replaceable runtime transport seam. The meanings remain a local experimental
vocabulary rather than a mutation of NODE.DC Platform Ontology. The current
transport is deliberately in process: it uses an explicitly injected K1
normalizer to produce transport-neutral local consumer views; portable SDK
stream envelopes, process isolation, durable operations, multi-device routing
and the remote Edge split remain later gates.
The application-control bootstrap reads and correlates live `DeviceInfo` before
START. The exact profile requires model `LixelKity K1`, observed platform type
`A4`, activated state, firmware `3.0.2`, and direct-LAN topology. The UI selects
that profile but does not ask the operator to attest firmware manually.
The repository now contains one narrowly gated state-changing command:
`ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning
@@ -18,12 +54,21 @@ profile and requires explicit `--confirm-write`; the Wi-Fi password is collected
through a hidden local macOS dialog. MQTT capture and decoding are read-only.
Nothing changes router settings, firmware or global Python packages.
## Available stand
## Verified XGRIDS K1 stand
- one XGRIDS/LixelKity K1;
- one Apple Silicon MacBook running macOS;
- one ordinary TP-Link Deco/mesh network used by other devices;
- no LixelGO, phone, Linux host, dedicated AP, OpenWrt, or vendor SDK.
- the proven baseline used no LixelGO, phone, Linux host, dedicated AP,
OpenWrt, or vendor SDK.
An owner-controlled iPhone with LixelGO is now available for a separate,
evidence-only observation stage. It does not invalidate the no-phone baseline
and is not a runtime dependency. The decision and gated runbook are
[`ADR 0005`](docs/adr/0005-owner-controlled-lixelgo-iphone-observation.md) and
[`docs/08_LIXELGO_IPHONE_OBSERVATION.md`](docs/08_LIXELGO_IPHONE_OBSERVATION.md).
The Mac capture environment is isolated under
`plugins/xgrids-k1/lab/iphone-capture/` and does not require full Xcode.
The ordinary router is sufficient for the first gates. We first observe the
existing LAN without changing it. A Guest/IoT SSID is optional and may be
@@ -39,10 +84,11 @@ The project has three independent gates:
3. Without LixelGO, K1 can be associated with Wi-Fi and a proprietary data
session can be opened.
All three gates are now proven on the tested unit. The external stream is plain
All three gates are now proven on the tested unit. The spatial stream is plain
MQTT 3.1.1 on TCP 1883. Firmware-3 `lio_pcl` is protobuf wrapped in a raw LZ4
block, and `lio_pose` is an uncompressed protobuf. Raw panoramic camera access is
still unproven and is not implied by point-cloud success.
block, and `lio_pose` is an uncompressed protobuf. Owner-operated LixelGO capture
also proved separate left/right RTSP/H.264 camera previews on TCP 8554. This is a
compressed preview contract, not proof of full-resolution raw camera access.
## Local environment
@@ -51,26 +97,76 @@ This does not install Python packages globally and does not modify neighboring
repositories.
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NDC_xgrids-k1-connector
uv sync --group dev
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE
uv sync --frozen --group dev
uv run k1link doctor
uv run pytest
```
## NODE.DC Control Station and visualization adapters
## External perception worker
The browser application is now a universal NODE.DC Control Station rather than
Mission Core can now package one sealed camera epoch as an immutable,
content-addressed `missioncore.compute-job/v1` without changing the source
evidence. The first recorded TEST007 job was digest-verified and decoded on a
separate Windows RTX 4090 worker, executed through pinned Triton/YOLOX-S and
returned a content-addressed `missioncore.compute-result/v1` containing 56
session-timestamped generic COCO detections. The worker never connects to K1 and
does not own the raw archive.
Mission Core now validates and projects that optional result into the same saved
Rerun recording. Open TEST007 and use **Распознавание** to see the recorded
left-camera frames and boxes; **Облако точек** returns to 3D. This remains a
recorded camera-only acceptance, not a navigation stack: large-epoch
performance, direct worker transport and bounded live fan-out remain open. See
[ADR 0014](docs/adr/0014-bounded-external-perception-worker.md)
and the [external worker contract](docs/10_EXTERNAL_PERCEPTION_WORKER.md).
## Simulation Polygon
Mission Core now has a parallel Polygon product branch for reproducible
autonomy qualification. Mission Core owns scenarios, qualification-run
lifecycle, authority, canonical contracts, provenance and reports; Gazebo, PX4
SITL, ROS 2, Nav2 and viewers remain replaceable providers. Simulation, replay,
digital twin, HIL and physical shadow keep distinct causal run kinds.
SIM S0 is accepted on the reviewed D-only worker. Its strict profile fixes
loopback-only process isolation, exact provider pins, a 2 ms physics step,
1×/2× RTF/resource budgets and disabled real/direct actuator authority. Two
accepted-profile stock-rover runs and nine digest-bound evidence claims passed;
the target doctor returned `GO`. The doctor remains read-only and a development
host without the private evidence pack still returns `INCOMPLETE`:
```bash
uv run missioncore-sim s0 doctor \
--evidence /path/to/private/evidence.yaml \
--json
```
This accepts simulation infrastructure, not navigation behavior, safety
behavior, S1 command authority or real actuator control.
See the [Polygon product/SRS](docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md),
[ADR 0015](docs/adr/0015-simulation-polygon-qualification-boundary.md) and the
[SIM S0 worker runbook](docs/runbooks/SIM_S0_AI_WORKER.md).
## Mission Core Control Station and visualization adapters
The browser application is the universal Mission Core Control Station rather than
a K1-specific Foxglove launcher. Its fixed shell contains six architectural
sections — Center, Fleet, Observation, Missions, Data and System — while the K1
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter.
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter. Its
React provisioning, acquisition/replay and diagnostic blocks live beside the
plugin manifest under `plugins/xgrids-k1/frontend`; the generic application
mounts them through one reviewed composition import.
Install, type-check, build and serve the complete local application from the
repository root:
```bash
uv sync --group dev
cd apps/k1-viewer
npm install
uv sync --frozen --group dev
cd apps/control-station
npm ci
npm run test:unit
npm run typecheck
npm run build
cd ../..
@@ -80,45 +176,146 @@ uv run k1link serve
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.
BLE Wi-Fi provisioning write, a one-shot CoreWLAN K1-AP association path,
read-only MQTT live capture, native `.k1mqtt` and
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. Operator-manual acquisition still finalizes only
local reception; plugin-commanded v0.5.0 acquisition uses the separately gated
canonical K1 START/STOP dialogue.
Plugin v0.6.0 makes the local connection direction explicit. Bridge remains the
default and accepted product path; Direct Connect sends the reviewed station
provisioning frame for an already-running controller hotspot. Quick Connect
sends one separately reviewed AP-enable frame and can associate a prepared Mac
through CoreWLAN. Its device activation and prepared-host association were
physically accepted, but credential bootstrap is not portable: a clean host
cannot acquire the firmware-defined AP material from the reviewed BLE protocol.
Quick Connect therefore remains laboratory functionality, with no automatic
firmware download, iPhone extraction or hard-coded fallback. Each mode has a
distinct topology attestation and no automatic retry; see
[ADR 0013](docs/adr/0013-k1-local-connection-matrix.md).
The exact recovered `ModelingRequest` start/stop encoder, response correlator
and device-status state machine are installed in the operator-gated interactive
control path.
The plugin also contains the exact ten-request pre-START bootstrap recovered
from retained LixelGO traffic. Its first `DeviceInfoRequest` has no device ID;
the response supplies live vendor identity, serial, model, activation and
version facts for the BLE-selected K1. Static and wire evidence identify OpenAPI
as one private application-level value in the observed client, not a manual
per-scanner profile. The whole pre-START order and START/STOP payloads are
byte-matched offline. A fixed macOS Keychain loader now supplies the exact
application authority without env/file/browser fallback, and a five-batch
orchestrator passes all retained response barriers. The legacy shadow publisher
boundary remains structurally write-disabled and cannot call its sink. Its
dormant coordinator can hold authority
for at most 300 seconds and expiry/disarm/reprovision/acquisition/shutdown revoke
it together with the orchestrator. Plugin v0.5.0 installs a separate
physical-acceptance transport reproducing MQTT 3.1.1 `clean_session=false`,
keepalive 60, exact
response subscriptions, QoS2 completion and the five response-gated batches.
It consumes operation keys before publish and poisons unknown outcomes without
retry. One background owner services that socket through response-gated
connection, workspace, project, START, STOP and device-standby stages. The normal
UI collapses only the pre-START controls into one explicit operator intent;
navigation cannot emit a command. The first operator-present physical attempt on 2026-07-18 emitted only
the first six bootstrap requests, then failed closed while correlating the third
response batch; START was not emitted. A second one-shot attempt correlated all
ten pre-START requests and the START acknowledgement, but incorrectly collapsed
the whole exchange into 365 ms and ended the control lifecycle before the
retained post-START reads. K1 stayed in `SCAN_STARTING` for about 24 seconds,
reported `ALGORITHM_ERROR` twice, emitted no point/pose data and showed steady
red until an operator power cycle restored steady green. The collapsed executor
is now disabled. The offline acceptance executor maps connection requests 16,
workspace-entry request 7, project-prompt reads 810, user-confirmed START 11,
immediate status read 12 and state-gated reads 1314 onto one continuously owned
MQTT session. No captured operator delay is a protocol timer: the final reads
wait for live `SCANNING`, project binding and `init_ready`, while STOP/save waits
for live unbound `READY`. Standalone START and STOP
are both rejected. The same socket is serviced throughout launch and STOP
confirmation, and no captured human delay is replayed as a protocol timer.
Plugin v0.5.0 wires this executor to the facade and plugin UI. The full staged
START/live/STOP/save cycle is physically accepted on the reviewed unit. START
and STOP each retain an explicit operator action; final local sealing now follows
the scanner's protocol-reported unbound READY without a redundant second click.
This locked bootstrap is repeatable in the current workspace, not yet a
standalone release install. The frontend consumes sibling `file:` packages from
`NODEDC_DESIGN_GUIDELINE`; `package-lock.json` does not pin that checkout's Git
revision or content hash. Publishing/vendoring those packages or enforcing an
immutable donor revision remains a packaging and CI prerequisite.
Polygon is planned as a seventh section, but UI delivery is gated on the
accepted clock/frame/authority contracts and a proven S1 backend lifecycle with
persisted run history. It is not present in the current shell.
The Observation spatial workspace embeds the open-source Rerun Web Viewer
inside the NODE.DC shell. It can open a compatible RRD file over HTTP(S) or a
Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not
use an external hosted viewer UI.
inside the Mission Core shell. It can open a compatible RRD over same-origin
HTTP or a Rerun gRPC/proxy source such as
`rerun+http://127.0.0.1:9876/proxy`; no external hosted viewer UI is used.
Dynamic point-cloud/camera composition and the live source contract are fixed in
[`ADR 0006`](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md).
The durable session catalog, recorded `session_time` scrubber and versioned
workspace-layout profile are fixed in
[`ADR 0008`](docs/adr/0008-durable-observation-sessions-and-workspace-layout.md).
The first K1 live session or replay in a `k1link serve` process creates one local
Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes
the resulting URL through control-plane state. Later sessions reset their
session-local scene and metrics and reuse that process-wide stream; this avoids
restarting the native listener while the embedded browser remains connected.
The first K1 live session or adapter file-replay (`.k1mqtt`/reviewed TSV) in a
`k1link serve` process creates one local Rerun `RecordingStream`, starts its
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 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
that URL to the embedded viewer. The complete runtime path is K1 MQTT → raw-first evidence
capture → bounded latest-wins preview queue → reviewed protobuf/LZ4 decoders →
Rerun `Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer.
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. 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
or available RGB coloring, Turbo/Viridis/Plasma/grayscale/custom palettes,
point and trajectory visibility, and the scene grid. Projection, custom
timeline transport and saved layout remain later product work. No synthetic
point cloud, trajectory, camera frame or latency value is generated.
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. 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. Raw panoramic camera frames remain absent. Rerun
`capture_time` is the Mac receive timestamp, not a proven K1 sensor timestamp or
photon-to-screen measurement.
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. 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/viewer/foxglove_bridge.py` and its regression tests. The current
`src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py` and its regression tests. The current
live/replay runtime does not start it or use TCP 8765. The
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records the active Rerun path and
its timing/security boundaries; the frontend contract is documented in
[`apps/k1-viewer/README.md`](apps/k1-viewer/README.md).
[`apps/control-station/README.md`](apps/control-station/README.md).
The host storage layout, recovery rules, replay API and operator path are in
[`docs/09_OBSERVATION_SESSIONS.md`](docs/09_OBSERVATION_SESSIONS.md).
The FastAPI application and credential endpoint bind to loopback, but the Rerun
gRPC server currently binds TCP 9876 on all network interfaces even though its
@@ -170,7 +367,23 @@ present.
- [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md)
- [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md)
- [Live console and embedded Rerun runbook](docs/06_K1_LIVE_VIEWER.md)
- [Mission Core monorepo and plugin boundary](docs/07_MISSION_CORE_MONOREPO.md)
- [Owner-controlled LixelGO/iPhone observation](docs/08_LIXELGO_IPHONE_OBSERVATION.md)
- [K1 application-control physical acceptance](docs/lab/003_K1_CONTROL_ACCEPTANCE_20260718.redacted.md)
- [Observation sessions, playback and workspace layout](docs/09_OBSERVATION_SESSIONS.md)
- [Monorepo architecture decision](docs/adr/0002-mission-core-monorepo.md)
- [Device plugin UI and runtime boundary](docs/adr/0003-device-plugin-ui-and-runtime-boundary.md)
- [Plugin SDK v0alpha2 and experimental device lifecycle](docs/adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md)
- [Owner-controlled LixelGO observation decision](docs/adr/0005-owner-controlled-lixelgo-iphone-observation.md)
- [Vendor-neutral live observation sources](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md)
- [K1 camera preview copy-remux gateway](docs/adr/0007-k1-camera-preview-copy-remux-gateway.md)
- [Durable observation sessions and workspace layout](docs/adr/0008-durable-observation-sessions-and-workspace-layout.md)
- [Device-plugin observation runtime and K1 extraction](docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md)
- [Plugin-owned frontend device workflows](docs/adr/0010-plugin-owned-frontend-device-workflows.md)
- [Laboratory plugin runtime handshake and transport seam](docs/adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md)
- [Device-bound K1 command authority](docs/adr/0012-device-bound-k1-command-authority.md)
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
- [Canonical control and durable archive milestone](docs/lab/004_K1_CANONICAL_CONTROL_ARCHIVE_20260719.redacted.md)
- [Session manifest schema](schemas/session-manifest.schema.json)
- [Reference input provenance](docs/reference/README.md)
@@ -186,13 +399,15 @@ capture of traffic to or from the confirmed K1 address, and offline analysis of
owned artifacts.
The reviewed provisioning write requires its named profile and explicit operator
confirmation. Application command publishing remains disabled: physical
double-click is the verified start/stop mechanism. Any future MQTT publisher,
router configuration change or new BLE write requires its own evidence and
confirmation. The interactive application-control publisher is enabled only for
the exact K1/A4/FW 3.0.2 direct-LAN profile and only inside one operator-opened,
response-gated session; physical double-click remains the independent fallback.
The legacy shadow publisher remains structurally disabled. Any broader firmware,
topology, scanner-family or command surface requires its own evidence and
reviewed step. Random writes, fuzzing, brute force, firmware operations,
destructive file access and credential guessing remain out of scope.
Real captures, projects, router metadata, serials, credentials, maps, images,
and logs are ignored by normal Git. Redacted manifests and SHA-256 inventories
are committed; encrypted artifact storage will be selected only when real data
exists.
and logs under `.runtime/`, canonical evidence roots and legacy `sessions/` are
ignored by normal Git. Redacted manifests and SHA-256 inventories are committed;
encrypted/replicated artifact storage remains a deployment decision.
+7
View File
@@ -0,0 +1,7 @@
# Mission Core applications
- `control-station/` — the current browser operator application.
Applications consume Mission Core contracts and capabilities. Device-specific
transport and codecs belong under `plugins/`, not inside application routing
or navigation.
+353
View File
@@ -0,0 +1,353 @@
# NODEDC MISSION CORE · Control Station
Универсальный браузерный пункт управления Mission Core на React 19, TypeScript и
Vite. Приложение задаёт общую операторскую оболочку для аппаратов, сенсоров,
наблюдения, миссий и записей. XGRIDS/LixelKity K1 является первым реальным
device adapter, но структура интерфейса от него не зависит.
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
переход во внешний облачный интерфейс. Для K1 live и adapter file-replay backend
сам создаёт Rerun gRPC/proxy source. Saved observation sessions открывают
проверенную digest-bound RRD generation через same-origin HTTP; ручной адрес
нужен только для другого совместимого Rerun source.
## Текущее состояние
| Контур | Состояние | Что это означает |
| --- | --- | --- |
| 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 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 сохранение, декодирование облака/позы и 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/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 ещё не подключены. |
Приложение не генерирует демонстрационное облако, траекторию, кадры или
метрики. Если реальных данных нет, область сцены остаётся пустой, а числовые поля
показывают `—`.
## Архитектура данных
```text
K1 MQTT :1883, read-only
└── 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
└── rerun_grpc_url в REST/WebSocket state
└── встроенный @rerun-io/web-viewer
└── пространственная сцена Mission Core
Mission Core Control Station ←→ REST /api/v1/device-plugins/*
+ plugin-scoped WebSocket events
FastAPI на 127.0.0.1:8000
CoreBluetooth + live/replay runtime
sealed/recovered observation session
└── SQLite catalog + bounded background preparation
└── capture-clock-bound atomic RRD cache v9 + recorded-media manifest v2
└── generation-bound same-origin HTTP
└── aggregate admission
└── Rerun native receiver + recorded fMP4 player
```
В момент готовности live/file-replay `RerunBridge` backend публикует адрес вида
`rerun+http://127.0.0.1:9876/proxy`. Frontend автоматически назначает его сцене,
если оператор не указал ручной source. После остановки приёма URL и встроенный
viewer остаются активны, а следующая сессия сбрасывает session-local геометрию,
траекторию и метрики и использует тот же listener. Он закрывается вместе с
процессом `k1link serve`.
Поля `foxglove_ws_url` и `foxglove_viewer_url` пока остаются в API как
совместимость со старым контрактом, но текущий runtime держит их пустыми.
## Фиксированная оболочка
Shell собран из локальных NODE.DC UI packages и сохраняет одну структуру для
всех функциональных модулей:
1. `AppHeader` — марка NODE DC, выбор архитектурного раздела и состояние
локального backend.
2. `AdminNavigationPanel` — контекст аппарата и список рабочих поверхностей
выбранного раздела.
3. `LandingStage` — стартовая ситуационная поверхность и быстрые переходы.
4. `ApplicationPanel` — единый контейнер активной рабочей поверхности.
5. `Window` и `Inspector` — источник, отображение, слои и компоновка без
раскрытия внутренних панелей визуального движка.
Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя,
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
Control Station. Размер точек, способ окрашивания и палитра, 12-секундное по
умолчанию накопление, видимость облака и траектории и сетка связаны с backend и
Rerun Blueprint. Host timeline управляет сохранённым `session_time`, а spatial
layout сохраняет display settings, tool windows и source-window geometry через
revisioned API. Смена 2D/3D/карты и семантические слои пока остаются
интерфейсным контрактом.
## Архитектурные разделы
Навигация описана данными в `src/productModel.ts`, а не зашита в разметку каждой
страницы.
| Раздел | Назначение |
| --- | --- |
| Центр | Оперативный обзор, состояние контура и активность оператора. |
| Парк | Аппараты, текущее устройство, сенсоры и конфигурации борта. |
| Наблюдение | Пространственная сцена, камеры, карта, объекты, телеметрия и время. |
| Миссии | Планировщик, маршруты, сценарии и исполнение. Командный backend отключён. |
| Данные | Сессии, потоки, сущности, playback и экспорт доказательств. |
| Система | Модули, интеграции, сеть, аудит и настройки платформы. |
Карточки возможностей имеют четыре честных уровня: работает сейчас, готово к
источнику, интерфейсный контракт и последующий этап. Каталог не следует читать
как утверждение, что для каждой карточки уже существует backend.
## Установка, проверка, сборка и запуск
Требуются Node.js 20.19+ либо 22.12+, `uv` и соседний checkout
`NODEDC_DESIGN_GUIDELINE`: зависимости `@nodedc/*` подключены к нему через
локальные `file:` пути. Python устанавливается только в `.venv` репозитория.
Канонический путь от корня репозитория:
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE
uv sync --frozen --group dev
cd apps/control-station
npm ci
npm run test:unit
npm run typecheck
npm run build
cd ../..
uv run k1link serve
```
Открыть `http://127.0.0.1:8000`. Команда `serve` отдаёт собранный `dist/` и
локальный API. Она намеренно привязана только к `127.0.0.1`; LAN bind не
предусмотрен, потому что endpoint подключения кратковременно принимает пароль
Wi-Fi.
Этот locked bootstrap повторяем в текущем workspace, но он ещё не является
standalone release install. Локальные `file:` зависимости берутся из соседнего
`NODEDC_DESIGN_GUIDELINE`, а lockfile не фиксирует Git revision или content hash
этого checkout. До CI/portable packaging эти пакеты нужно опубликовать,
завендорить либо проверять по неизменяемой donor revision.
Для разработки интерфейса после запуска backend:
```bash
cd apps/control-station
npm run dev
```
Для полного live-viewer контура Vite должен открыться на
`http://127.0.0.1:5173`: только 5173, production preview 4173 и backend 8000
входят в текущий Rerun CORS allowlist. Если Vite сообщает fallback на 5174 или
выше, освободите 5173 и перезапустите `npm run dev`; REST/WebSocket proxy на
fallback-порту может работать, но прямой browser → Rerun 9876 будет отклонён.
Vite проксирует весь `/api` (включая WebSocket) на `http://127.0.0.1:8000`.
Другой локальный backend можно указать переменной `VITE_API_TARGET`. Preview
production-сборки запускается командой `npm run preview` на
`http://127.0.0.1:4173`.
## Операторский путь для текущего K1 adapter
1. Запустить `uv run k1link serve` и открыть Mission Core Control Station.
2. Выбрать **Парк → Локальное устройство**.
3. В каталоге моделей выбрать **XGRIDS LixelKity K1**. Только после этого
активируется runtime и монтируется custom UI XGRIDS-плагина.
4. Включить K1, дождаться стабильного индикатора, вручную сверить firmware
`3.0.2` и direct-LAN топологию, затем подтвердить это в форме. Mission Core
не читает firmware с устройства; такое подтверждение остаётся
`operator-attested`, а не device-derived evidence.
5. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
шестисекундного поиска; метка совместимости является подсказкой, выбор делает
оператор.
6. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
reviewed provisioning-запись без автоматических повторов.
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 вводить не требуется.
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.
В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений:
38 кадров `lio_pcl`, 42 кадра `lio_pose`, 2 775 точек в последнем облаке и
0 ошибок декодирования.
## Источники Rerun
Live/replay runtime автоматически назначает первый URL из примера. В
**Наблюдение → Пространственная сцена → Источник** можно оставить ручное поле
пустым либо указать другой адрес, который понимает `@rerun-io/web-viewer`
зафиксированной в `package.json` версии:
```text
rerun+http://127.0.0.1:9876/proxy
http://127.0.0.1:8080/recording.rrd
https://example.internal/recording.rrd
```
- `rerun+http://…/proxy` — живой Rerun gRPC source через доступный браузеру
proxy;
- `http(s)://…/recording.rrd` — RRD-запись по HTTP(S);
- версия 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 в
оболочку. Backend принимает и применяет к Rerun размер точки `0.512.0`, режимы
цвета `intensity`, `height`, `distance`, `rgb`, `class`, палитры Turbo, Viridis,
Plasma, grayscale и custom, накопление `0120` секунд, а также видимость облака,
траектории и сетки. Значение по умолчанию — 12 секунд истории реальных кадров.
Для текущего firmware-3 `lio_pcl` доказана только интенсивность из младшего
байта `rgbi`; RGB используется лишь когда он действительно присутствует в
декодированном формате. Custom/class сейчас означает выбранный сплошной цвет,
а не готовую семантическую классификацию.
Для saved session host timeline вызывает Rerun play/pause/seek только после
полного admission. Accumulation и display settings применяются через отдельный
маленький blueprint RRD и не заменяют основную запись. Versioned
`observation.spatial` layout сохраняется на host с optimistic revision; он не
содержит sensor evidence. Переключатель 2D/3D/карты и семантические слои пока не
вызывают готовый presentation backend.
## Контракт локального API
| Метод | Route | Тело / назначение |
| --- | --- | --- |
| `GET` | `/api/health` | Проверка локального сервиса. |
| `GET` | `/api/v1/device-plugins` | Валидированные manifests установленных device plugins. |
| `GET` | `/api/v1/device-models` | Backend-каталог моделей из валидированных manifests; UI-каталог текущей сборки формируется отдельным static composition root. |
| `POST` | `/api/v1/device-plugins/{pluginId}/actions/{actionId}` | Namespaced действие через host allowlist; тело `{ "input": { ... } }`. |
| `WS` | `/api/v1/device-plugins/{pluginId}/events` | Plugin-scoped snapshots с `pluginId` и монотонным `sequence`. |
| `GET` | `/api/v1/observation-sessions` | Последние cataloged sessions и authoritative preparation state. |
| `POST` | `/api/v1/observation-sessions/{id}/replay` | Verified replay launch либо HTTP 202 preparation handle. |
| `GET` | `/api/v1/observation-sessions/{id}/recording-preparation` | Exact-job polling с `If-Match`. |
| `GET` | `/api/v1/observation-sessions/{id}/recording.rrd` | Immutable generation-bound RRD; arbitrary chunked `send_rrd` не используется. |
| `GET` | `/api/v1/observation-sessions/{id}/media/{artifact}/...` | Manifest/init/segments записанных камер по opaque identifiers. |
| `GET`, `PUT` | `/api/v1/workspace-layouts/observation.spatial` | Versioned host layout с optimistic revision. |
Frontend XGRIDS-плагина использует только v1alpha namespaced routes. Старые
`/api/state`, `/api/events`, `/api/ble/scan`, `/api/connect`, `/api/session/*` и
`/api/viewer/settings` сохранены как deprecated compatibility shims внутри
XGRIDS backend contribution.
В v1alpha1/v1alpha2 backend composition загружается из manifests, а frontend plugins
статически включаются в сборку через `src/composition/devicePlugins.ts`.
Автоматической runtime-сверки двух installed sets пока нет: их соответствие —
проверяемое требование сборки до появления подписанных plugin bundles и startup
compatibility handshake. Поля manifest `permissions`, `mutating` и
`secretFields` пока являются декларативными метаданными; generic host валидирует
их форму и action allowlist, но ещё не реализует на их основе RBAC, подтверждения
оператора или secret-vault substitution.
Точный K1 compatibility profile остаётся неактивным, пока оператор явно не
подтвердит firmware `3.0.2` и direct-LAN топологию. Runtime не получает версию
firmware с устройства и явно помечает основание как `operator-attested`.
Ошибки валидации namespaced action endpoint и deprecated `/api/connect`
возвращаются как общий `422` без исходных значений запроса, поэтому отклонённое
значение credential-поля не отражается клиенту.
Legacy `/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
как `{ "state": { ... } }`. v1alpha2 snapshot отдельно содержит `device_ref`,
`device_session`, `acquisition`, `operations`, compatibility/calibration/sensor
facts и старые presentation-поля `phase`, `message`, `devices`,
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
пустыми. OpenAPI доступен по `/api/docs`.
## Карта исходников frontend
| Файл | Ответственность |
| --- | --- |
| `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 и 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, 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` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |
| `src/components/RecordedFmp4Player.tsx` | Проверенный generation-bound MSE playback архивных камер. |
| `src/core/observation/sessionArchive.ts` | Строгий wire contract catalog/preparation/replay/media API. |
| `src/core/observation/recordedSessionAdmission.ts` | Общий RRD/camera gate до публикации recorded workspace. |
| `src/core/observation/workspaceLayout.ts` | Versioned spatial layout и optimistic revision. |
| `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. |
| `src/presentation.ts` | Vendor-neutral подписи lifecycle и форматирование нормализованных метрик. |
| `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
## Safety и чувствительные данные
- Mission Core Control Station и credential endpoint доступны только на loopback.
- Исключение: Rerun gRPC/proxy на TCP 9876 сейчас слушает все сетевые интерфейсы,
хотя автоматически возвращаемый URL содержит `127.0.0.1`. На нём нет
connector-level authentication или TLS. Этот порт допустим только в доверенной
лабораторной LAN; его нельзя пробрасывать в публичный Интернет или cellular
WAN без аутентифицированного TLS reverse proxy. Listener намеренно остаётся
активным между сессиями; для его закрытия нужно остановить `k1link serve`.
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
очищается после успешного ответа и не сохраняется в URL/local storage.
- Bridge и Direct Connect выполняют только отдельно рассмотренную provisioning-
запись; Quick Connect не пишет в GATT и передаёт пароль короткоживущему
CoreWLAN helper только через stdin. Случайные GATT writes и автоматические
повторы запрещены.
- MQTT data live/replay остаётся subscribe-only. Команды изолированы в
plugin-owned canonical control session и доступны только через отдельные
operator-present UI checkpoints.
- Exact start/stop codec, response correlator и device-status state machine не
владеют transport. Plugin v0.5.0 связывает их отдельным single-owner MQTT
контуром без wall-clock переходов, reconnect или automatic retry; после STOP
обязательны live READY и подтверждённый постоянный зелёный индикатор.
- 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-<sha256>.json`: только owner-sealed `session` scope
может рекламировать combined media replay. Cache v9 создаёт реальные
origin/end RRD rows; provisional `transport` scope fails closed.
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
накопления 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 и
безопасная документация.
@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Пункт управления NODE.DC — наблюдение, данные и планирование миссий беспилотных аппаратов."
content="NODEDC MISSION CORE — наблюдение, данные и планирование миссий беспилотных аппаратов."
/>
<meta name="theme-color" content="#08090b" />
<title>NODE.DC · Пункт управления</title>
<title>NODEDC MISSION CORE</title>
</head>
<body>
<div id="root"></div>
@@ -1,13 +1,15 @@
{
"name": "@nodedc/k1-viewer",
"name": "@nodedc/mission-core-control-station",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@nodedc/k1-viewer",
"name": "@nodedc/mission-core-control-station",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@noble/hashes": "^2.2.0",
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
@@ -25,7 +27,7 @@
"vite-plugin-wasm": "^3.6.0"
},
"engines": {
"node": ">=20"
"node": "^20.19.0 || >=22.12.0"
}
},
"../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": {
@@ -834,6 +836,18 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodedc/tokens": {
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"link": true
@@ -1,15 +1,18 @@
{
"name": "@nodedc/k1-viewer",
"name": "@nodedc/mission-core-control-station",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test:unit": "node --test test/*.test.mjs",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@noble/hashes": "^2.2.0",
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
@@ -27,6 +30,6 @@
"vite-plugin-wasm": "^3.6.0"
},
"engines": {
"node": ">=20"
"node": "^20.19.0 || >=22.12.0"
}
}

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 322 B

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1,111 @@
import { createHash } from "node:crypto";
import { copyFileSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1");
const sha256File = (path) =>
createHash("sha256").update(readFileSync(path)).digest("hex");
if (manifest.version !== "0.34.1") {
throw new Error(
`Refusing to patch @rerun-io/web-viewer ${manifest.version}; audit the new vendor lifecycle first.`,
);
}
const vendorRuntime = [
{
label: "wasm runtime",
packagePath: resolve(packageRoot, "re_viewer_bg.wasm"),
vendorPath: resolve(vendorRoot, "re_viewer_bg.nodedc.wasm"),
publishedSha256: "3fe7aab8ea6bb0fd03c3ef694932943411ee174029e398c539c390beb0824d35",
previousNodedcSha256: "6aca9da47ad561d2046ad0c7c0b8a7f653f023a77132c5c6304789320db512c2",
nodedcSha256: "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469",
},
{
label: "wasm JavaScript glue",
packagePath: resolve(packageRoot, "re_viewer.js"),
vendorPath: resolve(vendorRoot, "re_viewer.nodedc.js"),
publishedSha256: "7c4ba900820137a6ba23e0f7f57d56e3b007db0de3825d5c8bf0d7684b3cc6a6",
nodedcSha256: "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339",
},
];
for (const runtime of vendorRuntime) {
const vendorSha256 = sha256File(runtime.vendorPath);
if (vendorSha256 !== runtime.nodedcSha256) {
throw new Error(
`Refusing corrupt NODE.DC Rerun ${runtime.label}: ${vendorSha256} != ${runtime.nodedcSha256}`,
);
}
const installedSha256 = sha256File(runtime.packagePath);
if (
![
runtime.publishedSha256,
runtime.previousNodedcSha256,
runtime.nodedcSha256,
].includes(installedSha256)
) {
throw new Error(
`Refusing to replace unexpected Rerun ${runtime.label}: ${installedSha256}`,
);
}
copyFileSync(runtime.vendorPath, runtime.packagePath);
if (sha256File(runtime.packagePath) !== runtime.nodedcSha256) {
throw new Error(`NODE.DC Rerun ${runtime.label} verification failed after copy`);
}
}
const patches = [
{
label: "compiled watchdog",
path: resolve(packageRoot, "index.js"),
before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`,
after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`,
},
{
label: "source watchdog",
path: resolve(packageRoot, "index.ts"),
before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`,
after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`,
},
{
label: "compiled singleton global listener",
path: resolve(packageRoot, "index.js"),
before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`,
after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`,
},
{
label: "source singleton global listener",
path: resolve(packageRoot, "index.ts"),
before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`,
after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`,
},
];
for (const patch of patches) {
const source = readFileSync(patch.path, "utf8");
if (source.includes(patch.after)) continue;
if (!source.includes(patch.before)) {
throw new Error(`Vendor ${patch.label} patch no longer matches ${patch.path}.`);
}
writeFileSync(patch.path, source.replace(patch.before, patch.after));
}
for (const path of [resolve(packageRoot, "index.js"), resolve(packageRoot, "index.ts")]) {
const source = readFileSync(path, "utf8");
const keyupListenerCount = source.split('window.addEventListener("keyup"').length - 1;
if (!source.includes("let globalEventListenersInstalled = false;") || keyupListenerCount !== 1) {
throw new Error(`Vendor singleton global listener verification failed for ${path}.`);
}
}
console.log(
"Patched @rerun-io/web-viewer 0.34.1 pointer navigation, camera continuity, watchdog teardown, and singleton global keyup listener.",
);
@@ -0,0 +1,128 @@
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const [inputArgument, outputArgument] = process.argv.slice(2);
if (!inputArgument || !outputArgument) {
throw new Error("Usage: node transform-rerun-web-viewer-glue.mjs INPUT OUTPUT");
}
const inputPath = resolve(inputArgument);
const outputPath = resolve(outputArgument);
const expectedGeneratedSha256 =
"cc196a93c5be972c801d46be4dc9934f7f042eb62941f0aa0678f1c8416c6874";
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
let code = readFileSync(inputPath, "utf8");
if (sha256(code) !== expectedGeneratedSha256) {
throw new Error(`Refusing to transform unexpected wasm-bindgen output: ${inputPath}`);
}
// This is the same no-modules-base transformation used by Rerun 0.34.1's
// rerun_js/web-viewer/build-wasm.mjs. Each factory call gets isolated closure state.
const wrapperStart = "let wasm_bindgen = (function(exports) {";
const wrapperEnd = `return Object.assign(__wbg_init, { initSync }, exports);
})({ __proto__: null });`;
if (!code.includes(wrapperStart) || !code.includes(wrapperEnd)) {
throw new Error("Rerun wasm-bindgen wrapper markers no longer match");
}
code = code.replace(wrapperStart, "").replace(wrapperEnd, "");
code = `
export default function() {
const exports = { __proto__: null };
${code}
function deinit() {
__wbg_init.__wbindgen_wasm_module = null;
wasmModule = null;
wasm = null;
cachedDataViewMemory0 = null;
cachedFloat32ArrayMemory0 = null;
cachedInt16ArrayMemory0 = null;
cachedInt32ArrayMemory0 = null;
cachedInt8ArrayMemory0 = null;
cachedUint16ArrayMemory0 = null;
cachedUint32ArrayMemory0 = null;
cachedUint8ArrayMemory0 = null;
}
return Object.assign(__wbg_init, { initSync, deinit }, exports);
}
`;
const closureDtorsOriginal = `const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(state => wasm.__wbindgen_destroy_closure(state.a, state.b));`;
const closureDtorsPatch = `const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(state => {
if (wasm) wasm.__wbindgen_destroy_closure(state.a, state.b);
});`;
if (!code.includes(closureDtorsOriginal)) {
throw new Error("Rerun CLOSURE_DTORS block no longer matches");
}
code = code.replace(closureDtorsOriginal, closureDtorsPatch);
const makeMutClosureOriginal = `function makeMutClosure(arg0, arg1, f) {
const state = { a: arg0, b: arg1, cnt: 1 };
const real = (...args) => {
// First up with a closure we increment the internal reference
// count. This ensures that the Rust closure environment won't
// be deallocated while we're invoking it.
state.cnt++;
const a = state.a;
state.a = 0;
try {
return f(a, state.b, ...args);
} finally {
state.a = a;
real._wbg_cb_unref();
}
};
real._wbg_cb_unref = () => {
if (--state.cnt === 0) {
wasm.__wbindgen_destroy_closure(state.a, state.b);
state.a = 0;
CLOSURE_DTORS.unregister(state);
}
};
CLOSURE_DTORS.register(real, state, state);
return real;
}`;
const makeMutClosurePatch = `function makeMutClosure(arg0, arg1, f) {
const state = { a: arg0, b: arg1, cnt: 1 };
const real = (...args) => {
state.cnt++;
const a = state.a;
state.a = 0;
try {
if (!wasm) return;
return f(a, state.b, ...args);
} finally {
state.a = a;
real._wbg_cb_unref();
}
};
real._wbg_cb_unref = () => {
if (--state.cnt === 0) {
if (wasm) wasm.__wbindgen_destroy_closure(state.a, state.b);
state.a = 0;
CLOSURE_DTORS.unregister(state);
}
};
CLOSURE_DTORS.register(real, state, state);
return real;
}`;
if (!code.includes(makeMutClosureOriginal)) {
throw new Error("Rerun makeMutClosure block no longer matches");
}
code = code.replace(makeMutClosureOriginal, makeMutClosurePatch);
writeFileSync(outputPath, code);
console.log(`Transformed Rerun wasm glue: ${sha256(code)} ${outputPath}`);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
import { useLayoutEffect, useState, type RefObject } from "react";
import { WorkspaceWindow } from "@nodedc/ui-react";
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
import type { RecordedObservationPlayback } from "./RecordedFmp4Player";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
const WINDOW_WIDTH = 336;
const WINDOW_HEIGHT = 210;
const WINDOW_GAP = 16;
const WINDOW_INSET = 18;
const TIMELINE_CLEARANCE = 64;
type ClosestTarget = { closest: (selectors: string) => unknown };
export function shouldCaptureWorkspacePointer(
button: number,
target: ClosestTarget | null,
): boolean {
if (button !== 0 || !target) return false;
if (target.closest(".nodedc-workspace-window__resize")) return true;
if (!target.closest(".nodedc-workspace-window__head")) return false;
return !target.closest("button, input, select, textarea, a");
}
export function initialObservationWindowRect(
index: number,
count = 1,
bounds: { width: number; height: number } = { width: 1280, height: 720 },
): ObservationWindowRect {
const availableWidth = Math.max(0, bounds.width - WINDOW_INSET * 2);
const width = Math.max(1, Math.min(WINDOW_WIDTH, availableWidth || WINDOW_WIDTH));
const availableHeight = Math.max(0, bounds.height - WINDOW_INSET - TIMELINE_CLEARANCE);
const maxColumns = Math.max(
1,
Math.floor((availableWidth + WINDOW_GAP) / (width + WINDOW_GAP)),
);
const columns = Math.max(1, Math.min(Math.max(1, count), maxColumns));
const rows = Math.max(1, Math.ceil(Math.max(1, count) / columns));
const rowHeight = Math.max(
1,
(availableHeight - Math.max(0, rows - 1) * WINDOW_GAP) / rows,
);
const height = Math.max(1, Math.min(WINDOW_HEIGHT, rowHeight));
const row = Math.floor(index / columns);
const column = index % columns;
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
return {
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
y: Math.max(
0,
bounds.height - TIMELINE_CLEARANCE - height - row * (height + WINDOW_GAP),
),
width,
height,
};
}
export function FloatingObservationWindow({
source,
index,
count,
boundsRef,
rect,
maximized,
active,
hidden = false,
onRectChange,
onMaximizedChange,
onActivate,
onClose,
playback,
prepareRecorded,
recordedSessionGate,
recordedAdmissionKey,
onRecordedAdmissionChange,
}: {
source: ObservationSourceDescriptor;
index: number;
count: number;
boundsRef: RefObject<HTMLElement | null>;
rect?: ObservationWindowRect;
maximized: boolean;
active: boolean;
hidden?: boolean;
onRectChange: (rect: ObservationWindowRect) => void;
onMaximizedChange: (maximized: boolean) => void;
onActivate: () => void;
onClose: () => void;
playback?: RecordedObservationPlayback | null;
prepareRecorded?: boolean;
recordedSessionGate?: RecordedAdmissionPhase;
recordedAdmissionKey?: string | null;
onRecordedAdmissionChange?: (
sourceId: string,
state: RecordedCameraAdmissionState,
) => void;
}) {
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
useLayoutEffect(() => {
const element = boundsRef.current;
if (!element) return;
const measure = () => {
const next = { width: element.clientWidth, height: element.clientHeight };
setBounds((current) => (
current?.width === next.width && current.height === next.height ? current : next
));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [boundsRef]);
const initialRect = bounds ? initialObservationWindowRect(index, count, bounds) : null;
const windowRect = rect ?? initialRect;
if (!windowRect) return null;
return (
<WorkspaceWindow
boundsRef={boundsRef}
rect={windowRect}
onRectChange={onRectChange}
maximized={maximized}
onMaximizedChange={onMaximizedChange}
onActivate={onActivate}
onClose={onClose}
title={source.label}
subtitle={source.description}
status={(
<span className="floating-observation-window__status">
<i data-availability={source.availability} aria-hidden="true" />
{observationSourceStatusLabel(source)}
</span>
)}
footer={(
<span className="floating-observation-window__footer">
<span>{source.endpointLabel || source.transport}</span>
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
</span>
)}
minWidth={Math.min(280, windowRect.width)}
minHeight={Math.min(190, windowRect.height)}
resizable={source.capabilities.resizable}
active={active}
zIndex={maximized ? 15 : active ? 9 : 7}
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
onPointerDownCapture={(event) => {
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Pointer capture can fail if the browser ended the pointer between
// dispatch and capture. The donor's window listeners remain fallback.
}
}}
closeLabel={`Закрыть ${source.label}`}
maximizeLabel={`Развернуть ${source.label}`}
restoreLabel={`Восстановить ${source.label}`}
moveLabel={`Переместить ${source.label}`}
resizeLabel={`Изменить размер ${source.label}`}
>
<ObservationMedia
source={source}
playback={playback}
prepareRecorded={prepareRecorded}
recordedSessionGate={recordedSessionGate}
recordedAdmissionKey={recordedAdmissionKey}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
</WorkspaceWindow>
);
}
@@ -1,13 +1,13 @@
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
import type { RootDefinition } from "../productModel";
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
import type { BackendStatus } from "../useK1Console";
export interface LandingStageProps {
root: RootDefinition | null;
backendStatus: BackendStatus;
phase?: string | null;
phase?: RuntimePhase | null;
message?: string | null;
onOpenObservation: () => void;
onOpenDevice: () => void;
@@ -24,8 +24,8 @@ export function LandingStage({
return (
<section className="landing-stage" data-root={root?.id ?? "home"}>
<div className="landing-stage__copy">
<span className="section-eyebrow">{root?.eyebrow ?? "NODE.DC / ПУНКТ УПРАВЛЕНИЯ"}</span>
<h1>{root?.title ?? "Пункт управления"}</h1>
<span className="section-eyebrow">{root?.eyebrow ?? "NODEDC / MISSION CORE"}</span>
<h1>{root?.title ?? "Mission Core"}</h1>
<p>
{root?.statement ??
"Наблюдение, планирование и корректировка миссий в одной модульной рабочей области."}
@@ -0,0 +1,279 @@
import { useEffect, useRef, useState } from "react";
import { Icon } from "@nodedc/ui-react";
import type { ObservationSourceDelivery } from "../core/runtime/contracts";
type PlayerStatus = "connecting" | "buffering" | "playing" | "error";
export interface CameraLeaseRetryBudget {
deliveryId: string;
count: number;
}
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000] as const;
export function resetCameraLeaseRetryBudget(deliveryId: string): CameraLeaseRetryBudget {
return { deliveryId, count: 0 };
}
export function consumeCameraLeaseRetry(
current: CameraLeaseRetryBudget,
deliveryId: string,
): { budget: CameraLeaseRetryBudget; delay: number | null } {
const count = current.deliveryId === deliveryId ? current.count : 0;
const delay = CAMERA_LEASE_RETRY_DELAYS[count] ?? null;
return {
budget: { deliveryId, count: delay === null ? count : count + 1 },
delay,
};
}
function websocketUrl(path: string): string {
const url = new URL(path, window.location.href);
if (url.protocol === "http:") url.protocol = "ws:";
if (url.protocol === "https:") url.protocol = "wss:";
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
throw new Error("Адаптер вернул неподдерживаемый адрес видеопотока.");
}
return url.toString();
}
export function MseFmp4WebSocketPlayer({
delivery,
label,
}: {
delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" };
label: string;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const leaseRetryRef = useRef(resetCameraLeaseRetryBudget(delivery.id));
const [attempt, setAttempt] = useState(0);
const [status, setStatus] = useState<PlayerStatus>("connecting");
const [message, setMessage] = useState("Подключение к локальному видеопотоку");
useEffect(() => {
const video = videoRef.current;
if (!video) return;
let disposed = false;
let socket: WebSocket | null = null;
let sourceBuffer: SourceBuffer | null = null;
let objectUrl = "";
let retryTimer: number | undefined;
let receivedMedia = false;
let failed = false;
const queue: ArrayBuffer[] = [];
let queuedBytes = 0;
const fail = (copy: string) => {
if (disposed || failed) return;
failed = true;
setStatus("error");
setMessage(copy);
try {
if (socket && socket.readyState < WebSocket.CLOSING) {
socket.close(1011, "Live video buffer reset");
}
} catch {
// The manual reconnect button will create a fresh transport and MSE buffer.
}
};
const retryLease = () => {
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
leaseRetryRef.current = retry.budget;
if (retry.delay === null) {
fail("Camera adapter ещё занят предыдущим окном. Подключитесь повторно.");
return;
}
setStatus("connecting");
setMessage("Освобождение предыдущего окна камеры");
retryTimer = window.setTimeout(() => {
if (!disposed) setAttempt((value) => value + 1);
}, retry.delay);
};
const onPlaying = () => {
if (disposed) return;
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
setStatus("playing");
setMessage("");
};
video.addEventListener("playing", onPlaying);
const appendNext = () => {
if (disposed || !sourceBuffer || sourceBuffer.updating || queue.length === 0) return;
const chunk = queue.shift();
if (!chunk) return;
queuedBytes -= chunk.byteLength;
try {
sourceBuffer.appendBuffer(chunk);
} catch (error) {
fail(error instanceof DOMException && error.name === "QuotaExceededError"
? "Live-буфер переполнен и сброшен, чтобы не накапливать задержку."
: "Не удалось добавить видеосегмент. Повторите подключение.");
}
};
const enqueue = (chunk: ArrayBuffer) => {
if (disposed || chunk.byteLength === 0) return;
// Never drop arbitrary fMP4 fragments: the following samples may depend
// on them. A bounded reset is safer and keeps live latency deterministic.
if (queuedBytes + chunk.byteLength > 2 * 1024 * 1024) {
fail("Видеодекодер не успевает за эфиром. Live-буфер сброшен.");
return;
}
queue.push(chunk);
queuedBytes += chunk.byteLength;
appendNext();
};
const mediaType = delivery.mediaType?.trim();
if (typeof MediaSource === "undefined") {
fail("Этот браузер не поддерживает Media Source Extensions.");
return;
}
if (!mediaType || !MediaSource.isTypeSupported(mediaType)) {
fail(mediaType
? `Браузер не поддерживает ${mediaType}`
: "Адаптер не сообщил MIME/codec fMP4-потока.");
return;
}
setStatus("connecting");
setMessage("Подключение к локальному видеопотоку");
const mediaSource = new MediaSource();
objectUrl = URL.createObjectURL(mediaSource);
video.src = objectUrl;
const onSourceOpen = () => {
if (disposed) return;
try {
sourceBuffer = mediaSource.addSourceBuffer(mediaType);
} catch {
fail("Не удалось создать MSE-буфер для указанного кодека.");
return;
}
sourceBuffer.addEventListener("updateend", () => {
if (disposed || !sourceBuffer) return;
const buffered = sourceBuffer.buffered;
if (buffered.length > 0) {
const end = buffered.end(buffered.length - 1);
const start = buffered.start(0);
if (end - video.currentTime > 1) video.currentTime = Math.max(0, end - 0.1);
if (!receivedMedia) {
receivedMedia = true;
setStatus("buffering");
setMessage("Запуск первого декодированного кадра");
void video.play().catch(() => undefined);
}
const removeBefore = end - 3;
if (removeBefore > start && !sourceBuffer.updating) {
try {
sourceBuffer.remove(0, removeBefore);
return;
} catch {
// Continue appending; quota handling performs a clean reset.
}
}
}
appendNext();
});
sourceBuffer.addEventListener("error", () => {
fail("MSE сообщил об ошибке декодирования видеосегмента.");
});
try {
socket = new WebSocket(websocketUrl(delivery.url));
} catch (error) {
fail(error instanceof Error ? error.message : "Некорректный адрес видеопотока.");
return;
}
socket.binaryType = "arraybuffer";
socket.addEventListener("open", () => {
if (disposed) return;
setStatus("buffering");
setMessage("Ожидание первого видеокадра");
});
socket.addEventListener("message", (event) => {
if (event.data instanceof ArrayBuffer) {
enqueue(event.data);
} else if (event.data instanceof Blob) {
void event.data.arrayBuffer().then(enqueue).catch(() => {
fail("Получен повреждённый видеосегмент.");
});
}
});
socket.addEventListener("error", () => {
fail("Соединение с локальным video adapter потеряно.");
});
socket.addEventListener("close", (event) => {
if (!disposed && !failed && event.code === 1008) {
retryLease();
} else if (!disposed) {
fail(event.code === 1000
? "Видеопоток завершён. Можно подключиться повторно."
: "Видеопоток прерван. Проверьте устройство и повторите подключение.");
}
});
};
mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
return () => {
disposed = true;
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
queue.length = 0;
socket?.close(1000, "Источник скрыт оператором");
video.removeEventListener("playing", onPlaying);
try {
if (sourceBuffer?.updating) sourceBuffer.abort();
} catch {
// The MediaSource can already be closing.
}
try {
if (mediaSource.readyState === "open") mediaSource.endOfStream();
} catch {
// Cleanup must continue even if the browser has already detached MSE.
}
video.pause();
video.removeAttribute("src");
video.load();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attempt, delivery.id, delivery.mediaType, delivery.url]);
return (
<div className="mse-fmp4-player" data-status={status}>
<video
ref={videoRef}
className="observation-media__asset"
aria-label={label}
autoPlay
muted
playsInline
/>
{status !== "playing" ? (
<div className="mse-fmp4-player__status" role="status" aria-live="polite">
<Icon name={status === "error" ? "alert" : "video"} size={20} />
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong>
<span>{message}</span>
{status === "error" ? (
<button
type="button"
onClick={() => {
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
setAttempt((value) => value + 1);
}}
>
<Icon name="refresh" size={14} />
Подключиться повторно
</button>
) : null}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,302 @@
import { useState } from "react";
import { ConfirmationModal, Dropdown, Icon } from "@nodedc/ui-react";
import {
type ObservationSessionReplayLaunch,
type ObservationSessionSummary,
} from "../core/observation/sessionArchive";
import { useObservationSessions } from "../core/observation/useObservationSessions";
import type {
ObservationPreparationPhase,
ObservationReplayOutcome,
} from "../core/observation/useObservationSessions";
import "../styles/observation-sessions.css";
type SessionVisualState = "ready" | "processing" | "cold" | "error";
export function observationSessionVisualState(
session: ObservationSessionSummary,
{ pending = false, failed = false }: { pending?: boolean; failed?: boolean } = {},
): SessionVisualState {
if (failed) return "error";
if (pending) return "processing";
if (session.preparation !== null) {
if (["queued", "validating", "exporting", "finalizing"].includes(
session.preparation.state,
)) return "processing";
if (session.preparation.state === "ready" && session.replayable) return "ready";
return "error";
}
if (session.status === "recording") return "processing";
return session.replayable ? "cold" : "error";
}
export function observationSessionVisualLabel(state: SessionVisualState): string {
if (state === "ready") return "Готово";
if (state === "processing") return "Обработка";
if (state === "cold") return "Подготовить";
return "Ошибка";
}
const modalityLabel: Record<string, string> = {
"point-cloud": "облако точек",
pose: "траектория",
trajectory: "траектория",
video: "видео",
image: "изображения",
depth: "глубина",
telemetry: "телеметрия",
};
const preparationLabel: Record<ObservationPreparationPhase, string> = {
requesting: "Запрашиваем подготовку",
queued: "В очереди",
validating: "Проверяем запись",
exporting: "Готовим операторскую сцену",
finalizing: "Завершаем подготовку",
failed: "Подготовка не выполнена",
cancelled: "Подготовка отменена",
};
function progressCopy(phase: ObservationPreparationPhase): string {
// Backend progress values are phase markers and heartbeat revisions, not a
// measured fraction of bytes or frames. Presenting 0.5 as "50%" made a
// healthy long export look stalled, especially after a browser reload.
return preparationLabel[phase];
}
function formatStartedAt(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function formatDuration(seconds: number): string {
const totalSeconds = Math.max(0, Math.round(seconds));
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const remainingSeconds = totalSeconds % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
function sessionDescription(session: ObservationSessionSummary): string {
const modalities = session.modalities.length
? session.modalities.map((value) => modalityLabel[value] ?? value).join(" · ")
: "каналы не зафиксированы";
return `${formatStartedAt(session.startedAtUtc)} · ${formatDuration(session.durationSeconds)} · ${modalities}`;
}
export function ObservationSessionSelect({
limit = 100,
disabled = false,
blockedReason = null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: {
limit?: number;
disabled?: boolean;
blockedReason?: string | null;
onReplayBegin?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplayAccepted?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplaySettled?: (
session: ObservationSessionSummary,
outcome: ObservationReplayOutcome,
) => void | Promise<void>;
}) {
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({
limit,
replayEnabled: blockedReason === null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
});
const triggerCopy = sessions.replayProgress
? progressCopy(sessions.replayProgress.phase)
: sessions.state === "loading"
? "Загружаем сессии…"
: "Сохранённые сессии";
const presentedTriggerCopy = blockedReason ?? triggerCopy;
return <>
<Dropdown
className="observation-session-select"
placement="bottom-end"
width={390}
offset={10}
disabled={disabled}
surfaceRole="dialog"
surfaceClassName="observation-session-menu"
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
<button
ref={(node) => {
setAnchorRef(node);
setTriggerRef(node);
}}
type="button"
className="observation-session-select__trigger"
data-active={open ? "true" : undefined}
aria-label="Открыть сохранённые сессии наблюдения"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={surfaceId}
disabled={disabled}
title={blockedReason ?? undefined}
onClick={toggle}
>
<Icon name="database" size={15} />
<span>{presentedTriggerCopy}</span>
{sessions.state === "ready" ? <small>{sessions.items.length}</small> : null}
<Icon name="chevron-down" size={14} />
</button>
)}
>
{({ close }) => (
<div className="observation-session-menu__content">
<header className="observation-session-menu__head">
<div>
<span className="section-eyebrow">ИСТОРИЯ НАБЛЮДЕНИЯ</span>
<strong>Сохранённые сессии</strong>
</div>
<button
type="button"
aria-label="Обновить каталог сессий"
disabled={sessions.state === "loading"}
onClick={() => void sessions.refresh()}
>
<Icon name="refresh" size={14} />
</button>
</header>
{sessions.items.length > 0 ? (
<div className="observation-session-menu__list">
{sessions.items.map((session) => {
const pending = sessions.replayingSessionId === session.id;
const deleting = sessions.deletingSessionId === session.id;
const failed = sessions.failedSessionId === session.id;
const visualState = observationSessionVisualState(session, { pending, failed });
return (
<div
key={session.id}
className="observation-session-option"
data-deleting={deleting ? "true" : undefined}
>
<button
type="button"
className="nodedc-dropdown-option observation-session-option__open"
disabled={pending || deleting || !session.replayable}
onClick={() => {
void sessions.replay(session.id).then((accepted) => {
if (accepted) close();
});
}}
>
<span className="nodedc-dropdown-option__icon">
<i data-session-visual-state={visualState} aria-hidden="true" />
</span>
<span className="nodedc-dropdown-option__body">
<span className="nodedc-dropdown-option__label">
{session.lab &&
!session.label.startsWith(`${session.lab.labId} ·`) ? (
<span className="observation-session-option__lab">
{session.lab.labId}
</span>
) : null}
{session.label}
</span>
<span className="nodedc-dropdown-option__description">
{sessionDescription(session)}
</span>
</span>
<span className="observation-session-option__state">
{deleting ? "Удаление…" : observationSessionVisualLabel(visualState)}
</span>
</button>
<button
type="button"
className="observation-session-option__delete"
aria-label={`Удалить сохранённую сессию ${session.label}`}
disabled={pending || deleting}
onClick={() => setDeleteTarget(session)}
>
<Icon name="trash" size={15} />
</button>
</div>
);
})}
</div>
) : sessions.state === "loading" ? (
<div className="observation-session-menu__empty" role="status">
<span className="busy-indicator" aria-hidden="true" />
<strong>Читаем каталог</strong>
</div>
) : (
<div className="observation-session-menu__empty">
<Icon name={sessions.error ? "alert" : "database"} size={18} />
<strong>{sessions.error ? "Каталог недоступен" : "Сессий пока нет"}</strong>
<span>{sessions.error ?? "Завершённые записи появятся здесь автоматически."}</span>
</div>
)}
{sessions.error && sessions.items.length > 0 ? (
<footer className="observation-session-menu__error" role="alert">
<Icon name="alert" size={13} />
<span>{sessions.error}</span>
{sessions.failedSessionId ? (
<button type="button" onClick={() => void sessions.retry()}>
Повторить
</button>
) : null}
</footer>
) : null}
</div>
)}
</Dropdown>
<ConfirmationModal
open={deleteTarget !== null}
title="Удалить сохранённую сессию?"
description={deleteTarget ? <>
<strong>{deleteTarget.label}</strong>
{deleteTarget.lab ? (
<p>
Будут удалены только LAB-запись из каталога и её подготовленный кэш.
Исходная запись {deleteTarget.lab.sourceSessionId} и зафиксированный
лабораторный результат останутся неизменными.
</p>
) : (
<p>
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
видеоматериалы будут удалены с этого сервера без возможности восстановления.
</p>
)}
{sessions.error && sessions.deletingSessionId === null ? (
<p className="observation-session-delete-error" role="alert">{sessions.error}</p>
) : null}
</> : null}
confirmLabel="Удалить сессию"
pendingLabel="Удаление…"
danger
onClose={() => {
if (sessions.deletingSessionId === null) setDeleteTarget(null);
}}
onConfirm={async () => {
if (!deleteTarget) return;
if (await sessions.remove(deleteTarget.id)) setDeleteTarget(null);
}}
/>
</>;
}
@@ -0,0 +1,256 @@
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react";
import type {
ObservationSourceAvailability,
ObservationSourceDescriptor,
ObservationSourceModality,
} from "../core/runtime/contracts";
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
import {
RecordedFmp4Player,
type RecordedObservationPlayback,
} from "./RecordedFmp4Player";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
const sourceIcon: Record<ObservationSourceModality, IconName> = {
"point-cloud": "globe",
video: "video",
image: "image",
depth: "image",
};
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
unverified: "Не подтверждён",
declared: "Канал объявлен",
available: "Доступен",
connecting: "Подключение",
streaming: "Эфир",
degraded: "Нестабильно",
unavailable: "Недоступен",
error: "Ошибка",
};
export function observationSourceStatusLabel(source: ObservationSourceDescriptor): string {
return availabilityCopy[source.availability];
}
export function ObservationMedia({
source,
playback,
prepareRecorded = true,
recordedSessionGate = "ready",
recordedAdmissionKey = null,
onRecordedAdmissionChange,
}: {
source: ObservationSourceDescriptor;
playback?: RecordedObservationPlayback | null;
prepareRecorded?: boolean;
recordedSessionGate?: RecordedAdmissionPhase;
recordedAdmissionKey?: string | null;
onRecordedAdmissionChange?: (
sourceId: string,
state: RecordedCameraAdmissionState,
) => void;
}) {
const sourceSelected = source.activation ? source.activation.selected : true;
const deliveryActive = Boolean(source.delivery && sourceSelected);
if (
deliveryActive &&
source.delivery?.kind === "mse-fmp4-websocket" &&
source.modality === "video"
) {
return <MseFmp4WebSocketPlayer delivery={source.delivery} label={source.label} />;
}
if (
deliveryActive &&
source.delivery?.kind === "recorded-fmp4-manifest" &&
source.modality === "video"
) {
if (!prepareRecorded) {
return (
<div className="observation-media__empty" role="status">
<span className="busy-indicator" aria-hidden="true" />
<strong>Ожидает подготовки</strong>
<span>Канал будет проверен последовательно в рамках атомарной сессии.</span>
</div>
);
}
return (
<RecordedFmp4Player
source={source}
playback={playback}
prepare
sessionGate={recordedSessionGate}
admissionKey={recordedAdmissionKey}
onAdmissionChange={(state) => onRecordedAdmissionChange?.(source.id, state)}
/>
);
}
if (deliveryActive && source.delivery?.kind === "video-url" && source.modality === "video") {
return (
<video
className="observation-media__asset"
src={source.delivery.url}
autoPlay
muted
playsInline
/>
);
}
if (
deliveryActive &&
source.delivery?.kind === "image-url" &&
(source.modality === "image" || source.modality === "depth")
) {
return <img className="observation-media__asset" src={source.delivery.url} alt={source.label} />;
}
if (sourceSelected && source.previewUrl && source.modality === "video") {
return (
<video
className="observation-media__asset"
src={source.previewUrl}
autoPlay
muted
playsInline
/>
);
}
if (
sourceSelected &&
source.previewUrl &&
(source.modality === "image" || source.modality === "depth")
) {
return <img className="observation-media__asset" src={source.previewUrl} alt={source.label} />;
}
return (
<div className="observation-media__empty">
<Icon name={sourceIcon[source.modality]} size={20} />
<strong>
{source.modality === "video" && source.activation && !source.activation.selected
? "Источник отключён"
: observationSourceStatusLabel(source)}
</strong>
<span>
{source.modality === "video"
? source.activation?.controllable
? source.activation.selected
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
: "Канал остановлен оператором; при необходимости его можно открыть снова"
: "Канал известен, browser-preview сейчас недоступен"
: source.description}
</span>
</div>
);
}
export function ObservationSourcePicker({
sources,
visibleSourceIds,
pendingSourceIds,
onToggle,
}: {
sources: readonly ObservationSourceDescriptor[];
visibleSourceIds: ReadonlySet<string>;
pendingSourceIds?: ReadonlySet<string>;
onToggle: (sourceId: string) => void | Promise<boolean>;
}) {
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
return (
<Dropdown
className="scene-source-picker"
placement="bottom-start"
width={340}
offset={10}
surfaceRole="dialog"
surfaceClassName="observation-source-menu"
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
<button
ref={(node) => {
setAnchorRef(node);
setTriggerRef(node);
}}
type="button"
className="scene-source-picker__trigger"
data-active={open || visibleCount > 0 ? "true" : undefined}
aria-label="Источники данных сцены"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={surfaceId}
onClick={toggle}
>
<Icon name="database" size={17} />
{sources.length > 0 ? <span>{visibleCount}</span> : null}
</button>
)}
>
<header className="observation-source-menu__head">
<div>
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
<strong>Источники данных</strong>
</div>
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
</header>
{sources.length ? (
<div className="observation-source-menu__list">
{sources.map((source) => {
const selected = visibleSourceIds.has(source.id);
const pending = pendingSourceIds?.has(source.id) ?? false;
const canOpen = Boolean(
selected ||
source.modality === "point-cloud" ||
source.previewUrl ||
source.delivery ||
source.activation?.controllable,
);
return (
<button
key={source.id}
type="button"
className="nodedc-dropdown-option observation-source-option"
data-selected={selected ? "true" : undefined}
aria-pressed={selected}
disabled={pending || !canOpen}
onClick={() => void onToggle(source.id)}
>
<span className="nodedc-dropdown-option__icon">
<Icon name={sourceIcon[source.modality]} size={16} />
</span>
<span className="nodedc-dropdown-option__body">
<span className="nodedc-dropdown-option__label">{source.label}</span>
<span className="nodedc-dropdown-option__description">
<i data-availability={source.availability} aria-hidden="true" />
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
</span>
</span>
<span className="nodedc-dropdown-option__check">
{selected ? <Icon name="check" size={15} /> : null}
</span>
</button>
);
})}
</div>
) : (
<div className="observation-source-menu__empty">
<Icon name="database" size={18} />
<strong>Каталог пока пуст</strong>
<span>Выберите профиль устройства его плагин опубликует доступные каналы.</span>
</div>
)}
<footer className="observation-source-menu__foot">
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
модель оборудования.
</footer>
</Dropdown>
);
}
@@ -0,0 +1,174 @@
import { Button, Icon } from "@nodedc/ui-react";
import type { ObservationTimelineMode } from "../core/runtime/contracts";
export function ObservationTimeline({
active,
sourceCount,
mode = "live-only",
seekable = false,
synchronization = "host-arrival-best-effort",
rangeNs = null,
currentNs = null,
playing = false,
onSeek,
onPlayingChange,
onJumpToEnd,
accumulationSeconds,
onAccumulationChange,
onAccumulationCommit,
className = "",
}: {
active: boolean;
sourceCount: number;
mode?: ObservationTimelineMode;
seekable?: boolean;
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
rangeNs?: { min: number; max: number } | null;
currentNs?: number | null;
playing?: boolean;
onSeek?: (timeNs: number) => void;
onPlayingChange?: (playing: boolean) => void;
onJumpToEnd?: () => void;
accumulationSeconds?: number;
onAccumulationChange?: (value: number) => void;
onAccumulationCommit?: () => void;
className?: string;
}) {
const playbackRange = normalizeTimelineRange(rangeNs);
const buffered = Boolean(
seekable &&
(mode === "buffered" || mode === "recorded") &&
playbackRange,
);
const elapsedSeconds = playbackRange
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
: 0;
const durationSeconds = playbackRange
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
: 0;
const synchronizationLabel = {
"host-arrival-best-effort": "Синхронизация по приходу",
"shared-clock": "Общие часы",
"frame-accurate": "Покадровая синхронизация",
}[synchronization];
const accumulationValue = accumulationSeconds === undefined
? null
: normalizeAccumulationSeconds(accumulationSeconds);
return (
<div
className={`observation-timeline ${className}`.trim()}
data-active={active ? "true" : undefined}
data-mode={mode}
data-accumulation={accumulationValue !== null ? "true" : undefined}
>
{accumulationValue !== null ? (
<div className="observation-timeline__accumulation">
<span>Накопление</span>
<input
className="observation-timeline__track"
type="range"
min={0}
max={120}
step={1}
value={accumulationValue}
aria-label="Окно накопления облака точек"
aria-valuetext={formatAccumulationDuration(accumulationValue)}
onChange={(event) =>
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
onPointerUp={onAccumulationCommit}
onTouchEnd={onAccumulationCommit}
onKeyUp={onAccumulationCommit}
onBlur={onAccumulationCommit}
/>
<code>{formatAccumulationDuration(accumulationValue)}</code>
</div>
) : null}
<div className="observation-timeline__playback">
<Button
size="compact"
variant="ghost"
disabled={!buffered || !onSeek}
aria-label="Перейти к началу"
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
>
<Icon name="chevron-left" />
</Button>
<Button
size="compact"
variant="secondary"
disabled={!buffered || !onPlayingChange}
onClick={() => onPlayingChange?.(!playing)}
>
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
</Button>
<input
className="observation-timeline__track"
type="range"
min={0}
max={Math.max(durationSeconds, 0.001)}
step={0.01}
value={Math.min(elapsedSeconds, durationSeconds)}
disabled={!buffered || !onSeek}
aria-label="Позиция воспроизведения"
onChange={(event) => {
if (!playbackRange) return;
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
}}
/>
<div className="observation-timeline__meta">
<code>
{buffered
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
: active ? "LIVE" : "—:—:—.———"}
</code>
<small>
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
</small>
</div>
<button
type="button"
className="observation-timeline__follow"
data-active={!buffered && active ? "true" : undefined}
disabled={buffered ? !onJumpToEnd : true}
onClick={onJumpToEnd}
>
{buffered ? "К КОНЦУ" : "ЭФИР"}
</button>
</div>
</div>
);
}
export function normalizeAccumulationSeconds(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(120, Math.max(0, Math.round(value)));
}
export function formatAccumulationDuration(value: number): string {
const seconds = normalizeAccumulationSeconds(value);
return seconds === 0 ? "Кадр" : `${seconds} с`;
}
export function normalizeTimelineRange(
range: { min: number; max: number } | null | undefined,
): { min: number; max: number } | null {
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
if (range.max <= range.min) return null;
return range;
}
export function timelineOffsetSeconds(
range: { min: number; max: number },
currentNs: number,
): number {
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
return Math.max(0, (clamped - range.min) / 1_000_000_000);
}
export function formatTimelineDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
const wholeMinutes = Math.floor(seconds / 60);
const remaining = seconds - wholeMinutes * 60;
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
}
@@ -0,0 +1,494 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
fetchObservationRecordedMediaManifest,
ObservationSessionContractError,
type ObservationRecordedMediaEpoch,
type ObservationRecordedMediaManifest,
type ObservationRecordedMediaSource,
type ObservationSessionFetch,
} from "../core/observation/sessionArchive";
import {
type RecordedAdmissionPhase,
type RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
export interface RecordedObservationPlayback {
currentSeconds: number;
playing: boolean;
}
export interface RecordedMediaArchive {
manifest: ObservationRecordedMediaManifest;
byteLength: number;
}
export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "error";
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
let recordedMediaWorkerGeneration = 0;
export function recordedMediaPresentationState(
state: "loading" | "ready" | "error",
readyGeneration: string | null,
selectedGeneration: string | null,
waitingForEpoch: boolean,
sessionGate: RecordedAdmissionPhase = "ready",
): RecordedMediaPresentationState {
if (state === "error" || sessionGate === "error") return "error";
if (sessionGate !== "ready") return "loading";
if (waitingForEpoch) return "waiting";
return state === "ready" &&
selectedGeneration !== null &&
readyGeneration === selectedGeneration
? "ready"
: "loading";
}
export function selectRecordedMediaEpoch(
epochs: readonly ObservationRecordedMediaEpoch[],
currentSeconds: number,
): ObservationRecordedMediaEpoch | null {
if (!epochs.length || !Number.isFinite(currentSeconds)) return null;
let selected: ObservationRecordedMediaEpoch | null = null;
for (const epoch of epochs) {
if (epoch.timelineStartSeconds > currentSeconds) break;
selected = epoch;
}
return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null;
}
export function recordedMediaSeekableCoverage(
durationSeconds: number,
seekableEndSeconds: number,
declaredDurationSeconds: number,
toleranceSeconds = RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
seekableStartSeconds = 0,
): boolean {
return (
Number.isFinite(durationSeconds) &&
Number.isFinite(seekableEndSeconds) &&
Number.isFinite(declaredDurationSeconds) &&
Number.isFinite(seekableStartSeconds) &&
declaredDurationSeconds > 0 &&
toleranceSeconds >= 0 &&
durationSeconds + toleranceSeconds >= declaredDurationSeconds &&
seekableStartSeconds <= toleranceSeconds &&
seekableStartSeconds >= -toleranceSeconds &&
seekableEndSeconds + toleranceSeconds >= declaredDurationSeconds
);
}
export function recordedMediaLocalTime(
epochStartSeconds: number,
currentSeconds: number,
durationSeconds = Number.POSITIVE_INFINITY,
): number {
if (!Number.isFinite(epochStartSeconds) || !Number.isFinite(currentSeconds)) return 0;
const local = Math.max(0, currentSeconds - epochStartSeconds);
return Number.isFinite(durationSeconds)
? Math.min(local, Math.max(0, durationSeconds))
: local;
}
function sourceContract(source: ObservationSourceDescriptor): ObservationRecordedMediaSource | null {
const delivery = source.delivery;
if (!delivery || delivery.kind !== "recorded-fmp4-manifest") return null;
return {
id: source.id,
label: source.label,
modality: "video",
manifestUrl: delivery.url,
manifestGenerationSha256: delivery.manifestGenerationSha256,
byteLength: delivery.byteLength,
mediaType: delivery.mediaType,
timelineStartSeconds: delivery.timelineStartSeconds,
timelineEndSeconds: delivery.timelineEndSeconds,
seekable: true,
synchronization: "host-arrival-best-effort",
};
}
export async function fetchRecordedMediaArchive(
source: ObservationRecordedMediaSource,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservationSessionFetch;
} = {},
): Promise<RecordedMediaArchive> {
const manifest = await fetchObservationRecordedMediaManifest(source, {
signal,
fetcher,
expectedGenerationSha256: source.manifestGenerationSha256,
});
const totalBytes = manifest.epochs.reduce((total, epoch) => total + epoch.byteLength, 0);
if (
!Number.isSafeInteger(totalBytes) ||
totalBytes < 1 ||
totalBytes !== manifest.byteLength ||
totalBytes !== source.byteLength
) {
throw new ObservationSessionContractError(
"Размеры потоков записанного медиаканала не совпадают с manifest.",
);
}
return { manifest, byteLength: totalBytes };
}
function videoHasSeekableArchive(
video: HTMLVideoElement,
declaredDurationSeconds: number,
): boolean {
if (video.readyState < 1 || video.seekable.length < 1) return false;
return recordedMediaSeekableCoverage(
video.duration,
video.seekable.end(video.seekable.length - 1),
declaredDurationSeconds,
RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
video.seekable.start(0),
);
}
function waitForSeekableArchive(
video: HTMLVideoElement,
declaredDurationSeconds: number,
signal: AbortSignal,
): Promise<void> {
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (videoHasSeekableArchive(video, declaredDurationSeconds)) return Promise.resolve();
return new Promise((resolve, reject) => {
const events = ["loadedmetadata", "durationchange", "progress", "canplay"] as const;
let stallTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
const armStallTimer = () => {
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
stallTimer = globalThis.setTimeout(() => {
cleanup();
reject(new Error("Archived camera stream stalled"));
}, 45_000);
};
const cleanup = () => {
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
for (const event of events) video.removeEventListener(event, onProgress);
video.removeEventListener("error", onError);
signal.removeEventListener("abort", onAbort);
};
const onProgress = () => {
armStallTimer();
if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return;
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error("Archived camera decode failed"));
};
const onAbort = () => {
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
for (const event of events) video.addEventListener(event, onProgress);
video.addEventListener("error", onError, { once: true });
signal.addEventListener("abort", onAbort, { once: true });
armStallTimer();
});
}
async function mountRecordedEpochStream(
video: HTMLVideoElement,
descriptor: ObservationRecordedMediaEpoch,
signal: AbortSignal,
): Promise<() => void> {
if (!descriptor.mediaType.startsWith("video/mp4;") || !video.canPlayType(descriptor.mediaType)) {
throw new Error("Archived camera codec is not supported");
}
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
// The generation token binds the native media request to the exact manifest.
// The browser range-streams the virtual init+fragment file from the server;
// no complete camera archive is copied into JavaScript memory.
const cleanup = () => {
video.pause();
video.removeAttribute("src");
video.load();
};
video.preload = "auto";
video.src = descriptor.streamUrl;
video.load();
try {
await waitForSeekableArchive(
video,
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
signal,
);
return cleanup;
} catch (error) {
cleanup();
throw error;
}
}
export function RecordedFmp4Player({
source,
playback,
prepare = true,
sessionGate = "ready",
admissionKey = null,
onAdmissionChange,
}: {
source: ObservationSourceDescriptor;
playback?: RecordedObservationPlayback | null;
prepare?: boolean;
sessionGate?: RecordedAdmissionPhase;
admissionKey?: string | null;
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const onAdmissionChangeRef = useRef(onAdmissionChange);
onAdmissionChangeRef.current = onAdmissionChange;
const workerRef = useRef<{ admissionKey: string | null; generation: number } | null>(null);
if (!workerRef.current || workerRef.current.admissionKey !== admissionKey) {
recordedMediaWorkerGeneration += 1;
workerRef.current = { admissionKey, generation: recordedMediaWorkerGeneration };
}
const workerGeneration = workerRef.current.generation;
const reportAdmission = (next: RecordedCameraAdmissionState) => {
onAdmissionChangeRef.current?.({
...next,
admissionKey,
workerGeneration,
});
};
const recordedDelivery = source.delivery?.kind === "recorded-fmp4-manifest"
? source.delivery
: null;
const contract = useMemo(
() => sourceContract(source),
[
source.id,
source.label,
recordedDelivery?.id,
recordedDelivery?.url,
recordedDelivery?.mediaType,
recordedDelivery?.manifestGenerationSha256,
recordedDelivery?.byteLength,
recordedDelivery?.timelineStartSeconds,
recordedDelivery?.timelineEndSeconds,
],
);
const [archive, setArchive] = useState<RecordedMediaArchive | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
const [bufferRevision, setBufferRevision] = useState(0);
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
const epoch = useMemo(
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
[archive?.manifest.epochs, currentSeconds],
);
const waitingForEpoch = Boolean(archive && !epoch);
const selectedGeneration = contract && epoch
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
: null;
const visualState = recordedMediaPresentationState(
state,
readyGeneration,
selectedGeneration,
waitingForEpoch,
sessionGate,
);
useEffect(() => {
if (!contract) {
setArchive(null);
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: null,
message: "Некорректный descriptor записанной камеры.",
});
return;
}
if (!prepare) return;
const abort = new AbortController();
setArchive(null);
setReadyGeneration(null);
setState("loading");
reportAdmission({
phase: "loading",
byteLength: contract.byteLength,
message: null,
});
void fetchRecordedMediaArchive(contract, { signal: abort.signal })
.then((loaded) => {
if (abort.signal.aborted) return;
setArchive(loaded);
})
.catch((error: unknown) => {
if (abort.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) {
return;
}
setArchive(null);
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: contract.byteLength,
message: "Архив записанной камеры не прошёл проверку.",
});
});
return () => abort.abort();
}, [admissionKey, contract, prepare]);
useEffect(() => {
if (!archive || !contract || !prepare) return;
const abort = new AbortController();
let disposed = false;
void (async () => {
for (const candidate of archive.manifest.epochs) {
const probe = document.createElement("video");
probe.muted = true;
probe.playsInline = true;
const cleanup = await mountRecordedEpochStream(probe, candidate, abort.signal);
cleanup();
if (disposed || abort.signal.aborted) return;
}
if (disposed || abort.signal.aborted) return;
reportAdmission({
phase: "ready",
byteLength: archive.byteLength,
message: null,
});
})().catch((error: unknown) => {
if (
disposed ||
abort.signal.aborted ||
(error instanceof DOMException && error.name === "AbortError")
) return;
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archive.byteLength,
message: "Не все codec epoch записанной камеры декодируются и доступны для seek.",
});
});
return () => {
disposed = true;
abort.abort();
};
}, [admissionKey, archive, contract, prepare]);
useEffect(() => {
const video = videoRef.current;
if (!video || !epoch) return;
const epochDescriptor = epoch;
const generation = contract
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
: null;
setReadyGeneration(null);
setState("loading");
const abort = new AbortController();
let disposed = false;
let cleanup: (() => void) | null = null;
const loadEpoch = async () => {
try {
cleanup = await mountRecordedEpochStream(video, epochDescriptor, abort.signal);
if (disposed || abort.signal.aborted) {
cleanup();
cleanup = null;
return;
}
setBufferRevision((revision) => revision + 1);
setReadyGeneration(generation);
setState("ready");
} catch (error) {
if (
disposed ||
abort.signal.aborted ||
(error instanceof DOMException && error.name === "AbortError")
) {
return;
}
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archive?.byteLength ?? null,
message: "Записанная камера не стала seekable.",
});
}
};
void loadEpoch();
return () => {
disposed = true;
abort.abort();
cleanup?.();
};
}, [archive?.byteLength, contract, epoch]);
useEffect(() => {
const video = videoRef.current;
if (!video || !epoch || visualState !== "ready") return;
const target = recordedMediaLocalTime(
epoch.timelineStartSeconds,
currentSeconds,
video.duration,
);
if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.35) {
try {
video.currentTime = target;
} catch {
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archive?.byteLength ?? null,
message: "Seek записанной камеры завершился ошибкой.",
});
return;
}
}
if (playback?.playing) {
void video.play().catch(() => undefined);
} else {
video.pause();
}
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
return (
<div
className="recorded-media-player"
data-state={visualState}
aria-busy={visualState === "loading"}
>
<video
ref={videoRef}
className="observation-media__asset"
muted
playsInline
preload="auto"
aria-label={source.label}
/>
{visualState !== "ready" ? (
<div
className="recorded-media-player__notice"
role={visualState === "error" ? "alert" : "status"}
>
{visualState === "waiting"
? "Камера на этой позиции ещё не записывалась"
: visualState === "error"
? "Записанное видео недоступно"
: archive
? "Проверяем seek и codec записанного видео…"
: "Читаем manifest записанного видео…"}
</div>
) : null}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
// Composition root: this is the only place where Mission Core chooses which
// statically reviewed device plugins are shipped in the current build.
export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
xgridsK1Plugin,
]);
@@ -0,0 +1,160 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry";
interface DevicePluginHostValue {
registry: DevicePluginRegistry;
selection: RegisteredDeviceModel | null;
selectionTransitionPending: boolean;
selectionTransitionError: string | null;
selectModel: (modelId: string) => Promise<boolean>;
clearSelection: () => Promise<boolean>;
}
const DevicePluginHostContext = createContext<DevicePluginHostValue | null>(null);
export function DevicePluginHostProvider({
plugins,
children,
}: {
plugins: readonly DeviceUiPlugin[];
children: ReactNode;
}) {
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [selectionTransitionPending, setSelectionTransitionPending] = useState(false);
const [selectionTransitionError, setSelectionTransitionError] = useState<string | null>(null);
const transitionInFlight = useRef(false);
const deactivationHandlers = useRef(new Map<string, () => Promise<boolean>>());
const selection = selectedModelId ? registry.resolveModel(selectedModelId) : null;
const registerDeactivation = useCallback(
(pluginId: string, handler: () => Promise<boolean>) => {
deactivationHandlers.current.set(pluginId, handler);
return () => {
if (deactivationHandlers.current.get(pluginId) === handler) {
deactivationHandlers.current.delete(pluginId);
}
};
},
[],
);
const transitionTo = useCallback(
async (nextModelId: string | null): Promise<boolean> => {
if (transitionInFlight.current) return false;
if (nextModelId !== null && !registry.resolveModel(nextModelId)) {
throw new Error(`Модель устройства не зарегистрирована: ${nextModelId}.`);
}
if (nextModelId === selectedModelId) return true;
transitionInFlight.current = true;
setSelectionTransitionPending(true);
setSelectionTransitionError(null);
try {
const current = selectedModelId ? registry.resolveModel(selectedModelId) : null;
const deactivate = current
? deactivationHandlers.current.get(current.plugin.manifest.metadata.id)
: undefined;
if (current && !deactivate) {
setSelectionTransitionError(
"Активный плагин не зарегистрировал безопасное завершение сессии.",
);
return false;
}
if (deactivate) {
try {
if (!(await deactivate())) {
setSelectionTransitionError(
"Плагин не подтвердил завершение текущей сессии.",
);
return false;
}
} catch {
setSelectionTransitionError(
"Не удалось безопасно завершить текущую сессию плагина.",
);
return false;
}
}
setSelectedModelId(nextModelId);
return true;
} finally {
transitionInFlight.current = false;
setSelectionTransitionPending(false);
}
},
[registry, selectedModelId],
);
const deactivationRegistrars = useMemo(
() =>
new Map(
registry.plugins.map((plugin) => [
plugin.manifest.metadata.id,
(handler: () => Promise<boolean>) =>
registerDeactivation(plugin.manifest.metadata.id, handler),
]),
),
[registerDeactivation, registry.plugins],
);
const value = useMemo<DevicePluginHostValue>(
() => ({
registry,
selection,
selectionTransitionPending,
selectionTransitionError,
selectModel: (modelId) => transitionTo(modelId),
clearSelection: () => transitionTo(null),
}),
[
registry,
selection,
selectionTransitionError,
selectionTransitionPending,
transitionTo,
],
);
const runtimeTree = [...registry.plugins].reverse().reduce<ReactNode>((child, plugin) => {
const RuntimeProvider = plugin.RuntimeProvider;
return (
<RuntimeProvider
key={plugin.manifest.metadata.id}
activeModel={
selection?.plugin.manifest.metadata.id === plugin.manifest.metadata.id
? selection.model
: null
}
registerDeactivation={deactivationRegistrars.get(plugin.manifest.metadata.id)!}
>
{child}
</RuntimeProvider>
);
}, children);
return (
<DevicePluginHostContext.Provider value={value}>
<IdleMissionRuntimeProvider>{runtimeTree}</IdleMissionRuntimeProvider>
</DevicePluginHostContext.Provider>
);
}
export function useDevicePluginHost(): DevicePluginHostValue {
const value = useContext(DevicePluginHostContext);
if (!value) {
throw new Error("DevicePluginHostProvider не подключён в composition root.");
}
return value;
}
@@ -0,0 +1,121 @@
import type { ComponentType, ReactNode } from "react";
export const DEVICE_PLUGIN_API_VERSION_V1ALPHA1 = "missioncore.nodedc/v1alpha1" as const;
export const DEVICE_PLUGIN_API_VERSION_V1ALPHA2 = "missioncore.nodedc/v1alpha2" as const;
export const DEVICE_PLUGIN_API_VERSION = DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
export const SUPPORTED_DEVICE_PLUGIN_API_VERSIONS = Object.freeze([
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
] as const);
export const DEVICE_STATE_READ_ACTION_ID = "state.read" as const;
export type DevicePluginApiVersion =
(typeof SUPPORTED_DEVICE_PLUGIN_API_VERSIONS)[number];
export interface DeviceCapability {
id: string;
label: string;
}
export interface DevicePluginActionDefinition {
id: string;
mutating: boolean;
secretFields: readonly string[];
}
export interface DeviceModelDefinition {
id: string;
vendor: string;
displayName: string;
category: string;
description: string;
verified: boolean;
capabilities: readonly DeviceCapability[];
ui: {
slot: "device.connection";
componentKey: string;
};
}
export interface DeviceCompatibilityProfileDefinition {
profileId: string;
path: string;
modelId: string;
}
interface DevicePluginManifestBase {
apiVersion: DevicePluginApiVersion;
kind: "DevicePlugin";
metadata: {
id: string;
version: string;
displayName: string;
};
}
export interface DevicePluginManifestV1Alpha1 extends DevicePluginManifestBase {
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA1;
spec: {
hostApiRange: "v1alpha1";
runtime: {
backendEntrypoint: string;
isolation: "transitional-in-process";
};
permissions: readonly string[];
actions: readonly DevicePluginActionDefinition[];
models: readonly DeviceModelDefinition[];
};
}
export interface DevicePluginManifestV1Alpha2 extends DevicePluginManifestBase {
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
spec: {
hostApiRange: "v1alpha2";
runtime: {
backendEntrypoint: string;
isolation: "transitional-in-process";
};
permissions: readonly string[];
actions: readonly DevicePluginActionDefinition[];
models: readonly DeviceModelDefinition[];
compatibilityProfiles: readonly DeviceCompatibilityProfileDefinition[];
};
}
export type DevicePluginManifest =
| DevicePluginManifestV1Alpha1
| DevicePluginManifestV1Alpha2;
export function isDevicePluginManifestV1Alpha2(
manifest: DevicePluginManifest,
): manifest is DevicePluginManifestV1Alpha2 {
return manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
}
export interface DevicePluginHostActions {
openSpatialScene: () => void;
activateAutomaticSpatialSource: () => void;
}
export interface DevicePluginConnectionProps {
model: DeviceModelDefinition;
host: DevicePluginHostActions;
}
export interface DeviceUiPlugin {
manifest: DevicePluginManifest;
RuntimeProvider: ComponentType<{
activeModel: DeviceModelDefinition | null;
registerDeactivation: (handler: () => Promise<boolean>) => () => void;
children: ReactNode;
}>;
connectionViews: Readonly<Record<string, ComponentType<DevicePluginConnectionProps>>>;
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
}
export interface RegisteredDeviceModel {
plugin: DeviceUiPlugin;
model: DeviceModelDefinition;
ConnectionView: ComponentType<DevicePluginConnectionProps>;
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
}
@@ -0,0 +1,14 @@
/**
* Public frontend host surface for statically reviewed device plugins.
*
* Plugin source may depend on this module through the
* `@mission-core/plugin-sdk` alias. It must not reach into Control Station
* implementation paths directly.
*/
export * from "./contracts";
export { parseDevicePluginManifest, requirePluginAction } from "./manifestParser";
export {
MissionRuntimeProvider,
useMissionRuntime,
} from "../runtime/MissionRuntimeContext";
export type * from "../runtime/contracts";
@@ -0,0 +1,313 @@
import {
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
type DeviceCapability,
type DeviceCompatibilityProfileDefinition,
type DeviceModelDefinition,
type DevicePluginActionDefinition,
type DevicePluginManifest,
type DevicePluginManifestV1Alpha1,
type DevicePluginManifestV1Alpha2,
} from "./contracts";
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`Некорректный manifest: ${path} должен быть объектом.`);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: Record<string, unknown>,
path: string,
allowed: readonly string[],
): void {
const extras = Object.keys(value).filter((key) => !allowed.includes(key));
if (extras.length) {
throw new Error(`Некорректный manifest: ${path} содержит неизвестные поля ${extras.join(", ")}.`);
}
}
function text(value: unknown, path: string, maxLength = 160): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`Некорректный manifest: ${path} должен быть непустой строкой.`);
}
if (value.length > maxLength) {
throw new Error(
`Некорректный manifest: ${path} длиннее ${maxLength} символов.`,
);
}
return value;
}
function identifier(value: unknown, path: string): string {
const candidate = text(value, path, 192);
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(candidate)) {
throw new Error(`Некорректный manifest: ${path} не является идентификатором.`);
}
return candidate;
}
function flag(value: unknown, path: string): boolean {
if (typeof value !== "boolean") {
throw new Error(`Некорректный manifest: ${path} должен быть boolean.`);
}
return value;
}
function list(value: unknown, path: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`Некорректный manifest: ${path} должен быть массивом.`);
}
return value;
}
function contractText(value: unknown, path: string, strictIdentifiers: boolean): string {
return strictIdentifiers ? identifier(value, path) : text(value, path);
}
function capability(
value: unknown,
path: string,
strictIdentifiers: boolean,
): DeviceCapability {
const item = record(value, path);
exactKeys(item, path, ["id", "label"]);
return {
id: contractText(item.id, `${path}.id`, strictIdentifiers),
label: text(item.label, `${path}.label`),
};
}
function action(
value: unknown,
path: string,
strictIdentifiers: boolean,
): DevicePluginActionDefinition {
const item = record(value, path);
exactKeys(item, path, ["id", "mutating", "secretFields"]);
return {
id: contractText(item.id, `${path}.id`, strictIdentifiers),
mutating: flag(item.mutating, `${path}.mutating`),
secretFields: list(item.secretFields, `${path}.secretFields`).map((field, index) =>
contractText(field, `${path}.secretFields[${index}]`, strictIdentifiers),
),
};
}
function model(
value: unknown,
path: string,
strictIdentifiers: boolean,
): DeviceModelDefinition {
const item = record(value, path);
exactKeys(item, path, [
"id",
"vendor",
"displayName",
"category",
"description",
"verified",
"capabilities",
"ui",
]);
const ui = record(item.ui, `${path}.ui`);
exactKeys(ui, `${path}.ui`, ["slot", "componentKey"]);
const slot = text(ui.slot, `${path}.ui.slot`);
if (slot !== "device.connection") {
throw new Error(`Некорректный manifest: слот ${slot} пока не поддерживается.`);
}
return {
id: contractText(item.id, `${path}.id`, strictIdentifiers),
vendor: text(item.vendor, `${path}.vendor`),
displayName: text(item.displayName, `${path}.displayName`),
category: text(item.category, `${path}.category`),
description: text(item.description, `${path}.description`, 1024),
verified: flag(item.verified, `${path}.verified`),
capabilities: list(item.capabilities, `${path}.capabilities`).map((entry, index) =>
capability(entry, `${path}.capabilities[${index}]`, strictIdentifiers),
),
ui: {
slot,
componentKey: text(ui.componentKey, `${path}.ui.componentKey`),
},
};
}
function compatibilityProfile(
value: unknown,
path: string,
): DeviceCompatibilityProfileDefinition {
const item = record(value, path);
exactKeys(item, path, ["profileId", "path", "modelId"]);
const profilePath = text(item.path, `${path}.path`, 512);
const pathSegments = profilePath.split("/");
if (
profilePath.startsWith("/") ||
profilePath.includes("\\") ||
!profilePath.endsWith(".json") ||
pathSegments.some((segment) => !segment || segment === "." || segment === "..")
) {
throw new Error(
`Некорректный manifest: ${path}.path должен быть безопасным относительным JSON-путём.`,
);
}
return {
profileId: identifier(item.profileId, `${path}.profileId`),
path: profilePath,
modelId: identifier(item.modelId, `${path}.modelId`),
};
}
function validateV1Alpha2Profiles(
models: readonly DeviceModelDefinition[],
profiles: readonly DeviceCompatibilityProfileDefinition[],
): void {
if (!profiles.length) {
throw new Error("Manifest v1alpha2 должен объявлять compatibilityProfiles.");
}
const modelIds = new Set(models.map((modelItem) => modelItem.id));
const profileIds = new Set<string>();
const profilePaths = new Set<string>();
const coveredModels = new Set<string>();
for (const profile of profiles) {
if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) {
throw new Error(
`Manifest v1alpha2 повторяет профиль ${profile.profileId} или его путь.`,
);
}
profileIds.add(profile.profileId);
profilePaths.add(profile.path);
if (!modelIds.has(profile.modelId)) {
throw new Error(
`Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`,
);
}
coveredModels.add(profile.modelId);
}
const uncovered = [...modelIds].filter((modelId) => !coveredModels.has(modelId));
if (uncovered.length) {
throw new Error(
`Manifest v1alpha2 не содержит разрешённого профиля для моделей: ${uncovered.join(", ")}.`,
);
}
}
export function parseDevicePluginManifest(document: unknown): DevicePluginManifest {
const root = record(document, "root");
exactKeys(root, "root", ["apiVersion", "kind", "metadata", "spec"]);
const apiVersion = root.apiVersion;
if (
(apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA1 &&
apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA2) ||
root.kind !== "DevicePlugin"
) {
throw new Error("Manifest использует несовместимую версию или kind.");
}
const metadata = record(root.metadata, "metadata");
exactKeys(metadata, "metadata", ["id", "version", "displayName"]);
const spec = record(root.spec, "spec");
const specKeys = [
"hostApiRange",
"runtime",
"permissions",
"actions",
"models",
];
exactKeys(
spec,
"spec",
apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2
? [...specKeys, "compatibilityProfiles"]
: specKeys,
);
const expectedHostApiRange =
apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 ? "v1alpha2" : "v1alpha1";
if (spec.hostApiRange !== expectedHostApiRange) {
throw new Error(`Manifest требует несовместимый host API: ${String(spec.hostApiRange)}.`);
}
const runtime = record(spec.runtime, "spec.runtime");
exactKeys(runtime, "spec.runtime", ["backendEntrypoint", "isolation"]);
const isolation = text(runtime.isolation, "spec.runtime.isolation");
if (isolation !== "transitional-in-process") {
throw new Error(`Некорректный manifest: неизвестная изоляция ${isolation}.`);
}
const strictIdentifiers = apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
const models = list(spec.models, "spec.models").map((entry, index) =>
model(entry, `spec.models[${index}]`, strictIdentifiers),
);
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 && models.length !== 1) {
throw new Error("Manifest v1alpha1 должен объявлять ровно одну модель.");
}
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 && !models.length) {
throw new Error("Manifest v1alpha2 должен объявлять хотя бы одну модель.");
}
const version = text(metadata.version, "metadata.version");
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) {
throw new Error(`Некорректный manifest: версия ${version} не является semver.`);
}
const common = {
kind: "DevicePlugin",
metadata: {
id: contractText(metadata.id, "metadata.id", strictIdentifiers),
version,
displayName: text(metadata.displayName, "metadata.displayName"),
},
} as const;
const commonSpec = {
runtime: {
backendEntrypoint: text(
runtime.backendEntrypoint,
"spec.runtime.backendEntrypoint",
256,
),
isolation,
},
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
contractText(entry, `spec.permissions[${index}]`, strictIdentifiers),
),
actions: list(spec.actions, "spec.actions").map((entry, index) =>
action(entry, `spec.actions[${index}]`, strictIdentifiers),
),
models,
} as const;
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1) {
return {
...common,
apiVersion,
spec: {
...commonSpec,
hostApiRange: "v1alpha1",
},
} satisfies DevicePluginManifestV1Alpha1;
}
const profiles = list(spec.compatibilityProfiles, "spec.compatibilityProfiles").map(
(entry, index) => compatibilityProfile(entry, `spec.compatibilityProfiles[${index}]`),
);
validateV1Alpha2Profiles(models, profiles);
return {
...common,
apiVersion,
spec: {
...commonSpec,
hostApiRange: "v1alpha2",
compatibilityProfiles: profiles,
},
} satisfies DevicePluginManifestV1Alpha2;
}
export function requirePluginAction(
manifest: DevicePluginManifest,
actionId: string,
): string {
const action = manifest.spec.actions.find((candidate) => candidate.id === actionId);
if (!action) {
throw new Error(`Плагин ${manifest.metadata.id} не объявляет действие ${actionId}.`);
}
return action.id;
}
@@ -0,0 +1,141 @@
import {
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
DEVICE_STATE_READ_ACTION_ID,
SUPPORTED_DEVICE_PLUGIN_API_VERSIONS,
isDevicePluginManifestV1Alpha2,
type DeviceUiPlugin,
type RegisteredDeviceModel,
} from "./contracts";
export interface DevicePluginRegistry {
readonly plugins: readonly DeviceUiPlugin[];
readonly models: readonly RegisteredDeviceModel[];
resolveModel: (modelId: string) => RegisteredDeviceModel | null;
}
export function createDevicePluginRegistry(
installedPlugins: readonly DeviceUiPlugin[],
): DevicePluginRegistry {
const pluginIds = new Set<string>();
const modelIds = new Set<string>();
const models: RegisteredDeviceModel[] = [];
for (const plugin of installedPlugins) {
const { manifest } = plugin;
if (!SUPPORTED_DEVICE_PLUGIN_API_VERSIONS.includes(manifest.apiVersion)) {
throw new Error(
`Плагин ${manifest.metadata.id} использует несовместимый контракт ${manifest.apiVersion}.`,
);
}
if (manifest.kind !== "DevicePlugin") {
throw new Error(`Неподдерживаемый kind плагина: ${manifest.kind}.`);
}
if (!manifest.metadata.id.trim() || pluginIds.has(manifest.metadata.id)) {
throw new Error(
`Идентификатор плагина пуст или повторяется: ${manifest.metadata.id || "<empty>"}.`,
);
}
pluginIds.add(manifest.metadata.id);
if (
manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 &&
manifest.spec.models.length !== 1
) {
throw new Error(
`Плагин ${manifest.metadata.id} должен объявлять ровно одну модель в v1alpha1.`,
);
}
if (!manifest.spec.models.length) {
throw new Error(`Плагин ${manifest.metadata.id} не объявляет ни одной модели.`);
}
const permissions = new Set(manifest.spec.permissions);
if (permissions.size !== manifest.spec.permissions.length) {
throw new Error(`Плагин ${manifest.metadata.id} повторяет permission.`);
}
const actionIds = new Set<string>();
for (const action of manifest.spec.actions) {
if (!action.id.trim() || actionIds.has(action.id)) {
throw new Error(
`Идентификатор действия пуст или повторяется: ${action.id || "<empty>"}.`,
);
}
actionIds.add(action.id);
if (new Set(action.secretFields).size !== action.secretFields.length) {
throw new Error(`Действие ${action.id} повторяет secret field.`);
}
}
const stateRead = manifest.spec.actions.find(
(action) => action.id === DEVICE_STATE_READ_ACTION_ID,
);
if (!stateRead || stateRead.mutating || stateRead.secretFields.length) {
throw new Error(
`Плагин ${manifest.metadata.id} должен объявлять безопасное действие state.read.`,
);
}
for (const model of manifest.spec.models) {
if (!model.id.trim() || modelIds.has(model.id)) {
throw new Error(`Идентификатор модели пуст или повторяется: ${model.id || "<empty>"}.`);
}
modelIds.add(model.id);
const capabilityIds = new Set(model.capabilities.map((capability) => capability.id));
if (capabilityIds.size !== model.capabilities.length) {
throw new Error(`Модель ${model.id} повторяет capability.`);
}
const ConnectionView = plugin.connectionViews[model.ui.componentKey];
if (!ConnectionView) {
throw new Error(
`Плагин ${manifest.metadata.id} не реализует UI ${model.ui.componentKey}.`,
);
}
models.push({
plugin,
model,
ConnectionView,
...(plugin.SpatialControlsView
? { SpatialControlsView: plugin.SpatialControlsView }
: {}),
});
}
if (isDevicePluginManifestV1Alpha2(manifest)) {
const profileIds = new Set<string>();
const profilePaths = new Set<string>();
const declaredModelIds = new Set(manifest.spec.models.map((model) => model.id));
const coveredModelIds = new Set<string>();
if (!manifest.spec.compatibilityProfiles.length) {
throw new Error(`Плагин ${manifest.metadata.id} не объявляет compatibilityProfiles.`);
}
for (const profile of manifest.spec.compatibilityProfiles) {
if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) {
throw new Error(`Плагин ${manifest.metadata.id} повторяет compatibility profile.`);
}
profileIds.add(profile.profileId);
profilePaths.add(profile.path);
if (!declaredModelIds.has(profile.modelId)) {
throw new Error(
`Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`,
);
}
coveredModelIds.add(profile.modelId);
}
const uncovered = [...declaredModelIds].filter(
(modelId) => !coveredModelIds.has(modelId),
);
if (uncovered.length) {
throw new Error(
`Плагин ${manifest.metadata.id} не имеет разрешённого профиля для ${uncovered.join(", ")}.`,
);
}
}
}
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
return {
plugins: Object.freeze([...installedPlugins]),
models: Object.freeze(models),
resolveModel: (modelId) => modelById.get(modelId) ?? null,
};
}
@@ -0,0 +1,68 @@
import type { ObservationSourceDescriptor } from "../runtime/contracts";
export interface ObservationVisibilityChange {
visibleIds: string[];
removedIds: string[];
}
export function shouldRestartObservationSource(
source: ObservationSourceDescriptor,
): boolean {
return Boolean(
source.activation?.selected &&
source.activation.controllable &&
(source.availability === "error" || !source.delivery),
);
}
export function openObservationSource(
currentIds: readonly string[],
sourceId: string,
sources: readonly ObservationSourceDescriptor[],
): ObservationVisibilityChange {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return { visibleIds: [...currentIds], removedIds: [] };
const uniqueCurrent = [...new Set(currentIds)];
const activation = source.activation;
if (!activation) {
return {
visibleIds: uniqueCurrent.includes(sourceId) ? uniqueCurrent : [...uniqueCurrent, sourceId],
removedIds: [],
};
}
const groupSourceIds = new Set(
sources
.filter((candidate) => candidate.activation?.groupId === activation.groupId)
.map((candidate) => candidate.id),
);
const capacity = Math.max(1, Math.floor(activation.maxActive));
const currentPeers = uniqueCurrent.filter(
(candidate) => candidate !== sourceId && groupSourceIds.has(candidate),
);
const retainedPeerCount = Math.max(0, capacity - 1);
const retainedPeers = new Set(
retainedPeerCount > 0 ? currentPeers.slice(-retainedPeerCount) : [],
);
const visibleIds = uniqueCurrent.filter(
(candidate) => !groupSourceIds.has(candidate) || retainedPeers.has(candidate),
);
if (!visibleIds.includes(sourceId)) visibleIds.push(sourceId);
const visibleSet = new Set(visibleIds);
return {
visibleIds,
removedIds: uniqueCurrent.filter((candidate) => !visibleSet.has(candidate)),
};
}
export function closeObservationSource(
currentIds: readonly string[],
sourceId: string,
): ObservationVisibilityChange {
const visibleIds = currentIds.filter((candidate) => candidate !== sourceId);
return {
visibleIds,
removedIds: visibleIds.length === currentIds.length ? [] : [sourceId],
};
}
@@ -0,0 +1,94 @@
import type { ObservationSourceDescriptor } from "../runtime/contracts";
import type { ObservationSessionReplayLaunch } from "./sessionArchive";
export function recordedObservationSources(
launch: ObservationSessionReplayLaunch | null,
): ObservationSourceDescriptor[] {
if (!launch) return [];
const spatial: ObservationSourceDescriptor = {
id: "recorded.spatial.primary",
sourceId: "recorded.spatial.primary",
semanticChannelId: "spatial.point-cloud.recorded",
label: "Сохранённая пространственная сцена",
description: "Облако точек и траектория из записи Rerun",
modality: "point-cloud",
role: "primary",
availability: "available",
transport: "recording",
endpointLabel: "RRD · session_time",
previewUrl: launch.sourceUrl,
delivery: null,
activation: null,
provider: {
pluginId: "missioncore.session-archive",
pluginVersion: "1",
modelId: "recorded-spatial",
compatibilityProfileId: null,
},
binding: {},
capabilities: {
overlay: false,
fullscreen: true,
resizable: false,
defaultVisible: true,
timelineMode: "recorded",
seekable: true,
sessionRecording: true,
clockId: launch.timeline,
spatialRegistration: "native",
},
};
const media = launch.mediaSources.map((source, index): ObservationSourceDescriptor => {
const perception = source.id.startsWith("recorded.perception.");
return {
id: source.id,
sourceId: source.id,
semanticChannelId: perception
? "camera.perception.panoptic.recorded"
: "camera.video.recorded",
label: source.label,
description: perception
? "Покадровая instance + semantic сегментация на общей временной шкале"
: "Сохранённый видеоканал на общей временной шкале сессии",
modality: "video",
role: "auxiliary",
availability: "available",
transport: "recording",
endpointLabel: "Сохранённая сессия",
previewUrl: null,
delivery: {
id: `${launch.sessionId}:${source.id}`,
kind: "recorded-fmp4-manifest",
url: source.manifestUrl,
mediaType: source.mediaType,
manifestGenerationSha256: source.manifestGenerationSha256,
byteLength: source.byteLength,
timelineStartSeconds: source.timelineStartSeconds,
timelineEndSeconds: source.timelineEndSeconds,
},
activation: null,
provider: {
pluginId: "missioncore.session-archive",
pluginVersion: "1",
modelId: perception ? "recorded-panoptic-perception" : "recorded-media",
compatibilityProfileId: null,
},
binding: {},
capabilities: {
overlay: true,
fullscreen: true,
resizable: true,
// AI presentation belongs to the unified Rerun composition. Keep the
// pre-rendered perception video as an explicit fallback instead of
// opening it as a second, independently controlled stream.
defaultVisible: !perception && index < 2,
timelineMode: "recorded",
seekable: true,
sessionRecording: true,
clockId: "session_time",
spatialRegistration: perception ? "calibrated" : "unresolved",
},
};
});
return [spatial, ...media];
}
@@ -0,0 +1,111 @@
// Camera count and preparation concurrency remain device-agnostic scheduling
// policy. Recorded duration and aggregate bytes are deliberately not admission
// criteria: sealed media is presented through a generation-bound HTTP stream,
// so a one-, three- or ten-hour recording never has to fit in browser memory.
export const MAX_RECORDED_CAMERA_SOURCES = 16;
export const MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS = 1;
export type RecordedAdmissionPhase = "loading" | "ready" | "error";
export type RecordedCameraAdmissionPhase = "pending" | "loading" | "ready" | "error";
export interface RecordedCameraAdmissionState {
phase: RecordedCameraAdmissionPhase;
byteLength: number | null;
message: string | null;
admissionKey?: string | null;
workerGeneration?: number;
}
export type RecordedCameraAdmissionMap = Readonly<Record<string, RecordedCameraAdmissionState>>;
export interface RecordedCameraAdmissionDescriptor {
id: string;
byteLength: number;
}
export function recordedCameraDescriptorPreflight(
sources: readonly RecordedCameraAdmissionDescriptor[],
): RecordedAdmissionPhase {
if (sources.length > MAX_RECORDED_CAMERA_SOURCES) return "error";
if (new Set(sources.map(({ id }) => id)).size !== sources.length) return "error";
for (const source of sources) {
if (
!source.id ||
!Number.isSafeInteger(source.byteLength) ||
source.byteLength < 1
) return "error";
}
return "ready";
}
export function initialRecordedCameraAdmissions(
sourceIds: readonly string[],
declaredByteLengths: Readonly<Record<string, number>> = {},
): Record<string, RecordedCameraAdmissionState> {
return Object.fromEntries(sourceIds.map((sourceId) => [sourceId, {
phase: "pending" as const,
byteLength: declaredByteLengths[sourceId] ?? null,
message: null,
}]));
}
export function recordedSessionAdmissionPhase(
spatialPhase: RecordedAdmissionPhase,
sourceIds: readonly string[],
cameras: RecordedCameraAdmissionMap,
): RecordedAdmissionPhase {
if (sourceIds.length > MAX_RECORDED_CAMERA_SOURCES || spatialPhase === "error") {
return "error";
}
for (const sourceId of sourceIds) {
const camera = cameras[sourceId];
if (!camera || camera.phase === "error") return "error";
if (camera.phase === "ready" && camera.byteLength === null) return "error";
if (camera.byteLength !== null) {
if (
!Number.isSafeInteger(camera.byteLength) ||
camera.byteLength < 1
) return "error";
}
}
if (spatialPhase !== "ready") return "loading";
return sourceIds.every((sourceId) => cameras[sourceId]?.phase === "ready")
? "ready"
: "loading";
}
export function nextRecordedCameraPreparationIds(
sourceIds: readonly string[],
cameras: RecordedCameraAdmissionMap,
preferredSourceIds: ReadonlySet<string> = new Set(),
concurrency = MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS,
): string[] {
if (!Number.isInteger(concurrency) || concurrency < 1) return [];
const pending = sourceIds.filter((sourceId) => {
const phase = cameras[sourceId]?.phase;
return phase === "pending" || phase === "loading" || phase === undefined;
});
pending.sort((left, right) => (
Number(cameras[right]?.phase === "loading") - Number(cameras[left]?.phase === "loading") ||
Number(preferredSourceIds.has(right)) - Number(preferredSourceIds.has(left)) ||
sourceIds.indexOf(left) - sourceIds.indexOf(right)
));
return pending.slice(0, concurrency);
}
export function mergeRecordedCameraAdmission(
current: RecordedCameraAdmissionState,
next: RecordedCameraAdmissionState,
): RecordedCameraAdmissionState {
const normalized = next.byteLength === null && current.byteLength !== null
? { ...next, byteLength: current.byteLength }
: next;
const currentWorker = current.workerGeneration ?? 0;
const nextWorker = normalized.workerGeneration ?? currentWorker;
if (nextWorker < currentWorker) return current;
if (current.phase === "error") return current;
if (nextWorker > currentWorker) return normalized;
if (normalized.phase === "error") return normalized;
if (current.phase === "ready") return current;
return normalized;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,470 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ObservationSourceDescriptor } from "../runtime/contracts";
import {
closeObservationSource,
openObservationSource,
shouldRestartObservationSource,
} from "./layoutPolicy";
import {
normalizeObservationWindowRect,
projectObservationLayoutSnapshot,
validateObservationLayoutSnapshot,
validateObservationViewportSize,
type ObservationLayoutSnapshot,
type ObservationViewportSize,
type ObservationWindowRect,
} from "./workspaceLayout";
export type { ObservationWindowRect } from "./workspaceLayout";
export type ObservationLayoutPresentationMode = "preserve" | "reset";
export function observationPresentationSourceAfterLayoutApply(
currentSourceId: string | null,
mode: ObservationLayoutPresentationMode,
): string | null {
return mode === "preserve" ? currentSourceId : null;
}
export function visibleSourceIdsAfterRecordedCatalogActivation(
currentIds: readonly string[],
sources: readonly ObservationSourceDescriptor[],
): string[] {
let visibleIds = [...currentIds];
for (const source of sources) {
if (source.transport !== "recording" || !canOpenByDefault(source)) continue;
visibleIds = openObservationSource(visibleIds, source.id, sources).visibleIds;
}
return visibleIds;
}
export interface ObservationLayoutController {
visibleSourceIds: ReadonlySet<string>;
focusedSourceId: string | null;
activeFloatingSourceId: string | null;
maximizedFloatingSourceId: string | null;
windowRects: Readonly<Record<string, ObservationWindowRect>>;
viewportSize: ObservationViewportSize | null;
pendingSourceIds: ReadonlySet<string>;
toggleSource: (sourceId: string) => Promise<boolean>;
hideSource: (sourceId: string) => Promise<boolean>;
setFocusedSourceId: (sourceId: string | null) => void;
activateFloatingSource: (sourceId: string) => void;
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
setViewportSize: (size: ObservationViewportSize) => void;
snapshot: () => ObservationLayoutSnapshot | null;
restore: (snapshot: ObservationLayoutSnapshot) => void;
}
function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
if (!source.capabilities.defaultVisible) return false;
if (source.availability === "unavailable" || source.availability === "error") return false;
return (
source.modality === "point-cloud" ||
Boolean(source.previewUrl) ||
Boolean(source.delivery && (!source.activation || source.activation.selected))
);
}
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
return sources
.map((source) => [
source.id,
source.binding.deviceSessionId,
source.binding.deviceId,
].filter(Boolean).join(":"))
.sort()
.join("|");
}
function cloneSnapshot(snapshot: ObservationLayoutSnapshot): ObservationLayoutSnapshot {
return {
visibleSourceIds: [...snapshot.visibleSourceIds],
activeFloatingSourceId: snapshot.activeFloatingSourceId,
windowRects: Object.fromEntries(
Object.entries(snapshot.windowRects).map(([sourceId, rect]) => [sourceId, { ...rect }]),
),
viewportSize: { ...snapshot.viewportSize },
};
}
export function useObservationLayout(
sources: readonly ObservationSourceDescriptor[],
setSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>,
): ObservationLayoutController {
const [visibleIds, setVisibleIds] = useState<string[]>([]);
const visibleIdsRef = useRef<string[]>([]);
const [pendingIds, setPendingIds] = useState<string[]>([]);
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
const [activeFloatingSourceId, setActiveFloatingSourceIdState] = useState<string | null>(null);
const activeFloatingSourceIdRef = useRef<string | null>(null);
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = useState<string | null>(null);
const [windowRects, setWindowRectsState] = useState<Record<string, ObservationWindowRect>>({});
const windowRectsRef = useRef<Record<string, ObservationWindowRect>>({});
const [viewportSize, setViewportSizeState] = useState<ObservationViewportSize | null>(null);
const viewportSizeRef = useRef<ObservationViewportSize | null>(null);
const desiredSnapshotRef = useRef<ObservationLayoutSnapshot | null>(null);
const restoredLayoutAuthorityRef = useRef(false);
const initializedCatalog = useRef<string | null>(null);
const sourceIdList = sources.map((source) => source.id).sort();
const sourceIdsIdentity = sourceIdList.join("\u0000");
const sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]);
const sourceIdsRef = useRef<ReadonlySet<string>>(sourceIds);
sourceIdsRef.current = sourceIds;
const sourcesRef = useRef<readonly ObservationSourceDescriptor[]>(sources);
sourcesRef.current = sources;
const identity = catalogIdentity(sources);
const commitVisibleIds = useCallback((next: readonly string[]) => {
const unique = [...new Set(next)];
visibleIdsRef.current = unique;
setVisibleIds(unique);
}, []);
const commitActiveFloatingSourceId = useCallback((next: string | null) => {
activeFloatingSourceIdRef.current = next;
setActiveFloatingSourceIdState(next);
}, []);
const commitWindowRects = useCallback((next: Record<string, ObservationWindowRect>) => {
windowRectsRef.current = next;
setWindowRectsState(next);
}, []);
const persistLiveLayout = useCallback(() => {
const currentViewport = viewportSizeRef.current;
if (!currentViewport) return;
const currentKnownIds = sourceIdsRef.current;
const previous = desiredSnapshotRef.current;
const unknownVisibleIds = previous?.visibleSourceIds.filter(
(sourceId) => !currentKnownIds.has(sourceId),
) ?? [];
const visibleSourceIds = [...new Set([...unknownVisibleIds, ...visibleIdsRef.current])];
const unknownRects = Object.entries(previous?.windowRects ?? {}).filter(
([sourceId]) => !currentKnownIds.has(sourceId),
);
const knownRects = Object.entries(windowRectsRef.current).map(([sourceId, rect]) => [
sourceId,
normalizeObservationWindowRect(rect, currentViewport),
] as const);
const previousActive = previous?.activeFloatingSourceId ?? null;
const activeFloatingSourceId = activeFloatingSourceIdRef.current ?? (
previousActive && !currentKnownIds.has(previousActive) ? previousActive : null
);
desiredSnapshotRef.current = validateObservationLayoutSnapshot({
visibleSourceIds,
activeFloatingSourceId: activeFloatingSourceId && visibleSourceIds.includes(activeFloatingSourceId)
? activeFloatingSourceId
: null,
windowRects: Object.fromEntries([...unknownRects, ...knownRects]),
viewportSize: currentViewport,
});
}, []);
const applyDesiredSnapshot = useCallback((
snapshot: ObservationLayoutSnapshot,
presentationMode: ObservationLayoutPresentationMode,
) => {
const targetViewport = viewportSizeRef.current ?? snapshot.viewportSize;
const projected = projectObservationLayoutSnapshot(
snapshot,
sourceIdsRef.current,
targetViewport,
);
let visibleSourceIds = [...projected.visibleSourceIds];
let activeFloatingSourceId = projected.activeFloatingSourceId;
if (visibleSourceIds.length === 0 && sourcesRef.current.length > 0) {
for (const source of sourcesRef.current.filter(canOpenByDefault)) {
visibleSourceIds = openObservationSource(
visibleSourceIds,
source.id,
sourcesRef.current,
).visibleIds;
}
activeFloatingSourceId = sourcesRef.current.find(
(source) => visibleSourceIds.includes(source.id) && source.capabilities.overlay,
)?.id ?? null;
}
commitVisibleIds(visibleSourceIds);
commitActiveFloatingSourceId(activeFloatingSourceId);
commitWindowRects({ ...projected.windowRects });
setFocusedSourceIdState((current) =>
observationPresentationSourceAfterLayoutApply(current, presentationMode));
setMaximizedFloatingSourceId((current) =>
observationPresentationSourceAfterLayoutApply(current, presentationMode));
}, [commitActiveFloatingSourceId, commitVisibleIds, commitWindowRects]);
const clearPresentation = useCallback((removedIds: readonly string[], persist = true) => {
if (!removedIds.length) return;
const removed = new Set(removedIds);
setFocusedSourceIdState((current) => current && removed.has(current) ? null : current);
if (
activeFloatingSourceIdRef.current &&
removed.has(activeFloatingSourceIdRef.current)
) {
commitActiveFloatingSourceId(null);
}
setMaximizedFloatingSourceId((current) => current && removed.has(current) ? null : current);
if (persist) persistLiveLayout();
}, [commitActiveFloatingSourceId, persistLiveLayout]);
useEffect(() => {
const desired = desiredSnapshotRef.current;
if (desired) {
applyDesiredSnapshot(desired, "reset");
return;
}
const currentVisible = visibleIdsRef.current;
const nextVisible = currentVisible.filter((sourceId) => sourceIds.has(sourceId));
if (nextVisible.length !== currentVisible.length) commitVisibleIds(nextVisible);
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
if (activeFloatingSourceIdRef.current && !sourceIds.has(activeFloatingSourceIdRef.current)) {
commitActiveFloatingSourceId(null);
}
setMaximizedFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
const entries = Object.entries(windowRectsRef.current);
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
if (nextEntries.length !== entries.length) commitWindowRects(Object.fromEntries(nextEntries));
}, [
applyDesiredSnapshot,
commitActiveFloatingSourceId,
commitVisibleIds,
commitWindowRects,
sourceIds,
]);
useEffect(() => {
if (!identity) {
if (!desiredSnapshotRef.current) initializedCatalog.current = null;
return;
}
const desired = desiredSnapshotRef.current;
if (desired) {
const previousIdentity = initializedCatalog.current;
initializedCatalog.current = identity;
if (
previousIdentity !== identity &&
sources.some((source) => source.transport === "recording")
) {
const nextVisible = visibleSourceIdsAfterRecordedCatalogActivation(
visibleIdsRef.current,
sources,
);
commitVisibleIds(nextVisible);
const firstRecordedOverlay = sources.find((source) => (
source.transport === "recording" &&
source.capabilities.overlay &&
nextVisible.includes(source.id)
));
commitActiveFloatingSourceId(firstRecordedOverlay?.id ?? null);
persistLiveLayout();
}
return;
}
if (initializedCatalog.current === identity) return;
initializedCatalog.current = identity;
let defaults: string[] = [];
for (const source of sources.filter(canOpenByDefault)) {
defaults = openObservationSource(defaults, source.id, sources).visibleIds;
}
commitVisibleIds(defaults);
const firstFloating = sources.find(
(source) => canOpenByDefault(source) && source.capabilities.overlay,
);
commitActiveFloatingSourceId(firstFloating?.id ?? null);
persistLiveLayout();
}, [
applyDesiredSnapshot,
commitActiveFloatingSourceId,
commitVisibleIds,
identity,
persistLiveLayout,
sources,
]);
const selectedDeliveryIdentity = sources
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
.map((source) => [
source.id,
source.delivery?.id,
source.activation?.groupId,
source.activation?.maxActive,
].join(":"))
.sort()
.join("|");
useEffect(() => {
if (restoredLayoutAuthorityRef.current) return;
const selected = sources.filter(
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
);
if (!selected.length) return;
let change = { visibleIds: visibleIdsRef.current, removedIds: [] as string[] };
const removed = new Set<string>();
for (const source of selected) {
change = openObservationSource(change.visibleIds, source.id, sources);
change.removedIds.forEach((sourceId) => removed.add(sourceId));
}
commitVisibleIds(change.visibleIds);
clearPresentation([...removed], false);
persistLiveLayout();
}, [clearPresentation, commitVisibleIds, persistLiveLayout, selectedDeliveryIdentity]);
const markPending = useCallback((source: ObservationSourceDescriptor, pending: boolean) => {
const groupId = source.activation?.groupId;
const affected = groupId
? sources.filter((candidate) => candidate.activation?.groupId === groupId).map(({ id }) => id)
: [source.id];
setPendingIds((current) => pending
? [...new Set([...current, ...affected])]
: current.filter((candidate) => !affected.includes(candidate)));
}, [sources]);
const hideSource = useCallback(async (sourceId: string) => {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return false;
if (source.activation?.selected && source.activation.controllable) {
if (!setSourceActive) return false;
markPending(source, true);
try {
if (!await setSourceActive(source.sourceId, false)) return false;
} finally {
markPending(source, false);
}
}
restoredLayoutAuthorityRef.current = false;
const change = closeObservationSource(visibleIdsRef.current, sourceId);
commitVisibleIds(change.visibleIds);
clearPresentation(change.removedIds, false);
persistLiveLayout();
return true;
}, [clearPresentation, commitVisibleIds, markPending, persistLiveLayout, setSourceActive, sources]);
const toggleSource = useCallback(async (sourceId: string) => {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return false;
const restart = shouldRestartObservationSource(source);
if (visibleIdsRef.current.includes(sourceId) && !restart) return hideSource(sourceId);
if (source.activation && (!source.activation.selected || restart)) {
if (!source.activation.controllable || !setSourceActive) return false;
markPending(source, true);
try {
if (!await setSourceActive(source.sourceId, true)) return false;
} finally {
markPending(source, false);
}
}
restoredLayoutAuthorityRef.current = false;
const change = openObservationSource(visibleIdsRef.current, sourceId, sources);
commitVisibleIds(change.visibleIds);
clearPresentation(change.removedIds, false);
commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
return true;
}, [
clearPresentation,
commitActiveFloatingSourceId,
commitVisibleIds,
hideSource,
markPending,
persistLiveLayout,
setSourceActive,
sources,
]);
const setFocusedSourceId = useCallback((sourceId: string | null) => {
setFocusedSourceIdState(sourceId);
if (sourceId) {
commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
}
}, [commitActiveFloatingSourceId, persistLiveLayout]);
const activateFloatingSource = useCallback((sourceId: string) => {
commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
}, [commitActiveFloatingSourceId, persistLiveLayout]);
const setFloatingMaximized = useCallback((sourceId: string, maximized: boolean) => {
setMaximizedFloatingSourceId(maximized ? sourceId : null);
if (maximized) {
commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
}
}, [commitActiveFloatingSourceId, persistLiveLayout]);
const setWindowRect = useCallback((sourceId: string, rect: ObservationWindowRect) => {
if (
!Object.values(rect).every((value) => Number.isFinite(value)) ||
rect.width <= 0 ||
rect.height <= 0
) {
return;
}
const currentViewport = viewportSizeRef.current;
if (currentViewport) normalizeObservationWindowRect(rect, currentViewport);
commitWindowRects({ ...windowRectsRef.current, [sourceId]: { ...rect } });
persistLiveLayout();
}, [commitWindowRects, persistLiveLayout]);
const setViewportSize = useCallback((size: ObservationViewportSize) => {
const next = validateObservationViewportSize(size);
const current = viewportSizeRef.current;
if (current && current.width === next.width && current.height === next.height) return;
viewportSizeRef.current = next;
setViewportSizeState(next);
const desired = desiredSnapshotRef.current;
if (desired) {
desiredSnapshotRef.current = { ...desired, viewportSize: next };
// Entering fullscreen changes the shell geometry, which triggers this
// ResizeObserver path. Reproject persistent window rectangles without
// clearing the transient fullscreen/focus state that caused the resize.
applyDesiredSnapshot(desiredSnapshotRef.current, "preserve");
} else if (initializedCatalog.current) {
persistLiveLayout();
}
}, [applyDesiredSnapshot, persistLiveLayout]);
const snapshot = useCallback((): ObservationLayoutSnapshot | null => {
if (!viewportSizeRef.current) return null;
persistLiveLayout();
const desired = desiredSnapshotRef.current;
if (!desired) return null;
return cloneSnapshot(desired);
}, [persistLiveLayout]);
const restore = useCallback((saved: ObservationLayoutSnapshot) => {
const validated = validateObservationLayoutSnapshot(saved);
const currentViewport = viewportSizeRef.current;
desiredSnapshotRef.current = cloneSnapshot({
...validated,
viewportSize: currentViewport ?? validated.viewportSize,
});
restoredLayoutAuthorityRef.current = true;
applyDesiredSnapshot(desiredSnapshotRef.current, "reset");
}, [applyDesiredSnapshot]);
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
const pendingSourceIds = useMemo(() => new Set(pendingIds), [pendingIds]);
return {
visibleSourceIds,
focusedSourceId,
activeFloatingSourceId,
maximizedFloatingSourceId,
windowRects,
viewportSize,
pendingSourceIds,
toggleSource,
hideSource,
setFocusedSourceId,
activateFloatingSource,
setFloatingMaximized,
setWindowRect,
setViewportSize,
snapshot,
restore,
};
}
@@ -0,0 +1,630 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
decodeObservationSessionPreparation,
deleteObservationSession,
fetchObservationSessionCatalog,
fetchObservationSessionPreparation,
replayObservationSession,
type ObservationSessionFetch,
type ObservationSessionPreparation,
type ObservationSessionReplayLaunch,
type ObservationSessionSummary,
} from "./sessionArchive";
export type ObservationSessionsLoadState = "idle" | "loading" | "ready" | "error";
export type ObservationReplayOutcome = "accepted" | "error" | "cancelled";
export type ObservationPreparationPhase =
| "requesting"
| ObservationSessionPreparation["state"];
export interface ObservationReplayProgress {
readonly sessionId: string;
readonly phase: ObservationPreparationPhase;
readonly progress: number | null;
readonly cancellable: boolean;
}
export interface ObservationSessionsController {
items: readonly ObservationSessionSummary[];
state: ObservationSessionsLoadState;
error: string | null;
replayingSessionId: string | null;
preparation: ObservationSessionPreparation | null;
replayProgress: ObservationReplayProgress | null;
failedSessionId: string | null;
deletingSessionId: string | null;
refresh: () => Promise<boolean>;
replay: (sessionId: string) => Promise<boolean>;
retry: () => Promise<boolean>;
remove: (sessionId: string) => Promise<boolean>;
}
export interface ObservationReplayAttempt {
readonly signal: AbortSignal;
isCurrent: () => boolean;
finish: () => boolean;
}
export interface ObservationReplayCoordinator {
begin: () => ObservationReplayAttempt;
cancel: () => void;
}
export interface ObservationPreparationPollingOptions {
signal: AbortSignal;
fetcher?: ObservationSessionFetch;
onUpdate?: (preparation: ObservationSessionPreparation) => void;
requestTimeoutMs?: number;
heartbeatStallMs?: number;
maximumWaitMs?: number;
initialPollIntervalMs?: number;
maximumPollIntervalMs?: number;
now?: () => number;
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
}
const PREPARATION_STORAGE_KEY = "missioncore.observation-session-preparation/v1";
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
const DEFAULT_HEARTBEAT_STALL_MS = 45_000;
const DEFAULT_MAXIMUM_WAIT_MS = 30 * 60_000;
const DEFAULT_INITIAL_POLL_INTERVAL_MS = 750;
const DEFAULT_MAXIMUM_POLL_INTERVAL_MS = 3_000;
export class ObservationPreparationStalledError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservationPreparationStalledError";
}
}
/** Latest selection wins, even if an obsolete server job finishes later. */
export function createObservationReplayCoordinator(): ObservationReplayCoordinator {
let sequence = 0;
let active: AbortController | null = null;
return {
begin() {
active?.abort();
const controller = new AbortController();
const attemptSequence = ++sequence;
active = controller;
return {
signal: controller.signal,
isCurrent: () => (
!controller.signal.aborted &&
active === controller &&
sequence === attemptSequence
),
finish: () => {
if (active !== controller || sequence !== attemptSequence) return false;
active = null;
return true;
},
};
},
cancel() {
active?.abort();
// 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.
},
};
}
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Операция с сохранёнными сессиями завершилась ошибкой.";
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
type PendingPreparationState = Exclude<
ObservationSessionPreparation["state"],
"failed" | "cancelled"
>;
function pendingPreparation(
preparation: ObservationSessionPreparation,
): preparation is ObservationSessionPreparation & { state: PendingPreparationState } {
return preparation.state !== "failed" && preparation.state !== "cancelled";
}
const PREPARATION_PHASE_ORDER: Record<
PendingPreparationState,
number
> = {
queued: 0,
validating: 1,
exporting: 2,
finalizing: 3,
};
function terminalPreparationError(preparation: ObservationSessionPreparation): Error {
if (preparation.error) return new Error(preparation.error);
return new Error(
preparation.state === "cancelled"
? "Подготовка записи отменена."
: "Сервер не смог подготовить сохранённую сессию.",
);
}
function defaultSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("cancelled", "AbortError"));
return;
}
const onAbort = () => {
globalThis.clearTimeout(timer);
reject(new DOMException("cancelled", "AbortError"));
};
const timer = globalThis.setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, milliseconds);
signal.addEventListener("abort", onAbort, { once: true });
});
}
async function withRequestTimeout<T>(
signal: AbortSignal,
timeoutMs: number,
operation: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
if (signal.aborted) throw new DOMException("cancelled", "AbortError");
const controller = new AbortController();
let timedOut = false;
const forwardAbort = () => controller.abort();
signal.addEventListener("abort", forwardAbort, { once: true });
const timer = globalThis.setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
try {
return await operation(controller.signal);
} catch (error) {
if (timedOut && isAbortError(error)) {
throw new ObservationPreparationStalledError(
"Сервер слишком долго не отвечает. Подготовку можно повторить.",
);
}
throw error;
} finally {
globalThis.clearTimeout(timer);
signal.removeEventListener("abort", forwardAbort);
}
}
export async function waitForObservationReplayPreparation(
initial: ObservationSessionPreparation,
options: ObservationPreparationPollingOptions,
): Promise<ObservationSessionReplayLaunch> {
if (!pendingPreparation(initial)) throw terminalPreparationError(initial);
const requestTimeoutMs = Math.max(100, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
const heartbeatStallMs = Math.max(250, options.heartbeatStallMs ?? DEFAULT_HEARTBEAT_STALL_MS);
const maximumWaitMs = Math.max(heartbeatStallMs, options.maximumWaitMs ?? DEFAULT_MAXIMUM_WAIT_MS);
const initialInterval = Math.max(
50,
options.initialPollIntervalMs ?? DEFAULT_INITIAL_POLL_INTERVAL_MS,
);
const maximumInterval = Math.max(
initialInterval,
options.maximumPollIntervalMs ?? DEFAULT_MAXIMUM_POLL_INTERVAL_MS,
);
const now = options.now ?? Date.now;
const sleep = options.sleep ?? defaultSleep;
const startedAt = now();
let lastHeartbeatAt = startedAt;
let lastUpdatedAt = Date.parse(initial.updatedAtUtc);
let current: ObservationSessionPreparation = initial;
let interval = initialInterval;
options.onUpdate?.(current);
while (true) {
await sleep(interval, options.signal);
const response = await withRequestTimeout(
options.signal,
requestTimeoutMs,
(signal) => fetchObservationSessionPreparation(current, {
signal,
fetcher: options.fetcher,
}),
);
if (response.kind === "ready") return response.launch;
const next = response.preparation;
const nextUpdatedAt = Date.parse(next.updatedAtUtc);
if (nextUpdatedAt < lastUpdatedAt) {
throw new Error("Сервер вернул устаревшее состояние подготовки записи.");
}
if (nextUpdatedAt > lastUpdatedAt) {
lastUpdatedAt = nextUpdatedAt;
lastHeartbeatAt = now();
} else if (
next.state !== current.state ||
next.progress !== current.progress ||
next.cancellable !== current.cancellable
) {
throw new Error("Сервер изменил подготовку без обновления heartbeat timestamp.");
}
if (pendingPreparation(next) && pendingPreparation(current)) {
if (PREPARATION_PHASE_ORDER[next.state] < PREPARATION_PHASE_ORDER[current.state]) {
throw new Error("Сервер вернул подготовку на уже завершённую фазу.");
}
if (
next.progress !== null &&
current.progress !== null &&
next.progress + Number.EPSILON < current.progress
) {
throw new Error("Прогресс подготовки не может уменьшаться.");
}
}
current = next;
options.onUpdate?.(current);
if (!pendingPreparation(current)) throw terminalPreparationError(current);
if (current.state !== "queued" && now() - lastHeartbeatAt > heartbeatStallMs) {
throw new ObservationPreparationStalledError(
"Подготовка перестала обновляться. Текущая сцена сохранена; повторите запуск.",
);
}
if (now() - startedAt > maximumWaitMs) {
throw new ObservationPreparationStalledError(
"Подготовка превысила допустимое время. Текущая сцена сохранена; повторите запуск.",
);
}
interval = Math.min(maximumInterval, Math.round(interval * 1.35));
}
}
export async function resolveObservationSessionReplay(
sessionId: string,
options: ObservationPreparationPollingOptions,
): Promise<ObservationSessionReplayLaunch> {
// The initial replay request may restore and integrity-check large, already
// published artifacts before it can return either a launch descriptor or a
// background-preparation handle. Its duration therefore scales with the
// recording package and must not be confused with a stalled status poll.
// Keep it cancellable by the owning UI attempt, but do not impose the short
// per-poll timeout used once the server has returned a preparation handle.
const response = await replayObservationSession(sessionId, {
signal: options.signal,
fetcher: options.fetcher,
});
if (response.kind === "ready") return response.launch;
if (!pendingPreparation(response.preparation)) {
options.onUpdate?.(response.preparation);
throw terminalPreparationError(response.preparation);
}
return waitForObservationReplayPreparation(response.preparation, options);
}
function preparationStoragePayload(preparation: ObservationSessionPreparation): unknown {
return {
schema_version: "missioncore.observation-session-preparation/v1",
preparation: {
preparation_id: preparation.preparationId,
session_id: preparation.sessionId,
state: preparation.state,
progress: preparation.progress,
updated_at_utc: preparation.updatedAtUtc,
status_url: preparation.statusUrl,
cancellable: preparation.cancellable,
...(preparation.state === "failed" || preparation.state === "cancelled"
? { retryable: preparation.retryable, error: preparation.error }
: {}),
},
};
}
export function storeObservationReplayPreparation(
preparation: ObservationSessionPreparation,
storage: Storage = window.localStorage,
): void {
if (!pendingPreparation(preparation)) {
storage.removeItem(PREPARATION_STORAGE_KEY);
return;
}
storage.setItem(PREPARATION_STORAGE_KEY, JSON.stringify(preparationStoragePayload(preparation)));
}
export function loadObservationReplayPreparation(
storage: Storage = window.localStorage,
): ObservationSessionPreparation | null {
const serialized = storage.getItem(PREPARATION_STORAGE_KEY);
if (!serialized) return null;
try {
const parsed = JSON.parse(serialized) as unknown;
if (
typeof parsed !== "object" ||
parsed === null ||
!("preparation" in parsed) ||
typeof parsed.preparation !== "object" ||
parsed.preparation === null ||
!("session_id" in parsed.preparation) ||
typeof parsed.preparation.session_id !== "string"
) {
throw new Error("invalid persisted preparation");
}
const preparation = decodeObservationSessionPreparation(
parsed,
parsed.preparation.session_id,
);
return pendingPreparation(preparation) ? preparation : null;
} catch {
storage.removeItem(PREPARATION_STORAGE_KEY);
return null;
}
}
export function clearObservationReplayPreparation(
storage: Storage = window.localStorage,
): void {
storage.removeItem(PREPARATION_STORAGE_KEY);
}
export function useObservationSessions({
limit = 100,
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,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplayAccepted?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplaySettled?: (
session: ObservationSessionSummary,
outcome: ObservationReplayOutcome,
) => void | Promise<void>;
} = {}): ObservationSessionsController {
const [items, setItems] = useState<ObservationSessionSummary[]>([]);
const [state, setState] = useState<ObservationSessionsLoadState>("idle");
const [error, setError] = useState<string | null>(null);
const [replayingSessionId, setReplayingSessionId] = useState<string | null>(null);
const [preparation, setPreparation] = useState<ObservationSessionPreparation | null>(null);
const [replayProgress, setReplayProgress] = useState<ObservationReplayProgress | null>(null);
const [failedSessionId, setFailedSessionId] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
const mounted = useRef(true);
const catalogSequence = useRef(0);
const reattachStarted = useRef(false);
const replayEnabledRef = useRef(replayEnabled);
replayEnabledRef.current = replayEnabled;
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(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.min(100, Math.max(1, Math.floor(limit)))
: 100;
const loadCatalog = useCallback(async (foreground: boolean) => {
const sequence = ++catalogSequence.current;
if (foreground) setState("loading");
try {
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
if (!mounted.current || sequence !== catalogSequence.current) return false;
setItems(catalog.items.slice(0, safeLimit));
setState("ready");
setError(null);
return true;
} catch (loadError) {
if (!mounted.current || sequence !== catalogSequence.current) return false;
if (foreground) setState("error");
setError(errorMessage(loadError));
return false;
}
}, [safeLimit]);
const refresh = useCallback(() => loadCatalog(true), [loadCatalog]);
useEffect(() => {
mounted.current = true;
void refresh();
return () => {
mounted.current = false;
catalogSequence.current += 1;
replayCoordinator.current?.cancel();
};
}, [refresh]);
useEffect(() => {
if (state !== "ready") return;
let disposed = false;
let timer = 0;
const poll = async () => {
if (disposed) return;
await loadCatalog(false);
if (!disposed) timer = window.setTimeout(() => void poll(), 2_000);
};
timer = window.setTimeout(() => void poll(), 2_000);
return () => {
disposed = true;
window.clearTimeout(timer);
};
}, [loadCatalog, state]);
const executeReplay = useCallback(async (
session: ObservationSessionSummary,
resumedPreparation?: ObservationSessionPreparation,
) => {
if (!replayEnabledRef.current) return false;
const attempt = replayCoordinator.current!.begin();
setReplayingSessionId(session.id);
setFailedSessionId(null);
setError(null);
setReplayProgress({
sessionId: session.id,
phase: resumedPreparation?.state ?? "requesting",
progress: resumedPreparation?.progress ?? null,
cancellable: resumedPreparation?.cancellable ?? false,
});
let outcome: ObservationReplayOutcome = "cancelled";
try {
const onUpdate = (next: ObservationSessionPreparation) => {
if (!mounted.current || !attempt.isCurrent()) return;
setPreparation(next);
setReplayProgress({
sessionId: next.sessionId,
phase: next.state,
progress: next.progress,
cancellable: next.cancellable,
});
try {
storeObservationReplayPreparation(next);
} catch {
// Private browsing/storage quota must not break replay preparation.
}
};
const launch = resumedPreparation
? await waitForObservationReplayPreparation(resumedPreparation, {
signal: attempt.signal,
onUpdate,
})
: await resolveObservationSessionReplay(session.id, {
signal: attempt.signal,
onUpdate,
});
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() ||
!replayEnabledRef.current
) return false;
await onReplayAccepted?.(session, launch);
if (
!mounted.current ||
!attempt.isCurrent() ||
!replayEnabledRef.current
) return false;
outcome = "accepted";
try {
clearObservationReplayPreparation();
} catch {
// Storage is optional; an accepted replay must not turn into an error.
}
setPreparation(null);
setReplayProgress(null);
return true;
} catch (replayError) {
const aborted = isAbortError(replayError);
outcome = aborted ? "cancelled" : "error";
if (mounted.current && attempt.isCurrent() && !aborted) {
setError(errorMessage(replayError));
setFailedSessionId(session.id);
setReplayProgress(null);
try {
clearObservationReplayPreparation();
} catch {
// Storage is optional for the current browser lifetime.
}
}
return false;
} finally {
const current = attempt.finish();
if (mounted.current && current) {
setReplayingSessionId(null);
await onReplaySettled?.(session, outcome);
}
}
}, [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;
return executeReplay(session);
}, [executeReplay, items]);
useEffect(() => {
if (!replayEnabled || state !== "ready" || reattachStarted.current) return;
reattachStarted.current = true;
let stored: ObservationSessionPreparation | null = null;
try {
stored = loadObservationReplayPreparation();
} catch {
stored = null;
}
if (!stored) return;
const session = items.find((candidate) => candidate.id === stored?.sessionId);
if (!session?.replayable) {
try {
clearObservationReplayPreparation();
} catch {
// Storage may be unavailable in hardened browser profiles.
}
return;
}
void executeReplay(session, stored);
}, [executeReplay, items, replayEnabled, state]);
const retry = useCallback(async () => {
if (!failedSessionId) return false;
return replay(failedSessionId);
}, [failedSessionId, replay]);
const remove = useCallback(async (sessionId: string) => {
if (deletingSessionId !== null || replayingSessionId === sessionId) return false;
setDeletingSessionId(sessionId);
setError(null);
try {
await deleteObservationSession(sessionId);
if (!mounted.current) return false;
setItems((current) => current.filter((item) => item.id !== sessionId));
setFailedSessionId((current) => current === sessionId ? null : current);
return true;
} catch (deleteError) {
if (mounted.current) setError(errorMessage(deleteError));
return false;
} finally {
if (mounted.current) setDeletingSessionId(null);
}
}, [deletingSessionId, replayingSessionId]);
return {
items,
state,
error,
replayingSessionId,
preparation,
replayProgress,
failedSessionId,
deletingSessionId,
refresh,
replay,
retry,
remove,
};
}
@@ -0,0 +1,126 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ObservationSessionReplayLaunch } from "./sessionArchive";
import {
initialRecordedCameraAdmissions,
mergeRecordedCameraAdmission,
nextRecordedCameraPreparationIds,
recordedCameraDescriptorPreflight,
recordedSessionAdmissionPhase,
type RecordedAdmissionPhase,
type RecordedCameraAdmissionMap,
type RecordedCameraAdmissionState,
} from "./recordedSessionAdmission";
interface AdmissionSnapshot {
key: string | null;
spatialPhase: RecordedAdmissionPhase;
cameras: Record<string, RecordedCameraAdmissionState>;
}
export interface RecordedSessionAdmissionController {
key: string;
phase: RecordedAdmissionPhase;
cameraSourceIds: readonly string[];
cameras: RecordedCameraAdmissionMap;
activeCameraSourceIds: ReadonlySet<string>;
reportSpatial: (key: string, phase: RecordedAdmissionPhase) => void;
reportCamera: (
key: string,
sourceId: string,
state: RecordedCameraAdmissionState,
) => void;
}
function replayAdmissionKey(replay: ObservationSessionReplayLaunch): string {
return [
replay.sessionId,
replay.sha256,
...replay.mediaSources.map((source) => (
[
source.id,
source.manifestGenerationSha256,
source.byteLength,
source.timelineStartSeconds,
source.timelineEndSeconds,
].join(":")
)),
].join("|");
}
export function useRecordedSessionAdmission(
replay: ObservationSessionReplayLaunch | null,
): RecordedSessionAdmissionController | null {
const key = replay ? replayAdmissionKey(replay) : null;
const cameraSourceIds = useMemo(
() => replay?.mediaSources.map(({ id }) => id) ?? [],
[replay],
);
const declaredByteLengths = useMemo(
() => Object.fromEntries(
(replay?.mediaSources ?? []).map(({ id, byteLength }) => [id, byteLength]),
),
[replay],
);
const [snapshot, setSnapshot] = useState<AdmissionSnapshot>(() => ({
key,
spatialPhase: "loading",
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
}));
useEffect(() => {
setSnapshot({
key,
spatialPhase: "loading",
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
});
}, [cameraSourceIds, declaredByteLengths, key]);
const reportSpatial = useCallback((reportedKey: string, phase: RecordedAdmissionPhase) => {
setSnapshot((current) => current.key !== reportedKey
? current
: { ...current, spatialPhase: phase });
}, []);
const reportCamera = useCallback((
reportedKey: string,
sourceId: string,
state: RecordedCameraAdmissionState,
) => {
setSnapshot((current) => {
if (current.key !== reportedKey || !(sourceId in current.cameras)) return current;
const merged = mergeRecordedCameraAdmission(current.cameras[sourceId], state);
if (merged === current.cameras[sourceId]) return current;
return { ...current, cameras: { ...current.cameras, [sourceId]: merged } };
});
}, []);
if (!replay || !key) return null;
const current = snapshot.key === key
? snapshot
: {
key,
spatialPhase: "loading" as const,
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
};
const descriptorPhase = recordedCameraDescriptorPreflight(replay.mediaSources);
const phase = descriptorPhase === "error"
? "error"
: recordedSessionAdmissionPhase(
current.spatialPhase,
cameraSourceIds,
current.cameras,
);
const activeCameraSourceIds = new Set(phase === "error"
? []
: nextRecordedCameraPreparationIds(cameraSourceIds, current.cameras));
return {
key,
phase,
cameraSourceIds,
cameras: current.cameras,
activeCameraSourceIds,
reportSpatial,
reportCamera,
};
}
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchObservationWorkspaceLayoutProfile,
saveObservationWorkspaceLayoutProfile,
WorkspaceLayoutApiError,
type ObservationWorkspaceLayoutProfile,
} from "./workspaceLayout";
export type WorkspaceLayoutProfileState =
| "idle"
| "loading"
| "ready"
| "saving"
| "error"
| "conflict";
export interface WorkspaceLayoutProfileController {
profile: ObservationWorkspaceLayoutProfile | null;
state: WorkspaceLayoutProfileState;
error: string | null;
refresh: () => Promise<ObservationWorkspaceLayoutProfile | null>;
save: (
profile: ObservationWorkspaceLayoutProfile,
) => Promise<ObservationWorkspaceLayoutProfile | null>;
}
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Операция с профилем рабочей поверхности завершилась ошибкой.";
}
export function useWorkspaceLayoutProfile(): WorkspaceLayoutProfileController {
const [profile, setProfile] = useState<ObservationWorkspaceLayoutProfile | null>(null);
const profileRef = useRef<ObservationWorkspaceLayoutProfile | null>(null);
const [state, setState] = useState<WorkspaceLayoutProfileState>("idle");
const [error, setError] = useState<string | null>(null);
const mounted = useRef(true);
const requestSequence = useRef(0);
const saveInFlight = useRef(false);
const commitProfile = useCallback((next: ObservationWorkspaceLayoutProfile | null) => {
profileRef.current = next;
setProfile(next);
}, []);
const refresh = useCallback(async () => {
const sequence = ++requestSequence.current;
setState("loading");
setError(null);
try {
const loaded = await fetchObservationWorkspaceLayoutProfile();
if (!mounted.current || sequence !== requestSequence.current) return null;
commitProfile(loaded);
setState("ready");
return loaded;
} catch (loadError) {
if (!mounted.current || sequence !== requestSequence.current) return null;
setState("error");
setError(errorMessage(loadError));
return null;
}
}, [commitProfile]);
useEffect(() => {
mounted.current = true;
void refresh();
return () => {
mounted.current = false;
requestSequence.current += 1;
};
}, [refresh]);
useEffect(() => {
if (state !== "error") return;
// The desktop shell can become ready before its loopback API. Keep the
// last-layout restore self-healing instead of requiring a page reload once
// the local service finishes starting.
const timer = window.setTimeout(() => void refresh(), 5_000);
return () => window.clearTimeout(timer);
}, [refresh, state]);
useEffect(() => {
const refreshAfterNetworkRecovery = () => void refresh();
window.addEventListener("online", refreshAfterNetworkRecovery);
return () => window.removeEventListener("online", refreshAfterNetworkRecovery);
}, [refresh]);
const save = useCallback(async (draft: ObservationWorkspaceLayoutProfile) => {
if (saveInFlight.current) return null;
const current = profileRef.current;
if (current && draft.revision !== current.revision) {
setState("conflict");
setError("Профиль изменился после открытия. Обновите данные перед сохранением.");
return null;
}
saveInFlight.current = true;
setState("saving");
setError(null);
try {
const saved = await saveObservationWorkspaceLayoutProfile(draft);
if (!mounted.current) return null;
commitProfile(saved);
setState("ready");
return saved;
} catch (saveError) {
if (!mounted.current) return null;
const conflict = saveError instanceof WorkspaceLayoutApiError && saveError.conflict;
setState(conflict ? "conflict" : "error");
setError(errorMessage(saveError));
return null;
} finally {
saveInFlight.current = false;
}
}, [commitProfile]);
return { profile, state, error, refresh, save };
}
@@ -0,0 +1,548 @@
import type { SceneSettings } from "../../sceneSettings";
export const OBSERVATION_WORKSPACE_ID = "observation.spatial" as const;
export const OBSERVATION_WORKSPACE_LAYOUT_VERSION = 2 as const;
export const OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT =
"/api/v1/workspace-layouts/observation.spatial" as const;
export interface ObservationWindowRect {
x: number;
y: number;
width: number;
height: number;
}
export interface ObservationViewportSize {
width: number;
height: number;
}
export interface NormalizedObservationWindowRect {
x: number;
y: number;
width: number;
height: number;
}
export interface ObservationLayoutSnapshot {
visibleSourceIds: readonly string[];
activeFloatingSourceId: string | null;
windowRects: Readonly<Record<string, NormalizedObservationWindowRect>>;
viewportSize: ObservationViewportSize;
}
export interface ProjectedObservationLayout {
visibleSourceIds: readonly string[];
activeFloatingSourceId: string | null;
windowRects: Readonly<Record<string, ObservationWindowRect>>;
}
export interface ObservationWorkspaceLayoutProfile extends ObservationLayoutSnapshot {
version: typeof OBSERVATION_WORKSPACE_LAYOUT_VERSION;
revision: number;
workspaceId: typeof OBSERVATION_WORKSPACE_ID;
sceneSettings: SceneSettings;
}
export type WorkspaceLayoutFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
type WireProfile = {
version: number;
revision: number;
workspace_id: string;
scene_settings: {
projection: string;
point_size: number;
color_mode: string;
palette: string;
custom_color: string;
accumulation_seconds: number;
show_points: boolean;
show_trajectory: boolean;
show_grid: boolean;
show_labels: boolean;
show_camera_frustums: boolean;
};
visible_source_ids: string[];
active_floating_source_id: string | null;
window_rects: Record<string, NormalizedObservationWindowRect>;
viewport_size: ObservationViewportSize;
};
const PROFILE_KEYS = new Set([
"version",
"revision",
"workspace_id",
"scene_settings",
"visible_source_ids",
"active_floating_source_id",
"window_rects",
"viewport_size",
]);
const SCENE_KEYS = new Set([
"projection",
"point_size",
"color_mode",
"palette",
"custom_color",
"accumulation_seconds",
"show_points",
"show_trajectory",
"show_grid",
"show_labels",
"show_camera_frustums",
]);
const VIEWPORT_KEYS = new Set(["width", "height"]);
const RECT_KEYS = new Set(["x", "y", "width", "height"]);
const PROJECTIONS = new Set<SceneSettings["projection"]>(["3d", "2d", "map"]);
const COLOR_MODES = new Set<SceneSettings["colorMode"]>([
"intensity",
"height",
"distance",
"rgb",
"class",
]);
const PALETTES = new Set<SceneSettings["palette"]>([
"turbo",
"viridis",
"plasma",
"grayscale",
"custom",
]);
const SAFE_STABLE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const MAX_SOURCES = 256;
const MAX_VIEWPORT_EDGE = 100_000;
const NORMALIZED_EPSILON = 1e-9;
export class WorkspaceLayoutContractError extends Error {
constructor(message: string) {
super(message);
this.name = "WorkspaceLayoutContractError";
}
}
export class WorkspaceLayoutApiError extends Error {
readonly status: number;
constructor(message: string, status = 0) {
super(message);
this.name = "WorkspaceLayoutApiError";
this.status = status;
}
get conflict(): boolean {
return this.status === 409 || this.status === 412;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, field: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new WorkspaceLayoutContractError(`Поле ${field} должно быть объектом.`);
}
return value;
}
function assertExactKeys(
value: Record<string, unknown>,
expected: ReadonlySet<string>,
field: string,
): void {
const actual = Object.keys(value);
const unknown = actual.filter((key) => !expected.has(key));
const missing = [...expected].filter((key) => !(key in value));
if (unknown.length || missing.length) {
const details = [
unknown.length ? `неизвестные: ${unknown.sort().join(", ")}` : "",
missing.length ? `отсутствуют: ${missing.sort().join(", ")}` : "",
].filter(Boolean).join("; ");
throw new WorkspaceLayoutContractError(`${field}: неверная схема (${details}).`);
}
}
function requireBoolean(value: unknown, field: string): boolean {
if (typeof value !== "boolean") {
throw new WorkspaceLayoutContractError(`Поле ${field} должно быть boolean.`);
}
return value;
}
function requireFiniteInRange(
value: unknown,
field: string,
minimum: number,
maximum: number,
{ integer = false, minimumExclusive = false }: { integer?: boolean; minimumExclusive?: boolean } = {},
): number {
const belowMinimum = minimumExclusive
? typeof value === "number" && value <= minimum
: typeof value === "number" && value < minimum;
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
belowMinimum ||
value > maximum ||
(integer && !Number.isInteger(value))
) {
throw new WorkspaceLayoutContractError(
`Поле ${field} должно быть конечным ${integer ? "целым " : ""}числом в допустимом диапазоне.`,
);
}
return value;
}
function requireStableId(value: unknown, field: string): string {
if (typeof value !== "string" || !SAFE_STABLE_ID.test(value)) {
throw new WorkspaceLayoutContractError(
`Поле ${field} должно содержать безопасный стабильный идентификатор источника.`,
);
}
return value;
}
function decodeViewport(value: unknown, field = "viewport_size"): ObservationViewportSize {
const record = requireRecord(value, field);
assertExactKeys(record, VIEWPORT_KEYS, field);
return {
width: requireFiniteInRange(record.width, `${field}.width`, 1, MAX_VIEWPORT_EDGE),
height: requireFiniteInRange(record.height, `${field}.height`, 1, MAX_VIEWPORT_EDGE),
};
}
export function validateObservationViewportSize(
value: ObservationViewportSize,
): ObservationViewportSize {
return decodeViewport(value, "viewport");
}
function decodeNormalizedRect(
value: unknown,
field: string,
): NormalizedObservationWindowRect {
const record = requireRecord(value, field);
assertExactKeys(record, RECT_KEYS, field);
const rect = {
x: requireFiniteInRange(record.x, `${field}.x`, 0, 1),
y: requireFiniteInRange(record.y, `${field}.y`, 0, 1),
width: requireFiniteInRange(record.width, `${field}.width`, 0, 1, { minimumExclusive: true }),
height: requireFiniteInRange(record.height, `${field}.height`, 0, 1, { minimumExclusive: true }),
};
if (
rect.x + rect.width > 1 + NORMALIZED_EPSILON ||
rect.y + rect.height > 1 + NORMALIZED_EPSILON
) {
throw new WorkspaceLayoutContractError(`${field} выходит за нормализованные границы viewport.`);
}
return rect;
}
function decodeStableIds(value: unknown): string[] {
if (!Array.isArray(value) || value.length > MAX_SOURCES) {
throw new WorkspaceLayoutContractError(
`Поле visible_source_ids должно быть массивом не более ${MAX_SOURCES} элементов.`,
);
}
const result = value.map((entry, index) => requireStableId(entry, `visible_source_ids[${index}]`));
if (new Set(result).size !== result.length) {
throw new WorkspaceLayoutContractError("Поле visible_source_ids содержит повторяющиеся id.");
}
return result;
}
function decodeWindowRects(value: unknown): Record<string, NormalizedObservationWindowRect> {
const record = requireRecord(value, "window_rects");
if (Object.keys(record).length > MAX_SOURCES) {
throw new WorkspaceLayoutContractError(
`Поле window_rects содержит более ${MAX_SOURCES} элементов.`,
);
}
return Object.fromEntries(Object.entries(record).map(([sourceId, rect]) => [
requireStableId(sourceId, "window_rects key"),
decodeNormalizedRect(rect, `window_rects.${sourceId}`),
]));
}
function requireEnum<T extends string>(
value: unknown,
allowed: ReadonlySet<T>,
field: string,
): T {
if (typeof value !== "string" || !allowed.has(value as T)) {
throw new WorkspaceLayoutContractError(`Поле ${field} содержит неизвестное значение.`);
}
return value as T;
}
function decodeSceneSettings(value: unknown): SceneSettings {
const record = requireRecord(value, "scene_settings");
assertExactKeys(record, SCENE_KEYS, "scene_settings");
if (typeof record.custom_color !== "string" || !HEX_COLOR.test(record.custom_color)) {
throw new WorkspaceLayoutContractError("Поле scene_settings.custom_color должно быть цветом #RRGGBB.");
}
return {
projection: requireEnum(record.projection, PROJECTIONS, "scene_settings.projection"),
pointSize: requireFiniteInRange(record.point_size, "scene_settings.point_size", 0.1, 32),
colorMode: requireEnum(record.color_mode, COLOR_MODES, "scene_settings.color_mode"),
palette: requireEnum(record.palette, PALETTES, "scene_settings.palette"),
customColor: record.custom_color,
accumulationSeconds: requireFiniteInRange(
record.accumulation_seconds,
"scene_settings.accumulation_seconds",
0,
3_600,
),
showPoints: requireBoolean(record.show_points, "scene_settings.show_points"),
showTrajectory: requireBoolean(record.show_trajectory, "scene_settings.show_trajectory"),
showGrid: requireBoolean(record.show_grid, "scene_settings.show_grid"),
showLabels: requireBoolean(record.show_labels, "scene_settings.show_labels"),
showCameraFrustums: requireBoolean(
record.show_camera_frustums,
"scene_settings.show_camera_frustums",
),
};
}
export function decodeObservationWorkspaceLayoutProfile(
value: unknown,
): ObservationWorkspaceLayoutProfile {
const record = requireRecord(value, "workspace layout");
assertExactKeys(record, PROFILE_KEYS, "workspace layout");
if (record.version !== OBSERVATION_WORKSPACE_LAYOUT_VERSION) {
throw new WorkspaceLayoutContractError(
`Неподдерживаемая версия workspace layout: ${String(record.version)}.`,
);
}
if (record.workspace_id !== OBSERVATION_WORKSPACE_ID) {
throw new WorkspaceLayoutContractError("Профиль относится к другой рабочей поверхности.");
}
const visibleSourceIds = decodeStableIds(record.visible_source_ids);
const activeFloatingSourceId = record.active_floating_source_id === null
? null
: requireStableId(record.active_floating_source_id, "active_floating_source_id");
if (activeFloatingSourceId && !visibleSourceIds.includes(activeFloatingSourceId)) {
throw new WorkspaceLayoutContractError(
"Активное плавающее окно должно относиться к видимому источнику.",
);
}
return {
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
revision: requireFiniteInRange(record.revision, "revision", 0, Number.MAX_SAFE_INTEGER, {
integer: true,
}),
workspaceId: OBSERVATION_WORKSPACE_ID,
sceneSettings: decodeSceneSettings(record.scene_settings),
visibleSourceIds,
activeFloatingSourceId,
windowRects: decodeWindowRects(record.window_rects),
viewportSize: decodeViewport(record.viewport_size),
};
}
export function encodeObservationWorkspaceLayoutProfile(
profile: ObservationWorkspaceLayoutProfile,
): WireProfile {
const wire: WireProfile = {
version: profile.version,
revision: profile.revision,
workspace_id: profile.workspaceId,
scene_settings: {
projection: profile.sceneSettings.projection,
point_size: profile.sceneSettings.pointSize,
color_mode: profile.sceneSettings.colorMode,
palette: profile.sceneSettings.palette,
custom_color: profile.sceneSettings.customColor,
accumulation_seconds: profile.sceneSettings.accumulationSeconds,
show_points: profile.sceneSettings.showPoints,
show_trajectory: profile.sceneSettings.showTrajectory,
show_grid: profile.sceneSettings.showGrid,
show_labels: profile.sceneSettings.showLabels,
show_camera_frustums: profile.sceneSettings.showCameraFrustums,
},
visible_source_ids: [...profile.visibleSourceIds],
active_floating_source_id: profile.activeFloatingSourceId,
window_rects: Object.fromEntries(
Object.entries(profile.windowRects).map(([sourceId, rect]) => [sourceId, { ...rect }]),
),
viewport_size: { ...profile.viewportSize },
};
// The decoder is the single runtime schema authority for inbound and outbound documents.
decodeObservationWorkspaceLayoutProfile(wire);
return wire;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
export function normalizeObservationWindowRect(
rect: ObservationWindowRect,
viewport: ObservationViewportSize,
): NormalizedObservationWindowRect {
const safeViewport = decodeViewport(viewport, "viewport");
for (const [field, value] of Object.entries(rect)) {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new WorkspaceLayoutContractError(`Поле rect.${field} должно быть конечным числом.`);
}
}
const x = clamp(rect.x, 0, Math.max(0, safeViewport.width - 1));
const y = clamp(rect.y, 0, Math.max(0, safeViewport.height - 1));
const width = clamp(rect.width, 1, Math.max(1, safeViewport.width - x));
const height = clamp(rect.height, 1, Math.max(1, safeViewport.height - y));
return {
x: x / safeViewport.width,
y: y / safeViewport.height,
width: width / safeViewport.width,
height: height / safeViewport.height,
};
}
export function denormalizeObservationWindowRect(
rect: NormalizedObservationWindowRect,
viewport: ObservationViewportSize,
): ObservationWindowRect {
const normalized = decodeNormalizedRect(rect, "rect");
const safeViewport = decodeViewport(viewport, "viewport");
return {
x: normalized.x * safeViewport.width,
y: normalized.y * safeViewport.height,
width: normalized.width * safeViewport.width,
height: normalized.height * safeViewport.height,
};
}
export function validateObservationLayoutSnapshot(
snapshot: ObservationLayoutSnapshot,
): ObservationLayoutSnapshot {
const wire = {
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
revision: 0,
workspace_id: OBSERVATION_WORKSPACE_ID,
scene_settings: {
projection: "3d",
point_size: 1,
color_mode: "intensity",
palette: "turbo",
custom_color: "#ffffff",
accumulation_seconds: 0,
show_points: true,
show_trajectory: true,
show_grid: true,
show_labels: false,
show_camera_frustums: true,
},
visible_source_ids: [...snapshot.visibleSourceIds],
active_floating_source_id: snapshot.activeFloatingSourceId,
window_rects: snapshot.windowRects,
viewport_size: snapshot.viewportSize,
};
const decoded = decodeObservationWorkspaceLayoutProfile(wire);
return {
visibleSourceIds: decoded.visibleSourceIds,
activeFloatingSourceId: decoded.activeFloatingSourceId,
windowRects: decoded.windowRects,
viewportSize: decoded.viewportSize,
};
}
export function projectObservationLayoutSnapshot(
snapshot: ObservationLayoutSnapshot,
knownSourceIds: ReadonlySet<string>,
viewport: ObservationViewportSize,
): ProjectedObservationLayout {
const validated = validateObservationLayoutSnapshot(snapshot);
const targetViewport = decodeViewport(viewport, "viewport");
const visibleSourceIds = validated.visibleSourceIds.filter((sourceId) => knownSourceIds.has(sourceId));
const visibleSet = new Set(visibleSourceIds);
return {
visibleSourceIds,
activeFloatingSourceId: validated.activeFloatingSourceId && visibleSet.has(validated.activeFloatingSourceId)
? validated.activeFloatingSourceId
: null,
windowRects: Object.fromEntries(
Object.entries(validated.windowRects)
.filter(([sourceId]) => knownSourceIds.has(sourceId))
.map(([sourceId, rect]) => [
sourceId,
denormalizeObservationWindowRect(rect, targetViewport),
]),
),
};
}
async function responseMessage(response: Response, fallback: string): Promise<string> {
try {
const body: unknown = await response.json();
if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
return body.detail.trim();
}
} catch {
// A non-JSON error body is represented by the stable fallback.
}
return fallback;
}
export async function fetchObservationWorkspaceLayoutProfile({
fetcher = fetch,
}: { fetcher?: WorkspaceLayoutFetch } = {}): Promise<ObservationWorkspaceLayoutProfile | null> {
const response = await fetcher(OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT, {
method: "GET",
headers: { Accept: "application/json" },
cache: "no-store",
});
if (response.status === 404) return null;
if (!response.ok) {
throw new WorkspaceLayoutApiError(
await responseMessage(response, "Не удалось загрузить профиль рабочей поверхности."),
response.status,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new WorkspaceLayoutContractError("Сервер вернул профиль не в формате JSON.");
}
return decodeObservationWorkspaceLayoutProfile(body);
}
export async function saveObservationWorkspaceLayoutProfile(
profile: ObservationWorkspaceLayoutProfile,
{ fetcher = fetch }: { fetcher?: WorkspaceLayoutFetch } = {},
): Promise<ObservationWorkspaceLayoutProfile> {
const wire = encodeObservationWorkspaceLayoutProfile(profile);
const response = await fetcher(OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT, {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"If-Match": `"${profile.revision}"`,
},
body: JSON.stringify(wire),
});
if (!response.ok) {
throw new WorkspaceLayoutApiError(
await responseMessage(response, response.status === 409 || response.status === 412
? "Профиль уже изменён в другом окне. Обновите данные и повторите сохранение."
: "Не удалось сохранить профиль рабочей поверхности."),
response.status,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new WorkspaceLayoutContractError("Сервер не вернул сохранённый профиль в формате JSON.");
}
const saved = decodeObservationWorkspaceLayoutProfile(body);
if (saved.revision <= profile.revision) {
throw new WorkspaceLayoutContractError("Сервер не увеличил revision сохранённого профиля.");
}
return saved;
}
@@ -0,0 +1,36 @@
import { createContext, useContext, type ReactNode } from "react";
import type { MissionRuntimeController } from "./contracts";
const idleRuntime: MissionRuntimeController = {
state: {
phase: "unconfigured",
message: "Сначала выберите модель локального устройства.",
sourceMode: "idle",
},
backendStatus: "unconfigured",
pendingAction: null,
refresh: () => undefined,
updateViewerSettings: async () => false,
setObservationSourceActive: async () => false,
};
const MissionRuntimeContext = createContext<MissionRuntimeController>(idleRuntime);
export function MissionRuntimeProvider({
value,
children,
}: {
value: MissionRuntimeController;
children: ReactNode;
}) {
return <MissionRuntimeContext.Provider value={value}>{children}</MissionRuntimeContext.Provider>;
}
export function IdleMissionRuntimeProvider({ children }: { children: ReactNode }) {
return <MissionRuntimeProvider value={idleRuntime}>{children}</MissionRuntimeProvider>;
}
export function useMissionRuntime(): MissionRuntimeController {
return useContext(MissionRuntimeContext);
}
@@ -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)),
);
}
@@ -0,0 +1,205 @@
export type BackendStatus = "unconfigured" | "checking" | "online" | "degraded" | "offline";
export type RuntimePhase =
| "unconfigured"
| "idle"
| "configuring"
| "connected"
| "starting"
| "streaming"
| "replaying"
| "stopping"
| "error";
export type SourceMode = "idle" | "live" | "replay";
export interface ViewerSettings {
point_size: number;
color_mode: "intensity" | "height" | "distance" | "rgb" | "class";
palette: "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
custom_color: string;
accumulation_seconds: number;
show_points: boolean;
show_trajectory: boolean;
show_grid: boolean;
show_detections_2d: boolean;
show_segmentation: boolean;
show_cuboids_3d: boolean;
}
export interface StreamMetrics {
latencyMs?: number | null;
frameRateHz?: number | null;
pointCount?: number | null;
droppedPreviewFrames?: number | null;
aiLatencyMs?: number | null;
aiFrameRateHz?: number | null;
aiDroppedFrames?: number | null;
aiStaleMs?: number | null;
elapsedSeconds?: number | null;
routeDistanceMeters?: number | null;
speedMetersPerSecond?: number | null;
}
export interface ActiveDeviceSnapshot {
pluginId: string;
modelId: string;
displayName: string;
instanceId?: string | null;
endpointLabel?: string | null;
}
export interface RuntimeDeviceSessionSnapshot {
sessionId: string;
deviceId: string;
compatibilityProfileId?: string | null;
connectivity?: string | null;
}
export interface RuntimeAcquisitionSnapshot {
acquisitionId: string;
deviceId: string;
deviceSessionId: string;
compatibilityProfileId: string;
controlMode: string;
state: string;
stateRevision: number;
operatorInstructions: readonly string[];
cleanupPending?: boolean;
}
export interface RuntimeOperationSnapshot {
operationId: string;
action: string;
status: string;
stageCode?: string | null;
messageCode?: string | null;
}
export interface SpatialSourceDescriptor {
id: string;
url: string;
label: string;
kind: "rerun-grpc" | "rrd" | "other";
}
export type ObservationSourceModality = "point-cloud" | "video" | "image" | "depth";
export type ObservationSourceAvailability =
| "unverified"
| "declared"
| "available"
| "connecting"
| "streaming"
| "degraded"
| "unavailable"
| "error";
export type ObservationTimelineMode = "live-only" | "buffered" | "recorded";
export interface ObservationSourceProvider {
pluginId: string;
pluginVersion: string;
modelId: string;
compatibilityProfileId?: string | null;
}
export interface ObservationSourceBinding {
deviceId?: string | null;
deviceSessionId?: string | null;
acquisitionId?: string | null;
}
export interface ObservationSourceCapabilities {
overlay: boolean;
fullscreen: boolean;
resizable: boolean;
defaultVisible: boolean;
timelineMode: ObservationTimelineMode;
seekable: boolean;
sessionRecording: boolean;
clockId?: string | null;
spatialRegistration: "native" | "calibrated" | "unresolved" | "not-applicable";
}
export type ObservationSourceDelivery =
| {
id: string;
kind: "mse-fmp4-websocket";
url: string;
mediaType: string;
}
| {
id: string;
kind: "video-url" | "image-url";
url: string;
mediaType?: string | null;
}
| {
id: string;
kind: "recorded-fmp4-manifest";
url: string;
mediaType: "video/mp4";
manifestGenerationSha256: string;
byteLength: number;
timelineStartSeconds: number;
timelineEndSeconds: number;
};
export interface ObservationSourceActivation {
groupId: string;
maxActive: number;
selected: boolean;
controllable: boolean;
}
export interface ObservationSourceDescriptor {
id: string;
sourceId: string;
semanticChannelId: string;
label: string;
description: string;
modality: ObservationSourceModality;
role: "primary" | "auxiliary";
availability: ObservationSourceAvailability;
transport: "rerun-grpc" | "rtsp" | "websocket" | "recording" | "other";
endpointLabel?: string | null;
previewUrl?: string | null;
delivery?: ObservationSourceDelivery | null;
activation?: ObservationSourceActivation | null;
provider: ObservationSourceProvider;
binding: ObservationSourceBinding;
capabilities: ObservationSourceCapabilities;
}
export interface ObservationTimelineSnapshot {
mode: ObservationTimelineMode;
seekable: boolean;
sessionRecording: boolean;
synchronization: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
range: { startSeconds: number; endSeconds: number } | null;
}
export interface MissionRuntimeState {
phase: RuntimePhase;
message?: string | null;
activeDevice?: ActiveDeviceSnapshot | null;
deviceSession?: RuntimeDeviceSessionSnapshot | null;
acquisition?: RuntimeAcquisitionSnapshot | null;
operations?: readonly RuntimeOperationSnapshot[];
spatialSource?: SpatialSourceDescriptor | null;
observationSources?: readonly ObservationSourceDescriptor[];
observationTimeline?: ObservationTimelineSnapshot;
viewerSettings?: ViewerSettings | null;
sourceMode: SourceMode;
metrics?: StreamMetrics;
}
export interface MissionRuntimeController {
state: MissionRuntimeState | null;
backendStatus: BackendStatus;
pendingAction: string | null;
refresh: () => void | Promise<void>;
updateViewerSettings: (settings: ViewerSettings) => Promise<boolean>;
setObservationSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>;
}
@@ -0,0 +1,89 @@
export interface LatestAsyncCommitter<T> {
enqueue: (value: T) => void;
hasPending: () => boolean;
isBusy: () => boolean;
waitForIdle: () => Promise<void>;
dispose: () => void;
}
export interface LatestAsyncCommitResult<T> {
value: T;
applied: boolean;
superseded: boolean;
}
/**
* Serializes an expensive settings commit while retaining only the newest
* value submitted during the active request. This keeps slider and color
* interactions from creating an unbounded backend queue.
*/
export function createLatestAsyncCommitter<T>({
commit,
onSettled,
}: {
commit: (value: T) => Promise<boolean>;
onSettled?: (result: LatestAsyncCommitResult<T>) => void;
}): LatestAsyncCommitter<T> {
let pending: T | undefined;
let running = false;
let disposed = false;
const idleWaiters = new Set<() => void>();
const settleIdleWaiters = () => {
if (running || pending !== undefined) return;
for (const resolve of idleWaiters) resolve();
idleWaiters.clear();
};
const drain = async () => {
if (running || disposed) return;
running = true;
try {
while (!disposed && pending !== undefined) {
const value = pending;
pending = undefined;
let applied = false;
try {
applied = await commit(value);
} catch {
applied = false;
}
if (disposed) return;
onSettled?.({
value,
applied,
superseded: pending !== undefined,
});
}
} finally {
running = false;
if (!disposed && pending !== undefined) {
void drain();
} else {
settleIdleWaiters();
}
}
};
return {
enqueue(value) {
if (disposed) return;
pending = value;
void drain();
},
hasPending: () => pending !== undefined,
isBusy: () => running || pending !== undefined,
waitForIdle() {
if (!running && pending === undefined) return Promise.resolve();
return new Promise<void>((resolve) => idleWaiters.add(resolve));
},
dispose() {
disposed = true;
pending = undefined;
settleIdleWaiters();
},
};
}
@@ -6,6 +6,8 @@ import "@nodedc/tokens/themes.css";
import "@nodedc/ui-core/styles.css";
import App from "./App";
import { installedDevicePlugins } from "./composition/devicePlugins";
import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost";
import "./styles.css";
const rootElement = document.getElementById("root");
@@ -20,6 +22,8 @@ applyNodedcTheme(rootElement, { theme: "dark" });
createRoot(rootElement).render(
<StrictMode>
<App />
<DevicePluginHostProvider plugins={installedDevicePlugins}>
<App />
</DevicePluginHostProvider>
</StrictMode>,
);
+71
View File
@@ -0,0 +1,71 @@
import type { StatusTone } from "@nodedc/ui-react";
import type {
BackendStatus,
RuntimePhase,
SourceMode,
StreamMetrics,
} from "./core/runtime/contracts";
const phaseLabels: Record<RuntimePhase, string> = {
unconfigured: "Модель не выбрана",
idle: "Ожидание",
configuring: "Настройка устройства",
connected: "Устройство подключено",
starting: "Запуск потока",
streaming: "Поток в реальном времени",
replaying: "Повтор записи",
stopping: "Остановка",
error: "Ошибка",
};
export function phaseLabel(phase: RuntimePhase | null | undefined): string {
return phase ? phaseLabels[phase] : "Нет состояния";
}
export function phaseTone(phase: RuntimePhase | null | undefined): StatusTone {
if (!phase || phase === "unconfigured" || phase === "idle") return "neutral";
if (phase === "error") return "danger";
if (["connected", "streaming", "replaying"].includes(phase)) return "success";
return "accent";
}
export function backendLabel(status: BackendStatus): string {
return {
unconfigured: "Модель не выбрана",
checking: "Проверка контура",
online: "Контур доступен",
degraded: "Контур ограничен",
offline: "Контур недоступен",
}[status];
}
export function backendTone(status: BackendStatus): StatusTone {
if (status === "online") return "success";
if (status === "degraded" || status === "checking") return "warning";
if (status === "unconfigured") return "neutral";
return "danger";
}
export function finiteMetric(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export function pipelineLatency(metrics: StreamMetrics | undefined): number | null {
return finiteMetric(metrics?.latencyMs);
}
export function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—";
return value.toLocaleString("ru-RU", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
}
export function sourceModeLabel(mode: SourceMode | null | undefined): string {
if (mode === "live") return "Реальное время";
if (mode === "replay") return "Повтор записи";
if (mode === "idle") return "Ожидание";
return "Неизвестно";
}
@@ -175,7 +175,7 @@ export const workspaces: WorkspaceDefinition[] = [
description: "От физического устройства до операторского интерфейса.",
capabilities: [
active("Локальный API", "Проверка состояния, резервный REST-опрос и канал событий WebSocket."),
active("Приём данных", "MQTT, декодирование облака точек и позы."),
active("Приём данных", "Плагинский транспорт, нормализация облака точек и позы."),
ready("Визуальный движок", "Встроенный веб-визуализатор Rerun ожидает совместимый источник."),
contract("Бортовой шлюз", "Будущий транспортный адаптер между ROS 2/Zenoh и пунктом управления."),
],
@@ -231,7 +231,7 @@ export const workspaces: WorkspaceDefinition[] = [
label: "Локальное устройство",
title: "Локальное устройство",
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
description: "Рабочий путь BLE → Wi‑Fi → поток для первого подключённого устройства.",
description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
icon: "network",
kind: "device",
groups: [],
@@ -526,8 +526,8 @@ export const workspaces: WorkspaceDefinition[] = [
title: "Артефакты",
description: "Сохраняется политика «сначала исходные данные».",
capabilities: [
active("Нативные захваты", "Сырые данные MQTT и обезличенный манифест."),
active("Воспроизведение", "Воспроизведение исходной записи MQTT и проверенного TSV."),
active("Нативные захваты", "Сырые конверты устройства и обезличенный манифест."),
active("Воспроизведение", "Повтор исходной записи через адаптер выбранной модели."),
ready("Запись RRD", "Совместимая запись Rerun после появления потокового адаптера."),
ready("Компоновка RBL", "Версионируемая компоновка визуализатора рядом с кодом."),
],
@@ -548,7 +548,7 @@ export const workspaces: WorkspaceDefinition[] = [
title: "Транспорт",
description: "Источник отделён от визуального представления.",
capabilities: [
active("MQTT вход", "Доказанный локальный поток текущего устройства."),
active("Плагинский вход", "Локальный поток активной модели устройства."),
active("Управление REST / WebSocket", "Состояние и управляющие операции локального адаптера."),
ready("Rerun gRPC", "Нативный источник реального времени для встроенного веб-визуализатора."),
ready("RRD по HTTP", "Открытие одной или нескольких записей."),
@@ -4,3 +4,4 @@
@import "./styles/spatial.css";
@import "./styles/device.css";
@import "./styles/responsive.css";
@import "./styles/observation.css";
@@ -15,6 +15,7 @@ body,
body {
margin: 0;
font-family: var(--nodedc-font-family);
}
#root[data-nodedc-theme="dark"],
@@ -331,7 +332,6 @@ body {
display: grid;
grid-template-columns: minmax(12rem, 0.9fr) minmax(0, 1.1fr);
gap: 1rem;
border-top: 1px solid var(--station-hairline);
padding: 0.7rem 0;
}
+116
View File
@@ -0,0 +1,116 @@
.device-workspace--model-picker,
.device-plugin-slot {
display: grid;
min-width: 0;
gap: 0.85rem;
}
.device-model-catalog {
background: var(--station-panel);
}
.device-model-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
gap: 0.85rem;
margin-top: 1rem;
}
.device-model-card {
display: grid;
min-width: 0;
gap: 0.9rem;
border-radius: 1rem;
background: rgb(255 255 255 / 0.025);
padding: 1rem;
}
.device-model-card > header {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
}
.device-model-card__mark {
display: grid;
width: 2.25rem;
height: 2.25rem;
place-items: center;
border-radius: 0.7rem;
background: rgb(255 255 255 / 0.06);
color: var(--nodedc-text-primary);
}
.device-model-card header span,
.device-model-card header h3,
.device-model-card > p,
.device-model-card dl {
margin: 0;
}
.device-model-card header span {
color: var(--nodedc-text-muted);
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.device-model-card header h3 {
margin-top: 0.12rem;
color: var(--nodedc-text-primary);
font-size: 0.82rem;
}
.device-model-card > p {
color: var(--nodedc-text-secondary);
font-size: 0.67rem;
line-height: 1.5;
}
.device-model-card dl {
display: grid;
gap: 0.42rem;
padding: 0.7rem 0;
}
.device-model-card dl > div {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.61rem;
}
.device-model-card dt { color: var(--nodedc-text-muted); }
.device-model-card dd { margin: 0; color: var(--nodedc-text-secondary); text-align: right; }
.device-model-card__capabilities {
display: flex;
flex-wrap: wrap;
gap: 0.38rem;
}
.device-model-card__capabilities span {
border-radius: 999px;
background: rgb(255 255 255 / 0.055);
color: var(--nodedc-text-secondary);
padding: 0.28rem 0.5rem;
font-size: 0.54rem;
}
.device-plugin-slot__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-radius: 0.9rem;
background: rgb(255 255 255 / 0.035);
padding: 0.72rem 0.85rem;
}
.device-plugin-slot__bar > div { display: grid; min-width: 0; gap: 0.12rem; }
.device-plugin-slot__bar strong { color: var(--nodedc-text-primary); font-size: 0.74rem; }
.device-plugin-slot__bar small { color: var(--nodedc-text-muted); font-size: 0.58rem; }
.device-plugin-slot__bar .device-plugin-slot__error { color: rgb(var(--nodedc-danger-rgb)); }
@@ -0,0 +1,290 @@
.observation-header-tools {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.65rem;
}
.workspace-layout-feedback {
display: inline-flex;
max-width: 15rem;
align-items: center;
gap: 0.45rem;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.2;
}
.workspace-layout-feedback[data-error="true"] {
color: rgb(var(--nodedc-danger-rgb));
}
.observation-session-select__trigger {
display: inline-flex;
min-width: 15rem;
height: 2.75rem;
align-items: center;
justify-content: flex-start;
gap: 0.55rem;
border: 0;
border-radius: 999px;
background: rgb(255 255 255 / 0.055);
color: var(--nodedc-text-secondary);
padding: 0 0.85rem;
font: inherit;
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-strong);
cursor: pointer;
}
.observation-session-select__trigger:hover,
.observation-session-select__trigger[data-active="true"] {
background: rgb(255 255 255 / 0.095);
color: var(--nodedc-text-primary);
}
.observation-session-select__trigger > span {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.observation-session-select__trigger > small {
display: grid;
min-width: 1.35rem;
height: 1.35rem;
place-items: center;
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.observation-session-menu {
overflow: hidden;
font-family: var(--nodedc-font-family);
}
.observation-session-menu__content,
.observation-session-menu__list {
display: grid;
}
.observation-session-menu__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 0.9rem 0.6rem;
}
.observation-session-menu__head span,
.observation-session-menu__head strong {
display: block;
}
.observation-session-menu__head strong {
margin-top: 0.3rem;
color: var(--nodedc-text-primary);
font-size: var(--nodedc-font-size-md);
}
.observation-session-menu__head button {
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 0.055);
color: var(--nodedc-text-secondary);
cursor: pointer;
}
.observation-session-menu__head button:hover {
background: rgb(255 255 255 / 0.1);
color: var(--nodedc-text-primary);
}
.observation-session-menu__list {
max-height: min(26rem, 60vh);
gap: 0.2rem;
overflow: auto;
padding: 0.35rem;
}
.observation-session-option {
display: grid;
grid-template-columns: minmax(0, 1fr) 2.35rem;
align-items: stretch;
gap: 0.2rem;
}
.observation-session-option__open {
width: 100%;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
}
.observation-session-option__delete {
display: grid;
min-width: 2.35rem;
place-items: center;
border: 0;
border-radius: 0;
background: transparent;
color: var(--nodedc-text-muted);
cursor: pointer;
}
.observation-session-option__delete:hover:not(:disabled),
.observation-session-option__delete:focus-visible {
background: transparent;
color: rgb(var(--nodedc-danger-rgb));
outline: none;
}
.observation-session-option__delete:disabled,
.observation-session-option[data-deleting="true"] {
opacity: 0.55;
cursor: default;
}
.observation-session-option i {
display: block;
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.observation-session-option i[data-session-visual-state="ready"],
.observation-session-option i[data-session-visual-state="processing"] {
background: rgb(var(--nodedc-success-rgb));
}
.observation-session-option i[data-session-visual-state="processing"] {
animation: observation-session-processing 1.1s ease-in-out infinite;
}
.observation-session-option i[data-session-visual-state="cold"],
.observation-session-option i[data-session-visual-state="error"] {
background: var(--nodedc-text-muted);
}
.observation-session-option i[data-session-visual-state="error"] {
opacity: 0.48;
}
@keyframes observation-session-processing {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.observation-session-option i[data-session-visual-state="processing"] {
animation: none;
opacity: 0.72;
}
}
.observation-session-option__state {
max-width: 9.5rem;
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
font-weight: var(--nodedc-font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.observation-session-option__lab {
align-items: center;
background: color-mix(in srgb, var(--accent, #b8ff5a) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--accent, #b8ff5a) 42%, transparent);
border-radius: 999px;
color: var(--accent, #b8ff5a);
display: inline-flex;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.08em;
line-height: 1;
margin-right: 7px;
padding: 4px 6px 3px;
vertical-align: 1px;
}
.observation-session-menu__empty {
display: grid;
min-height: 9rem;
place-items: center;
align-content: center;
gap: 0.5rem;
padding: 1rem;
color: var(--nodedc-text-muted);
text-align: center;
}
.observation-session-menu__empty strong {
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
}
.observation-session-menu__empty span {
max-width: 24rem;
font-size: var(--nodedc-font-size-xs);
line-height: 1.45;
}
.observation-session-menu__error {
display: flex;
align-items: flex-start;
gap: 0.45rem;
padding: 0.65rem 0.9rem 0.8rem;
color: rgb(var(--nodedc-danger-rgb));
font-size: var(--nodedc-font-size-xs);
line-height: 1.4;
}
.observation-session-menu__error > span {
min-width: 0;
flex: 1 1 auto;
}
.observation-session-menu__error button {
flex: 0 0 auto;
border: 0;
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
color: var(--nodedc-text-primary);
padding: 0.4rem 0.65rem;
font: inherit;
font-weight: var(--nodedc-font-weight-strong);
cursor: pointer;
}
.observation-session-delete-error {
color: rgb(var(--nodedc-danger-rgb));
}
@media (max-width: 860px) {
.workspace-layout-feedback {
display: none;
}
.observation-session-select__trigger {
min-width: 2.75rem;
width: 2.75rem;
padding: 0;
justify-content: center;
}
.observation-session-select__trigger > span,
.observation-session-select__trigger > small,
.observation-session-select__trigger > svg:last-child {
display: none;
}
}
@@ -0,0 +1,647 @@
.scene-source-controls {
position: absolute;
z-index: 12;
top: 0.85rem;
left: 0.85rem;
display: flex;
align-items: center;
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 {
display: inline-grid;
width: 2.75rem;
height: 2.75rem;
place-items: center;
border: 1px solid rgb(255 255 255 / 0.09);
border-radius: 50%;
background: rgb(12 13 16 / 0.82);
color: var(--nodedc-text-secondary);
cursor: pointer;
backdrop-filter: blur(18px);
}
.scene-navigation-hint {
position: absolute;
z-index: 11;
right: 0.85rem;
bottom: 4.9rem;
display: flex;
align-items: center;
gap: 0.75rem;
border: 1px solid rgb(255 255 255 / 0.07);
border-radius: 999px;
background: rgb(9 10 13 / 0.7);
color: var(--nodedc-text-muted);
padding: 0.42rem 0.65rem;
font-size: 0.52rem;
font-weight: 680;
pointer-events: none;
backdrop-filter: blur(14px);
}
.scene-source-picker__trigger {
position: relative;
}
.scene-source-picker__trigger:hover,
.scene-source-control:hover,
.scene-focus-exit:hover,
.scene-source-picker__trigger[data-active="true"] {
border-color: rgb(255 255 255 / 0.18);
color: var(--nodedc-text-primary);
}
.scene-source-picker__trigger > span {
position: absolute;
top: -0.2rem;
right: -0.2rem;
display: grid;
min-width: 1.05rem;
height: 1.05rem;
place-items: center;
border-radius: 999px;
background: var(--nodedc-text-primary);
color: #090a0c;
padding: 0 0.25rem;
font-size: 0.5rem;
font-weight: 800;
}
.scene-focus-exit {
position: absolute;
z-index: 20;
top: 0.85rem;
right: 0.85rem;
}
.scene-status--top-left {
left: 7.1rem;
}
.scene-status[aria-hidden="true"],
.scene-metrics[aria-hidden="true"] {
display: none;
}
.observation-source-menu {
overflow: hidden;
font-family: var(--nodedc-font-family);
}
.observation-source-menu__head,
.observation-source-menu__foot {
padding: 0.85rem 0.9rem;
}
.observation-source-menu__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
}
.observation-source-menu__head strong,
.observation-source-menu__head span {
display: block;
}
.observation-source-menu__head strong {
margin-top: 0.34rem;
color: var(--nodedc-text-primary);
font-size: var(--nodedc-font-size-md);
}
.observation-source-menu__head small,
.observation-source-menu__foot {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.4;
}
.observation-source-menu__list {
display: grid;
max-height: min(28rem, 60vh);
overflow: auto;
padding: 0.35rem;
}
.observation-source-option .nodedc-dropdown-option__description {
display: flex;
align-items: center;
gap: 0.35rem;
}
.observation-source-option i,
.floating-observation-window i,
.camera-slot__status i {
width: 0.42rem;
height: 0.42rem;
flex: 0 0 0.42rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
i[data-availability="available"],
i[data-availability="streaming"] {
background: rgb(var(--nodedc-success-rgb));
}
i[data-availability="connecting"],
i[data-availability="declared"] {
background: rgb(var(--nodedc-warning-rgb));
}
i[data-availability="unverified"] {
background: var(--nodedc-text-muted);
}
i[data-availability="degraded"],
i[data-availability="error"] {
background: rgb(var(--nodedc-danger-rgb));
}
.observation-source-menu__empty,
.camera-grid__empty,
.observation-media__empty {
display: grid;
place-items: center;
align-content: center;
gap: 0.5rem;
color: var(--nodedc-text-muted);
text-align: center;
}
.observation-source-menu__empty {
min-height: 10rem;
padding: 1rem;
}
.observation-source-menu__empty strong,
.observation-media__empty strong,
.camera-grid__empty strong {
color: var(--nodedc-text-secondary);
font-size: 0.7rem;
}
.observation-source-menu__empty span,
.observation-media__empty span,
.camera-grid__empty span {
max-width: 24rem;
font-size: 0.59rem;
line-height: 1.45;
}
.observation-source-menu__foot {
border-top: 0;
}
.observation-media__asset,
.observation-media__empty {
width: 100%;
height: 100%;
}
.observation-media__asset {
display: block;
object-fit: contain;
background: #050608;
}
.recorded-media-player {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #070809;
}
.recorded-media-player:not([data-state="ready"]) .observation-media__asset {
visibility: hidden;
}
.recorded-media-player__notice {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 18px;
color: rgba(247, 248, 244, 0.72);
background: #070809;
font: inherit;
font-size: 11px;
text-align: center;
pointer-events: none;
}
.mse-fmp4-player {
position: relative;
width: 100%;
height: 100%;
min-height: 9rem;
overflow: hidden;
background: #050608;
}
.mse-fmp4-player__status {
position: absolute;
inset: 0;
display: grid;
place-items: center;
align-content: center;
gap: 0.5rem;
padding: 1rem;
background: rgb(5 6 8 / 0.88);
color: var(--nodedc-text-muted);
text-align: center;
}
.mse-fmp4-player__status strong {
color: var(--nodedc-text-secondary);
font-size: 0.7rem;
}
.mse-fmp4-player__status span {
max-width: 24rem;
font-size: 0.59rem;
line-height: 1.45;
}
.mse-fmp4-player__status button {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.25rem;
border: 1px solid rgb(255 255 255 / 0.12);
border-radius: 999px;
background: rgb(255 255 255 / 0.06);
color: var(--nodedc-text-primary);
padding: 0.48rem 0.72rem;
font: inherit;
font-size: 0.58rem;
font-weight: 760;
cursor: pointer;
}
.mse-fmp4-player__status button:hover {
background: rgb(255 255 255 / 0.11);
}
.observation-media__empty {
min-height: 9rem;
background: #07080a;
}
.floating-observation-window__status,
.floating-observation-window__footer {
display: flex;
align-items: center;
gap: 0.45rem;
}
.camera-slot__head-actions button {
display: grid;
width: 1.9rem;
height: 1.9rem;
place-items: center;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 0.05);
color: var(--nodedc-text-secondary);
cursor: pointer;
}
.camera-slot__head-actions button:hover {
background: rgb(255 255 255 / 0.1);
color: var(--nodedc-text-primary);
}
.camera-slot__head-actions .camera-slot__activation {
width: auto;
min-width: 5.6rem;
height: 1.9rem;
border: 1px solid rgb(255 255 255 / 0.09);
border-radius: 999px;
padding: 0 0.65rem;
font: inherit;
font-size: 0.53rem;
font-weight: 760;
white-space: nowrap;
}
.camera-slot__head-actions .camera-slot__activation:disabled {
cursor: wait;
opacity: 0.48;
}
.floating-observation-window__status {
color: var(--nodedc-text-secondary);
font-size: 0.54rem;
}
.floating-observation-window__footer {
width: 100%;
justify-content: space-between;
color: var(--nodedc-text-muted);
font-size: 0.53rem;
}
.floating-observation-window .nodedc-workspace-window__body {
background: #06070a;
}
.floating-observation-window.nodedc-material-rim::after {
display: none;
}
.floating-observation-window[data-active="true"] {
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.34);
}
.floating-observation-window--hidden {
visibility: hidden;
pointer-events: none;
}
.observation-timeline {
display: block;
min-width: 0;
border: 0;
border-radius: 0.9rem;
background: rgb(9 10 13 / 0.78);
padding: 0.4rem;
backdrop-filter: blur(16px);
}
.observation-timeline[data-accumulation="true"] {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: center;
gap: 0.8rem;
}
.observation-timeline__playback {
display: grid;
min-width: 0;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.6rem;
}
.observation-timeline__accumulation {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.7rem;
padding: 0 0.45rem;
}
.observation-timeline__accumulation span,
.observation-timeline__accumulation code {
color: var(--nodedc-text-muted);
font-size: 0.55rem;
white-space: nowrap;
}
.observation-timeline__accumulation code {
min-width: 2.65rem;
color: var(--nodedc-text-primary);
text-align: right;
}
.observation-timeline__track {
width: 100%;
height: 0.3rem;
appearance: none;
border: 0;
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
cursor: pointer;
}
.observation-timeline__track::-webkit-slider-thumb {
width: 0.72rem;
height: 0.72rem;
appearance: none;
border: 0;
border-radius: 50%;
background: var(--nodedc-text-primary);
}
.observation-timeline__track::-moz-range-thumb {
width: 0.72rem;
height: 0.72rem;
border: 0;
border-radius: 50%;
background: var(--nodedc-text-primary);
}
.observation-timeline__track:disabled {
opacity: 0.46;
cursor: default;
}
.observation-timeline__meta {
display: grid;
justify-items: end;
gap: 0.12rem;
}
.observation-timeline__meta code,
.observation-timeline__meta small {
color: var(--nodedc-text-muted);
font-size: 0.52rem;
white-space: nowrap;
}
.observation-timeline__follow {
display: inline-flex;
align-items: center;
gap: 0.38rem;
color: var(--nodedc-text-muted);
border: 0;
background: transparent;
padding: 0.3rem 0.45rem;
font-size: 0.52rem;
font-weight: 820;
}
.observation-timeline__follow:not(:disabled) {
cursor: pointer;
}
.observation-timeline__follow::before {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.observation-timeline__follow[data-active="true"] {
color: var(--nodedc-text-primary);
}
.observation-timeline__follow[data-active="true"]::before {
background: rgb(var(--nodedc-success-rgb));
}
.scene-timeline {
position: absolute;
z-index: 10;
right: 0.75rem;
bottom: 0.75rem;
left: 0.75rem;
}
.spatial-workspace[data-focused="true"] {
min-height: 0;
grid-template-rows: minmax(0, 1fr);
gap: 0;
}
.spatial-workspace[data-focused="true"] > .spatial-toolbar,
.spatial-workspace[data-focused="true"] > .spatial-contract-strip {
display: none;
}
.cameras-workspace {
height: 100%;
min-height: 0;
grid-template-rows: auto auto minmax(0, 1fr) auto;
overflow: hidden;
padding-bottom: 0;
}
.camera-workspace__catalog {
display: flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: 0.8rem;
color: var(--nodedc-text-muted);
font-size: 0.6rem;
}
.camera-grid {
display: grid;
min-height: 0;
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
grid-template-rows: none;
grid-auto-rows: minmax(16rem, 1fr);
gap: 0.75rem;
overflow: auto;
}
.camera-grid[data-count="1"] {
grid-template-columns: minmax(0, 1fr);
}
.camera-grid__empty {
min-height: 18rem;
border-radius: 1rem;
background: #07080a;
box-shadow: none;
}
.camera-slot {
grid-template-rows: auto minmax(0, 1fr) auto;
}
.camera-slot header,
.camera-slot footer {
z-index: 2;
}
.camera-slot__head-actions,
.camera-slot__status {
display: flex;
align-items: center;
gap: 0.45rem;
}
.camera-slot__status {
color: var(--nodedc-text-muted);
font-size: 0.56rem;
}
.camera-slot__body {
position: relative;
z-index: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.camera-slot footer {
align-items: flex-end;
}
.camera-slot footer span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.camera-slot footer small {
flex: 0 0 auto;
color: var(--nodedc-text-secondary);
font-size: 0.53rem;
}
.camera-timeline {
position: relative;
}
.cameras-workspace[data-focused="true"] {
height: 100%;
grid-template-rows: minmax(0, 1fr) auto;
padding-bottom: 0;
}
.cameras-workspace[data-focused="true"] > .workspace-lead,
.cameras-workspace[data-focused="true"] > .camera-workspace__catalog {
display: none;
}
.cameras-workspace[data-focused="true"] .camera-grid {
min-height: 0;
overflow: hidden;
}
@media (max-width: 760px) {
.scene-status--top-left {
top: 4.2rem;
left: 0.6rem;
}
.observation-timeline[data-accumulation="true"] {
grid-template-columns: minmax(0, 1fr);
}
.observation-timeline__playback {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.observation-timeline__playback > .nodedc-button:first-child,
.observation-timeline__meta {
display: none;
}
.camera-grid {
grid-template-columns: 1fr;
grid-auto-rows: minmax(14rem, 1fr);
}
}
@@ -3,17 +3,12 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.device-workspace__grid {
grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr);
}
.landing-stage__copy {
width: min(35rem, 54%);
}
}
@media (max-width: 1280px) {
.device-workspace__grid,
.overview-grid,
.mission-layout {
grid-template-columns: 1fr;
@@ -72,10 +67,6 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.diagnostics-grid {
grid-template-columns: 1fr;
}
.workspace-lead {
align-items: flex-start;
flex-direction: column;
@@ -104,7 +95,6 @@
}
@media (max-width: 760px) {
.station-label,
.control-station .nodedc-header__profile-button {
display: none;
}
@@ -178,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;
@@ -210,36 +205,12 @@
display: none;
}
.session-form--replay {
grid-template-columns: 1fr;
}
.session-form--replay > :first-child {
grid-column: auto;
}
.session-footer,
.error-banner,
.source-format-list > div {
align-items: stretch;
grid-template-columns: 1fr;
flex-direction: column;
}
.error-banner {
grid-template-columns: auto minmax(0, 1fr);
}
.error-banner__actions {
grid-column: 2;
justify-content: flex-end;
}
.device-row__action {
align-items: stretch;
flex-direction: column;
}
.feature-row {
grid-template-columns: auto minmax(0, 1fr);
}
@@ -1,13 +1,3 @@
.station-label {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.61rem;
font-weight: 820;
letter-spacing: 0.13em;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-dot {
width: 0.48rem;
height: 0.48rem;
@@ -29,6 +19,36 @@
background: rgb(var(--nodedc-danger-rgb));
}
.control-station[data-observation-fullscreen="true"] .nodedc-app-shell__content {
z-index: calc(var(--nodedc-layer-panel) + 1);
right: var(--nodedc-app-page-pad);
left: var(--nodedc-app-page-pad);
width: auto;
transition: none;
}
.control-station[data-observation-fullscreen="true"] .nodedc-app-shell__navigation {
pointer-events: none;
}
.control-station[data-observation-fullscreen="true"] .nodedc-application-panel {
grid-template-rows: minmax(0, 1fr);
gap: 0;
background: #06070a;
padding: 0;
box-shadow: none;
animation: none;
}
.control-station[data-observation-fullscreen="true"] .nodedc-application-panel__head {
display: none;
}
.control-station[data-observation-fullscreen="true"] .nodedc-application-panel__body {
height: 100%;
overflow: hidden;
}
.landing-stage {
position: relative;
min-width: 0;
@@ -79,7 +99,6 @@
display: grid;
width: min(24rem, 30vw);
gap: 0.6rem;
border: 1px solid rgb(255 255 255 / 0.055);
border-radius: 1.25rem;
background: rgb(10 10 12 / 0.58);
padding: 1rem;
@@ -13,11 +13,10 @@
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 1rem;
}
.spatial-toolbar__mode,
.spatial-toolbar__actions {
display: flex;
min-width: 0;
@@ -25,6 +24,20 @@
gap: 0.45rem;
}
.spatial-toolbar__view-switch {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid rgb(255 255 255 / 0.07);
border-radius: 999px;
background: rgb(255 255 255 / 0.025);
padding: 0.2rem;
}
.spatial-toolbar__view-switch .nodedc-button {
border-radius: 999px;
}
.spatial-viewport-shell {
position: relative;
min-width: 0;
@@ -32,7 +45,7 @@
overflow: hidden;
border-radius: 1rem;
background: #06070a;
box-shadow: inset 0 0 0 1px var(--station-hairline);
box-shadow: none;
}
.rerun-viewport,
@@ -45,15 +58,57 @@
min-height: 0;
}
.rerun-viewport__canvas[data-presented="false"] {
visibility: hidden;
}
.recorded-session-preloaders {
position: fixed;
left: -10000px;
top: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.rerun-viewport {
--rerun-native-chrome-height: 72px;
overflow: hidden;
}
/* Rerun WebViewer 0.34.1 keeps three fixed 24px canvas rows even after its
panels are overridden: the native top row, recording tab and view tab.
They are drawn inside WASM and cannot be styled independently, so crop the
fixed native chrome while keeping the actual 3D viewport full-height. */
.rerun-viewport__canvas {
top: calc(-1 * var(--rerun-native-chrome-height));
bottom: auto;
height: calc(100% + var(--rerun-native-chrome-height));
}
.rerun-viewport__canvas canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
.rerun-viewport__camera-lock {
position: absolute;
z-index: 2;
top: 0;
bottom: 0;
left: 0;
width: calc(46% - 2px);
cursor: default;
touch-action: none;
}
.rerun-viewport:is([data-status="loading"], [data-status="error"])
.rerun-viewport__canvas > div {
.rerun-viewport__canvas {
visibility: hidden;
pointer-events: none;
}
.rerun-viewport__notice {
@@ -63,7 +118,7 @@
display: flex;
align-items: center;
gap: 0.8rem;
border: 1px solid var(--station-hairline);
border: 0;
border-radius: 1rem;
background: rgb(10 11 14 / 0.82);
padding: 0.9rem 1rem;
@@ -90,6 +145,24 @@
background: rgb(10 11 14 / 0.9);
}
.rerun-viewport__retry {
margin-top: 0.65rem;
border: 1px solid var(--station-hairline-strong);
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
padding: 0.42rem 0.7rem;
color: var(--nodedc-text-primary);
font: inherit;
font-size: 0.61rem;
cursor: pointer;
}
.rerun-viewport__retry:hover,
.rerun-viewport__retry:focus-visible {
background: rgb(255 255 255 / 0.14);
outline: none;
}
.busy-indicator {
width: 1rem;
height: 1rem;
@@ -161,7 +234,7 @@
width: 3.4rem;
height: 3.4rem;
place-items: center;
border: 1px solid var(--station-hairline-strong);
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 0.035);
color: var(--nodedc-text-secondary);
@@ -186,7 +259,7 @@
.scene-timeline {
position: absolute;
z-index: 3;
border: 1px solid rgb(255 255 255 / 0.055);
border: 0;
background: rgb(9 10 13 / 0.74);
backdrop-filter: blur(16px);
}
@@ -222,7 +295,7 @@
align-items: baseline;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid var(--station-hairline);
border-bottom: 0;
padding: 0.38rem 0;
}
@@ -262,6 +335,58 @@
.scene-selection small { color: var(--nodedc-text-muted); font-size: 0.55rem; }
.scene-selection strong { overflow: hidden; font-size: 0.66rem; text-overflow: ellipsis; white-space: nowrap; }
.scene-operation-status-stack {
position: absolute;
z-index: 11;
right: 0.85rem;
bottom: 7.15rem;
display: grid;
justify-items: end;
gap: 0.3rem;
}
.scene-operation-status {
display: inline-flex;
max-width: min(24rem, calc(100% - 1.7rem));
align-items: center;
gap: 0.42rem;
border: 1px solid rgb(255 255 255 / 0.07);
border-radius: 999px;
background: rgb(9 10 13 / 0.7);
color: var(--nodedc-text-muted);
padding: 0.42rem 0.65rem;
font: inherit;
font-size: 0.52rem;
font-weight: 680;
line-height: 1;
white-space: nowrap;
backdrop-filter: blur(14px);
}
.scene-operation-status .busy-indicator {
width: 0.66rem;
height: 0.66rem;
flex-basis: 0.66rem;
border-width: 1px;
}
.scene-operation-status--action {
color: var(--nodedc-text-secondary);
cursor: pointer;
}
.scene-operation-status--action:hover,
.scene-operation-status--action:focus-visible {
border-color: rgb(255 255 255 / 0.14);
background: rgb(20 21 25 / 0.82);
color: var(--nodedc-text-primary);
outline: none;
}
.scene-operation-status--error {
color: rgb(var(--nodedc-danger-rgb));
}
.scene-timeline {
right: 0.75rem;
bottom: 0.75rem;
@@ -16,7 +16,6 @@
min-width: 0;
grid-template-columns: minmax(14rem, 0.72fr) minmax(0, 1.6fr);
gap: 1.6rem;
border-top: 1px solid var(--station-hairline);
padding-top: 1.2rem;
}
@@ -47,7 +46,6 @@
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.8rem;
border-bottom: 1px solid var(--station-hairline);
padding: 0.78rem 0;
}
@@ -270,7 +268,7 @@
overflow: hidden;
border-radius: 1rem;
background: #090a0c;
box-shadow: inset 0 0 0 1px var(--station-hairline);
box-shadow: none;
}
.camera-slot[data-primary="true"] {
@@ -347,7 +345,7 @@
overflow: hidden;
border-radius: 1rem;
background: #0a0b0d;
box-shadow: inset 0 0 0 1px var(--station-hairline);
box-shadow: none;
}
.map-canvas__grid,
@@ -484,7 +482,6 @@
}
.timeline-ruler span {
border-left: 1px solid var(--station-hairline);
padding-left: 0.2rem;
}
@@ -498,7 +495,6 @@
grid-template-columns: 7.5rem minmax(0, 1fr) 10rem;
align-items: center;
gap: 1rem;
border-top: 1px solid var(--station-hairline);
padding: 0.82rem 0;
}
@@ -0,0 +1,131 @@
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost";
import type { RegisteredDeviceModel } from "../core/device-plugins/contracts";
function ModelCard({
registered,
onSelect,
}: {
registered: RegisteredDeviceModel;
onSelect: () => void;
}) {
const { model, plugin } = registered;
return (
<article className="device-model-card">
<header>
<div className="device-model-card__mark" aria-hidden="true">
<Icon name="network" />
</div>
<div>
<span>{model.vendor}</span>
<h3>{model.displayName}</h3>
</div>
<StatusBadge tone={model.verified ? "success" : "warning"}>
{model.verified ? "Проверено" : "Экспериментально"}
</StatusBadge>
</header>
<p>{model.description}</p>
<dl>
<div><dt>Категория</dt><dd>{model.category}</dd></div>
<div><dt>Плагин</dt><dd>{plugin.manifest.metadata.displayName} · v{plugin.manifest.metadata.version}</dd></div>
</dl>
<div className="device-model-card__capabilities" aria-label="Возможности модели">
{model.capabilities.map((capability) => <span key={capability.id}>{capability.label}</span>)}
</div>
<Button width="full" variant="primary" onClick={onSelect}>
Выбрать модель
</Button>
</article>
);
}
export function DeviceWorkspace({
onOpenSpatialScene,
onActivateAutomaticSpatialSource,
}: {
onOpenSpatialScene: () => void;
onActivateAutomaticSpatialSource: () => void;
}) {
const {
registry,
selection,
selectionTransitionPending,
selectionTransitionError,
selectModel,
clearSelection,
} = useDevicePluginHost();
if (!selection) {
return (
<div className="device-workspace device-workspace--model-picker">
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">ЛОКАЛЬНОЕ УСТРОЙСТВО · ШАГ 01</span>
<h2>Выберите модель устройства</h2>
<p>
Mission Core покажет только сценарий выбранного плагина. До выбора модели нет
поиска оборудования, сетевых реквизитов, управления потоком или фиктивных метрик.
</p>
</div>
<div className="workspace-lead__status">
<StatusBadge tone="neutral">Модель не выбрана</StatusBadge>
<span>{registry.models.length} моделей доступно в локальной сборке</span>
</div>
</section>
<GlassSurface className="device-model-catalog" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">УСТАНОВЛЕННЫЕ ПЛАГИНЫ</span>
<h2>Парк поддерживаемых моделей</h2>
</div>
<StatusBadge tone="neutral">{registry.plugins.length} плагин</StatusBadge>
</header>
<div className="device-model-grid">
{registry.models.map((registered) => (
<ModelCard
key={registered.model.id}
registered={registered}
onSelect={() => void selectModel(registered.model.id)}
/>
))}
</div>
</GlassSurface>
</div>
);
}
const ConnectionView = selection.ConnectionView;
return (
<div className="device-plugin-slot">
<div className="device-plugin-slot__bar">
<div>
<span className="section-eyebrow">АКТИВНАЯ МОДЕЛЬ</span>
<strong>{selection.model.displayName}</strong>
<small>{selection.plugin.manifest.metadata.displayName} · v{selection.plugin.manifest.metadata.version}</small>
{selectionTransitionError ? (
<small className="device-plugin-slot__error" role="alert">
{selectionTransitionError}
</small>
) : null}
</div>
<Button
variant="secondary"
size="compact"
disabled={selectionTransitionPending}
onClick={() => void clearSelection()}
>
{selectionTransitionPending ? "Завершаем текущую сессию…" : "Выбрать другую модель"}
</Button>
</div>
<ConnectionView
model={selection.model}
host={{
openSpatialScene: onOpenSpatialScene,
activateAutomaticSpatialSource: onActivateAutomaticSpatialSource,
}}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,690 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let parseDevicePluginManifest;
let createDevicePluginRegistry;
let xgridsK1Manifest;
let xgridsK1Actions;
let xgridsK1Api;
let ApiError;
let localizeRuntimeMessage;
let lifecycle;
let projectName;
let automaticSourceStart;
let presentation;
let operatorIntentGeneration;
let configuration;
let compatibility;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ parseDevicePluginManifest } = await server.ssrLoadModule(
"/src/core/device-plugins/manifestParser.ts",
));
({ createDevicePluginRegistry } = await server.ssrLoadModule(
"/src/core/device-plugins/registry.ts",
));
({ xgridsK1Manifest, xgridsK1Actions } = await server.ssrLoadModule(
"@xgrids-k1/frontend/manifest.ts",
));
lifecycle = await server.ssrLoadModule(
"@xgrids-k1/frontend/lifecycle.ts",
);
projectName = await server.ssrLoadModule(
"@xgrids-k1/frontend/projectName.ts",
);
automaticSourceStart = await server.ssrLoadModule(
"@xgrids-k1/frontend/automaticSourceStart.ts",
);
presentation = await server.ssrLoadModule(
"@xgrids-k1/frontend/presentation.ts",
);
operatorIntentGeneration = await server.ssrLoadModule(
"@xgrids-k1/frontend/operatorIntentGeneration.ts",
);
configuration = await server.ssrLoadModule(
"@xgrids-k1/frontend/configuration.ts",
);
compatibility = await server.ssrLoadModule(
"@xgrids-k1/frontend/compatibility.ts",
);
({ xgridsK1Api, ApiError } = await server.ssrLoadModule(
"@xgrids-k1/frontend/api.ts",
));
({ localizeRuntimeMessage } = await server.ssrLoadModule(
"@xgrids-k1/frontend/messages.ts",
));
});
after(async () => {
await server?.close();
});
function manifestDocument({
apiVersion = "missioncore.nodedc/v1alpha1",
pluginId = "test.device.plugin",
modelId = "test.device.model",
} = {}) {
const v1alpha2 = apiVersion === "missioncore.nodedc/v1alpha2";
return {
apiVersion,
kind: "DevicePlugin",
metadata: {
id: pluginId,
version: "1.0.0",
displayName: "Test device",
},
spec: {
hostApiRange: v1alpha2 ? "v1alpha2" : "v1alpha1",
runtime: {
backendEntrypoint: "test.plugin:build",
isolation: "transitional-in-process",
},
...(v1alpha2
? {
compatibilityProfiles: [
{
profileId: `${modelId}.fw-1.v1`,
path: "profiles/fw-1/profile.v1.json",
modelId,
},
],
}
: {}),
permissions: ["device.read"],
actions: [{ id: "state.read", mutating: false, secretFields: [] }],
models: [
{
id: modelId,
vendor: "Test",
displayName: "Test model",
category: "Sensor",
description: "Fixture model",
verified: true,
capabilities: [{ id: "device.read", label: "Read" }],
ui: { slot: "device.connection", componentKey: "test.connection" },
},
],
},
};
}
function uiPlugin(manifest) {
return {
manifest,
RuntimeProvider: ({ children }) => children,
connectionViews: { "test.connection": () => null },
};
}
test("parser accepts reviewed v1alpha1 and v1alpha2 shapes", () => {
const legacy = parseDevicePluginManifest(manifestDocument());
const current = parseDevicePluginManifest(
manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" }),
);
assert.equal(legacy.apiVersion, "missioncore.nodedc/v1alpha1");
assert.equal(legacy.spec.hostApiRange, "v1alpha1");
assert.equal(current.apiVersion, "missioncore.nodedc/v1alpha2");
assert.equal(current.spec.hostApiRange, "v1alpha2");
assert.deepEqual(current.spec.compatibilityProfiles, [
{
profileId: "test.device.model.fw-1.v1",
path: "profiles/fw-1/profile.v1.json",
modelId: "test.device.model",
},
]);
});
test("v1alpha1 keeps the original nonblank identifier compatibility", () => {
const legacyDocument = manifestDocument({
pluginId: "legacy plugin id",
modelId: "legacy model id",
});
legacyDocument.spec.permissions = ["legacy permission"];
legacyDocument.spec.actions[0].secretFields = ["legacy secret field"];
legacyDocument.spec.models[0].capabilities = [
{ id: "legacy capability", label: "Legacy" },
];
const legacy = parseDevicePluginManifest(legacyDocument);
assert.equal(legacy.metadata.id, "legacy plugin id");
assert.equal(legacy.spec.models[0].id, "legacy model id");
assert.deepEqual(legacy.spec.permissions, ["legacy permission"]);
});
test("v1alpha2 applies strict identifiers without redefining v1alpha1", () => {
const current = manifestDocument({
apiVersion: "missioncore.nodedc/v1alpha2",
pluginId: "current plugin id",
});
assert.throws(
() => parseDevicePluginManifest(current),
/не является идентификатором/,
);
});
test("v1alpha2 parser and registry accept multiple independently profiled models", () => {
const document = manifestDocument({
apiVersion: "missioncore.nodedc/v1alpha2",
pluginId: "test.family",
modelId: "test.family.model-a",
});
document.spec.models.push({
...document.spec.models[0],
id: "test.family.model-b",
displayName: "Test model B",
});
document.spec.compatibilityProfiles.push({
profileId: "test.family.model-b.fw-1.v1",
path: "profiles/fw-1/model-b.v1.json",
modelId: "test.family.model-b",
});
const manifest = parseDevicePluginManifest(document);
const plugin = {
...uiPlugin(manifest),
connectionViews: { "test.connection": () => null },
};
const registry = createDevicePluginRegistry([plugin]);
assert.deepEqual(registry.models.map(({ model }) => model.id), [
"test.family.model-a",
"test.family.model-b",
]);
});
test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions", () => {
assert.equal(xgridsK1Manifest.apiVersion, "missioncore.nodedc/v1alpha2");
assert.deepEqual(xgridsK1Manifest.spec.compatibilityProfiles, [
{
profileId: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
path: "profiles/fw-3.0.2/local-network.v2.json",
modelId: "xgrids.lixelkity-k1",
},
]);
assert.equal(xgridsK1Actions.acquisitionPrepare, "acquisition.prepare");
assert.equal(xgridsK1Actions.acquisitionStart, "acquisition.start");
assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop");
});
test("one operator action can open at most one K1 control session", () => {
assert.equal(lifecycle.controlSessionEntryPlan("idle", false, true), "open");
assert.equal(lifecycle.controlSessionEntryPlan("failed", false, true), "open");
assert.equal(lifecycle.controlSessionEntryPlan("failed", false, false), "failed");
assert.equal(lifecycle.controlSessionEntryPlan("failed", true, true), "failed");
assert.equal(
lifecycle.controlSessionEntryPlan("idle", true, true),
"duplicate-open",
);
assert.equal(
lifecycle.controlSessionEntryPlan("connection-ready", true, false),
"continue",
);
});
test("K1 operator intent stays invalid after runtime deactivate and reactivate", () => {
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
generation.activateRuntime();
const oldIntent = generation.beginOperatorIntent();
assert.ok(oldIntent);
assert.equal(generation.isOperatorIntentCurrent(oldIntent), true);
generation.deactivateRuntime();
generation.activateRuntime();
const freshIntent = generation.beginOperatorIntent();
assert.ok(freshIntent);
assert.notEqual(freshIntent.runtimeGeneration, oldIntent.runtimeGeneration);
assert.equal(generation.isOperatorIntentCurrent(oldIntent), false);
assert.equal(generation.isOperatorIntentCurrent(freshIntent), true);
});
test("stale K1 intent cannot continue past an await after runtime reactivation", async () => {
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
generation.activateRuntime();
const oldIntent = generation.beginOperatorIntent();
assert.ok(oldIntent);
let resolveOldRead;
const oldRead = new Promise((resolve) => {
resolveOldRead = resolve;
});
const writes = [];
const assertOldIntent = () => {
if (!generation.isOperatorIntentCurrent(oldIntent)) {
throw new Error("stale operator intent");
}
};
const oldContinuation = operatorIntentGeneration.awaitWhileIntentCurrent(
assertOldIntent,
() => oldRead,
).then(() => writes.push("old-checkpoint"));
generation.deactivateRuntime();
generation.activateRuntime();
const freshIntent = generation.beginOperatorIntent();
assert.ok(freshIntent);
resolveOldRead({ phase: "connection-ready" });
await assert.rejects(oldContinuation, /stale operator intent/);
assert.deepEqual(writes, []);
const assertFreshIntent = () => {
if (!generation.isOperatorIntentCurrent(freshIntent)) {
throw new Error("stale fresh intent");
}
};
await operatorIntentGeneration.awaitWhileIntentCurrent(
assertFreshIntent,
async () => ({ phase: "connection-ready" }),
);
writes.push("fresh-checkpoint");
assert.deepEqual(writes, ["fresh-checkpoint"]);
});
test("a fresh explicit K1 intent supersedes the previous intent in one runtime", () => {
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
generation.activateRuntime();
const first = generation.beginOperatorIntent();
const second = generation.beginOperatorIntent();
assert.ok(first);
assert.ok(second);
assert.equal(first.runtimeGeneration, second.runtimeGeneration);
assert.notEqual(first.intentGeneration, second.intentGeneration);
assert.equal(generation.isOperatorIntentCurrent(first), false);
assert.equal(generation.isOperatorIntentCurrent(second), true);
});
test("canonical K1 launch wires the generation guard through every async stage", async () => {
const hookSource = await readFile(
new URL(
"../../../plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts",
import.meta.url,
),
"utf8",
);
const canonicalStart = hookSource.slice(
hookSource.indexOf("const startCanonicalAcquisition"),
hookSource.indexOf("const prepareAcquisition"),
);
const pollingLoop = hookSource.slice(
hookSource.indexOf("async function waitForControlPhase"),
hookSource.indexOf("function messageFor"),
);
assert.doesNotMatch(hookSource, /mounted\.current/);
assert.match(canonicalStart, /beginOperatorIntent\(\)/);
assert.match(canonicalStart, /isOperatorIntentCurrent\(intentToken\)/);
assert.doesNotMatch(canonicalStart, /await xgridsK1Api\./);
assert.doesNotMatch(canonicalStart, /await waitForControlPhase\(/);
assert.ok(
canonicalStart.match(/awaitWhileIntentCurrent\(/g)?.length >= 8,
"each canonical REST/checkpoint boundary must use the intent guard",
);
assert.match(pollingLoop, /for \(;;\) \{\s*assertOperatorIntentCurrent\(\)/);
assert.equal(pollingLoop.match(/awaitWhileIntentCurrent\(/g)?.length, 2);
});
test("K1 control errors stay informative and only fetch failures mark transport unavailable", async () => {
assert.equal(
localizeRuntimeMessage(
"control MQTT connect call failed: [Errno 61] Connection refused",
),
"Управляющее соединение со сканером не открылось: устройство не приняло MQTT-соединение. Команды сканирования не отправлялись.",
);
const domainError = new ApiError("Диалог остановлен до команды START.");
assert.equal(domainError.transportUnavailable, false);
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new TypeError("synthetic network failure");
};
try {
await assert.rejects(
() => xgridsK1Api.getState(),
(error) => error instanceof ApiError && error.transportUnavailable === true,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("v1alpha2 compatibility profile is exact-key and rejects duplicated profile status", () => {
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
document.spec.compatibilityProfiles[0].status = "verified";
assert.throws(
() => parseDevicePluginManifest(document),
/неизвестные поля status/,
);
});
test("v1alpha2 compatibility profile fails closed on unsafe paths", () => {
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
document.spec.compatibilityProfiles[0].path = "../private/profile.json";
assert.throws(
() => parseDevicePluginManifest(document),
/безопасным относительным JSON-путём/,
);
});
test("v1alpha2 compatibility profile cannot reference an unknown model", () => {
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
document.spec.compatibilityProfiles[0].modelId = "missing.model";
assert.throws(
() => parseDevicePluginManifest(document),
/ссылается на неизвестную модель missing\.model/,
);
});
test("registry accepts v1alpha1 and v1alpha2 plugins together", () => {
const legacy = parseDevicePluginManifest(
manifestDocument({ pluginId: "test.legacy", modelId: "test.legacy.model" }),
);
const current = parseDevicePluginManifest(
manifestDocument({
apiVersion: "missioncore.nodedc/v1alpha2",
pluginId: "test.current",
modelId: "test.current.model",
}),
);
const registry = createDevicePluginRegistry([uiPlugin(legacy), uiPlugin(current)]);
assert.equal(registry.plugins.length, 2);
assert.equal(registry.models.length, 2);
assert.equal(registry.resolveModel("test.current.model")?.plugin.manifest.metadata.id, "test.current");
});
test("registry independently rejects an uncovered v1alpha2 model", () => {
const current = parseDevicePluginManifest(
manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" }),
);
current.spec.compatibilityProfiles[0].modelId = "missing.model";
assert.throws(
() => createDevicePluginRegistry([uiPlugin(current)]),
/ссылается на неизвестную модель missing\.model/,
);
});
test("live source is confirmed only by an acquiring acquisition", () => {
const waiting = {
source_mode: "live",
phase: "live",
acquisition: { state: "awaiting_external_start" },
};
const acquiring = {
...waiting,
acquisition: { state: "acquiring" },
};
assert.equal(lifecycle.isConfirmedLiveState(waiting), false);
assert.equal(lifecycle.confirmedRuntimeSourceMode(waiting), "idle");
assert.equal(lifecycle.sourceStatusLabel(waiting), "Ожидание реальных данных");
assert.equal(lifecycle.isConfirmedLiveState(acquiring), true);
assert.equal(lifecycle.confirmedRuntimeSourceMode(acquiring), "live");
});
test("prepared acquisition resumes without another prepare and remains recoverable", () => {
const prepared = {
source_mode: "idle",
acquisition: {
acquisition_id: "acq-1",
state: "prepared",
},
};
assert.equal(lifecycle.liveStartPlan(prepared), "resume-prepared");
assert.equal(lifecycle.recoverableAcquisition(prepared)?.acquisition_id, "acq-1");
});
test("project name is canonicalized and rejected outside the bounded safe contract", () => {
assert.deepEqual(projectName.validateProjectName(" Mission 01 "), {
value: "Mission 01",
error: null,
});
assert.match(projectName.validateProjectName("line\nbreak").error, /управляющие/);
assert.match(projectName.validateProjectName("\ud800").error, /управляющие/);
assert.match(projectName.validateProjectName("x".repeat(97)).error, /не длиннее 96/);
assert.match(projectName.validateProjectName(" \t ").error, /Введите название/);
});
test("automatic spatial source replaces the old scene only after a successful start", async () => {
const failedEvents = [];
assert.equal(await automaticSourceStart.runAutomaticSpatialSourceStart(
async () => {
failedEvents.push("start");
return false;
},
() => failedEvents.push("activate"),
() => failedEvents.push("open"),
), false);
assert.deepEqual(failedEvents, ["start"]);
const successfulEvents = [];
assert.equal(await automaticSourceStart.runAutomaticSpatialSourceStart(
async () => {
successfulEvents.push("start");
return true;
},
() => successfulEvents.push("activate"),
() => successfulEvents.push("open"),
), true);
assert.deepEqual(successfulEvents, ["start", "activate", "open"]);
});
test("vendor commands fail closed unless the profile and acquisition both enable them", () => {
const capability = {
compatibility: {
vendor_writes_enabled: true,
permitted_mode: "active-control",
},
};
assert.equal(lifecycle.isVendorWriteCapable(capability), true);
assert.equal(lifecycle.isVendorWriteCapable({
compatibility: { vendor_writes_enabled: true, permitted_mode: "read-only" },
}), false);
assert.equal(lifecycle.isSoftwareCommandedAcquisition({
...capability,
acquisition: { control_mode: "operator-manual" },
}), false);
assert.equal(lifecycle.isSoftwareCommandedAcquisition({
...capability,
acquisition: { control_mode: "plugin-commanded" },
}), true);
});
test("device modeling telemetry maps only finite non-negative values", () => {
assert.deepEqual(presentation.deviceTelemetry({
device_elapsed_seconds: 12.5,
device_route_distance_meters: 8.25,
device_speed_meters_per_second: 0.75,
}), {
elapsedSeconds: 12.5,
routeDistanceMeters: 8.25,
speedMetersPerSecond: 0.75,
});
assert.deepEqual(presentation.deviceTelemetry({
device_elapsed_seconds: -1,
device_route_distance_meters: Number.NaN,
device_speed_meters_per_second: Number.POSITIVE_INFINITY,
}), {
elapsedSeconds: null,
routeDistanceMeters: null,
speedMetersPerSecond: null,
});
});
test("spatial K1 action failures have an explicit retry-safe presentation", () => {
assert.equal(presentation.spatialActionFailure(null), null);
assert.equal(presentation.spatialActionFailure(" "), null);
assert.deepEqual(presentation.spatialActionFailure(" stop failed "), {
title: "Действие K1 не выполнено",
detail: "stop failed",
});
assert.equal(lifecycle.shouldRenderSpatialControls({
source_mode: "live",
acquisition: { state: "failed", cleanup_pending: true },
}), true);
assert.equal(lifecycle.shouldRenderSpatialControls({
source_mode: "idle",
acquisition: { state: "failed", cleanup_pending: false },
}), false);
});
test("replay ignores a stale failed live acquisition", () => {
const replay = {
source_mode: "replay",
phase: "replay",
rerun_grpc_url: "rerun+http://127.0.0.1:9876/proxy",
acquisition: {
acquisition_id: "old-live-acquisition",
state: "failed",
},
};
assert.equal(lifecycle.normalizeRuntimePhase(replay), "replaying");
assert.equal(lifecycle.effectiveAcquisition(replay), null);
assert.equal(
lifecycle.spatialSourceId(replay, replay.rerun_grpc_url),
`replay:${replay.rerun_grpc_url}`,
);
});
test("K1 configuration exposes all reviewed local connection directions", () => {
assert.equal(configuration.DEFAULT_CONNECTION_MODE, "bridge");
assert.equal(configuration.SUPPORTED_MOUNT_TYPE, "handheld");
assert.equal(configuration.SUPPORTED_GNSS_MODE, "none");
assert.deepEqual(
configuration.connectionModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
[
{ value: "bridge", disabled: false },
{ value: "quick-connect", disabled: false },
{ value: "direct-connect", disabled: false },
],
);
assert.deepEqual(
configuration.mountTypeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
[
{ value: "handheld", disabled: false },
{ value: "vehicle-mounted", disabled: true },
{ value: "uav", disabled: true },
{ value: "backpack", disabled: true },
],
);
assert.deepEqual(
configuration.gnssModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
[
{ value: "none", disabled: false },
{ value: "rtk", disabled: true },
{ value: "ppk", disabled: true },
],
);
});
test("connection directions select distinct fail-closed topologies", () => {
assert.deepEqual(compatibility.profileSelectionForConnectionMode("bridge"), {
firmware_version: "3.0.2",
topology: "direct-lan",
verification: "live-device-info",
});
assert.equal(
compatibility.profileSelectionForConnectionMode("quick-connect").topology,
"device-ap",
);
assert.equal(
compatibility.profileSelectionForConnectionMode("direct-connect").topology,
"controller-hotspot",
);
});
test("a provisioned address is not presented as a verified device connection", () => {
assert.equal(lifecycle.normalizeRuntimePhase({
phase: "connected",
application_control_session: { state: "idle" },
}), "configuring");
assert.equal(lifecycle.normalizeRuntimePhase({
phase: "connected",
application_control_session: { state: "connection-ready" },
}), "connected");
});
test("provisioning intent keeps one idempotency key and exposes unsafe outcomes", () => {
let created = 0;
const createUuid = () => {
created += 1;
return "11111111-1111-4111-8111-111111111111";
};
const first = lifecycle.provisioningIntentKey(null, createUuid);
const repeated = lifecycle.provisioningIntentKey(first, createUuid);
const failedOperation = {
status: "failed",
error: { safe_to_retry: false },
};
assert.equal(first, "network-provision:11111111-1111-4111-8111-111111111111");
assert.equal(repeated, first);
assert.equal(created, 1);
assert.equal(lifecycle.operationNeedsReconciliation(failedOperation), true);
});
test("device mutations send explicit nested compatibility attestation", async () => {
const originalFetch = globalThis.fetch;
const calls = [];
const syntheticCredential = "x".repeat(32);
globalThis.fetch = async (path, init) => {
calls.push({ path, init });
return new Response(JSON.stringify({ state: { source_mode: "idle" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const attestation = {
firmware_version: "3.0.2",
topology: "direct-lan",
verification: "live-device-info",
};
try {
await xgridsK1Api.connect({
device_id: "ble-device",
ssid: "lab-network",
password: syntheticCredential,
connection_mode: "bridge",
compatibility_attestation: attestation,
idempotency_key: "network-provision:test",
});
await xgridsK1Api.prepareAcquisition({
project_name: "Mission 01",
mount_type: "handheld",
gnss_mode: "none",
compatibility_attestation: attestation,
});
} finally {
globalThis.fetch = originalFetch;
}
assert.equal(calls.length, 2);
const provisioning = JSON.parse(calls[0].init.body);
const prepare = JSON.parse(calls[1].init.body);
assert.deepEqual(provisioning.input.compatibility_attestation, attestation);
assert.equal(provisioning.input.idempotency_key, "network-provision:test");
assert.deepEqual(prepare.input.compatibility_attestation, attestation);
assert.equal(prepare.input.project_name, "Mission 01");
});
@@ -0,0 +1,180 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { after, before, test } from "node:test";
import { createServer } from "vite";
const testRoot = dirname(fileURLToPath(import.meta.url));
const controlStationRoot = resolve(testRoot, "..");
const repositoryRoot = resolve(controlStationRoot, "../..");
const coreSourceRoot = join(controlStationRoot, "src");
const pluginFrontendRoot = join(repositoryRoot, "plugins/xgrids-k1/frontend/src");
const legacyPluginRoot = join(coreSourceRoot, "device-plugins/xgrids-k1");
function sourceFiles(root) {
return readdirSync(root).flatMap((entry) => {
const path = join(root, entry);
if (statSync(path).isDirectory()) return sourceFiles(path);
return /\.(?:css|ts|tsx)$/.test(entry) ? [path] : [];
});
}
let server;
let createDevicePluginRegistry;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ createDevicePluginRegistry } = await server.ssrLoadModule(
"/src/core/device-plugins/registry.ts",
));
});
after(async () => {
await server?.close();
});
function syntheticPlugin({
pluginId,
modelId,
componentKey,
connectionView,
spatialControlsView,
}) {
return {
manifest: {
apiVersion: "missioncore.nodedc/v1alpha2",
kind: "DevicePlugin",
metadata: { id: pluginId, version: "1.0.0", displayName: pluginId },
spec: {
hostApiRange: "v1alpha2",
runtime: { backendEntrypoint: `${pluginId}:build`, isolation: "transitional-in-process" },
permissions: ["device.read"],
actions: [{ id: "state.read", mutating: false, secretFields: [] }],
compatibilityProfiles: [{
profileId: `${modelId}.profile.v1`,
path: `profiles/${modelId}.json`,
modelId,
}],
models: [{
id: modelId,
vendor: pluginId,
displayName: modelId,
category: "Sensor",
description: "Synthetic frontend contribution",
verified: true,
capabilities: [{ id: "device.read", label: "Read" }],
ui: { slot: "device.connection", componentKey },
}],
},
},
RuntimeProvider: ({ children }) => children,
connectionViews: { [componentKey]: connectionView },
...(spatialControlsView ? { SpatialControlsView: spatialControlsView } : {}),
};
}
test("each device plugin contributes its own connection pipeline component", () => {
const alphaConnection = () => null;
const betaConnection = () => null;
const registry = createDevicePluginRegistry([
syntheticPlugin({
pluginId: "synthetic.alpha",
modelId: "synthetic.alpha.sensor",
componentKey: "alpha.connection",
connectionView: alphaConnection,
}),
syntheticPlugin({
pluginId: "synthetic.beta",
modelId: "synthetic.beta.sensor",
componentKey: "beta.connection",
connectionView: betaConnection,
}),
]);
assert.equal(registry.resolveModel("synthetic.alpha.sensor").ConnectionView, alphaConnection);
assert.equal(registry.resolveModel("synthetic.beta.sensor").ConnectionView, betaConnection);
assert.notEqual(
registry.resolveModel("synthetic.alpha.sensor").ConnectionView,
registry.resolveModel("synthetic.beta.sensor").ConnectionView,
);
});
test("registry exposes an optional model-scoped spatial controls contribution", () => {
const connectionView = () => null;
const spatialControlsView = () => null;
const registry = createDevicePluginRegistry([
syntheticPlugin({
pluginId: "synthetic.spatial",
modelId: "synthetic.spatial.sensor",
componentKey: "spatial.connection",
connectionView,
spatialControlsView,
}),
syntheticPlugin({
pluginId: "synthetic.connection-only",
modelId: "synthetic.connection-only.sensor",
componentKey: "connection-only.connection",
connectionView,
}),
]);
assert.equal(
registry.resolveModel("synthetic.spatial.sensor").SpatialControlsView,
spatialControlsView,
);
assert.equal(
registry.resolveModel("synthetic.connection-only.sensor").SpatialControlsView,
undefined,
);
});
test("XGRIDS frontend is physically plugin-owned and split by operator pipeline", () => {
if (existsSync(legacyPluginRoot)) {
assert.deepEqual(readdirSync(legacyPluginRoot), []);
}
for (const relativePath of [
"plugin.ts",
"runtimeContext.tsx",
"styles.css",
"components/K1ProvisioningPipeline.tsx",
"components/K1AcquisitionPipeline.tsx",
"components/K1SpatialControls.tsx",
"components/K1Diagnostics.tsx",
"projectName.ts",
]) {
assert.equal(existsSync(join(pluginFrontendRoot, relativePath)), true, relativePath);
}
const spatialControls = readFileSync(
join(pluginFrontendRoot, "components/K1SpatialControls.tsx"),
"utf8",
);
assert.match(spatialControls, /shouldRenderSpatialControls\(state\)/);
assert.match(spatialControls, /cleanup_pending/);
assert.match(spatialControls, /spatialActionFailure/);
assert.match(spatialControls, /role="alert"/);
assert.match(spatialControls, /Повторить остановку/);
});
test("generic Control Station has one composition import and no K1 implementation knowledge", () => {
const compositionPath = join(coreSourceRoot, "composition/devicePlugins.ts");
const composition = readFileSync(compositionPath, "utf8");
assert.match(composition, /from "@xgrids-k1\/frontend\/plugin"/);
for (const path of sourceFiles(coreSourceRoot)) {
if (path === compositionPath) continue;
const source = readFileSync(path, "utf8");
assert.doesNotMatch(source, /xgrids|lixel|\bk1\b/i, path);
}
for (const path of sourceFiles(pluginFrontendRoot)) {
const source = readFileSync(path, "utf8");
assert.doesNotMatch(source, /apps\/control-station|\.\.\/\.\.\/core|\.\.\/\.\.\/components/, path);
}
});
@@ -0,0 +1,999 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let decodeObservationSessionCatalog;
let decodeObservationSessionReplay;
let decodeObservationSessionPreparation;
let fetchObservationSessionCatalog;
let deleteObservationSession;
let replayObservationSession;
let fetchObservationSessionPreparation;
let cancelObservationSessionPreparation;
let ObservationSessionApiError;
let ObservationSessionContractError;
let decodeObservationRecordedMediaManifest;
let createObservationReplayCoordinator;
let resolveObservationSessionReplay;
let waitForObservationReplayPreparation;
let storeObservationReplayPreparation;
let loadObservationReplayPreparation;
let observationSessionVisualState;
let observationSessionVisualLabel;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
decodeObservationSessionCatalog,
decodeObservationSessionReplay,
decodeObservationSessionPreparation,
fetchObservationSessionCatalog,
deleteObservationSession,
replayObservationSession,
fetchObservationSessionPreparation,
cancelObservationSessionPreparation,
ObservationSessionApiError,
ObservationSessionContractError,
decodeObservationRecordedMediaManifest,
} = await server.ssrLoadModule("/src/core/observation/sessionArchive.ts"));
({
createObservationReplayCoordinator,
resolveObservationSessionReplay,
waitForObservationReplayPreparation,
storeObservationReplayPreparation,
loadObservationReplayPreparation,
} = await server.ssrLoadModule(
"/src/core/observation/useObservationSessions.ts",
));
({ observationSessionVisualState, observationSessionVisualLabel } = await server.ssrLoadModule(
"/src/components/ObservationSessionSelect.tsx",
));
});
after(async () => {
await server?.close();
});
function session(overrides = {}) {
return {
id: "20260716T205632Z_viewer_live",
label: "K1 · наблюдение 16 июля",
started_at_utc: "2026-07-16T20:56:32.635Z",
completed_at_utc: "2026-07-16T21:20:43.379Z",
status: "ready",
modalities: ["point-cloud", "pose"],
duration_seconds: 1_450.744,
replayable: true,
...overrides,
};
}
function replay(overrides = {}) {
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
const sourceUrl = overrides.source_url ??
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/recording.rrd`;
const sha256 = overrides.sha256 ?? "a".repeat(64);
return {
schema_version: "missioncore.observation-session-replay/v2",
launch: {
kind: "rerun-recording",
session_id: sessionId,
source_url: sourceUrl,
viewer_source_url: overrides.viewer_source_url ?? `${sourceUrl}?generation=${sha256}`,
media_type: "application/vnd.rerun.rrd",
timeline: "session_time",
timeline_start_seconds: 0,
timeline_end_seconds: 1_450.744,
seekable: true,
byte_length: 123_456,
sha256,
playback: { speed: 1, loop: false },
media_sources: [],
...overrides,
},
};
}
function preparation(overrides = {}) {
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
return {
schema_version: "missioncore.observation-session-preparation/v1",
preparation: {
preparation_id: "prepare-20260717T131400Z",
session_id: sessionId,
state: "queued",
progress: null,
updated_at_utc: "2026-07-17T10:14:00Z",
status_url: `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/recording-preparation`,
cancellable: true,
...overrides,
},
};
}
const preparationEtag = '"prepare-20260717T131400Z"';
test("session catalog decodes canonical snake_case into a path-free camelCase model", () => {
const catalog = decodeObservationSessionCatalog({ items: [session()] });
assert.deepEqual(catalog.items, [{
id: "20260716T205632Z_viewer_live",
label: "K1 · наблюдение 16 июля",
startedAtUtc: "2026-07-16T20:56:32.635Z",
completedAtUtc: "2026-07-16T21:20:43.379Z",
status: "ready",
modalities: ["point-cloud", "pose"],
durationSeconds: 1_450.744,
replayable: true,
preparation: null,
lab: null,
}]);
assert.equal("raw_capture" in catalog.items[0], false);
assert.equal("path" in catalog.items[0], false);
});
test("opened archive is named in the scene header and trash hover has no pill", async () => {
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
const styles = await readFile(
new URL("../src/styles/observation-sessions.css", import.meta.url),
"utf8",
);
const spatialControls = await readFile(
new URL(
"../../../plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx",
import.meta.url,
),
"utf8",
);
const acquisitionPipeline = await readFile(
new URL(
"../../../plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx",
import.meta.url,
),
"utf8",
);
assert.match(appSource, /setRecordedReplayLabel\(session\.label\)/);
assert.match(appSource, /`\$\{activeDefinition\.title\}: \$\{recordedReplayLabel\}`/);
const deleteStyle = styles.slice(
styles.indexOf(".observation-session-option__delete"),
styles.indexOf(".observation-session-option__delete:disabled"),
);
assert.match(deleteStyle, /background:\s*transparent/);
assert.doesNotMatch(deleteStyle, /danger-rgb\) \/ 0\.12/);
assert.doesNotMatch(spatialControls, /Индикатор постоянно зелёный/);
assert.doesNotMatch(acquisitionPipeline, /Индикатор постоянно зелёный/);
});
test("session catalog exposes authoritative background preparation state", () => {
const catalog = decodeObservationSessionCatalog({
items: [session({
preparation: {
preparation_id: "prepare-catalog-1",
state: "exporting",
progress: 0.42,
updated_at_utc: "2026-07-17T10:14:00Z",
cancellable: true,
retryable: false,
},
})],
});
assert.deepEqual(catalog.items[0].preparation, {
preparationId: "prepare-catalog-1",
state: "exporting",
progress: 0.42,
updatedAtUtc: "2026-07-17T10:14:00Z",
cancellable: true,
retryable: false,
error: null,
});
});
test("saved-session rows distinguish durable, active, cold and failed states", () => {
const makeDecoded = (state, overrides = {}) => decodeObservationSessionCatalog({
items: [session({
status: "interrupted",
preparation: state === null ? null : {
preparation_id: `prepare-${state}`,
state,
progress: state === "ready" ? 1 : 0.5,
updated_at_utc: "2026-07-17T10:14:00Z",
cancellable: ["queued", "validating", "exporting", "finalizing"].includes(state),
retryable: state === "failed",
...(state === "failed" ? { error: "export failed" } : {}),
},
...overrides,
})],
}).items[0];
assert.equal(observationSessionVisualState(makeDecoded("ready")), "ready");
for (const state of ["queued", "validating", "exporting", "finalizing"]) {
assert.equal(observationSessionVisualState(makeDecoded(state)), "processing");
}
assert.equal(observationSessionVisualState(makeDecoded("failed")), "error");
assert.equal(observationSessionVisualState(makeDecoded("cancelled")), "error");
assert.equal(
observationSessionVisualState(makeDecoded("failed", { status: "recording" })),
"error",
);
assert.equal(observationSessionVisualState(makeDecoded(null)), "cold");
assert.equal(
observationSessionVisualState(makeDecoded(null, { status: "recording" })),
"processing",
);
assert.equal(
observationSessionVisualState(makeDecoded("ready", { replayable: false })),
"error",
);
assert.equal(
observationSessionVisualState(makeDecoded("ready"), { failed: true }),
"error",
);
assert.equal(
observationSessionVisualState(makeDecoded("failed"), { pending: true, failed: true }),
"error",
);
assert.equal(observationSessionVisualLabel("ready"), "Готово");
assert.equal(observationSessionVisualLabel("processing"), "Обработка");
assert.equal(observationSessionVisualLabel("cold"), "Подготовить");
assert.equal(observationSessionVisualLabel("error"), "Ошибка");
});
test("saved-session lamps use green or dim gray only, never warning or danger colors", async () => {
const css = await readFile(
new URL("../src/styles/observation-sessions.css", import.meta.url),
"utf8",
);
const start = css.indexOf(".observation-session-option i {");
const end = css.indexOf(".observation-session-option__state", start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);
const lampRules = css.slice(start, end);
assert.match(lampRules, /data-session-visual-state="ready"[\s\S]*--nodedc-success-rgb/);
assert.match(lampRules, /data-session-visual-state="processing"[\s\S]*animation:/);
assert.match(lampRules, /data-session-visual-state="error"[\s\S]*--nodedc-text-muted/);
assert.match(lampRules, /data-session-visual-state="error"[\s\S]*opacity:\s*0\.48/);
assert.doesNotMatch(lampRules, /\b(?:warning|danger|yellow|red)\b/i);
});
test("session catalog sorts newest first and uses id as a deterministic tie-breaker", () => {
const catalog = decodeObservationSessionCatalog({
items: [
session({ id: "same-b", started_at_utc: "2026-07-16T19:00:00Z", completed_at_utc: null }),
session({ id: "newest", started_at_utc: "2026-07-17T00:00:00Z", completed_at_utc: null }),
session({ id: "same-a", started_at_utc: "2026-07-16T19:00:00Z", completed_at_utc: null }),
],
});
assert.deepEqual(catalog.items.map(({ id }) => id), ["newest", "same-a", "same-b"]);
});
test("session catalog rejects raw storage fields, unsafe ids and duplicate ids", () => {
assert.throws(
() => decodeObservationSessionCatalog({
items: [session({ raw_capture: "sessions/private/mqtt.raw.k1mqtt" })],
}),
ObservationSessionContractError,
);
assert.throws(
() => decodeObservationSessionCatalog({ items: [session({ id: "../private" })] }),
ObservationSessionContractError,
);
assert.throws(
() => decodeObservationSessionCatalog({ items: [session(), session()] }),
/повторяющийся id/,
);
});
test("session catalog rejects invalid temporal, status and duration values", () => {
assert.throws(
() => decodeObservationSessionCatalog({
items: [session({ started_at_utc: "16 July 2026" })],
}),
/ISO-датой/,
);
assert.throws(
() => decodeObservationSessionCatalog({ items: [session({ status: "complete" })] }),
/неизвестное состояние/,
);
assert.throws(
() => decodeObservationSessionCatalog({ items: [session({ duration_seconds: -1 })] }),
/неотрицательным числом/,
);
assert.throws(
() => decodeObservationSessionCatalog({
items: [session({ completed_at_utc: "2026-07-16T19:00:00Z" })],
}),
/раньше времени начала/,
);
});
test("catalog API preserves HTTP detail without accepting a malformed success body", async () => {
await assert.rejects(
fetchObservationSessionCatalog({
fetcher: async () => new Response(JSON.stringify({ detail: "storage unavailable" }), {
status: 503,
headers: { "Content-Type": "application/json" },
}),
}),
(error) => error instanceof ObservationSessionApiError &&
error.status === 503 && error.message === "storage unavailable",
);
await assert.rejects(
fetchObservationSessionCatalog({
fetcher: async () => new Response(JSON.stringify({ items: "not-an-array" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
}),
ObservationSessionContractError,
);
});
test("delete API uses one opaque same-origin target and requires an empty 204", async () => {
const calls = [];
await deleteObservationSession("session-20260716T205632Z", {
fetcher: async (input, init) => {
calls.push({ input: String(input), init });
return new Response(null, { status: 204 });
},
});
assert.equal(calls.length, 1);
assert.equal(
calls[0].input,
"/api/v1/observation-sessions/session-20260716T205632Z",
);
assert.equal(calls[0].init.method, "DELETE");
await assert.rejects(
deleteObservationSession("../private", { fetcher: async () => new Response(null) }),
ObservationSessionContractError,
);
await assert.rejects(
deleteObservationSession("session-20260716T205632Z", {
fetcher: async () => new Response(JSON.stringify({ detail: "recording is open" }), {
status: 409,
headers: { "Content-Type": "application/json" },
}),
}),
(error) => error instanceof ObservationSessionApiError &&
error.status === 409 && error.message === "recording is open",
);
});
test("replay API accepts only a same-origin seekable recording descriptor", async () => {
const calls = [];
const launch = await replayObservationSession("session-20260716T205632Z", {
fetcher: async (input, init) => {
calls.push({ input: String(input), init });
return new Response(JSON.stringify(replay()), {
status: 200,
headers: { "Content-Type": "application/json" },
});
},
});
assert.equal(calls.length, 1);
assert.equal(calls[0].input, "/api/v1/observation-sessions/session-20260716T205632Z/replay");
assert.equal(calls[0].init.method, "POST");
assert.equal(launch.kind, "ready");
assert.equal(launch.launch.timeline, "session_time");
assert.equal(
launch.launch.sourceUrl,
"/api/v1/observation-sessions/session-20260716T205632Z/recording.rrd",
);
await assert.rejects(
replayObservationSession("../private", { fetcher: async () => new Response(null) }),
ObservationSessionContractError,
);
});
test("preparation contract is strict, session-scoped and same-origin", async () => {
const sessionId = "session-20260716T205632Z";
const decoded = decodeObservationSessionPreparation(preparation(), sessionId);
assert.equal(decoded.sessionId, sessionId);
assert.equal(decoded.state, "queued");
assert.equal(decoded.progress, null);
assert.throws(
() => decodeObservationSessionPreparation(
preparation({ status_url: "https://invalid.test/status" }),
sessionId,
),
/канонический same-origin/,
);
assert.throws(
() => decodeObservationSessionPreparation(preparation({ debug_path: "/tmp/raw" }), sessionId),
/неизвестные поля/,
);
assert.throws(
() => decodeObservationSessionPreparation(preparation({ progress: 1.1 }), sessionId),
/недопустимое число/,
);
await assert.rejects(
replayObservationSession(sessionId, {
fetcher: async () => new Response(JSON.stringify(preparation()), {
status: 202,
headers: { "Content-Type": "application/json" },
}),
}),
/preparation ETag/,
);
});
test("202 preparation polls through explicit phases and reveals launch only when ready", async () => {
const sessionId = "session-20260716T205632Z";
const calls = [];
const phases = [];
let poll = 0;
let clock = 0;
let previousViewerMounted = true;
const launch = await resolveObservationSessionReplay(sessionId, {
signal: new AbortController().signal,
requestTimeoutMs: 1_000,
heartbeatStallMs: 5_000,
maximumWaitMs: 10_000,
initialPollIntervalMs: 50,
maximumPollIntervalMs: 50,
now: () => clock,
sleep: async (milliseconds) => {
assert.equal(previousViewerMounted, true);
clock += milliseconds;
},
onUpdate: (value) => {
assert.equal(previousViewerMounted, true);
phases.push([value.state, value.progress]);
},
fetcher: async (input, init) => {
calls.push([String(input), init.method, new Headers(init.headers).get("If-Match")]);
if (init.method === "POST") {
return new Response(JSON.stringify(preparation()), {
status: 202,
headers: { "Content-Type": "application/json", ETag: preparationEtag },
});
}
poll += 1;
if (poll === 1) {
return new Response(JSON.stringify(preparation({
state: "exporting",
progress: 0.5,
updated_at_utc: "2026-07-17T10:14:01Z",
})), {
status: 202,
headers: { "Content-Type": "application/json", ETag: preparationEtag },
});
}
return new Response(JSON.stringify(replay({ session_id: sessionId })), {
status: 200,
headers: { "Content-Type": "application/json", ETag: preparationEtag },
});
},
});
// This is the point where App is allowed to unmount the old viewer.
previousViewerMounted = false;
assert.equal(launch.sessionId, sessionId);
assert.deepEqual(phases, [["queued", null], ["exporting", 0.5]]);
assert.deepEqual(calls.map((entry) => entry[1]), ["POST", "GET", "GET"]);
assert.deepEqual(calls.map((entry) => entry[2]), [null, preparationEtag, preparationEtag]);
});
test("initial replay restore is not mistaken for a stalled preparation poll", async () => {
const sessionId = "session-20260716T205632Z";
let requestSignal;
const launch = await resolveObservationSessionReplay(sessionId, {
signal: new AbortController().signal,
requestTimeoutMs: 100,
fetcher: async (_input, init) => {
requestSignal = init.signal;
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, 150);
init.signal.addEventListener("abort", () => {
clearTimeout(timer);
reject(new DOMException("cancelled", "AbortError"));
}, { once: true });
});
return new Response(JSON.stringify(replay({ session_id: sessionId })), {
status: 200,
headers: { "Content-Type": "application/json" },
});
},
});
assert.equal(requestSignal.aborted, false);
assert.equal(launch.sessionId, sessionId);
});
test("status-ready response must match the preparation ETag before launch is accepted", async () => {
const decoded = decodeObservationSessionPreparation(
preparation({ state: "finalizing", progress: 0.95 }),
"session-20260716T205632Z",
);
let receivedIfMatch;
await assert.rejects(
fetchObservationSessionPreparation(decoded, {
fetcher: async (_input, init) => {
receivedIfMatch = new Headers(init.headers).get("If-Match");
return new Response(JSON.stringify(replay()), {
status: 200,
headers: { "Content-Type": "application/json", ETag: '"replacement-job"' },
});
},
}),
/актуальный preparation ETag/,
);
assert.equal(receivedIfMatch, preparationEtag);
});
test("preparation cancellation is conditional on the same preparation ETag", async () => {
const decoded = decodeObservationSessionPreparation(
preparation({ state: "exporting", progress: 0.5 }),
"session-20260716T205632Z",
);
let request;
const result = await cancelObservationSessionPreparation(decoded, {
fetcher: async (input, init) => {
request = {
input: String(input),
method: init.method,
ifMatch: new Headers(init.headers).get("If-Match"),
};
return new Response(null, { status: 204 });
},
});
assert.equal(result, null);
assert.deepEqual(request, {
input: decoded.statusUrl,
method: "DELETE",
ifMatch: preparationEtag,
});
});
test("preparation polling fails explicitly when the server heartbeat stalls", async () => {
const decoded = decodeObservationSessionPreparation(
preparation({ state: "exporting", progress: 0.1 }),
"session-20260716T205632Z",
);
let clock = 0;
await assert.rejects(
waitForObservationReplayPreparation(decoded, {
signal: new AbortController().signal,
requestTimeoutMs: 1_000,
heartbeatStallMs: 250,
maximumWaitMs: 5_000,
initialPollIntervalMs: 150,
maximumPollIntervalMs: 150,
now: () => clock,
sleep: async (milliseconds) => { clock += milliseconds; },
fetcher: async () => new Response(JSON.stringify(preparation({
state: "exporting",
progress: 0.1,
})), {
status: 202,
headers: { "Content-Type": "application/json", ETag: preparationEtag },
}),
}),
/перестала обновляться/,
);
});
test("queued preparation can wait behind the bounded backend worker without false heartbeat failure", async () => {
const decoded = decodeObservationSessionPreparation(
preparation({ state: "queued", progress: 0 }),
"session-20260716T205632Z",
);
let clock = 0;
let polls = 0;
const launch = await waitForObservationReplayPreparation(decoded, {
signal: new AbortController().signal,
requestTimeoutMs: 1_000,
heartbeatStallMs: 250,
maximumWaitMs: 5_000,
initialPollIntervalMs: 150,
maximumPollIntervalMs: 150,
now: () => clock,
sleep: async (milliseconds) => { clock += milliseconds; },
fetcher: async () => {
polls += 1;
if (polls <= 2) {
return new Response(JSON.stringify(preparation({ state: "queued", progress: 0 })), {
status: 202,
headers: { "Content-Type": "application/json", ETag: preparationEtag },
});
}
return new Response(JSON.stringify(replay()), {
status: 200,
headers: { "Content-Type": "application/json", ETag: preparationEtag },
});
},
});
assert.equal(clock, 450);
assert.equal(launch.sessionId, "session-20260716T205632Z");
});
test("terminal preparation failure is explicit and retryable", async () => {
const sessionId = "session-20260716T205632Z";
const response = await replayObservationSession(sessionId, {
fetcher: async () => new Response(JSON.stringify(preparation({
state: "failed",
progress: 0.4,
cancellable: false,
retryable: true,
error: "Повреждён индекс исходной записи.",
})), {
status: 409,
headers: { "Content-Type": "application/json", ETag: preparationEtag },
}),
});
assert.equal(response.kind, "preparing");
assert.equal(response.preparation.retryable, true);
assert.equal(response.preparation.error, "Повреждён индекс исходной записи.");
});
test("pending preparation survives reload only through its strict canonical contract", () => {
const values = new Map();
const storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, String(value)),
};
const decoded = decodeObservationSessionPreparation(
preparation({ state: "exporting", progress: 0.7 }),
"session-20260716T205632Z",
);
storeObservationReplayPreparation(decoded, storage);
assert.deepEqual(loadObservationReplayPreparation(storage), decoded);
const key = storage.key(0);
storage.setItem(key, JSON.stringify({ ...preparation(), raw_path: "/private/raw" }));
assert.equal(loadObservationReplayPreparation(storage), null);
assert.equal(storage.length, 0);
});
test("replay API forwards cancellation and preserves AbortError semantics", async () => {
const controller = new AbortController();
let receivedSignal;
const pending = replayObservationSession("session-20260716T205632Z", {
signal: controller.signal,
fetcher: async (_input, init) => {
receivedSignal = init.signal;
return await new Promise((_resolve, reject) => {
init.signal.addEventListener("abort", () => {
reject(new DOMException("cancelled", "AbortError"));
}, { once: true });
});
},
});
controller.abort();
assert.equal(receivedSignal, controller.signal);
await assert.rejects(pending, (error) => error?.name === "AbortError");
});
test("replay coordinator makes rapid saved-session selection latest-request-wins", () => {
const coordinator = createObservationReplayCoordinator();
const first = coordinator.begin();
assert.equal(first.isCurrent(), true);
const second = coordinator.begin();
assert.equal(first.signal.aborted, true);
assert.equal(first.isCurrent(), false);
assert.equal(first.finish(), false);
assert.equal(second.isCurrent(), true);
assert.equal(second.finish(), true);
assert.equal(second.isCurrent(), false);
const third = coordinator.begin();
coordinator.cancel();
assert.equal(third.signal.aborted, true);
assert.equal(third.isCurrent(), false);
assert.equal(third.finish(), true);
assert.equal(third.finish(), false);
});
test("an acquisition guard cancellation keeps settlement ownership", () => {
const coordinator = createObservationReplayCoordinator();
const archiveSwitch = coordinator.begin();
let replacementBlanked = false;
let replacementSettled = false;
// Models the point after onReplayBegin has released the previous viewer.
replacementBlanked = true;
coordinator.cancel();
assert.equal(archiveSwitch.signal.aborted, true);
assert.equal(archiveSwitch.isCurrent(), false);
if (archiveSwitch.finish()) replacementSettled = true;
assert.equal(replacementBlanked, true);
assert.equal(replacementSettled, true);
});
test("switching saved sessions aborts only local polling and never cancels shared backend work", async () => {
const hookSource = await readFile(
new URL("../src/core/observation/useObservationSessions.ts", import.meta.url),
"utf8",
);
const selectorSource = await readFile(
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
"utf8",
);
assert.doesNotMatch(hookSource, /cancelObservationSessionPreparation|method:\s*["']DELETE/);
assert.doesNotMatch(selectorSource, /cancelReplay|>\s*Отменить\s*</);
});
test("recording preparation never presents phase heartbeats as fake percentages", async () => {
const selectorSource = await readFile(
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
"utf8",
);
assert.match(selectorSource, /exporting:\s*["']Готовим операторскую сцену["']/);
assert.doesNotMatch(selectorSource, /Math\.round\(progress\s*\*\s*100\)/);
});
test("unified recorded AI view keeps the raw camera mounted but does not cover overlays", async () => {
const workspaceSource = await readFile(
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
"utf8",
);
assert.match(workspaceSource, /hidden=\{pointCloudFocused \|\| unifiedPerception\}/);
assert.match(
workspaceSource,
/presentedMediaSourceCount = unifiedPerception \? 0 : visibleMediaSources\.length/,
);
});
test("replay descriptor rejects path leaks, mismatched sessions and non-seekable data", () => {
assert.throws(
() => decodeObservationSessionReplay(replay({ source_url: "file:///private/session.rrd" })),
/same-origin/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({ source_url: "/api/v1/observation-sessions/other/recording.rrd" })),
/same-origin/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({ seekable: false })),
/seekable/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({
viewer_source_url: "/api/v1/observation-sessions/session-20260716T205632Z/recording.rrd",
})),
/канонически привязан/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({
viewer_source_url: `https://foreign.test/recording.rrd?generation=${"a".repeat(64)}`,
})),
/канонически привязан/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({ raw_path: "/private/raw.k1mqtt" })),
/неизвестные поля/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({ byte_length: Number.MAX_SAFE_INTEGER + 1 })),
/недопустимое число/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({ playback: { speed: 101, loop: false } })),
/недопустимое число/,
);
});
test("replay decodes opaque recorded cameras and their same-origin fMP4 manifest", () => {
const sessionId = "session-20260716T205632Z";
const source = {
id: "recorded.camera.5a5a5a5a5a5a5a5a",
label: "Записанная камера 1",
modality: "video",
manifest_url: `/api/v1/observation-sessions/${sessionId}/media/recorded-video-5a5a5a5a5a5a5a5a/manifest`,
manifest_generation_sha256: "c".repeat(64),
byte_length: 3_266,
media_type: "video/mp4",
timeline_start_seconds: 0.25,
timeline_end_seconds: 20,
seekable: true,
synchronization: "host-arrival-best-effort",
};
const decoded = decodeObservationSessionReplay(replay({
session_id: sessionId,
timeline_end_seconds: 20,
media_sources: [source],
}));
assert.equal(
decoded.viewerSourceUrl,
`/api/v1/observation-sessions/${sessionId}/recording.rrd?generation=${"a".repeat(64)}`,
);
assert.equal(decoded.mediaSources.length, 1);
assert.equal(decoded.mediaSources[0].id, source.id);
assert.equal(decoded.mediaSources[0].manifestUrl, source.manifest_url);
assert.equal(
decoded.mediaSources[0].manifestGenerationSha256,
source.manifest_generation_sha256,
);
const manifest = decodeObservationRecordedMediaManifest({
schema_version: "missioncore.observation-recorded-media/v3",
source_id: source.id,
generation_sha256: "c".repeat(64),
byte_length: 3_266,
timeline_start_seconds: 0.25,
timeline_end_seconds: 20,
synchronization: "host-arrival-best-effort",
epochs: [{
ordinal: 1,
timeline_start_seconds: 0.25,
timeline_end_seconds: 20,
media_type: 'video/mp4; codecs="avc1.640028"',
byte_length: 3_266,
stream_url: source.manifest_url.replace(
"/manifest",
`/epochs/1/recording.mp4?generation=${"c".repeat(64)}`,
),
}],
}, decoded.mediaSources[0]);
assert.equal(manifest.epochs[0].byteLength, 3_266);
assert.match(manifest.epochs[0].streamUrl, /recording\.mp4\?generation=/);
assert.equal(manifest.epochs[0].timelineStartSeconds, 0.25);
assert.throws(
() => decodeObservationRecordedMediaManifest({
...{
schema_version: "missioncore.observation-recorded-media/v1",
source_id: source.id,
generation_sha256: "c".repeat(64),
byte_length: 3_266,
timeline_start_seconds: 0.25,
timeline_end_seconds: 20,
synchronization: "host-arrival-best-effort",
epochs: [],
},
}, decoded.mediaSources[0]),
/несовместим/,
);
assert.throws(
() => decodeObservationRecordedMediaManifest({
schema_version: "missioncore.observation-recorded-media/v3",
source_id: source.id,
generation_sha256: "c".repeat(64),
byte_length: 3_267,
timeline_start_seconds: 0.25,
timeline_end_seconds: 20,
synchronization: "host-arrival-best-effort",
epochs: [],
}, decoded.mediaSources[0]),
/несовместим|launch descriptor/,
);
});
test("replay decodes a synchronized full-epoch perception video", () => {
const sessionId = "20260720T065719Z_viewer_live";
const resultId = `result-${"d".repeat(64)}`;
const generation = "e".repeat(64);
const source = {
id: "recorded.perception.right",
label: "Сегментация · камера right",
modality: "video",
manifest_url: `/api/v1/observation-sessions/${sessionId}/perception-media/${resultId}/manifest`,
manifest_generation_sha256: generation,
byte_length: 12_345_678,
media_type: "video/mp4",
timeline_start_seconds: 35.421857292,
timeline_end_seconds: 484.144857292,
seekable: true,
synchronization: "host-arrival-best-effort",
};
const decoded = decodeObservationSessionReplay(replay({
session_id: sessionId,
timeline_end_seconds: 500,
media_sources: [source],
}));
assert.equal(decoded.mediaSources[0].id, "recorded.perception.right");
assert.equal(decoded.mediaSources[0].manifestUrl, source.manifest_url);
const manifest = decodeObservationRecordedMediaManifest({
schema_version: "missioncore.observation-recorded-media/v3",
source_id: source.id,
generation_sha256: generation,
byte_length: source.byte_length,
timeline_start_seconds: source.timeline_start_seconds,
timeline_end_seconds: source.timeline_end_seconds,
synchronization: "host-arrival-best-effort",
epochs: [{
ordinal: 1,
timeline_start_seconds: source.timeline_start_seconds,
timeline_end_seconds: source.timeline_end_seconds,
media_type: 'video/mp4; codecs="avc1.640028"',
byte_length: source.byte_length,
stream_url: `/api/v1/observation-sessions/${sessionId}/perception-media/${resultId}/recording.mp4?generation=${generation}`,
}],
}, decoded.mediaSources[0]);
assert.equal(manifest.epochs.length, 1);
assert.equal(manifest.epochs[0].timelineStartSeconds, source.timeline_start_seconds);
assert.equal(manifest.epochs[0].timelineEndSeconds, source.timeline_end_seconds);
});
test("recorded camera contracts reject foreign origins, path escapes and unknown fields", () => {
const sessionId = "session-20260716T205632Z";
const base = {
id: "recorded.camera.5a5a5a5a5a5a5a5a",
label: "Записанная камера 1",
modality: "video",
manifest_url: `/api/v1/observation-sessions/${sessionId}/media/recorded-video-5a5a5a5a5a5a5a5a/manifest`,
manifest_generation_sha256: "c".repeat(64),
byte_length: 384,
media_type: "video/mp4",
timeline_start_seconds: 0,
timeline_end_seconds: 20,
seekable: true,
synchronization: "host-arrival-best-effort",
};
assert.throws(
() => decodeObservationSessionReplay(replay({
session_id: sessionId,
timeline_end_seconds: 20,
media_sources: [{ ...base, manifest_url: "https://invalid.test/private" }],
})),
/same-origin/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({
session_id: sessionId,
timeline_end_seconds: 20,
media_sources: [{ ...base, path: "/private/archive" }],
})),
/неизвестные поля/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({
session_id: sessionId,
timeline_end_seconds: 20,
media_sources: [{ ...base, manifest_generation_sha256: undefined }],
})),
/immutable generation/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({
session_id: sessionId,
timeline_end_seconds: 20,
media_sources: [{ ...base, manifest_generation_sha256: "not-a-digest" }],
})),
/immutable generation/,
);
const decoded = decodeObservationSessionReplay(replay({
session_id: sessionId,
timeline_end_seconds: 20,
media_sources: [base],
}));
assert.throws(
() => decodeObservationRecordedMediaManifest({
schema_version: "missioncore.observation-recorded-media/v3",
source_id: base.id,
generation_sha256: "c".repeat(64),
byte_length: 384,
timeline_start_seconds: 0,
timeline_end_seconds: 20,
synchronization: "host-arrival-best-effort",
epochs: [{
ordinal: 1,
timeline_start_seconds: 0,
timeline_end_seconds: 20,
media_type: 'video/mp4; codecs="avc1.640028"',
byte_length: 384,
stream_url: "file:///private/recording.mp4",
}],
}, decoded.mediaSources[0]),
/небезопасный API URL/,
);
});
@@ -0,0 +1,884 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { createServer } from "vite";
let server;
let xgridsK1Manifest;
let xgridsK1ObservationSources;
let openObservationSource;
let shouldRestartObservationSource;
let consumeCameraLeaseRetry;
let resetCameraLeaseRetryBudget;
let initialObservationWindowRect;
let ObservationTimeline;
let shouldCaptureWorkspacePointer;
let normalizeTimelineRange;
let timelineOffsetSeconds;
let formatTimelineDuration;
let normalizeAccumulationSeconds;
let formatAccumulationDuration;
let resolveRerunSourceUrl;
let resolveRecordedBlueprintUrl;
let fetchRecordedBlueprintRrd;
let resolveRecordedPerceptionUrl;
let fetchRecordedPerceptionRrd;
let resolveRecordedPointColorsUrl;
let fetchRecordedPointColorsRrd;
let recordedPointColorKey;
let isRecordedPlaybackFullyBuffered;
let recordedObservationSources;
let selectRecordedMediaEpoch;
let recordedMediaLocalTime;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ xgridsK1Manifest } = await server.ssrLoadModule(
"@xgrids-k1/frontend/manifest.ts",
));
({ xgridsK1ObservationSources } = await server.ssrLoadModule(
"@xgrids-k1/frontend/observationSources.ts",
));
({ openObservationSource, shouldRestartObservationSource } = await server.ssrLoadModule(
"/src/core/observation/layoutPolicy.ts",
));
({ consumeCameraLeaseRetry, resetCameraLeaseRetryBudget } = await server.ssrLoadModule(
"/src/components/MseFmp4WebSocketPlayer.tsx",
));
({ initialObservationWindowRect, shouldCaptureWorkspacePointer } = await server.ssrLoadModule(
"/src/components/FloatingObservationWindow.tsx",
));
({
ObservationTimeline,
normalizeTimelineRange,
timelineOffsetSeconds,
formatTimelineDuration,
normalizeAccumulationSeconds,
formatAccumulationDuration,
} =
await server.ssrLoadModule("/src/components/ObservationTimeline.tsx"));
({
resolveRerunSourceUrl,
resolveRecordedBlueprintUrl,
fetchRecordedBlueprintRrd,
resolveRecordedPerceptionUrl,
fetchRecordedPerceptionRrd,
resolveRecordedPointColorsUrl,
fetchRecordedPointColorsRrd,
recordedPointColorKey,
isRecordedPlaybackFullyBuffered,
} = await server.ssrLoadModule(
"/src/components/RerunViewport.tsx",
));
({ recordedObservationSources } = await server.ssrLoadModule(
"/src/core/observation/recordedObservationSources.ts",
));
({ selectRecordedMediaEpoch, recordedMediaLocalTime } = await server.ssrLoadModule(
"/src/components/RecordedFmp4Player.tsx",
));
});
test("observation camera windows tile from the bottom-right above the live timeline", () => {
const bounds = { width: 1280, height: 720 };
const left = initialObservationWindowRect(0, 2, bounds);
const right = initialObservationWindowRect(1, 2, bounds);
assert.equal(left.y, right.y);
assert.ok(left.x + left.width < right.x);
assert.equal(right.x + right.width, bounds.width - 18);
assert.ok(right.y + right.height <= bounds.height - 64);
});
test("observation camera windows stack without overlap when the viewport is narrow", () => {
const bounds = { width: 420, height: 700 };
const lower = initialObservationWindowRect(0, 2, bounds);
const upper = initialObservationWindowRect(1, 2, bounds);
assert.equal(lower.x, upper.x);
assert.ok(upper.y + upper.height < lower.y);
for (const rect of [lower, upper]) {
assert.ok(rect.x >= 0 && rect.y >= 0);
assert.ok(rect.x + rect.width <= bounds.width);
assert.ok(rect.y + rect.height <= bounds.height - 64);
}
});
test("observation camera tiling remains in bounds on a constrained viewport", () => {
const bounds = { width: 260, height: 280 };
const rects = Array.from({ length: 2 }, (_, index) => (
initialObservationWindowRect(index, 2, bounds)
));
for (const rect of rects) {
assert.ok(rect.width > 0 && rect.height > 0);
assert.ok(rect.x >= 0 && rect.y >= 0);
assert.ok(rect.x + rect.width <= bounds.width);
assert.ok(rect.y + rect.height <= bounds.height - 64);
}
assert.ok(rects[1].y + rects[1].height <= rects[0].y);
});
test("floating observation interaction captures resize and movable header pointers", () => {
const target = (matches) => ({ closest: (selector) => matches.includes(selector) });
assert.equal(
shouldCaptureWorkspacePointer(0, target([".nodedc-workspace-window__resize"])),
true,
);
assert.equal(
shouldCaptureWorkspacePointer(0, target([".nodedc-workspace-window__head"])),
true,
);
assert.equal(
shouldCaptureWorkspacePointer(
0,
target([".nodedc-workspace-window__head", "button, input, select, textarea, a"]),
),
false,
);
assert.equal(
shouldCaptureWorkspacePointer(2, target([".nodedc-workspace-window__resize"])),
false,
);
});
test("recorded observation timeline clamps relative seek time without epoch precision in the UI", () => {
const range = normalizeTimelineRange({
min: 0,
max: 12_500_000_000,
});
assert.ok(range);
assert.equal(timelineOffsetSeconds(range, range.min + 2_250_000_000), 2.25);
assert.equal(timelineOffsetSeconds(range, range.min - 1), 0);
assert.equal(timelineOffsetSeconds(range, range.max + 1), 12.5);
assert.equal(formatTimelineDuration(62.125), "01:02.125");
});
test("recorded observation timeline rejects empty and non-finite ranges", () => {
assert.equal(normalizeTimelineRange(null), null);
assert.equal(normalizeTimelineRange({ min: 10, max: 10 }), null);
assert.equal(normalizeTimelineRange({ min: Number.NaN, max: 10 }), null);
});
test("accumulation control normalizes UI values and distinguishes a single frame", () => {
assert.equal(normalizeAccumulationSeconds(-3), 0);
assert.equal(normalizeAccumulationSeconds(12.6), 13);
assert.equal(normalizeAccumulationSeconds(999), 120);
assert.equal(normalizeAccumulationSeconds(Number.NaN), 0);
assert.equal(formatAccumulationDuration(0), "Кадр");
assert.equal(formatAccumulationDuration(12), "12 с");
});
test("spatial timeline renders synchronized accumulation and playback controls", () => {
const markup = renderToStaticMarkup(createElement(ObservationTimeline, {
active: true,
sourceCount: 2,
mode: "recorded",
seekable: true,
rangeNs: { min: 0, max: 20_000_000_000 },
currentNs: 5_000_000_000,
accumulationSeconds: 12,
onAccumulationChange: () => undefined,
onAccumulationCommit: () => undefined,
onSeek: () => undefined,
}));
assert.match(markup, /data-accumulation="true"/);
assert.match(markup, /Накопление/);
assert.match(markup, /aria-label="Окно накопления облака точек"/);
assert.match(markup, /aria-valuetext="12 с"/);
assert.match(markup, /aria-label="Позиция воспроизведения"/);
assert.equal((markup.match(/type="range"/g) ?? []).length, 2);
});
test("Rerun expands only root-relative session recordings onto the current origin", () => {
assert.equal(
resolveRerunSourceUrl(
" /api/v1/observation-sessions/session-1/recording.rrd ",
"http://127.0.0.1:5174",
),
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/recording.rrd",
);
assert.equal(
resolveRerunSourceUrl("rerun+http://127.0.0.1:9877/proxy", "http://127.0.0.1:5174"),
"rerun+http://127.0.0.1:9877/proxy",
);
assert.equal(
resolveRerunSourceUrl("//different-authority.invalid/session.rrd", "http://127.0.0.1:5174"),
"//different-authority.invalid/session.rrd",
);
});
test("recorded blueprint endpoint is derived only from canonical same-origin RRD sources", () => {
assert.equal(
resolveRecordedBlueprintUrl(
"/api/v1/observation-sessions/session-1/recording.rrd",
"http://127.0.0.1:5174",
),
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
);
assert.equal(
resolveRecordedBlueprintUrl(
"https://outside.invalid/api/v1/observation-sessions/session-1/recording.rrd",
"http://127.0.0.1:5174",
),
null,
);
assert.equal(
resolveRecordedBlueprintUrl(
"/api/v1/observation-sessions/../recording.rrd",
"http://127.0.0.1:5174",
),
null,
);
});
test("recorded replay becomes ready only after the complete declared timeline is buffered", () => {
assert.equal(isRecordedPlaybackFullyBuffered(null, 20), false);
assert.equal(
isRecordedPlaybackFullyBuffered({ min: 0, max: 19_500_000_000 }, 20),
false,
);
assert.equal(
isRecordedPlaybackFullyBuffered({ min: 0, max: 19_999_500_000 }, 20),
true,
);
assert.equal(
isRecordedPlaybackFullyBuffered({ min: 0, max: 1 }, undefined),
true,
);
assert.equal(
isRecordedPlaybackFullyBuffered({ min: 0, max: 20_000_000_000 }, Number.NaN),
false,
);
});
test("recorded blueprint fetch is bounded, strict and sends only display settings", async () => {
const calls = [];
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
const result = await fetchRecordedBlueprintRrd(
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
{
accumulationSeconds: 24,
showGrid: false,
showPoints: true,
showTrajectory: false,
pointSize: 4.5,
colorMode: "height",
palette: "custom",
customColor: "#35d7c1",
},
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
{
origin: "http://127.0.0.1:5174",
blueprintSessionId: "a".repeat(32),
activeView: "perception3d",
viewResetGeneration: 1,
followTrajectory: true,
perceptionLayers: {
enabled: true,
detections2d: true,
segmentation: false,
cuboids3d: true,
},
fetcher: async (input, init) => {
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
return new Response(payload, {
status: 200,
headers: { "Content-Type": "application/vnd.rerun.rrd" },
});
},
},
);
assert.deepEqual([...result], [...payload]);
assert.equal(calls[0].init.method, "POST");
assert.equal(calls[0].init.credentials, "same-origin");
assert.deepEqual(calls[0].body, {
application_id: "nodedc_mission_core_recorded",
recording_id: "recording-001",
blueprint_session_id: "a".repeat(32),
accumulation_seconds: 24,
show_grid: false,
show_points: true,
show_trajectory: false,
point_size: 4.5,
color_mode: "height",
palette: "custom",
custom_color: "#35d7c1",
active_view: "perception3d",
view_reset_generation: 1,
follow_trajectory: true,
unified_perception: true,
show_detections_2d: true,
show_segmentation: false,
show_cuboids_3d: true,
});
await fetchRecordedBlueprintRrd(
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
{
accumulationSeconds: 0,
showGrid: true,
showPoints: true,
showTrajectory: true,
pointSize: 2.5,
colorMode: "intensity",
palette: "turbo",
customColor: "#ffffff",
},
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
{
origin: "http://127.0.0.1:5174",
blueprintSessionId: "b".repeat(32),
perceptionLayers: {
enabled: true,
detections2d: false,
segmentation: false,
cuboids3d: true,
},
fetcher: async (input, init) => {
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
return new Response(payload, {
status: 200,
headers: { "Content-Type": "application/vnd.rerun.rrd" },
});
},
},
);
assert.equal(calls[1].body.unified_perception, false);
assert.equal(calls[1].body.show_cuboids_3d, true);
await assert.rejects(
fetchRecordedBlueprintRrd(
"https://outside.invalid/api/v1/observation-sessions/session-1/blueprint.rrd",
{
accumulationSeconds: 24,
showGrid: false,
showPoints: true,
showTrajectory: false,
pointSize: 4.5,
colorMode: "height",
palette: "custom",
customColor: "#35d7c1",
},
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
{
origin: "http://127.0.0.1:5174",
blueprintSessionId: "a".repeat(32),
fetcher: async () => new Response(payload),
},
),
/Unsafe recorded blueprint request/,
);
});
test("recorded point colors use one strict same-origin component overlay", async () => {
const endpoint = resolveRecordedPointColorsUrl(
"/api/v1/observation-sessions/session-1/recording.rrd",
"http://127.0.0.1:5174",
);
assert.equal(
endpoint,
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/point-colors.rrd",
);
assert.equal(
resolveRecordedPointColorsUrl(
"https://outside.invalid/api/v1/observation-sessions/session-1/recording.rrd",
"http://127.0.0.1:5174",
),
null,
);
assert.equal(
recordedPointColorKey({
colorMode: "intensity",
palette: "turbo",
customColor: "#112233",
}),
"intensity|turbo|-",
);
assert.equal(
recordedPointColorKey({
colorMode: "class",
palette: "turbo",
customColor: "#112233",
}),
"class|turbo|#112233",
);
const calls = [];
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
const result = await fetchRecordedPointColorsRrd(
endpoint,
{
colorMode: "distance",
palette: "viridis",
customColor: "#35d7c1",
},
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
{
origin: "http://127.0.0.1:5174",
fetcher: async (input, init) => {
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
return new Response(payload, {
status: 200,
headers: {
"Content-Type": "application/vnd.rerun.rrd",
"Content-Length": String(payload.byteLength),
},
});
},
},
);
assert.deepEqual([...result], [...payload]);
assert.deepEqual(calls[0].body, {
application_id: "nodedc_mission_core_recorded",
recording_id: "recording-001",
color_mode: "distance",
palette: "viridis",
custom_color: "#35d7c1",
});
});
test("recorded perception fetch admits one complete same-origin RRD or no layer", async () => {
const endpoint = resolveRecordedPerceptionUrl(
"/api/v1/observation-sessions/session-1/recording.rrd",
"http://127.0.0.1:5174",
);
assert.equal(
endpoint,
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/perception.rrd",
);
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
const progress = [];
const result = await fetchRecordedPerceptionRrd(
endpoint,
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
{
origin: "http://127.0.0.1:5174",
onProgress: (receivedBytes, totalBytes) => {
progress.push([receivedBytes, totalBytes]);
},
fetcher: async (_input, init) => {
assert.deepEqual(JSON.parse(String(init.body)), {
application_id: "nodedc_mission_core_recorded",
recording_id: "recording-001",
});
return new Response(payload, {
status: 200,
headers: {
"Content-Type": "application/vnd.rerun.rrd",
"Content-Length": String(payload.byteLength),
},
});
},
},
);
assert.deepEqual([...result], [...payload]);
assert.deepEqual(progress[0], [0, payload.byteLength]);
assert.deepEqual(progress.at(-1), [payload.byteLength, payload.byteLength]);
const absent = await fetchRecordedPerceptionRrd(
endpoint,
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
{
origin: "http://127.0.0.1:5174",
fetcher: async () => new Response(null, { status: 204 }),
},
);
assert.equal(absent, null);
});
test("recorded replay creates an isolated source catalog without live device bindings", () => {
const sources = recordedObservationSources({
kind: "rerun-recording",
sessionId: "session-1",
sourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd",
viewerSourceUrl: `/api/v1/observation-sessions/session-1/recording.rrd?generation=${"a".repeat(64)}`,
mediaType: "application/vnd.rerun.rrd",
timeline: "session_time",
timelineStartSeconds: 0,
timelineEndSeconds: 20,
seekable: true,
byteLength: 123,
sha256: "a".repeat(64),
playback: { speed: 1, loop: false },
mediaSources: [{
id: "recorded.camera.abc123",
label: "Записанная камера 1",
modality: "video",
manifestUrl: "/api/v1/observation-sessions/session-1/media/recorded-video-abc123/manifest",
manifestGenerationSha256: "c".repeat(64),
byteLength: 1_024,
mediaType: "video/mp4",
timelineStartSeconds: 0.25,
timelineEndSeconds: 20,
seekable: true,
synchronization: "host-arrival-best-effort",
}],
});
assert.deepEqual(sources.map(({ modality }) => modality), ["point-cloud", "video"]);
assert.equal(sources[1].delivery.kind, "recorded-fmp4-manifest");
assert.equal(sources[1].delivery.manifestGenerationSha256, "c".repeat(64));
assert.deepEqual(sources[1].binding, {});
assert.equal(sources.some(({ provider }) => provider.pluginId.includes("xgrids")), false);
assert.equal(JSON.stringify(sources).includes("192.168"), false);
});
test("recorded camera epoch selection and shared-clock offset are deterministic", () => {
const epochs = [
{ ordinal: 1, timelineStartSeconds: 0.25, timelineEndSeconds: 9 },
{ ordinal: 2, timelineStartSeconds: 10, timelineEndSeconds: 20 },
];
assert.equal(selectRecordedMediaEpoch(epochs, 0), null);
assert.equal(selectRecordedMediaEpoch(epochs, 0.25).ordinal, 1);
assert.equal(selectRecordedMediaEpoch(epochs, 9), epochs[0]);
assert.equal(selectRecordedMediaEpoch(epochs, 9.9), null);
assert.equal(selectRecordedMediaEpoch(epochs, 10).ordinal, 2);
assert.equal(recordedMediaLocalTime(10, 12.5), 2.5);
assert.equal(recordedMediaLocalTime(10, 8), 0);
assert.equal(recordedMediaLocalTime(10, 30, 4), 4);
});
after(async () => {
await server?.close();
});
function model() {
const activeModel = xgridsK1Manifest.spec.models[0];
assert.ok(activeModel, "XGRIDS plugin must declare at least one model");
return activeModel;
}
function cameraRow(sourceId, label) {
return {
stream_id: `camera.preview.${sourceId.split(".").at(-1)}`,
source_id: sourceId,
semantic_channel_id: "camera.preview.live",
label,
sensor_kind: "camera",
modality: "encoded-video",
availability: "available",
endpoint_label: "MSE · fMP4",
activation: {
group_id: "camera.preview.decoder",
max_active: 1,
selected: false,
controllable: true,
},
delivery: null,
};
}
function declaredState(cameraRows = [
cameraRow("sensor.camera.left", "K1 · камера слева"),
cameraRow("sensor.camera.right", "K1 · камера справа"),
]) {
const activeModel = model();
return {
phase: "connected",
source_mode: "idle",
k1_ip: "192.168.7.10",
foxglove_ws_url: "ws://192.168.7.10:8765",
foxglove_viewer_url: "http://192.168.7.10:8765/vendor-viewer",
compatibility: {
profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
camera_preview: "rtsp://192.168.7.10:8554/vendor-preview",
},
device_ref: {
device_id: "device-k1-001",
model_id: activeModel.id,
identity_stability: "stable",
identity_basis: "hardware-identifier",
},
device_session: {
device_session_id: "device-session-001",
device_id: "device-k1-001",
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
connectivity: "connected",
},
sensor_catalog: {
schema_version: "missioncore.sensor-catalog/v1alpha2",
revision: "test-profile",
streams: [
{ stream_id: "spatial.point-cloud.live", modality: "point-cloud", availability: "observed" },
...cameraRows,
],
},
camera_preview: {
phase: "idle",
revision: 1,
generation: 0,
active_source_id: null,
delivery: null,
},
};
}
function pointCloudStreamingState() {
return {
...declaredState(),
phase: "streaming",
source_mode: "live",
rerun_grpc_url: "rerun+http://127.0.0.1:9877/proxy",
acquisition: {
acquisition_id: "acquisition-001",
device_id: "device-k1-001",
device_session_id: "device-session-001",
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
control_mode: "operator-manual",
requested_streams: ["spatial.point-cloud.live"],
target_host: "127.0.0.1",
duration_seconds: 0,
evidence_policy: "required",
state: "acquiring",
state_revision: 3,
},
};
}
function cameraStreamingState(sourceId) {
const state = pointCloudStreamingState();
const delivery = {
id: `preview-generation-7:${sourceId}`,
kind: "mse-fmp4-websocket",
url: "/api/v1/device-plugins/xgrids-k1/camera-preview/ws?generation=7",
media_type: 'video/mp4; codecs="avc1.640028"',
};
state.sensor_catalog = {
...state.sensor_catalog,
streams: state.sensor_catalog.streams.map((stream) =>
stream.source_id === sourceId
? {
...stream,
availability: "streaming",
activation: { ...stream.activation, selected: true },
delivery,
}
: stream),
};
state.camera_preview = {
phase: "streaming",
revision: 4,
generation: 7,
active_source_id: sourceId,
delivery,
};
return state;
}
function collectUrlLikeStrings(value, found = []) {
if (typeof value === "string") {
if (value.includes("://")) found.push(value);
return found;
}
if (Array.isArray(value)) {
for (const item of value) collectUrlLikeStrings(item, found);
return found;
}
if (value && typeof value === "object") {
for (const item of Object.values(value)) collectUrlLikeStrings(item, found);
}
return found;
}
test("K1 maps zero, one or N catalog cameras without model-specific source ids", () => {
const zero = xgridsK1ObservationSources(declaredState([]), model());
const one = xgridsK1ObservationSources(
declaredState([cameraRow("rig.front", "Передняя камера")]),
model(),
);
const many = xgridsK1ObservationSources(declaredState(), model());
assert.deepEqual(zero.map(({ modality }) => modality), ["point-cloud"]);
assert.deepEqual(one.map(({ sourceId }) => sourceId), ["sensor.lidar.primary", "rig.front"]);
assert.deepEqual(many.map(({ sourceId }) => sourceId), [
"sensor.lidar.primary",
"sensor.camera.left",
"sensor.camera.right",
]);
assert.equal(new Set(many.map(({ id }) => id)).size, many.length);
assert.ok(many.every(({ provider }) => provider.pluginId && provider.modelId));
assert.ok(many.every(({ binding }) => binding.deviceId === "device-k1-001"));
});
test("descriptor ids remain stable while point cloud and selected camera start streaming", () => {
const declared = xgridsK1ObservationSources(declaredState(), model());
const streaming = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
);
assert.deepEqual(streaming.map(({ id }) => id), declared.map(({ id }) => id));
assert.equal(declared[0].availability, "available");
assert.equal(streaming[0].availability, "streaming");
assert.equal(streaming[0].previewUrl, "rerun+http://127.0.0.1:9877/proxy");
});
test("only the authoritative selected camera receives browser delivery", () => {
const sources = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
);
const cameras = sources.filter(({ modality }) => modality === "video");
const [left, right] = cameras;
assert.equal(cameras.length, 2);
assert.equal(left.activation.selected, true);
assert.equal(left.activation.maxActive, 1);
assert.equal(left.availability, "streaming");
assert.equal(left.delivery.kind, "mse-fmp4-websocket");
assert.equal(left.transport, "websocket");
assert.equal(right.activation.selected, false);
assert.equal(right.availability, "available");
assert.equal(right.delivery, null);
assert.equal(left.activation.groupId, right.activation.groupId);
assert.match(left.activation.groupId, /device-session-001/);
});
test("switching left to right keeps ids stable and never exposes both deliveries", () => {
const left = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
).filter(({ modality }) => modality === "video");
const right = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.right"),
model(),
).filter(({ modality }) => modality === "video");
assert.deepEqual(right.map(({ id }) => id), left.map(({ id }) => id));
assert.deepEqual(left.filter(({ delivery }) => delivery).map(({ sourceId }) => sourceId), [
"sensor.camera.left",
]);
assert.deepEqual(right.filter(({ delivery }) => delivery).map(({ sourceId }) => sourceId), [
"sensor.camera.right",
]);
});
test("camera descriptors never leak vendor RTSP endpoints or device IP addresses", () => {
const sources = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
);
const cameras = sources.filter(({ modality }) => modality === "video");
assert.ok(cameras.every(({ previewUrl }) => previewUrl === null));
assert.deepEqual(collectUrlLikeStrings(sources), [
"rerun+http://127.0.0.1:9877/proxy",
]);
const serialized = JSON.stringify(cameras);
assert.doesNotMatch(serialized, /rtsp:\/\//i);
assert.doesNotMatch(serialized, /192\.168\.7\.10/);
assert.doesNotMatch(serialized, /vendor-(?:preview|viewer)/);
});
test("unattested, duplicate and unsafe camera entries fail closed", () => {
const duplicate = cameraRow("sensor.camera.left", "Duplicate");
const state = cameraStreamingState("sensor.camera.left");
state.compatibility.profile_id = null;
state.device_session.compatibility_profile_id = null;
state.sensor_catalog.streams.push(duplicate);
state.camera_preview.delivery = {
...state.camera_preview.delivery,
url: "ws://192.168.7.10:9000/leak",
};
const cameras = xgridsK1ObservationSources(state, model()).filter(
({ modality }) => modality === "video",
);
assert.deepEqual(cameras.map(({ sourceId }) => sourceId), ["sensor.camera.right"]);
assert.equal(cameras[0].availability, "unverified");
assert.equal(cameras[0].activation.controllable, false);
assert.equal(cameras[0].delivery, null);
});
test("camera delivery rejects literal and encoded endpoint or credential leaks", () => {
const unsafeUrls = [
"/api/preview?upstream=rtsp://camera.local/live",
"/api/preview?upstream=rtsp%3A%2F%2Fcamera.local%2Flive",
"/api/preview?upstream=rtsp%253A%252F%252Fcamera.local%252Flive",
"/api/preview?endpoint=192.0.2.52:8554",
"/api/preview?endpoint=192%2E168%2E68%2E52",
"/api/preview?password=not-for-the-browser",
"/api/preview?%70%61%73%73%77%6f%72%64=not-for-the-browser",
"/api/preview/camera:secret@device",
"/%2f%2fevil.example/preview",
];
for (const url of unsafeUrls) {
const state = cameraStreamingState("sensor.camera.left");
state.camera_preview.delivery = { ...state.camera_preview.delivery, url };
state.sensor_catalog.streams = state.sensor_catalog.streams.map((stream) =>
stream.source_id === "sensor.camera.left"
? { ...stream, delivery: { ...stream.delivery, url } }
: stream,
);
const left = xgridsK1ObservationSources(state, model()).find(
({ sourceId }) => sourceId === "sensor.camera.left",
);
assert.ok(left, `left camera descriptor missing for ${url}`);
assert.equal(left.delivery, null, `unsafe delivery escaped for ${url}`);
}
});
test("manual camera reconnect restores an exhausted lease retry budget", () => {
let budget = resetCameraLeaseRetryBudget("delivery-7");
for (const expectedDelay of [400, 1_000, 2_000]) {
const retry = consumeCameraLeaseRetry(budget, "delivery-7");
assert.equal(retry.delay, expectedDelay);
budget = retry.budget;
}
assert.equal(consumeCameraLeaseRetry(budget, "delivery-7").delay, null);
budget = resetCameraLeaseRetryBudget("delivery-7");
const retryAfterManualReset = consumeCameraLeaseRetry(budget, "delivery-7");
assert.equal(retryAfterManualReset.delay, 400);
assert.equal(retryAfterManualReset.budget.count, 1);
});
test("layout policy evicts only exclusive camera peers", () => {
const sources = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
);
const left = sources.find(({ sourceId }) => sourceId === "sensor.camera.left");
const right = sources.find(({ sourceId }) => sourceId === "sensor.camera.right");
const pointCloud = sources.find(({ modality }) => modality === "point-cloud");
assert.ok(left && right && pointCloud);
const change = openObservationSource(
[pointCloud.id, left.id],
right.id,
sources,
);
assert.deepEqual(change.visibleIds, [pointCloud.id, right.id]);
assert.deepEqual(change.removedIds, [left.id]);
});
test("selected camera without delivery is explicitly restartable", () => {
const state = cameraStreamingState("sensor.camera.left");
state.camera_preview = {
...state.camera_preview,
phase: "error",
delivery: null,
};
state.sensor_catalog.streams = state.sensor_catalog.streams.map((stream) =>
stream.source_id === "sensor.camera.left"
? { ...stream, availability: "error", delivery: null }
: stream,
);
const sources = xgridsK1ObservationSources(state, model());
const left = sources.find(({ sourceId }) => sourceId === "sensor.camera.left");
const right = sources.find(({ sourceId }) => sourceId === "sensor.camera.right");
assert.ok(left && right);
assert.equal(left.activation.selected, true);
assert.equal(left.delivery, null);
assert.equal(left.availability, "error");
assert.equal(shouldRestartObservationSource(left), true);
assert.equal(shouldRestartObservationSource(right), false);
});
@@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let fetchRecordedMediaArchive;
let recordedMediaPresentationState;
let recordedMediaSeekableCoverage;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
fetchRecordedMediaArchive,
recordedMediaPresentationState,
recordedMediaSeekableCoverage,
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
});
after(async () => {
await server?.close();
});
function fixture({ byteLength = 36_000_000_000 } = {}) {
const manifestUrl = "/api/v1/observation-sessions/session-1/media/camera-1/manifest";
const generation = "a".repeat(64);
const streamUrl = manifestUrl.replace(
"/manifest",
`/epochs/1/recording.mp4?generation=${generation}`,
);
const manifest = {
schema_version: "missioncore.observation-recorded-media/v3",
source_id: "recorded.camera.camera-1",
generation_sha256: generation,
byte_length: byteLength,
timeline_start_seconds: 0,
timeline_end_seconds: 36_000,
synchronization: "host-arrival-best-effort",
epochs: [{
ordinal: 1,
timeline_start_seconds: 0,
timeline_end_seconds: 36_000,
media_type: 'video/mp4; codecs="avc1.640028"',
byte_length: byteLength,
stream_url: streamUrl,
}],
};
const source = {
id: manifest.source_id,
label: "Записанная камера",
modality: "video",
manifestUrl,
manifestGenerationSha256: generation,
byteLength,
mediaType: "video/mp4",
timelineStartSeconds: 0,
timelineEndSeconds: 36_000,
seekable: true,
synchronization: "host-arrival-best-effort",
};
return { source, manifest, generation, streamUrl };
}
function jsonResponse(payload, generation) {
return new Response(JSON.stringify(payload), {
status: 200,
headers: {
"Content-Type": "application/json",
ETag: `"sha256:${generation}"`,
},
});
}
test("multi-hour camera admission fetches only its compact generation-bound manifest", async () => {
const { source, manifest, generation, streamUrl } = fixture();
const requested = [];
const archive = await fetchRecordedMediaArchive(source, {
fetcher: async (input, request = {}) => {
requested.push(String(input));
assert.equal(new Headers(request.headers).get("If-Match"), `"sha256:${generation}"`);
return jsonResponse(manifest, generation);
},
});
assert.deepEqual(requested, [source.manifestUrl]);
assert.equal(archive.byteLength, 36_000_000_000);
assert.equal(archive.manifest.epochs[0].streamUrl, streamUrl);
});
test("first camera manifest request rejects a replaced generation", async () => {
const { source, manifest, generation } = fixture();
const replacementGeneration = "d".repeat(64);
await assert.rejects(
fetchRecordedMediaArchive(source, {
fetcher: async (_input, request = {}) => {
assert.equal(new Headers(request.headers).get("If-Match"), `"sha256:${generation}"`);
return jsonResponse(
{ ...manifest, generation_sha256: replacementGeneration },
replacementGeneration,
);
},
}),
/несовместим|заменён/,
);
});
test("camera manifest fails closed when epoch bytes do not match launch bytes", async () => {
const { source, manifest, generation } = fixture();
const mismatched = {
...manifest,
epochs: [{ ...manifest.epochs[0], byte_length: manifest.byte_length - 1 }],
};
await assert.rejects(
fetchRecordedMediaArchive(source, {
fetcher: async () => jsonResponse(mismatched, generation),
}),
/не совпадает/,
);
});
test("camera presentation gate opens only for the seekable selected epoch", () => {
assert.equal(recordedMediaPresentationState("loading", null, "g1", false), "loading");
assert.equal(recordedMediaPresentationState("ready", null, "g1", false), "loading");
assert.equal(recordedMediaPresentationState("ready", "g2", "g1", false), "loading");
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false), "ready");
assert.equal(recordedMediaPresentationState("ready", "g1", null, true), "waiting");
assert.equal(recordedMediaPresentationState("error", "g1", "g1", false), "error");
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false, "loading"), "loading");
assert.equal(recordedMediaPresentationState("ready", "g1", null, true, "loading"), "loading");
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false, "error"), "error");
});
test("decoded duration and seekable range cover the complete declared epoch", () => {
assert.equal(recordedMediaSeekableCoverage(20, 20, 20), true);
assert.equal(recordedMediaSeekableCoverage(19, 19, 20), true);
assert.equal(recordedMediaSeekableCoverage(18.99, 20, 20), false);
assert.equal(recordedMediaSeekableCoverage(20, 18.99, 20), false);
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1), true);
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
});
test("recorded player range-streams and never builds a whole-video RAM Blob", async () => {
const source = await readFile(
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
"utf8",
);
assert.match(source, /video\.src\s*=\s*descriptor\.streamUrl/);
assert.doesNotMatch(source, /new Blob\(|response\.arrayBuffer\(|SourceBuffer/);
});
test("loading and error overlays fully conceal recorded camera pixels", async () => {
const css = await readFile(
new URL("../src/styles/observation.css", import.meta.url),
"utf8",
);
assert.match(
css,
/\.recorded-media-player:not\(\[data-state="ready"\]\) \.observation-media__asset\s*\{[^}]*visibility:\s*hidden/s,
);
assert.match(
css,
/\.recorded-media-player__notice\s*\{[^}]*inset:\s*0;[^}]*background:\s*#070809/s,
);
});
test("point-cloud fullscreen keeps the admitted recorded camera worker mounted", async () => {
const source = await readFile(
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
"utf8",
);
assert.match(source, /\{visibleMediaSources\.map\(\(source, index\) => \(/);
assert.match(source, /hidden=\{pointCloudFocused \|\| unifiedPerception\}/);
assert.doesNotMatch(
source,
/\{!pointCloudFocused \? visibleMediaSources\.map/,
);
assert.doesNotMatch(
source,
/\(pointCloudFocused \|\| !observationLayout\.visibleSourceIds\.has\(source\.id\)\)/,
);
});
@@ -0,0 +1,168 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let admission;
let rerunPresentationStatus;
let recordedMediaPresentationState;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
admission = await server.ssrLoadModule(
"/src/core/observation/recordedSessionAdmission.ts",
);
({ rerunPresentationStatus } = await server.ssrLoadModule(
"/src/components/RerunViewport.tsx",
));
({ recordedMediaPresentationState } = await server.ssrLoadModule(
"/src/components/RecordedFmp4Player.tsx",
));
});
after(async () => {
await server?.close();
});
function camera(phase, byteLength = 1_024) {
return { phase, byteLength, message: null };
}
test("recorded session admits RRD and every declared camera as one atomic generation", () => {
const ids = ["camera.left", "camera.right"];
const oneCamera = {
"camera.left": camera("ready"),
"camera.right": camera("pending"),
};
const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera);
assert.equal(partialGate, "loading");
assert.equal(rerunPresentationStatus("ready", partialGate, true), "loading");
assert.equal(
recordedMediaPresentationState("ready", "generation", "generation", false, partialGate),
"loading",
);
const completeGate = admission.recordedSessionAdmissionPhase("ready", ids, {
...oneCamera,
"camera.right": camera("ready"),
});
assert.equal(completeGate, "ready");
assert.equal(rerunPresentationStatus("ready", completeGate, true), "ready");
assert.equal(
recordedMediaPresentationState("ready", "generation", "generation", false, completeGate),
"ready",
);
});
test("any RRD or camera failure closes the complete recorded session", () => {
const ids = ["camera.left", "camera.right"];
const cameras = {
"camera.left": camera("ready"),
"camera.right": camera("error"),
};
assert.equal(admission.recordedSessionAdmissionPhase("ready", ids, cameras), "error");
assert.equal(admission.recordedSessionAdmissionPhase("error", ids, {
...cameras,
"camera.right": camera("ready"),
}), "error");
assert.equal(rerunPresentationStatus("ready", "error", true), "error");
assert.equal(
recordedMediaPresentationState("ready", "generation", "generation", false, "error"),
"error",
);
});
test("camera admission keeps source scheduling bounded but has no duration or byte ceiling", () => {
const {
MAX_RECORDED_CAMERA_SOURCES,
recordedCameraDescriptorPreflight,
} = admission;
assert.equal(MAX_RECORDED_CAMERA_SOURCES, 16);
assert.equal(recordedCameraDescriptorPreflight(
Array.from({ length: 2 }, (_, index) => ({
id: `camera.${index}`,
byteLength: 36_000_000_000,
})),
), "ready");
assert.equal(recordedCameraDescriptorPreflight(
Array.from({ length: 17 }, (_, index) => ({ id: `camera.${index}`, byteLength: 1 })),
), "error");
assert.equal(recordedCameraDescriptorPreflight([
{ id: "camera.ten-hours", byteLength: 36_000_000_000 },
]), "ready");
assert.equal(recordedCameraDescriptorPreflight([
{ id: "camera.invalid", byteLength: Number.MAX_SAFE_INTEGER + 1 },
]), "error");
assert.equal(recordedCameraDescriptorPreflight([
{ id: "camera.duplicate", byteLength: 1 },
{ id: "camera.duplicate", byteLength: 1 },
]), "error");
});
test("camera preparation remains single-flight and retains the loading permit", () => {
const ids = ["camera.first", "camera.preferred"];
const cameras = {
"camera.first": camera("loading"),
"camera.preferred": camera("pending"),
};
assert.deepEqual(
admission.nextRecordedCameraPreparationIds(
ids,
cameras,
new Set(["camera.preferred"]),
),
["camera.first"],
);
assert.deepEqual(
admission.nextRecordedCameraPreparationIds(ids, {
...cameras,
"camera.first": camera("ready"),
}),
["camera.preferred"],
);
});
test("new render workers close readiness and stale callbacks cannot reopen it", () => {
const ready = { ...camera("ready"), workerGeneration: 1 };
const remounted = admission.mergeRecordedCameraAdmission(ready, {
...camera("loading"),
workerGeneration: 2,
});
assert.equal(remounted.phase, "loading");
assert.equal(remounted.workerGeneration, 2);
assert.equal(admission.mergeRecordedCameraAdmission(remounted, {
...camera("ready"),
workerGeneration: 1,
}), remounted);
const admitted = admission.mergeRecordedCameraAdmission(remounted, {
...camera("ready"),
workerGeneration: 2,
});
assert.equal(admitted.phase, "ready");
const failed = admission.mergeRecordedCameraAdmission(admitted, {
...camera("error"),
workerGeneration: 2,
});
assert.equal(failed.phase, "error");
assert.equal(admission.mergeRecordedCameraAdmission(failed, {
...camera("loading"),
workerGeneration: 3,
}), failed);
});
test("recorded player is not mounted for a camera without a scheduler permit", async () => {
const source = await readFile(
new URL("../src/components/ObservationSources.tsx", import.meta.url),
"utf8",
);
const placeholder = source.indexOf("if (!prepareRecorded)");
const player = source.indexOf("<RecordedFmp4Player", placeholder);
assert.ok(placeholder >= 0 && player > placeholder);
assert.match(source.slice(placeholder, player), /return\s*\(/);
});
@@ -0,0 +1,154 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let createRecordedOpenWatchdog;
let recordedOpenWatchdogTimeoutMs;
let rerunViewerInitialSource;
let resolveRecordedViewerSourceUrl;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
createRecordedOpenWatchdog,
recordedOpenWatchdogTimeoutMs,
rerunViewerInitialSource,
resolveRecordedViewerSourceUrl,
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
});
after(async () => {
await server?.close();
});
const sourceUrl = "/api/v1/observation-sessions/session-atomic/recording.rrd";
const sha256 = "a".repeat(64);
const viewerSourceUrl = `${sourceUrl}?generation=${sha256}`;
test("only the canonical digest-bound generation URL reaches the native Rerun receiver", () => {
const descriptor = {
sourceUrl,
viewerSourceUrl,
byteLength: 246_331_680,
sha256,
};
const resolved = resolveRecordedViewerSourceUrl(descriptor, "http://mission-core.test");
assert.equal(resolved, `http://mission-core.test${viewerSourceUrl}`);
assert.equal(rerunViewerInitialSource(resolved), resolved);
for (const unsafeViewerSourceUrl of [
sourceUrl,
`${sourceUrl}?generation=${"b".repeat(64)}`,
`${viewerSourceUrl}&extra=true`,
`https://foreign.test${viewerSourceUrl}`,
]) {
assert.throws(
() => resolveRecordedViewerSourceUrl({
...descriptor,
viewerSourceUrl: unsafeViewerSourceUrl,
}, "http://mission-core.test"),
/Unsafe recorded RRD viewer descriptor/,
);
}
});
test("recorded admission watchdog is size-aware and exits an incomplete load", () => {
const smallDelay = recordedOpenWatchdogTimeoutMs(4);
const currentDelay = recordedOpenWatchdogTimeoutMs(246_331_680);
const largeDelay = recordedOpenWatchdogTimeoutMs(805_687_122);
assert.ok(smallDelay >= 120_000);
assert.ok(currentDelay >= smallDelay);
assert.ok(largeDelay > currentDelay);
assert.ok(largeDelay <= 1_800_000);
assert.ok(largeDelay > 12_000);
let scheduledCallback;
let scheduledDelay;
let timeoutCount = 0;
const cancelled = [];
const watchdog = createRecordedOpenWatchdog({
byteLength: 246_331_680,
schedule(callback, timeoutMs) {
scheduledCallback = callback;
scheduledDelay = timeoutMs;
return 17;
},
cancel(handle) {
cancelled.push(handle);
},
onTimeout() {
timeoutCount += 1;
},
});
watchdog.arm();
assert.equal(watchdog.pending(), true);
assert.equal(scheduledDelay, currentDelay);
scheduledCallback();
assert.equal(timeoutCount, 1);
assert.equal(watchdog.pending(), false);
assert.deepEqual(cancelled, []);
});
test("complete recorded admission clears its watchdog", () => {
let timeoutCount = 0;
const cancelled = [];
const watchdog = createRecordedOpenWatchdog({
byteLength: 805_687_122,
schedule() {
return 23;
},
cancel(handle) {
cancelled.push(handle);
},
onTimeout() {
timeoutCount += 1;
},
});
watchdog.arm();
watchdog.clear();
assert.equal(watchdog.pending(), false);
assert.equal(timeoutCount, 0);
assert.deepEqual(cancelled, [23]);
});
test("recorded RRD bytes are never split across LogChannel.send_rrd calls", async () => {
const source = await readFile(
new URL("../src/components/RerunViewport.tsx", import.meta.url),
"utf8",
);
assert.doesNotMatch(source, /streamVerifiedRecordedRrd/);
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
assert.doesNotMatch(source, /recordedChannel/);
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
assert.match(
source,
/recordingOpened = true;[\s\S]*if \(!isRecordedSource\) clearLiveRecordingOpenTimer\(\);/,
);
assert.match(
source,
/viewerStartResolved = true;\s*if \(isRecordedSource && !recordedSceneAdmitted\) recordedOpenWatchdog\?\.arm\(\);/,
);
assert.match(
source,
/if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/,
);
});
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
const css = await readFile(new URL("../src/styles/spatial.css", import.meta.url), "utf8");
assert.match(
css,
/\.rerun-viewport:is\(\[data-status="loading"\], \[data-status="error"\]\)\s+\.rerun-viewport__canvas\s*\{[^}]*visibility:\s*hidden;[^}]*pointer-events:\s*none;/s,
);
assert.doesNotMatch(
css,
/data-status="loading"[^}]*\.rerun-viewport__canvas\s*>\s*div/s,
);
});
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let attemptRecordedAutoplay;
let createLatestAnimationFrameEmitter;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ attemptRecordedAutoplay, createLatestAnimationFrameEmitter } =
await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
});
after(async () => {
await server?.close();
});
test("recorded autoplay reports success only after seek and play both succeed", () => {
let seekAttempts = 0;
let playAttempts = 0;
const seekToStart = () => {
seekAttempts += 1;
};
const startPlaying = () => {
playAttempts += 1;
if (playAttempts === 1) throw new Error("receiver is not ready yet");
};
assert.equal(attemptRecordedAutoplay(seekToStart, startPlaying), false);
assert.equal(attemptRecordedAutoplay(seekToStart, startPlaying), true);
assert.equal(seekAttempts, 2);
assert.equal(playAttempts, 2);
});
test("recorded autoplay does not try play when the seek itself fails", () => {
let playAttempts = 0;
assert.equal(attemptRecordedAutoplay(
() => {
throw new Error("timeline is not ready yet");
},
() => {
playAttempts += 1;
},
), false);
assert.equal(playAttempts, 0);
});
test("time updates coalesce to the latest value once per animation frame", () => {
let nextHandle = 1;
const frames = new Map();
const cancelled = [];
const emitted = [];
const emitter = createLatestAnimationFrameEmitter({
emit(value) {
emitted.push(value);
},
requestFrame(callback) {
const handle = nextHandle;
nextHandle += 1;
frames.set(handle, callback);
return handle;
},
cancelFrame(handle) {
cancelled.push(handle);
frames.delete(handle);
},
});
emitter.push(1);
emitter.push(2);
emitter.push(3);
assert.equal(frames.size, 1);
assert.deepEqual(emitted, []);
const firstFrame = frames.get(1);
frames.delete(1);
firstFrame(0);
assert.deepEqual(emitted, [3]);
emitter.push(4);
assert.equal(frames.size, 1);
emitter.cancel();
assert.deepEqual(cancelled, [2]);
assert.deepEqual(emitted, [3]);
emitter.push(5);
assert.equal(frames.size, 0);
assert.deepEqual(emitted, [3]);
});
@@ -0,0 +1,241 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let canPublishRecordedPlaybackController;
let createRecordedAutoplayGate;
let isRecordedPlaybackReady;
let isRecordedPlaybackPresentationReady;
let isUsableRecordedPlaybackRange;
let recordedPlaybackBufferState;
let recordedPlaybackRangeWhenReady;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
canPublishRecordedPlaybackController,
createRecordedAutoplayGate,
isRecordedPlaybackReady,
isRecordedPlaybackPresentationReady,
isUsableRecordedPlaybackRange,
recordedPlaybackBufferState,
recordedPlaybackRangeWhenReady,
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
});
after(async () => {
await server?.close();
});
test("a first frame reports buffer telemetry but is not ready for presentation", () => {
assert.equal(isUsableRecordedPlaybackRange(null), false);
assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false);
assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false);
assert.equal(isUsableRecordedPlaybackRange({ min: 0, max: 0 }), true);
const firstFrame = recordedPlaybackBufferState({ min: 0, max: 0 }, 20);
assert.deepEqual(firstFrame, {
bufferedEndNs: 0,
expectedStartNs: 0,
expectedEndNs: 20_000_000_000,
bufferProgress: 0,
fullyBuffered: false,
});
assert.equal(isRecordedPlaybackReady(true, true, firstFrame), false);
assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null);
});
test("host timeline remains unmounted until the verified recording is fully ready", () => {
const partial = {
recordingId: "partial",
timeline: "session_time",
rangeNs: null,
currentNs: 0,
playing: false,
bufferedEndNs: 5,
expectedStartNs: 0,
expectedEndNs: 10,
bufferProgress: 0.5,
fullyBuffered: false,
};
assert.equal(isRecordedPlaybackPresentationReady("loading", partial), false);
assert.equal(isRecordedPlaybackPresentationReady("ready", partial), false);
assert.equal(isRecordedPlaybackPresentationReady("error", partial), false);
assert.equal(isRecordedPlaybackPresentationReady("ready", {
...partial,
rangeNs: { min: 0, max: 10 },
bufferedEndNs: 10,
bufferProgress: 1,
fullyBuffered: true,
}), true);
});
test("buffer progress grows independently and preserves the full-buffer tolerance", () => {
assert.deepEqual(recordedPlaybackBufferState({ min: 0, max: 5_000_000_000 }, 20), {
bufferedEndNs: 5_000_000_000,
expectedStartNs: 0,
expectedEndNs: 20_000_000_000,
bufferProgress: 0.25,
fullyBuffered: false,
});
const complete = recordedPlaybackBufferState({ min: 0, max: 19_999_500_000 }, 20);
assert.equal(complete.bufferProgress, 0.999975);
assert.equal(complete.fullyBuffered, true);
assert.equal(isRecordedPlaybackReady(false, true, complete), false);
assert.equal(isRecordedPlaybackReady(true, false, complete), false);
assert.equal(isRecordedPlaybackReady(true, true, complete), true);
assert.equal(
recordedPlaybackRangeWhenReady({ min: 0, max: 19_999_500_000 }, complete, false),
null,
);
assert.deepEqual(
recordedPlaybackRangeWhenReady({ min: 0, max: 19_999_500_000 }, complete, true),
{ min: 0, max: 19_999_500_000 },
);
const missingDeclaredStart = recordedPlaybackBufferState(
{ min: 5_000_000_000, max: 20_000_000_000 },
20,
0,
);
assert.equal(missingDeclaredStart.bufferProgress, 1);
assert.equal(missingDeclaredStart.fullyBuffered, false);
assert.equal(isRecordedPlaybackReady(true, true, missingDeclaredStart), false);
});
test("a verified split boundary spill covers and clamps the declared LAB window", () => {
const actualRange = {
min: 34_756_228_209,
max: 95_383_857_292,
};
const buffer = recordedPlaybackBufferState(
actualRange,
95.383857292,
35.421857292,
);
assert.equal(buffer.bufferProgress, 1);
assert.equal(buffer.fullyBuffered, true);
assert.equal(isRecordedPlaybackReady(true, true, buffer), true);
assert.deepEqual(
recordedPlaybackRangeWhenReady(actualRange, buffer, true),
{
min: 35_421_857_292,
max: 95_383_857_292,
},
);
});
test("recorded autoplay waits for the full range and then runs exactly once", () => {
const gate = createRecordedAutoplayGate();
const seeks = [];
let plays = 0;
const seek = (value) => seeks.push(value);
const play = () => {
plays += 1;
};
assert.equal(gate.attempt(false, true, true, { min: 0, max: 0 }, seek, play), false);
assert.equal(gate.attempt(true, false, true, { min: 0, max: 500_000_000 }, seek, play), false);
assert.equal(gate.attempt(true, true, false, { min: 0, max: 20_000_000_000 }, seek, play), false);
assert.deepEqual(seeks, []);
assert.equal(plays, 0);
assert.equal(gate.attempt(true, true, true, { min: 0, max: 20_000_000_000 }, seek, play), true);
assert.equal(gate.attempt(true, true, true, { min: 0, max: 20_000_000_000 }, seek, play), false);
assert.deepEqual(seeks, [0]);
assert.equal(plays, 1);
assert.equal(gate.attempted(), true);
});
test("recorded autoplay starts at the first presentable camera frame without shrinking the range", () => {
const gate = createRecordedAutoplayGate();
const seeks = [];
const range = { min: 0, max: 535_717_620_042 };
assert.equal(gate.attempt(
true,
true,
true,
range,
(value) => seeks.push(value),
() => {},
35_421_857_292,
), true);
assert.deepEqual(seeks, [35_421_857_292]);
assert.deepEqual(range, { min: 0, max: 535_717_620_042 });
});
test("recorded autoplay clamps an invalid presentation start to the admitted archive", () => {
const before = createRecordedAutoplayGate();
const after = createRecordedAutoplayGate();
const seeks = [];
const range = { min: 5, max: 10 };
assert.equal(before.attempt(
true,
true,
true,
range,
(value) => seeks.push(value),
() => {},
-50,
), true);
assert.equal(after.attempt(
true,
true,
true,
range,
(value) => seeks.push(value),
() => {},
50,
), true);
assert.deepEqual(seeks, [5, 10]);
});
test("a failed vendor autoplay attempt is consumed instead of rewinding later", () => {
const gate = createRecordedAutoplayGate();
let seeks = 0;
let plays = 0;
assert.equal(gate.attempt(
true,
true,
true,
{ min: 0, max: 1 },
() => {
seeks += 1;
},
() => {
plays += 1;
throw new Error("receiver raced its first frame");
},
), false);
assert.equal(gate.attempt(
true,
true,
true,
{ min: 0, max: 2 },
() => {
seeks += 1;
},
() => {
plays += 1;
},
), false);
assert.equal(seeks, 1);
assert.equal(plays, 1);
});
test("recorded playback controller stays unpublished until the aggregate gate is ready", () => {
assert.equal(canPublishRecordedPlaybackController(false, "ready"), false);
assert.equal(canPublishRecordedPlaybackController(true, "loading"), false);
assert.equal(canPublishRecordedPlaybackController(true, "error"), false);
assert.equal(canPublishRecordedPlaybackController(true, "ready"), true);
});
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import test from "node:test";
import makeRerunRuntime from "../vendor/rerun-web-viewer-0.34.1/re_viewer.nodedc.js";
const root = resolve(import.meta.dirname, "..");
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1");
const sha256 = (path) =>
createHash("sha256").update(readFileSync(path)).digest("hex");
test("NODE.DC Rerun runtime is the audited 0.34.1 spatial camera build", () => {
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
assert.equal(manifest.version, "0.34.1");
const expectedWasm = "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469";
const expectedGlue = "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339";
assert.equal(sha256(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")), expectedWasm);
assert.equal(sha256(resolve(vendorRoot, "re_viewer.nodedc.js")), expectedGlue);
assert.equal(sha256(resolve(packageRoot, "re_viewer_bg.wasm")), expectedWasm);
assert.equal(sha256(resolve(packageRoot, "re_viewer.js")), expectedGlue);
});
test("custom JavaScript glue references only exports present in its paired WASM", () => {
const wasmPath = resolve(vendorRoot, "re_viewer_bg.nodedc.wasm");
const gluePath = resolve(vendorRoot, "re_viewer.nodedc.js");
const module = new WebAssembly.Module(readFileSync(wasmPath));
const exports = new Set(WebAssembly.Module.exports(module).map(({ name }) => name));
const imports = WebAssembly.Module.imports(module);
const glue = readFileSync(gluePath, "utf8");
const referencedExports = new Set(
[...glue.matchAll(/\bwasm\.([A-Za-z_$][\w$]*)/g)].map((match) => match[1]),
);
const missingExports = [...referencedExports].filter((name) => !exports.has(name));
assert.equal(imports.length, 927);
assert.equal(exports.size, 79);
assert.deepEqual(missingExports, []);
assert.match(glue, /export default function\(\)/);
assert.match(glue, /if \(!wasm\) return;/);
});
test("custom Rerun WASM initializes and grows its externref table", () => {
const runtime = makeRerunRuntime();
runtime.initSync({
module: readFileSync(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")),
});
assert.equal(typeof runtime.WebHandle, "function");
runtime.deinit();
});
test("source patch carries pointer navigation, persistent follow, and camera continuity tests", () => {
const patch = readFileSync(resolve(vendorRoot, "NODEDC_ZOOM_TO_CURSOR.patch"), "utf8");
assert.match(patch, /fn pointer_ray_direction/);
assert.match(patch, /fn zoom_orbit_towards_pointer/);
assert.match(patch, /near_limit_hands_excess_zoom_to_cursor_directed_dolly/);
assert.match(patch, /crossing_near_limit_preserves_unconsumed_scene_scaled_zoom/);
assert.match(patch, /remaining_zoom_factor\.ln\(\) \* self\.speed/);
assert.match(patch, /off_center_pointer_stays_on_the_same_view_ray/);
assert.match(patch, /fn rotate_radians_around_anchor/);
assert.match(patch, /orbit_drag_anchor/);
assert.match(patch, /minimum_orbital_navigation_speed/);
assert.match(patch, /orbital_rotation_keeps_selected_anchor_on_the_same_view_ray/);
assert.match(patch, /orbital_navigation_speed_floor_tracks_scene_scale/);
assert.match(patch, /NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY/);
assert.match(patch, /nodedc_rig_orbit_tracking_is_persistent/);
assert.match(patch, /restore_persistent_orbit_eye_after_blueprint_update/);
assert.match(
patch,
/persistent_rig_follow_restores_the_last_rendered_eye_after_blueprint_update/,
);
assert.match(patch, /explicit_blueprint_pose_is_not_replaced_by_the_previous_eye/);
assert.match(patch, /previous_picking_result/);
});
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let guard;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
guard = await server.ssrLoadModule("/src/core/runtime/acquisitionGuard.ts");
});
after(async () => {
await server?.close();
});
function runtimeState(acquisitionState, cleanupPending = false) {
return {
phase: "idle",
sourceMode: "idle",
acquisition: acquisitionState === null
? null
: {
acquisitionId: "acq-1",
deviceId: "device-1",
deviceSessionId: "device-session-1",
compatibilityProfileId: "profile-1",
controlMode: "operator-manual",
state: acquisitionState,
stateRevision: 1,
operatorInstructions: [],
cleanupPending,
},
};
}
test("every nonterminal acquisition phase blocks saved and manual source replacement", () => {
for (const state of [
"preparing",
"prepared",
"awaiting_external_start",
"starting",
"acquiring",
"awaiting_external_stop",
"stopping",
"finalizing",
]) {
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(state)), true, state);
}
});
test("terminal or absent acquisitions allow an explicit source selection", () => {
for (const state of ["completed", "failed", "aborted", "interrupted"]) {
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(state)), false, state);
}
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(null)), false);
assert.equal(guard.isSpatialSourceSwitchBlocked(null), false);
});
test("an unknown acquisition state fails closed", () => {
assert.equal(
guard.isSpatialSourceSwitchBlocked(runtimeState("future_vendor_phase")),
true,
);
assert.match(guard.SPATIAL_SOURCE_SWITCH_BLOCKED_REASON, /Завершите текущий приём/);
});
test("terminal acquisition with retained cleanup remains source-switch blocked", () => {
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState("failed", true)), true);
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState("failed", false)), false);
});
test("saved-session and manual source controls share the acquisition guard", async () => {
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
const sessionSelectSource = await readFile(
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
"utf8",
);
const observationHookSource = await readFile(
new URL("../src/core/observation/useObservationSessions.ts", import.meta.url),
"utf8",
);
assert.match(appSource, /blockedReason=\{sourceSwitchBlockedReason\}/);
assert.match(appSource, /disabled=\{sourceSwitchBlocked \|\| !sourceDraft\.trim\(\)\}/);
assert.match(appSource, /if \(sourceSwitchBlockedRef\.current\) return;/);
assert.match(sessionSelectSource, /replayEnabled: blockedReason === null/);
assert.match(observationHookSource, /!replayEnabledRef\.current/);
});
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let selectMonotonicXgridsState;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ selectMonotonicXgridsState } = await server.ssrLoadModule(
"@xgrids-k1/frontend/stateOrdering.ts",
));
});
after(async () => {
await server?.close();
});
function snapshot(revision, generation, phase = "streaming", sessionId = "device-session-a") {
return {
phase: "connected",
message: `${phase}:${revision}:${generation}`,
device_session: sessionId === null
? null
: {
device_session_id: sessionId,
device_id: `device-for-${sessionId}`,
connectivity: "connected",
},
camera_preview: {
phase,
revision,
generation,
active_source_id: generation === null ? null : "sensor.camera.left",
delivery: null,
},
};
}
test("accepts the first camera preview snapshot", () => {
const incoming = snapshot(1, 1);
assert.equal(selectMonotonicXgridsState(null, incoming), incoming);
});
test("rejects a lower revision even when its generation is higher", () => {
const current = snapshot(8, 3);
const stale = snapshot(7, 99);
assert.equal(selectMonotonicXgridsState(current, stale), current);
});
test("uses generation only as a tie-breaker for equal revisions", () => {
const current = snapshot(8, 3);
const stale = snapshot(8, 2);
const equal = snapshot(8, 3, "selected");
assert.equal(selectMonotonicXgridsState(current, stale), current);
assert.equal(selectMonotonicXgridsState(current, equal), equal);
});
test("accepts a newer stop revision with a null generation", () => {
const current = snapshot(8, 3);
const stopped = snapshot(9, null, "idle");
assert.equal(selectMonotonicXgridsState(current, stopped), stopped);
});
test("rejects an unversioned camera snapshot after a versioned one", () => {
const current = snapshot(8, 3);
const stale = {
phase: "connected",
device_session: current.device_session,
};
assert.equal(selectMonotonicXgridsState(current, stale), current);
});
test("accepts revision reset when the authoritative device session changes", () => {
const current = snapshot(18, 7, "streaming", "device-session-a");
const nextDevice = snapshot(0, null, "idle", "device-session-b");
assert.equal(selectMonotonicXgridsState(current, nextDevice), nextDevice);
});
test("accepts backend reset that clears and later recreates the device session", () => {
const current = snapshot(18, 7, "streaming", "device-session-a");
const reset = snapshot(0, null, "idle", null);
const reconnected = snapshot(0, null, "idle", "device-session-c");
assert.equal(selectMonotonicXgridsState(current, reset), reset);
assert.equal(selectMonotonicXgridsState(reset, reconnected), reconnected);
});
test("rejects the entire stale atomic snapshot instead of merging unrelated fields", () => {
const current = {
...snapshot(8, 3),
metrics: { point_count: 2_500 },
};
const stale = {
...snapshot(7, 2),
metrics: { point_count: 9_999 },
};
const accepted = selectMonotonicXgridsState(current, stale);
assert.equal(accepted, current);
assert.equal(accepted.metrics.point_count, 2_500);
});
@@ -0,0 +1,178 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let createLatestAsyncCommitter;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ createLatestAsyncCommitter } = await server.ssrLoadModule(
"/src/core/runtime/latestAsyncCommitter.ts",
));
});
after(async () => {
await server?.close();
});
async function eventually(predicate, timeoutMs = 1_000) {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) throw new Error("Timed out waiting for async commit queue");
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
test("viewer settings commits are serialized and intermediate slider values collapse", async () => {
const calls = [];
const resolvers = [];
const settled = [];
const committer = createLatestAsyncCommitter({
commit(value) {
calls.push(value);
return new Promise((resolve) => resolvers.push(resolve));
},
onSettled(result) {
settled.push(result);
},
});
committer.enqueue({ accumulationSeconds: 1 });
committer.enqueue({ accumulationSeconds: 2 });
committer.enqueue({ accumulationSeconds: 12 });
assert.deepEqual(calls, [{ accumulationSeconds: 1 }]);
assert.equal(committer.isBusy(), true);
resolvers.shift()(true);
await eventually(() => calls.length === 2);
assert.deepEqual(calls[1], { accumulationSeconds: 12 });
assert.equal(settled[0].superseded, true);
resolvers.shift()(true);
await eventually(() => !committer.isBusy());
assert.deepEqual(settled.map(({ value, applied, superseded }) => ({
value,
applied,
superseded,
})), [
{ value: { accumulationSeconds: 1 }, applied: true, superseded: true },
{ value: { accumulationSeconds: 12 }, applied: true, superseded: false },
]);
});
test("viewer settings commit failures settle as rejected without wedging the queue", async () => {
const settled = [];
const committer = createLatestAsyncCommitter({
async commit(value) {
if (value === "broken") throw new Error("offline");
return true;
},
onSettled(result) {
settled.push(result);
},
});
committer.enqueue("broken");
await eventually(() => settled.length === 1);
committer.enqueue("healthy");
await eventually(() => settled.length === 2);
assert.deepEqual(settled.map(({ value, applied }) => ({ value, applied })), [
{ value: "broken", applied: false },
{ value: "healthy", applied: true },
]);
assert.equal(committer.isBusy(), false);
});
test("waitForIdle resolves only after the running commit and latest queued value settle", async () => {
const calls = [];
const resolvers = [];
const committer = createLatestAsyncCommitter({
commit(value) {
calls.push(value);
return new Promise((resolve) => resolvers.push(resolve));
},
});
committer.enqueue("initial");
committer.enqueue("latest");
let idle = false;
const idlePromise = committer.waitForIdle().then(() => {
idle = true;
});
await Promise.resolve();
assert.equal(idle, false);
resolvers.shift()(true);
await eventually(() => calls.length === 2);
assert.deepEqual(calls, ["initial", "latest"]);
assert.equal(idle, false);
resolvers.shift()(true);
await idlePromise;
assert.equal(idle, true);
assert.equal(committer.isBusy(), false);
});
test("waitForIdle observes work enqueued by onSettled and resolves immediately when idle", async () => {
const calls = [];
let committer;
committer = createLatestAsyncCommitter({
async commit(value) {
calls.push(value);
return true;
},
onSettled({ value }) {
if (value === "first") committer.enqueue("follow-up");
},
});
await committer.waitForIdle();
committer.enqueue("first");
await committer.waitForIdle();
assert.deepEqual(calls, ["first", "follow-up"]);
assert.equal(committer.isBusy(), false);
});
test("layout serialization can flush a staged setting and read the confirmed latest value", async () => {
let staged = { pointSize: 2 };
let confirmed = { pointSize: 1 };
let resolveCommit;
const committer = createLatestAsyncCommitter({
commit() {
return new Promise((resolve) => {
resolveCommit = resolve;
});
},
onSettled({ value, applied }) {
if (applied) confirmed = value;
},
});
const saveLayout = async () => {
committer.enqueue(staged);
await committer.waitForIdle();
return { sceneSettings: confirmed };
};
const savePromise = saveLayout();
staged = { pointSize: 9 };
let saved = false;
void savePromise.then(() => {
saved = true;
});
await Promise.resolve();
assert.equal(saved, false);
resolveCommit(true);
const layout = await savePromise;
assert.deepEqual(layout, { sceneSettings: { pointSize: 2 } });
});
@@ -0,0 +1,275 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let decodeObservationWorkspaceLayoutProfile;
let encodeObservationWorkspaceLayoutProfile;
let fetchObservationWorkspaceLayoutProfile;
let normalizeObservationWindowRect;
let projectObservationLayoutSnapshot;
let saveObservationWorkspaceLayoutProfile;
let WorkspaceLayoutApiError;
let WorkspaceLayoutContractError;
let observationPresentationSourceAfterLayoutApply;
let visibleSourceIdsAfterRecordedCatalogActivation;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
decodeObservationWorkspaceLayoutProfile,
encodeObservationWorkspaceLayoutProfile,
fetchObservationWorkspaceLayoutProfile,
normalizeObservationWindowRect,
projectObservationLayoutSnapshot,
saveObservationWorkspaceLayoutProfile,
WorkspaceLayoutApiError,
WorkspaceLayoutContractError,
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
({
observationPresentationSourceAfterLayoutApply,
visibleSourceIdsAfterRecordedCatalogActivation,
} = await server.ssrLoadModule("/src/core/observation/useObservationLayout.ts"));
});
after(async () => {
await server?.close();
});
function wireProfile(overrides = {}) {
return {
version: 2,
revision: 7,
workspace_id: "observation.spatial",
scene_settings: {
projection: "3d",
point_size: 2.5,
color_mode: "intensity",
palette: "turbo",
custom_color: "#35d7c1",
accumulation_seconds: 12,
show_points: true,
show_trajectory: true,
show_grid: true,
show_labels: false,
show_camera_frustums: true,
},
visible_source_ids: ["spatial.point-cloud.live", "camera.left"],
active_floating_source_id: "camera.left",
window_rects: {
"camera.left": { x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
"camera.right": { x: 0.55, y: 0.1, width: 0.4, height: 0.4 },
},
viewport_size: { width: 1_000, height: 500 },
...overrides,
};
}
test("workspace layout v2 excludes transient tool-window state", () => {
const wire = wireProfile();
const profile = decodeObservationWorkspaceLayoutProfile(wire);
assert.deepEqual(profile.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
assert.equal(profile.sceneSettings.pointSize, 2.5);
assert.deepEqual(encodeObservationWorkspaceLayoutProfile(profile), wire);
assert.equal("tool_windows" in encodeObservationWorkspaceLayoutProfile(profile), false);
});
test("workspace layout rejects unknown fields, transient transport data and invalid schema values", () => {
assert.throws(
() => decodeObservationWorkspaceLayoutProfile({
...wireProfile(),
source_url: "ws://192.168.68.52:9877",
}),
WorkspaceLayoutContractError,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ version: 1 })),
/Неподдерживаемая версия/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ revision: 1.5 })),
/revision/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
visible_source_ids: ["../camera"],
active_floating_source_id: null,
})),
/стабильный идентификатор/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
window_rects: { "camera.left": { x: 0.8, y: 0, width: 0.4, height: 0.5 } },
})),
/выходит за нормализованные границы/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile({
...wireProfile(),
tool_windows: {
sources_open: true,
display_open: true,
layers_open: true,
order: ["sources", "display", "layers"],
},
}),
/неверная схема/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
scene_settings: { ...wireProfile().scene_settings, point_size: Number.POSITIVE_INFINITY },
})),
/point_size/,
);
});
test("restored desired layout survives an empty catalog and reveals only known stable sources later", () => {
const profile = decodeObservationWorkspaceLayoutProfile(wireProfile());
const snapshot = {
visibleSourceIds: profile.visibleSourceIds,
activeFloatingSourceId: profile.activeFloatingSourceId,
windowRects: profile.windowRects,
viewportSize: profile.viewportSize,
};
const empty = projectObservationLayoutSnapshot(snapshot, new Set(), { width: 500, height: 1_000 });
assert.deepEqual(empty, {
visibleSourceIds: [],
activeFloatingSourceId: null,
windowRects: {},
});
assert.deepEqual(snapshot.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
const cameraAppeared = projectObservationLayoutSnapshot(
snapshot,
new Set(["camera.left"]),
{ width: 500, height: 1_000 },
);
assert.deepEqual(cameraAppeared.visibleSourceIds, ["camera.left"]);
assert.equal(cameraAppeared.activeFloatingSourceId, "camera.left");
assert.deepEqual(cameraAppeared.windowRects["camera.left"], {
x: 50,
y: 100,
width: 200,
height: 400,
});
});
test("window rectangles normalize once and project proportionally into a different viewport", () => {
assert.deepEqual(
normalizeObservationWindowRect(
{ x: 100, y: 50, width: 400, height: 200 },
{ width: 1_000, height: 500 },
),
{ x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
);
});
test("fullscreen presentation survives the viewport resize it causes", () => {
assert.equal(
observationPresentationSourceAfterLayoutApply("camera.left", "preserve"),
"camera.left",
);
assert.equal(
observationPresentationSourceAfterLayoutApply("camera.left", "reset"),
null,
);
});
test("opening a recorded catalog reveals its sealed cameras beside the point cloud", () => {
const source = (id, modality, transport = "recording") => ({
id,
modality,
transport,
availability: "available",
previewUrl: null,
delivery: modality === "video" ? { kind: "recorded-fmp4-manifest" } : null,
activation: null,
capabilities: {
defaultVisible: true,
overlay: modality === "video",
},
});
const sources = [
source("recorded.spatial.primary", "point-cloud"),
source("recorded.camera.left", "video"),
source("recorded.camera.right", "video"),
source("live.camera", "video", "websocket"),
];
assert.deepEqual(
visibleSourceIdsAfterRecordedCatalogActivation(
["recorded.spatial.primary"],
sources,
),
["recorded.spatial.primary", "recorded.camera.left", "recorded.camera.right"],
);
});
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
const calls = [];
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
const saved = await saveObservationWorkspaceLayoutProfile(current, {
fetcher: async (input, init) => {
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
return new Response(JSON.stringify(wireProfile({ revision: 8 })), {
status: 200,
headers: { "Content-Type": "application/json" },
});
},
});
assert.equal(calls.length, 1);
assert.equal(calls[0].input, "/api/v1/workspace-layouts/observation.spatial");
assert.equal(calls[0].init.method, "PUT");
assert.equal(calls[0].init.headers["If-Match"], '"7"');
assert.equal(calls[0].body.revision, 7);
assert.equal(calls[0].body.workspace_id, "observation.spatial");
assert.equal(saved.revision, 8);
});
test("workspace layout API treats 404 as no profile and exposes revision conflicts", async () => {
assert.equal(
await fetchObservationWorkspaceLayoutProfile({
fetcher: async () => new Response(null, { status: 404 }),
}),
null,
);
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
await assert.rejects(
saveObservationWorkspaceLayoutProfile(current, {
fetcher: async () => new Response(JSON.stringify({ detail: "revision mismatch" }), {
status: 412,
headers: { "Content-Type": "application/json" },
}),
}),
(error) => error instanceof WorkspaceLayoutApiError &&
error.conflict && error.status === 412 && error.message === "revision mismatch",
);
});
test("scene tool windows are route-scoped and toolbar actions are not duplicated", async () => {
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
const utilityStart = appSource.indexOf("const contentActions");
const utilityEnd = appSource.indexOf("const header", utilityStart);
const utilitySource = appSource.slice(utilityStart, utilityEnd);
assert.match(appSource, /open=\{spatialWorkspaceActive && sourceWindowOpen\}/);
assert.match(appSource, /open=\{spatialWorkspaceActive && displayWindowOpen\}/);
assert.match(appSource, /open=\{spatialWorkspaceActive && layerInspectorOpen\}/);
assert.match(
appSource,
/if \(spatialWorkspaceActive\) return;[\s\S]*setSceneWindowOrder\(\[\]\)/,
);
assert.doesNotMatch(utilitySource, /Настроить визуальный движок/);
assert.doesNotMatch(utilitySource, /Настроить отображение/);
assert.doesNotMatch(utilitySource, /Открыть слои/);
});
@@ -12,6 +12,14 @@
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"baseUrl": ".",
"paths": {
"@mission-core/plugin-sdk": ["src/core/device-plugins/frontendSdk.ts"],
"@xgrids-k1/frontend/*": ["../../plugins/xgrids-k1/frontend/src/*"],
"react": ["node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["node_modules/@types/react/jsx-runtime.d.ts"],
"@nodedc/ui-react": ["node_modules/@nodedc/ui-react/dist/index.d.ts"]
},
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
@@ -20,5 +28,5 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src", "../../plugins/xgrids-k1/frontend/src"]
}
@@ -0,0 +1,83 @@
# NODE.DC Rerun web viewer 0.34.1
This directory contains the audited Mission Core camera-controller override for
`@rerun-io/web-viewer` 0.34.1. It changes only native spatial-camera behavior:
- the pointer ray selects an anchor on the current focus plane;
- eye position and look target scale around that anchor, so the point under the
cursor remains under the cursor;
- orbital drag anchors to the picked world-space point under the initial cursor
position, with the focus plane as an empty-space fallback;
- after Rerun's `0.02 m` near-plane safety radius is reached, excess zoom becomes
a cursor-directed dolly scaled by the scene's navigation speed instead of
silently ignoring the wheel or moving by imperceptible millimeters;
- orbital navigation has a scene-scale speed floor, so WASD and post-limit
dolly remain useful at the minimum radius;
- Mission Core's explicit `/world/sensor_pose` orbital follow keeps the current
eye radius and orientation when enabled, remains attached through manual
orbit and zoom, and releases only when the operator toggles follow off;
- repeated server-side blueprint activation for 2D detections, segmentation,
and 3D cuboids restores the last rendered operator eye instead of applying
Rerun's scene-bounds fallback, so layer toggles cannot reset the camera;
- first-person movement, panning and Rerun's zoom-out cap are unchanged.
## Source identity
- Upstream: `rerun-io/rerun`
- Tag: `0.34.1`
- Commit: `4efb18f17f6f0e41985cda99a2bdcd012febc8d5`
- Patched files: `crates/viewer/re_view_spatial/src/eye.rs`,
`crates/viewer/re_view_spatial/src/ui_3d.rs`
- Patch: `NODEDC_ZOOM_TO_CURSOR.patch`
- Rust: `1.92.0`
- Binaryen / `wasm-opt`: `117` (the version pinned by Rerun's `pixi.lock`)
- Build image: `rust:1.92-bookworm`
- Build image digest:
`sha256:e90e846de4124376164ddfbaab4b0774c7bdeef5e738866295e5a90a34a307a2`
- Build-only system packages: `clang 14.0.6`, `libudev-dev 252.39`
- Build date: `2026-07-23` (`Europe/Moscow`)
- Measured build stages: Rust to WASM `296.7 s`, JavaScript bindings
`8.5 s`, Binaryen `wasm-opt -O2` `1244.2 s`
## Reproduction
Apply the patch to the exact upstream commit, then run Rerun's own builder:
```sh
git apply --unidiff-zero NODEDC_ZOOM_TO_CURSOR.patch
cargo test -p re_view_spatial --lib eye::tests:: -- --nocapture
cargo run -p re_dev_tools -- build-web-viewer \
--release -g \
--target no-modules-base \
--no-default-features \
--features map_view \
-o rerun_js/web-viewer
```
The container also needs Binaryen `117` for Rerun's final `wasm-opt -O2` step.
Binaryen `108` from Debian 12 must not be used: it produced a module whose
`externref` table could not grow during initialization. The
generated `re_viewer.js` is transformed with
`scripts/transform-rerun-web-viewer-glue.mjs`, which mirrors Rerun 0.34.1's
`rerun_js/web-viewer/build-wasm.mjs` no-modules wrapper and teardown guards.
## Verified artifacts
| Artifact | SHA-256 |
| --- | --- |
| `re_viewer_bg.nodedc.wasm` | `ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469` |
| raw generated `re_viewer.js` | `cc196a93c5be972c801d46be4dc9934f7f042eb62941f0aa0678f1c8416c6874` |
| `re_viewer.nodedc.js` | `0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339` |
The focused Rust suite completed with `10 passed, 0 failed`, including the
orbit-to-dolly boundary, pointer-anchored rotation and scene-scale speed-floor
cases, persistent rig-orbit tracking, camera continuity across blueprint
reactivation, and explicit reset precedence. A Node `initSync`
smoke test completed successfully, including the `externref` table-growth step.
The Mission Core Node suite also checks both artifact hashes and verifies that
every `wasm.*` reference in the JavaScript glue exists in the paired WASM
exports.
`scripts/patch-rerun-web-viewer.mjs` installs the pair after npm extracts the
official package. It accepts only the known published or NODE.DC hashes and
fails closed on any other package contents.
@@ -0,0 +1,542 @@
diff --git a/crates/viewer/re_view_spatial/src/eye.rs b/crates/viewer/re_view_spatial/src/eye.rs
index e59b311..b98653a 100644
--- a/crates/viewer/re_view_spatial/src/eye.rs
+++ b/crates/viewer/re_view_spatial/src/eye.rs
@@ -207,0 +208,3 @@ pub struct EyeState {
+ /// World-space point selected when the current orbital drag started.
+ orbit_drag_anchor: Option<Vec3>,
+
@@ -245,0 +249,50 @@ pub(crate) struct EyeController {
+/// NODE.DC's operator follow mode is explicitly toggled outside the embedded
+/// viewer. Unlike Rerun's transient entity tracking, manual orbit/zoom must
+/// therefore keep this rig pivot attached until the operator toggles it off.
+const NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY: &str = "/world/sensor_pose";
+
+fn keeps_orbital_tracking_after_interaction(
+ kind: Eye3DKind,
+ tracking_entity: &EntityPath,
+) -> bool {
+ kind == Eye3DKind::Orbital
+ && tracking_entity == &EntityPath::from(NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY)
+}
+
+/// Keeps the last rendered operator eye when a server-side blueprint update
+/// reactivates the same persistent follow view without explicit pose fields.
+///
+/// Rerun normally falls back to the scene bounding box when a blueprint has no
+/// position or look target. Mission Core layer toggles intentionally omit those
+/// fields so that the browser-owned eye remains authoritative.
+fn restore_persistent_orbit_eye_after_blueprint_update(
+ eye_controller: &mut EyeController,
+ previous_eye: Option<&Eye>,
+ previous_orbit_radius: Option<f32>,
+ tracking_entity: Option<&EntityPath>,
+ has_blueprint_position: bool,
+ has_blueprint_look_target: bool,
+) -> bool {
+ let Some(tracking_entity) = tracking_entity else {
+ return false;
+ };
+ let persistent =
+ keeps_orbital_tracking_after_interaction(eye_controller.kind, tracking_entity);
+ if persistent
+ && !has_blueprint_position
+ && !has_blueprint_look_target
+ && let (Some(previous_eye), Some(previous_orbit_radius)) =
+ (previous_eye, previous_orbit_radius)
+ {
+ let pos = previous_eye.pos_in_world();
+ let radius = previous_orbit_radius.max(EyeController::MIN_ORBIT_DISTANCE);
+ eye_controller.pos = pos;
+ eye_controller.look_target = pos + previous_eye.forward_in_world() * radius;
+ eye_controller.eye_up = previous_eye
+ .world_from_rub_view
+ .transform_vector3(Vec3::Y);
+ eye_controller.fov_y = previous_eye.fov_y;
+ }
+ persistent
+}
+
@@ -434 +487 @@ impl EyeController {
- let mut rot = self.rotation();
+ let rot = self.rotation_after_delta(delta);
@@ -435,0 +489,2 @@ impl EyeController {
+ self.apply_rotation_and_radius(rot, radius);
+ }
@@ -436,0 +492,2 @@ impl EyeController {
+ fn rotation_after_delta(&self, delta: egui::Vec2) -> Quat {
+ let mut rot = self.rotation();
@@ -452 +509,2 @@ impl EyeController {
- rot = rot.normalize();
+ rot.normalize()
+ }
@@ -454 +512,13 @@ impl EyeController {
- self.apply_rotation_and_radius(rot, radius);
+ /// Rotate around the point selected under the pointer without moving that
+ /// point on screen.
+ fn rotate_radians_around_anchor(&mut self, delta: egui::Vec2, anchor: Vec3) {
+ let old_rotation = self.rotation();
+ let new_rotation = self.rotation_after_delta(delta);
+ let world_delta = new_rotation * old_rotation.inverse();
+ self.pos = anchor + world_delta * (self.pos - anchor);
+ self.look_target = anchor + world_delta * (self.look_target - anchor);
+ }
+
+ fn rotate_around_anchor(&mut self, delta: egui::Vec2, anchor: Vec3) {
+ let sensitivity = 0.004;
+ self.rotate_radians_around_anchor(sensitivity * delta, anchor);
@@ -491 +561,10 @@ impl EyeController {
- fn handle_drag(&mut self, response: &egui::Response, drag_threshold: f32) {
+ fn handle_drag(
+ &mut self,
+ eye_state: &mut EyeState,
+ response: &egui::Response,
+ drag_threshold: f32,
+ pointer_space_position: Option<Vec3>,
+ ) {
+ if !response.dragged_by(ROTATE3D_BUTTON) {
+ eye_state.orbit_drag_anchor = None;
+ }
@@ -503 +582,15 @@ impl EyeController {
- self.rotate(response.drag_delta());
+ if self.kind == Eye3DKind::Orbital {
+ let anchor = *eye_state.orbit_drag_anchor.get_or_insert_with(|| {
+ pointer_space_position
+ .filter(|position| position.is_finite())
+ .or_else(|| {
+ response.ctx.pointer_latest_pos().and_then(|pointer| {
+ self.pointer_focus_plane_anchor(response.rect, pointer)
+ })
+ })
+ .unwrap_or(self.look_target)
+ });
+ self.rotate_around_anchor(response.drag_delta(), anchor);
+ } else {
+ self.rotate(response.drag_delta());
+ }
@@ -514,0 +608,86 @@ impl EyeController {
+ /// Returns the world-space ray under the pointer.
+ ///
+ /// Keeping this calculation local to the eye controller lets orbital zoom use the pointer
+ /// synchronously. GPU picking arrives a frame later and would make the zoom pivot visibly lag.
+ fn pointer_ray_direction(&self, rect: Rect, pointer: egui::Pos2) -> Option<Vec3> {
+ if !rect.contains(pointer) || rect.width() <= 0.0 || rect.height() <= 0.0 {
+ return None;
+ }
+
+ let fov_y = self.fov_y.unwrap_or(Eye::DEFAULT_FOV_Y);
+ let aspect_ratio = rect.width() / rect.height();
+ let focal_scale = (fov_y * 0.5).tan();
+ let x = (2.0 * (pointer.x - rect.left()) / rect.width() - 1.0) * focal_scale * aspect_ratio;
+ let y = (1.0 - 2.0 * (pointer.y - rect.top()) / rect.height()) * focal_scale;
+
+ (self.rotation() * vec3(x, y, -1.0)).try_normalize()
+ }
+
+ /// Intersect the pointer ray with the plane through the current look
+ /// target. Empty-space drags then have a deterministic pivot even when GPU
+ /// picking did not hit a rendered point.
+ fn pointer_focus_plane_anchor(&self, rect: Rect, pointer: egui::Pos2) -> Option<Vec3> {
+ let ray_direction = self.pointer_ray_direction(rect, pointer)?;
+ let denominator = ray_direction.dot(self.fwd());
+ if denominator <= 1.0e-4 {
+ return None;
+ }
+ Some(self.pos + ray_direction * (self.radius() / denominator))
+ }
+
+ /// Zoom an orbital eye around the point under the pointer on the current focus plane.
+ ///
+ /// The position and look target are scaled around the same anchor. This preserves the
+ /// projected pointer position while retaining the existing orbit direction and controls.
+ /// Once the near-plane safety radius is reached, excess zoom becomes a cursor-directed dolly
+ /// measured in the scene's navigation speed instead of near-plane millimeters.
+ fn zoom_orbit_towards_pointer(
+ &mut self,
+ zoom_factor: f32,
+ max_radius: f32,
+ rect: Rect,
+ pointer: Option<egui::Pos2>,
+ ) {
+ let radius = self.radius();
+ if !radius.is_finite() || radius <= f32::MIN_POSITIVE {
+ return;
+ }
+
+ let requested_radius = radius / zoom_factor;
+ let new_radius = requested_radius.clamp(Self::MIN_ORBIT_DISTANCE, max_radius);
+ let scale = new_radius / radius;
+
+ let pointer_ray = pointer.and_then(|pointer| {
+ Some((
+ self.pointer_ray_direction(rect, pointer)?,
+ self.pointer_focus_plane_anchor(rect, pointer)?,
+ ))
+ });
+ if let Some((ray_direction, anchor)) = pointer_ray {
+ self.pos = anchor + (self.pos - anchor) * scale;
+ self.look_target = anchor + (self.look_target - anchor) * scale;
+
+ if requested_radius < Self::MIN_ORBIT_DISTANCE {
+ // Shrinking the remaining 2 cm orbit radius consumes only part of this input.
+ // Hand the logarithmic remainder to the same scene-scaled speed used by WASD
+ // and first-person scroll. Basing this on the near-plane remainder itself made
+ // each wheel event move by millimeters and felt indistinguishable from a hard
+ // zoom limit on building- and map-scale recordings.
+ let orbit_zoom_factor = (radius / Self::MIN_ORBIT_DISTANCE).max(1.0);
+ let remaining_zoom_factor = zoom_factor / orbit_zoom_factor;
+ if remaining_zoom_factor > 1.0 && remaining_zoom_factor.is_finite() {
+ let dolly = remaining_zoom_factor.ln() * self.speed as f32;
+ self.pos += ray_direction * dolly;
+ self.look_target += ray_direction * dolly;
+ }
+ }
+ self.did_interact = true;
+ return;
+ }
+
+ // Pointer data can be absent for synthetic zoom events. Preserve Rerun's centered zoom
+ // behavior in that case instead of dropping the input.
+ self.pos = self.look_target - self.fwd() * new_radius;
+ self.did_interact = true;
+ }
+
@@ -516 +695,2 @@ impl EyeController {
- fn handle_zoom(&mut self, egui_ctx: &egui::Context, scene_bounding_box: &macaw::BoundingBox) {
+ fn handle_zoom(&mut self, response: &egui::Response, scene_bounding_box: &macaw::BoundingBox) {
+ let egui_ctx = &response.ctx;
@@ -531,2 +710,0 @@ impl EyeController {
- let radius = self.pos.distance(self.look_target);
-
@@ -534,0 +713 @@ impl EyeController {
+ let radius = self.radius();
@@ -536,11 +715,2 @@ impl EyeController {
- let new_radius = (radius / zoom_factor).clamp(Self::MIN_ORBIT_DISTANCE, max_radius);
-
- // The user may be scrolling to move the camera closer, but are not realizing
- // the radius is now tiny.
- // TODO(emilk): inform the users somehow that scrolling won't help, and that they should use WSAD instead.
- // It might be tempting to start moving the camera here on scroll, but that would is bad for other reasons.
-
- if f32::MIN_POSITIVE < new_radius {
- self.pos = self.look_target - self.fwd() * new_radius;
- self.did_interact = true;
- }
+ let pointer = response.ctx.pointer_latest_pos();
+ self.zoom_orbit_towards_pointer(zoom_factor, max_radius, response.rect, pointer);
@@ -669,0 +840 @@ impl EyeController {
+ pointer_space_position: Option<Vec3>,
@@ -671,0 +843,5 @@ impl EyeController {
+ if self.kind == Eye3DKind::Orbital {
+ self.speed = self
+ .speed
+ .max(minimum_orbital_navigation_speed(scene_bounding_box) as f64);
+ }
@@ -687 +863 @@ impl EyeController {
- self.handle_drag(response, drag_threshold);
+ self.handle_drag(eye_state, response, drag_threshold, pointer_space_position);
@@ -690 +866 @@ impl EyeController {
- self.handle_zoom(&response.ctx, scene_bounding_box);
+ self.handle_zoom(response, scene_bounding_box);
@@ -728,0 +905,18 @@ fn max_orbital_radius(scene_bounding_box: &macaw::BoundingBox) -> f32 {
+/// Lower bound for free-flight and post-limit zoom speed in orbital mode.
+///
+/// The default orbital speed is the current orbit radius. Close to the near
+/// limit that makes both WASD and cursor-directed dolly effectively stop.
+fn minimum_orbital_navigation_speed(scene_bounding_box: &macaw::BoundingBox) -> f32 {
+ const FALLBACK: f32 = 0.25;
+ const SCENE_DIAGONAL_FACTOR: f32 = 0.01;
+
+ if !scene_bounding_box.is_finite() || scene_bounding_box.is_nothing() {
+ return FALLBACK;
+ }
+ let scene_diagonal = scene_bounding_box.size().length();
+ if !scene_diagonal.is_finite() || scene_diagonal <= 0.0 {
+ return FALLBACK;
+ }
+ (scene_diagonal * SCENE_DIAGONAL_FACTOR).max(FALLBACK)
+}
+
@@ -773,0 +968 @@ impl EyeState {
+ pointer_space_position: Option<Vec3>,
@@ -775 +970 @@ impl EyeState {
- ) -> Result<Eye, ViewPropertyQueryError> {
+ ) -> Result<(Eye, bool), ViewPropertyQueryError> {
@@ -777,0 +973,25 @@ impl EyeState {
+ let tracking_entity = eye_property
+ .component_or_empty::<re_sdk_types::components::EntityPath>(
+ EyeControls3D::descriptor_tracking_entity().component,
+ )?
+ .filter(|tracking_entity| !tracking_entity.is_empty());
+ let tracking_entity_path = tracking_entity
+ .as_ref()
+ .map(|tracking_entity| EntityPath::from(tracking_entity.as_str()));
+ let has_blueprint_position = eye_property
+ .component_or_empty::<Position3D>(EyeControls3D::descriptor_position().component)?
+ .is_some();
+ let has_blueprint_look_target = eye_property
+ .component_or_empty::<Position3D>(
+ EyeControls3D::descriptor_look_target().component,
+ )?
+ .is_some();
+ let persistent_orbit_tracking = restore_persistent_orbit_eye_after_blueprint_update(
+ &mut eye_controller,
+ self.last_eye.as_ref(),
+ self.last_orbit_radius,
+ tracking_entity_path.as_ref(),
+ has_blueprint_position,
+ has_blueprint_look_target,
+ );
+
@@ -794,6 +1013,0 @@ impl EyeState {
- let tracking_entity = eye_property
- .component_or_empty::<re_sdk_types::components::EntityPath>(
- EyeControls3D::descriptor_tracking_entity().component,
- )?
- .filter(|tracking_entity| !tracking_entity.is_empty());
-
@@ -816,0 +1031 @@ impl EyeState {
+ pointer_space_position,
@@ -865 +1080 @@ impl EyeState {
- return Ok(tracked_eye);
+ return Ok((tracked_eye, persistent_orbit_tracking));
@@ -872 +1087 @@ impl EyeState {
- Ok(eye_controller.get_eye())
+ Ok((eye_controller.get_eye(), persistent_orbit_tracking))
@@ -891,0 +1107,2 @@ impl EyeState {
+ let persistent_orbit_tracking =
+ keeps_orbital_tracking_after_interaction(eye_controller.kind, &tracking_entity);
@@ -946 +1163 @@ impl EyeState {
- if new_tracking {
+ if new_tracking && !persistent_orbit_tracking {
@@ -998 +1215,4 @@ impl EyeState {
- if eye_controller.did_interact && did_eye_orbit_center_change {
+ if eye_controller.did_interact
+ && did_eye_orbit_center_change
+ && !persistent_orbit_tracking
+ {
@@ -1189,0 +1410 @@ impl EyeState {
+ pointer_space_position: Option<Vec3>,
@@ -1198 +1419 @@ impl EyeState {
- let target_eye = self.control_and_sync_with_blueprint(
+ let (target_eye, persistent_orbit_tracking) = self.control_and_sync_with_blueprint(
@@ -1203,0 +1425 @@ impl EyeState {
+ pointer_space_position,
@@ -1210,3 +1432,6 @@ impl EyeState {
- if eye_property
- .component_or_empty::<Position3D>(EyeControls3D::descriptor_position().component)?
- .is_none()
+ if !persistent_orbit_tracking
+ && eye_property
+ .component_or_empty::<Position3D>(
+ EyeControls3D::descriptor_position().component,
+ )?
+ .is_none()
@@ -1251,0 +1477,196 @@ impl EyeState {
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn orbital_controller(pos: Vec3, look_target: Vec3) -> EyeController {
+ EyeController {
+ pos,
+ look_target,
+ kind: Eye3DKind::Orbital,
+ speed: 1.0,
+ eye_up: Vec3::Z,
+ fov_y: Some(Eye::DEFAULT_FOV_Y),
+ did_interact: false,
+ }
+ }
+
+ fn test_rect() -> Rect {
+ Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))
+ }
+
+ fn assert_vec3_close(actual: Vec3, expected: Vec3) {
+ assert!(
+ actual.abs_diff_eq(expected, 1.0e-5),
+ "actual={actual:?}, expected={expected:?}"
+ );
+ }
+
+ #[test]
+ fn centered_pointer_keeps_the_orbit_target() {
+ let rect = test_rect();
+ let mut controller = orbital_controller(vec3(0.0, -10.0, 0.0), Vec3::ZERO);
+
+ controller.zoom_orbit_towards_pointer(2.0, 100.0, rect, Some(rect.center()));
+
+ assert_vec3_close(controller.pos, vec3(0.0, -5.0, 0.0));
+ assert_vec3_close(controller.look_target, Vec3::ZERO);
+ assert!(controller.did_interact);
+ }
+
+ #[test]
+ fn off_center_pointer_stays_on_the_same_view_ray() {
+ let rect = test_rect();
+ let pointer = egui::pos2(650.0, 240.0);
+ let mut controller = orbital_controller(vec3(0.0, -10.0, 0.0), Vec3::ZERO);
+ let original_ray = controller
+ .pointer_ray_direction(rect, pointer)
+ .expect("test pointer must produce a ray");
+ let forward = controller.fwd();
+ let anchor =
+ controller.pos + original_ray * (controller.radius() / original_ray.dot(forward));
+
+ controller.zoom_orbit_towards_pointer(2.0, 100.0, rect, Some(pointer));
+
+ assert!((controller.radius() - 5.0).abs() < 1.0e-5);
+ let ray_after = (anchor - controller.pos).normalize();
+ assert!(ray_after.dot(original_ray) > 0.99999);
+ }
+
+ #[test]
+ fn near_limit_hands_excess_zoom_to_cursor_directed_dolly() {
+ let rect = test_rect();
+ let radius = EyeController::MIN_ORBIT_DISTANCE;
+ let mut controller = orbital_controller(vec3(0.0, -radius, 0.0), Vec3::ZERO);
+ let old_pos = controller.pos;
+ let old_target = controller.look_target;
+
+ controller.zoom_orbit_towards_pointer(2.0, 100.0, rect, Some(rect.center()));
+
+ assert!((controller.radius() - radius).abs() < 1.0e-6);
+ assert_vec3_close(controller.pos, old_pos + Vec3::Y * 2.0_f32.ln());
+ assert_vec3_close(controller.look_target, old_target + Vec3::Y * 2.0_f32.ln());
+ assert_vec3_close(controller.look_target - controller.pos, Vec3::Y * radius);
+ }
+
+ #[test]
+ fn crossing_near_limit_preserves_unconsumed_scene_scaled_zoom() {
+ let rect = test_rect();
+ let mut controller = orbital_controller(vec3(0.0, -0.03, 0.0), Vec3::ZERO);
+
+ controller.zoom_orbit_towards_pointer(3.0, 100.0, rect, Some(rect.center()));
+
+ assert!((controller.radius() - EyeController::MIN_ORBIT_DISTANCE).abs() < 1.0e-6);
+ assert_vec3_close(controller.pos, vec3(0.0, 2.0_f32.ln() - 0.02, 0.0));
+ assert_vec3_close(controller.look_target, vec3(0.0, 2.0_f32.ln(), 0.0));
+ }
+
+ #[test]
+ fn zoom_out_respects_scene_radius_cap() {
+ let rect = test_rect();
+ let mut controller = orbital_controller(vec3(0.0, -10.0, 0.0), Vec3::ZERO);
+
+ controller.zoom_orbit_towards_pointer(0.1, 25.0, rect, Some(rect.center()));
+
+ assert!((controller.radius() - 25.0).abs() < 1.0e-5);
+ }
+
+ #[test]
+ fn orbital_rotation_keeps_selected_anchor_on_the_same_view_ray() {
+ let anchor = vec3(3.0, 2.0, 1.5);
+ let mut controller = orbital_controller(vec3(0.0, -10.0, 4.0), Vec3::ZERO);
+ let original_view_ray = controller.rotation().inverse() * (anchor - controller.pos);
+
+ controller.rotate_radians_around_anchor(egui::vec2(0.35, -0.2), anchor);
+
+ let rotated_view_ray = controller.rotation().inverse() * (anchor - controller.pos);
+ assert!(
+ rotated_view_ray
+ .normalize()
+ .dot(original_view_ray.normalize())
+ > 0.99999
+ );
+ assert!((rotated_view_ray.length() - original_view_ray.length()).abs() < 1.0e-5);
+ }
+
+ #[test]
+ fn orbital_navigation_speed_floor_tracks_scene_scale() {
+ let building_scale = macaw::BoundingBox::from_min_max(Vec3::ZERO, vec3(300.0, 400.0, 0.0));
+
+ assert!((minimum_orbital_navigation_speed(&building_scale) - 5.0).abs() < 1.0e-6);
+ assert_eq!(
+ minimum_orbital_navigation_speed(&macaw::BoundingBox::nothing()),
+ 0.25
+ );
+ }
+
+ #[test]
+ fn nodedc_rig_orbit_tracking_is_persistent() {
+ let rig = EntityPath::from(NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY);
+ let other = EntityPath::from("/world/another_entity");
+
+ assert!(keeps_orbital_tracking_after_interaction(
+ Eye3DKind::Orbital,
+ &rig
+ ));
+ assert!(!keeps_orbital_tracking_after_interaction(
+ Eye3DKind::FirstPerson,
+ &rig
+ ));
+ assert!(!keeps_orbital_tracking_after_interaction(
+ Eye3DKind::Orbital,
+ &other
+ ));
+ }
+
+ #[test]
+ fn persistent_rig_follow_restores_the_last_rendered_eye_after_blueprint_update() {
+ let previous_controller =
+ orbital_controller(vec3(12.0, -7.0, 4.0), vec3(10.0, 2.0, 1.0));
+ let previous_eye = previous_controller.get_eye();
+ let previous_radius = previous_controller.radius();
+ let mut fallback_controller =
+ orbital_controller(vec3(500.0, -800.0, 300.0), Vec3::ZERO);
+ let rig = EntityPath::from(NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY);
+
+ assert!(restore_persistent_orbit_eye_after_blueprint_update(
+ &mut fallback_controller,
+ Some(&previous_eye),
+ Some(previous_radius),
+ Some(&rig),
+ false,
+ false,
+ ));
+ assert_vec3_close(fallback_controller.pos, previous_eye.pos_in_world());
+ assert!((fallback_controller.radius() - previous_radius).abs() < 1.0e-5);
+ assert!(
+ fallback_controller
+ .fwd()
+ .dot(previous_eye.forward_in_world())
+ > 0.99999
+ );
+ }
+
+ #[test]
+ fn explicit_blueprint_pose_is_not_replaced_by_the_previous_eye() {
+ let previous_controller =
+ orbital_controller(vec3(12.0, -7.0, 4.0), vec3(10.0, 2.0, 1.0));
+ let previous_eye = previous_controller.get_eye();
+ let mut explicit_controller =
+ orbital_controller(vec3(500.0, -800.0, 300.0), Vec3::ZERO);
+ let explicit_pos = explicit_controller.pos;
+ let explicit_target = explicit_controller.look_target;
+ let rig = EntityPath::from(NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY);
+
+ assert!(restore_persistent_orbit_eye_after_blueprint_update(
+ &mut explicit_controller,
+ Some(&previous_eye),
+ Some(previous_controller.radius()),
+ Some(&rig),
+ true,
+ true,
+ ));
+ assert_vec3_close(explicit_controller.pos, explicit_pos);
+ assert_vec3_close(explicit_controller.look_target, explicit_target);
+ }
+}
diff --git a/crates/viewer/re_view_spatial/src/ui_3d.rs b/crates/viewer/re_view_spatial/src/ui_3d.rs
index 56257a8..07498ab 100644
--- a/crates/viewer/re_view_spatial/src/ui_3d.rs
+++ b/crates/viewer/re_view_spatial/src/ui_3d.rs
@@ -180,0 +181,4 @@ impl SpatialView3D {
+ let pointer_space_position = state
+ .previous_picking_result
+ .as_ref()
+ .and_then(crate::picking::PickingResult::space_position);
@@ -186,0 +191 @@ impl SpatialView3D {
+ pointer_space_position,
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
import { fileURLToPath } from "node:url";
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import wasm from "vite-plugin-wasm";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
return {
plugins: [react(), wasm()],
resolve: {
alias: {
"@mission-core/plugin-sdk": fileURLToPath(
new URL("./src/core/device-plugins/frontendSdk.ts", import.meta.url),
),
"@xgrids-k1/frontend": fileURLToPath(
new URL("../../plugins/xgrids-k1/frontend/src", import.meta.url),
),
},
dedupe: ["react", "react-dom", "@nodedc/ui-react"],
},
optimizeDeps: {
// Rerun resolves its WASM asset relative to the package entrypoint. Vite's
// dependency pre-bundler flattens that entrypoint and leaves the WASM URL
// pointing at the SPA fallback, which browsers reject as text/html.
exclude: ["@rerun-io/web-viewer"],
},
build: {
target: "esnext",
},
server: {
host: "127.0.0.1",
port: 5173,
strictPort: true,
fs: {
allow: [
fileURLToPath(new URL(".", import.meta.url)),
fileURLToPath(new URL("../../plugins/xgrids-k1", import.meta.url)),
fileURLToPath(new URL("../../../NODEDC_DESIGN_GUIDELINE/packages", import.meta.url)),
],
},
proxy: {
"/api": {
target: apiTarget,
changeOrigin: false,
ws: true,
},
},
},
preview: {
host: "127.0.0.1",
port: 4173,
strictPort: true,
proxy: {
"/api": {
target: apiTarget,
changeOrigin: false,
ws: true,
},
},
},
};
});
-256
View File
@@ -1,256 +0,0 @@
# Пункт управления NODE.DC
Универсальный браузерный пункт управления NODE.DC на React 19, TypeScript и
Vite. Приложение задаёт общую операторскую оболочку для аппаратов, сенсоров,
наблюдения, миссий и записей. XGRIDS/LixelKity K1 является первым реальным
device adapter, но структура интерфейса от него не зависит.
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
переход во внешний облачный интерфейс. При запуске K1 live/replay backend сам
создаёт Rerun gRPC/proxy source; ручной адрес нужен только для другого Rerun
потока или совместимой RRD-записи.
## Текущее состояние
| Контур | Состояние | Что это означает |
| --- | --- | --- |
| NODE.DC fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
| Автоматический MQTT → Rerun | Реализован | Первый live/replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие сессии сбрасывают сцену и метрики и переиспользуют его. |
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория и сетка. Проекция, собственный timeline и сохранённые layout-профили ещё не подключены. |
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
| Камеры, карты и миссии | Интерфейс готов | Серверная логика и реальные каналы для этих рабочих поверхностей ещё не подключены. |
Приложение не генерирует демонстрационное облако, траекторию, кадры или
метрики. Если реальных данных нет, область сцены остаётся пустой, а числовые поля
показывают `—`.
## Архитектура данных
```text
K1 MQTT :1883, read-only
└── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
└── bounded latest-wins preview queue (32 сообщения)
└── проверенный protobuf/LZ4 decoder
└── Rerun Points3D + Transform3D + LineStrips3D
└── gRPC/proxy TCP 9876
└── rerun_grpc_url в REST/WebSocket state
└── встроенный @rerun-io/web-viewer
└── пространственная сцена NODE.DC
React Control Station ←→ REST /api/* + WebSocket /api/events
FastAPI на 127.0.0.1:8000
CoreBluetooth + live/replay runtime
```
В момент готовности `RerunBridge` backend публикует адрес вида
`rerun+http://127.0.0.1:9876/proxy`. Frontend автоматически назначает его сцене,
если оператор не указал ручной source. После остановки приёма URL и встроенный
viewer остаются активны, а следующая сессия сбрасывает session-local геометрию,
траекторию и метрики и использует тот же listener. Он закрывается вместе с
процессом `k1link serve`.
Поля `foxglove_ws_url` и `foxglove_viewer_url` пока остаются в API как
совместимость со старым контрактом, но текущий runtime держит их пустыми.
## Фиксированная оболочка
Shell собран из локальных NODE.DC UI packages и сохраняет одну структуру для
всех функциональных модулей:
1. `AppHeader` — марка NODE.DC, выбор архитектурного раздела и состояние
локального backend.
2. `AdminNavigationPanel` — контекст аппарата и список рабочих поверхностей
выбранного раздела.
3. `LandingStage` — стартовая ситуационная поверхность и быстрые переходы.
4. `ApplicationPanel` — единый контейнер активной рабочей поверхности.
5. `Window` и `Inspector` — источник, отображение, слои и компоновка без
раскрытия внутренних панелей визуального движка.
Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя,
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
Control Station. Размер точек, способ окрашивания и палитра, 12-секундное по
умолчанию накопление, видимость облака и траектории и сетка связаны с backend и
Rerun Blueprint. Кнопки собственного timeline, смена 2D/3D/карты и сохранение
layout пока остаются интерфейсным контрактом.
## Архитектурные разделы
Навигация описана данными в `src/productModel.ts`, а не зашита в разметку каждой
страницы.
| Раздел | Назначение |
| --- | --- |
| Центр | Оперативный обзор, состояние контура и активность оператора. |
| Парк | Аппараты, текущее устройство, сенсоры и конфигурации борта. |
| Наблюдение | Пространственная сцена, камеры, карта, объекты, телеметрия и время. |
| Миссии | Планировщик, маршруты, сценарии и исполнение. Командный backend отключён. |
| Данные | Сессии, потоки, сущности, playback и экспорт доказательств. |
| Система | Модули, интеграции, сеть, аудит и настройки платформы. |
Карточки возможностей имеют четыре честных уровня: работает сейчас, готово к
источнику, интерфейсный контракт и последующий этап. Каталог не следует читать
как утверждение, что для каждой карточки уже существует backend.
## Установка, проверка, сборка и запуск
Требуются Node.js 20.19+ либо 22.12+, `uv` и соседний checkout
`NODEDC_DESIGN_GUIDELINE`: зависимости `@nodedc/*` подключены к нему через
локальные `file:` пути. Python устанавливается только в `.venv` репозитория.
Канонический путь от корня репозитория:
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NDC_xgrids-k1-connector
uv sync --group dev
cd apps/k1-viewer
npm install
npm run typecheck
npm run build
cd ../..
uv run k1link serve
```
Открыть `http://127.0.0.1:8000`. Команда `serve` отдаёт собранный `dist/` и
локальный API. Она намеренно привязана только к `127.0.0.1`; LAN bind не
предусмотрен, потому что endpoint подключения кратковременно принимает пароль
Wi-Fi.
Для разработки интерфейса после запуска backend:
```bash
cd apps/k1-viewer
npm run dev
```
Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/api/events` на
`http://127.0.0.1:8000`. Другой локальный backend можно указать переменной
`VITE_API_TARGET`. Preview production-сборки запускается командой
`npm run preview` на `http://127.0.0.1:4173`.
## Операторский путь для текущего K1 adapter
1. Запустить `uv run k1link serve` и открыть Control Station.
2. Выбрать **Парк → Локальное устройство**.
3. Включить K1, дождаться стабильного индикатора и подтвердить это в форме.
4. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
шестисекундного поиска; метка совместимости является подсказкой, выбор делает
оператор.
5. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
reviewed provisioning-запись без автоматических повторов.
6. Запустить live-приём по определённому адресу K1 либо replay локального
`.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и
останавливается подтверждённым двойным нажатием кнопки устройства.
7. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
state. Ручной source вводить не требуется.
8. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
частота, число точек, задержка и пропуски preview появятся после прихода
сообщений K1.
В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений:
38 кадров `lio_pcl`, 42 кадра `lio_pose`, 2 775 точек в последнем облаке и
0 ошибок декодирования.
## Источники Rerun
Live/replay runtime автоматически назначает первый URL из примера. В
**Наблюдение → Пространственная сцена → Источник** можно оставить ручное поле
пустым либо указать другой адрес, который понимает `@rerun-io/web-viewer`
зафиксированной в `package.json` версии:
```text
rerun+http://127.0.0.1:9876/proxy
http://127.0.0.1:8080/recording.rrd
https://example.internal/recording.rrd
```
- `rerun+http://…/proxy` — живой Rerun gRPC source через доступный браузеру
proxy;
- `http(s)://…/recording.rrd` — RRD-запись по HTTP(S);
- версия RRD должна быть совместима с версией Web Viewer;
- ручной URL хранится только в состоянии текущей страницы и имеет приоритет над
автоматическим source;
- ошибка запуска показывается в viewport, без подстановки фиктивных данных.
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
оболочку. Backend принимает и применяет к Rerun размер точки `0.512.0`, режимы
цвета `intensity`, `height`, `distance`, `rgb`, `class`, палитры Turbo, Viridis,
Plasma, grayscale и custom, накопление `0120` секунд, а также видимость облака,
траектории и сетки. Значение по умолчанию — 12 секунд истории реальных кадров.
Для текущего firmware-3 `lio_pcl` доказана только интенсивность из младшего
байта `rgbi`; RGB используется лишь когда он действительно присутствует в
декодированном формате. Custom/class сейчас означает выбранный сплошной цвет,
а не готовую семантическую классификацию.
Custom timeline, переключатель 2D/3D/карты, семантические слои и сохранение RBL
пока не вызывают Blueprint/playback API. Черновик компоновки фиксируется только
в памяти текущей страницы и не записывается на диск.
## Контракт локального API
| Метод | Route | Тело / назначение |
| --- | --- | --- |
| `GET` | `/api/health` | Проверка локального сервиса. |
| `GET` | `/api/state` | Авторитетный snapshot состояния. |
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }`. |
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }`. |
| `POST` | `/api/session/live` | Опционально `{ "host", "duration_seconds" }`. |
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }`. |
| `POST` | `/api/session/stop` | Остановка активного источника. |
| `POST` | `/api/viewer/settings` | Размер/цвет точек, накопление, видимость облака, траектории и сетки. |
| `WS` | `/api/events` | Периодические snapshots для live UI. |
`/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`,
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
пустыми. OpenAPI доступен по `/api/docs`.
## Карта исходников frontend
| Файл | Ответственность |
| --- | --- |
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
| `src/workspaces/DeviceWorkspace.tsx` | Реальный K1 BLE/Wi-Fi/live/replay adapter UI. |
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
| `src/components/RerunViewport.tsx` | Жизненный цикл встроенного Rerun Web Viewer и selection events. |
| `src/api.ts` | REST/WebSocket контракт с FastAPI. |
| `src/useK1Console.ts` | Состояние backend, polling, события и действия оператора. |
| `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. |
| `src/presentation.ts` | Русские подписи фаз и форматирование реальных метрик. |
| `src/messages.ts` | Обезличивание и локализация технических сообщений в пользовательском интерфейсе. |
| `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
## Safety и чувствительные данные
- Control Station и credential endpoint доступны только на loopback.
- Исключение: Rerun gRPC/proxy на TCP 9876 сейчас слушает все сетевые интерфейсы,
хотя автоматически возвращаемый URL содержит `127.0.0.1`. На нём нет
connector-level authentication или TLS. Этот порт допустим только в доверенной
лабораторной LAN; его нельзя пробрасывать в публичный Интернет или cellular
WAN без аутентифицированного TLS reverse proxy. Listener намеренно остаётся
активным между сессиями; для его закрытия нужно остановить `k1link serve`.
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
очищается после успешного ответа и не сохраняется в URL/local storage.
- BLE-подключение выполняет только отдельно рассмотренную provisioning-запись;
случайные GATT writes и автоматические повторы запрещены.
- MQTT live/replay не публикует команды устройству. Запуск и остановка
физического сканирования остаются за кнопкой K1.
- Live-сессии сначала сохраняют сырые сообщения, затем формируют preview. При
перегрузке preview может быть отброшен, raw evidence сохраняется.
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
накопления viewer использует session-local `stream_time`. `capture_time` — не
доказанный timestamp сенсора K1 и не photon-to-screen latency.
- Панорамный камерный поток в наблюдавшихся MQTT report topics отсутствует;
текущая Rerun-сцена содержит только облако точек и позу/траекторию, а
операционные метрики отображаются внешней оболочкой Control Station.
- `sessions/` игнорируется Git и может содержать адреса, идентификаторы,
траекторию и карту помещения. В репозиторий попадают только redacted manifests
и безопасная документация.
-740
View File
@@ -1,740 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AdminNavigationPanel,
AppHeader,
ApplicationPanel,
ApplicationShell,
Button,
Checker,
ColorField,
ControlRow,
HeaderAvatar,
HeaderNavigation,
HeaderProfile,
HeaderProfileButton,
HeaderWorkspace,
Icon,
Inspector,
RangeControl,
Select,
StatusBadge,
TextField,
Window,
WindowFooterActions,
useApplicationWorkspace,
type ApplicationPanelUtilityAction,
} from "@nodedc/ui-react";
import { LandingStage } from "./components/LandingStage";
import type { ViewerSettings } from "./api";
import {
rootById,
roots,
workspaceById,
workspacesForRoot,
type RootId,
} from "./productModel";
import { backendLabel, phaseLabel, phaseTone } from "./presentation";
import { localizeRuntimeMessage } from "./messages";
import {
defaultSceneSettings,
type PointColorMode,
type PointPalette,
type SceneSettings,
} from "./sceneSettings";
import { useK1Console } from "./useK1Console";
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { WorkspaceRenderer } from "./workspaces/Workspaces";
import "./styles/scene-windows.css";
type SceneToolWindowId = "sources" | "display" | "layers" | "layout";
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
{ value: "height", label: "Высота Z", description: "Градиент по вертикальной координате" },
{ value: "distance", label: "Расстояние", description: "Дальность точки от сенсора" },
{ value: "rgb", label: "RGB", description: "Цвет, если он присутствует в данных" },
{ value: "class", label: "Класс", description: "Категория внешнего модуля восприятия" },
];
const paletteOptions: Array<{ value: PointPalette; label: string; description: string }> = [
{ value: "turbo", label: "Turbo", description: "Контрастный спектральный градиент" },
{ value: "viridis", label: "Viridis", description: "Равномерный перцептивный градиент" },
{ value: "plasma", label: "Plasma", description: "Тёплый контрастный градиент" },
{ value: "grayscale", label: "Серый", description: "Монохромное отображение" },
{ value: "custom", label: "Свой цвет", description: "Один назначенный цвет" },
];
function toViewerSettings(settings: SceneSettings): ViewerSettings {
return {
point_size: settings.pointSize,
color_mode: settings.colorMode,
palette: settings.palette,
custom_color: settings.customColor,
accumulation_seconds: settings.accumulationSeconds,
show_points: settings.showPoints,
show_trajectory: settings.showTrajectory,
show_grid: settings.showGrid,
};
}
function mergeViewerSettings(
current: SceneSettings,
remote: ViewerSettings,
): SceneSettings {
const next = {
...current,
pointSize: remote.point_size,
colorMode: remote.color_mode,
palette: remote.palette,
customColor: remote.custom_color,
accumulationSeconds: remote.accumulation_seconds,
showPoints: remote.show_points,
showTrajectory: remote.show_trajectory,
showGrid: remote.show_grid,
};
const unchanged =
current.pointSize === next.pointSize &&
current.colorMode === next.colorMode &&
current.palette === next.palette &&
current.customColor === next.customColor &&
current.accumulationSeconds === next.accumulationSeconds &&
current.showPoints === next.showPoints &&
current.showTrajectory === next.showTrajectory &&
current.showGrid === next.showGrid;
return unchanged ? current : next;
}
export default function App() {
const console = useK1Console();
const workspace = useApplicationWorkspace<string>({
navigationOpen: false,
contentExpanded: true,
});
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
const [sourceUrl, setSourceUrl] = useState("");
const [sourceDraft, setSourceDraft] = useState("");
const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
const [displayWindowOpen, setDisplayWindowOpen] = useState(false);
const [layerInspectorOpen, setLayerInspectorOpen] = useState(false);
const [sceneWindowOrder, setSceneWindowOrder] = useState<SceneToolWindowId[]>([]);
const [layoutWindowOpen, setLayoutWindowOpen] = useState(false);
const [layoutName, setLayoutName] = useState("Операторская сцена");
const [layoutDraftSaved, setLayoutDraftSaved] = useState(false);
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings);
const currentRoot = rootById(activeRoot);
const activeDefinition = workspaceById(workspace.activeView);
const rootWorkspaces = workspacesForRoot(activeRoot);
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
const automaticSourceUrl = console.state?.rerun_grpc_url?.trim() ?? "";
const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
useEffect(() => {
const remote = console.state?.viewer_settings;
if (!remote) return;
setSceneSettings((current) => mergeViewerSettings(current, remote));
if (!displayWindowOpen) {
setDisplayDraft((current) => mergeViewerSettings(current, remote));
}
}, [console.state?.viewer_settings, displayWindowOpen]);
const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => {
setSceneWindowOrder((current) => [
...current.filter((candidate) => candidate !== windowId),
windowId,
]);
}, []);
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
if (windowId === "sources") setSourceWindowOpen(false);
if (windowId === "display") setDisplayWindowOpen(false);
if (windowId === "layers") setLayerInspectorOpen(false);
if (windowId === "layout") setLayoutWindowOpen(false);
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
}, []);
useEffect(() => {
if (!activeSceneWindow) return;
const closeActiveWindow = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
if (document.querySelector(".nodedc-dropdown-surface")) return;
if (document.querySelector('.nodedc-overlay[data-placement="center"]')) return;
event.preventDefault();
closeSceneWindow(activeSceneWindow);
};
document.addEventListener("keydown", closeActiveWindow);
return () => document.removeEventListener("keydown", closeActiveWindow);
}, [activeSceneWindow, closeSceneWindow]);
const selectRoot = (rootId: RootId) => {
setActiveRoot(rootId);
workspace.closeView();
workspace.openNavigation();
};
const openView = (viewId: string) => {
const definition = workspaceById(viewId);
if (!definition) return;
setActiveRoot(definition.root);
workspace.openView(viewId);
};
const openSource = () => {
setSourceDraft(sourceUrl);
setSourceWindowOpen(true);
activateSceneWindow("sources");
};
const openDisplay = () => {
setDisplayDraft(sceneSettings);
setDisplayWindowOpen(true);
activateSceneWindow("display");
};
const openLayers = () => {
setLayerInspectorOpen(true);
activateSceneWindow("layers");
};
const openLayout = () => {
setLayoutDraftSaved(false);
setLayoutWindowOpen(true);
activateSceneWindow("layout");
};
const applyDisplaySettings = async () => {
const applied = await console.updateViewerSettings(toViewerSettings(displayDraft));
if (applied) setSceneSettings(displayDraft);
};
const applyScenePatch = async (patch: Partial<SceneSettings>) => {
const previous = sceneSettings;
const next = { ...sceneSettings, ...patch };
setSceneSettings(next);
setDisplayDraft((current) => (displayWindowOpen ? { ...current, ...patch } : next));
const applied = await console.updateViewerSettings(toViewerSettings(next));
if (!applied) {
setSceneSettings(previous);
if (!displayWindowOpen) setDisplayDraft(previous);
}
};
const contentActions = useMemo<ApplicationPanelUtilityAction[]>(() => {
const actions: ApplicationPanelUtilityAction[] = [];
if (activeDefinition?.kind === "device") {
actions.push({
label: "Обновить состояние локального контура",
icon: "refresh",
onClick: () => void console.refresh(),
});
}
if (activeDefinition?.kind === "spatial") {
actions.push(
{ label: "Настроить источник", icon: "network", onClick: openSource },
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
{ label: "Открыть слои", icon: "list", onClick: openLayers },
);
}
actions.push({
label: "Сохранить компоновку",
icon: "save",
onClick: openLayout,
});
return actions;
}, [activeDefinition?.kind, console, sceneSettings, sourceUrl]);
const header = (
<AppHeader
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
brandLabel="NODE.DC"
left={<span className="station-label">ПУНКТ УПРАВЛЕНИЯ</span>}
center={
<>
<HeaderWorkspace kind="mark" label="NODE.DC Пункт управления" imageUrl="/nodedc-mark.svg" />
<HeaderNavigation
label="Архитектурные блоки пункта управления"
value={activeRoot ?? undefined}
items={roots.map((root) => ({ value: root.id, label: root.label }))}
onChange={selectRoot}
/>
</>
}
right={
<HeaderProfile>
<HeaderProfileButton onClick={() => void console.refresh()} title="Обновить локальный контур">
<span className="api-dot" data-status={console.backendStatus} aria-hidden="true" />
{backendLabel(console.backendStatus)}
</HeaderProfileButton>
<HeaderAvatar label="DC" />
</HeaderProfile>
}
/>
);
return (
<>
<ApplicationShell
data-nodedc-ui
className="control-station"
navigationOpen={workspace.navigationOpen && activeRoot !== null}
contentOpen={workspace.contentOpen && activeDefinition !== null}
contentExpanded={workspace.contentExpanded}
header={header}
stage={
<LandingStage
root={currentRoot}
backendStatus={console.backendStatus}
phase={console.state?.phase}
message={localizeRuntimeMessage(console.state?.message)}
onOpenObservation={() => openView("spatial-scene")}
onOpenDevice={() => openView("local-device")}
/>
}
navigation={currentRoot ? (
<AdminNavigationPanel
eyebrow="NODE.DC"
title={currentRoot.title}
closeLabel={`Закрыть раздел «${currentRoot.label}»`}
navigationLabel={`Рабочие поверхности раздела «${currentRoot.label}»`}
onClose={workspace.closeNavigation}
contexts={[
{
id: "local-contour",
label: "Локальный контур",
description: console.state?.k1_ip || "Устройство не назначено",
icon: <Icon name="network" />,
active: console.backendStatus !== "offline",
},
]}
items={rootWorkspaces.map((item) => ({
id: item.id,
label: item.label,
icon: <Icon name={item.icon} />,
}))}
activeId={workspace.activeView ?? undefined}
onItemChange={openView}
footer={
<>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
<Icon name="activity" />
</span>
<span>{rootWorkspaces.length} рабочих поверхностей</span>
</>
}
/>
) : null}
content={activeDefinition ? (
<ApplicationPanel
key={activeDefinition.id}
eyebrow={activeDefinition.eyebrow}
title={activeDefinition.title}
description={activeDefinition.description}
expanded={workspace.contentExpanded}
onExpandedChange={workspace.setContentExpanded}
headerTools={
activeDefinition.kind === "device" ? (
<StatusBadge tone={phaseTone(console.state?.phase)}>
{phaseLabel(console.state?.phase)}
</StatusBadge>
) : activeDefinition.kind === "spatial" ? (
<StatusBadge tone={effectiveSourceUrl ? "accent" : "warning"}>
{effectiveSourceUrl ? "Источник назначен" : "Без источника"}
</StatusBadge>
) : (
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
)
}
utilityActions={contentActions}
onClose={workspace.closeView}
>
{activeDefinition.kind === "device" ? (
<DeviceWorkspace
console={console}
onOpenSpatialScene={() => openView("spatial-scene")}
/>
) : (
<WorkspaceRenderer
definition={activeDefinition}
state={console.state}
backendStatus={console.backendStatus}
sourceUrl={effectiveSourceUrl}
sceneSettings={sceneSettings}
navigation={{
openView,
openSource,
openDisplay,
openLayers,
}}
/>
)}
</ApplicationPanel>
) : null}
/>
<Window
open={sourceWindowOpen}
title="Источники"
subtitle="Потоки и записи пространственной сцены"
placement="end"
draggable
closeOnBackdrop={false}
closeOnEscape={false}
lockBodyScroll={false}
trapFocus={false}
className="scene-tool-window scene-tool-window--sources"
data-scene-window="sources"
data-scene-active={activeSceneWindow === "sources" ? "true" : undefined}
onPointerDown={() => activateSceneWindow("sources")}
onClose={() => closeSceneWindow("sources")}
footer={
<WindowFooterActions>
<Button
variant="ghost"
onClick={() => {
setSourceDraft("");
setSourceUrl("");
}}
>
Сбросить адрес
</Button>
<Button
variant="primary"
shape="pill"
disabled={!sourceDraft.trim()}
onClick={() => {
setSourceUrl(sourceDraft.trim());
}}
>
Применить адрес
</Button>
</WindowFooterActions>
}
>
<Inspector
defaultOpen={["connection"]}
singleOpen
sections={[
{
id: "connection",
label: "Подключение",
description: "Адрес потока или записи",
content: (
<div className="inspector-control-stack">
<ControlRow label="Состояние">
<span className="scene-window-state">
<i className="api-dot" data-status={effectiveSourceUrl ? "online" : "checking"} aria-hidden="true" />
{effectiveSourceUrl ? "Источник назначен" : "Источник не назначен"}
</span>
</ControlRow>
{effectiveSourceUrl ? (
<ControlRow label="Активный адрес" layout="stack">
<code className="scene-window-code">{effectiveSourceUrl}</code>
</ControlRow>
) : null}
<ControlRow label="Автоматический" layout="stack">
<code className="scene-window-code">
{automaticSourceUrl || "Локальный источник ещё не опубликован"}
</code>
</ControlRow>
<TextField
label="Ручной адрес"
hint="необязательно"
value={sourceDraft}
onChange={(event) => setSourceDraft(event.target.value)}
spellCheck={false}
placeholder="rerun+http://127.0.0.1:9876/proxy"
description="Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."
/>
</div>
),
},
{
id: "formats",
label: "Поддерживаемые адреса",
description: "Rerun gRPC и RRD",
content: (
<div className="inspector-control-stack">
<ControlRow label="Живой поток" layout="stack">
<code className="scene-window-code">rerun+http://…/proxy</code>
</ControlRow>
<ControlRow label="Локальная запись" layout="stack">
<code className="scene-window-code">http://…/recording.rrd</code>
</ControlRow>
<ControlRow label="Удалённая запись" layout="stack">
<code className="scene-window-code">https://…/recording.rrd</code>
</ControlRow>
</div>
),
},
{
id: "adapter",
label: "Локальный адаптер",
description: "Транспорт устройства",
content: (
<div className="inspector-control-stack">
<ControlRow label="Контур">
<span className="scene-window-state">
<i className="api-dot" data-status={console.backendStatus} aria-hidden="true" />
{backendLabel(console.backendStatus)}
</span>
</ControlRow>
<p className="scene-window-note">
Встроенный адаптер публикует локальный Rerun gRPC автоматически после запуска
живого приёма или повтора записи. Ручной адрес для этого не требуется.
</p>
</div>
),
},
]}
/>
</Window>
<Window
open={displayWindowOpen}
title="Отображение"
subtitle="Параметры пространственной сцены"
placement="end"
draggable
closeOnBackdrop={false}
closeOnEscape={false}
lockBodyScroll={false}
trapFocus={false}
className="scene-tool-window scene-tool-window--display"
data-scene-window="display"
data-scene-active={activeSceneWindow === "display" ? "true" : undefined}
onPointerDown={() => activateSceneWindow("display")}
onClose={() => closeSceneWindow("display")}
footer={
<WindowFooterActions>
<Button variant="ghost" onClick={() => setDisplayDraft(defaultSceneSettings)}>
Сбросить
</Button>
<Button
variant="primary"
shape="pill"
disabled={console.pendingAction === "viewer"}
onClick={() => void applyDisplaySettings()}
>
{console.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"}
</Button>
</WindowFooterActions>
}
>
<Inspector
defaultOpen={["points"]}
singleOpen
sections={[
{
id: "points",
label: "Облако точек",
description: "Размер и способ окрашивания",
content: (
<div className="inspector-control-stack">
<RangeControl
label="Размер точки"
value={displayDraft.pointSize}
min={0.5}
max={12}
step={0.5}
formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => setDisplayDraft((current) => ({ ...current, pointSize }))}
/>
<ControlRow label="Атрибут цвета">
<Select
variant="split"
label="Атрибут цвета"
value={displayDraft.colorMode}
options={colorModeOptions}
onChange={(colorMode) => setDisplayDraft((current) => ({ ...current, colorMode }))}
/>
</ControlRow>
<ControlRow label="Палитра">
<Select
variant="split"
label="Палитра"
value={displayDraft.palette}
options={paletteOptions}
onChange={(palette) => setDisplayDraft((current) => ({ ...current, palette }))}
/>
</ControlRow>
{displayDraft.palette === "custom" ? (
<ControlRow label="Цвет точек">
<ColorField
label="Цвет точек"
value={displayDraft.customColor}
onChange={(customColor) => setDisplayDraft((current) => ({ ...current, customColor }))}
/>
</ControlRow>
) : null}
</div>
),
},
{
id: "history",
label: "Накопление и время",
description: "История облака и траектория",
content: (
<div className="inspector-control-stack">
<RangeControl
label="Окно накопления"
value={displayDraft.accumulationSeconds}
min={0}
max={120}
step={1}
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
onChange={(accumulationSeconds) => setDisplayDraft((current) => ({ ...current, accumulationSeconds }))}
/>
<Checker
checked={displayDraft.showTrajectory}
label="Показывать траекторию"
description="Линия пути устройства в координатах сцены"
onChange={(showTrajectory) => setDisplayDraft((current) => ({ ...current, showTrajectory }))}
/>
</div>
),
},
{
id: "scene",
label: "Окружение сцены",
description: "Сетка, подписи и камеры",
content: (
<div className="inspector-control-stack">
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => setDisplayDraft((current) => ({ ...current, showGrid }))} />
<Checker checked={false} disabled label="Подписи сущностей" description="Появятся после подключения семантических сущностей" onChange={() => undefined} />
<Checker checked={false} disabled label="Области обзора камер" description="Камерный канал пока не подключён" onChange={() => undefined} />
</div>
),
},
]}
/>
</Window>
<Window
open={layerInspectorOpen}
title="Слои сцены"
subtitle="Сущности пространственной сцены"
placement="end"
draggable
closeOnBackdrop={false}
closeOnEscape={false}
lockBodyScroll={false}
trapFocus={false}
className="scene-tool-window scene-tool-window--layers"
data-scene-window="layers"
data-scene-active={activeSceneWindow === "layers" ? "true" : undefined}
onPointerDown={() => activateSceneWindow("layers")}
onClose={() => closeSceneWindow("layers")}
>
<div className="layer-inspector-intro">
<span className="scene-window-state">
<i className="api-dot" data-status={effectiveSourceUrl ? "online" : "checking"} aria-hidden="true" />
{effectiveSourceUrl ? "Источник назначен" : "Источник не назначен"}
</span>
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
</div>
<Inspector
defaultOpen={["geometry"]}
singleOpen
sections={[
{
id: "geometry",
label: "Геометрия",
description: "Облако точек и путь",
content: (
<div className="inspector-control-stack">
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" description="Основной поток геометрии лидара" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" description="Положение и ориентация устройства во времени" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
</div>
),
},
{
id: "frames",
label: "Координаты и камеры",
description: "Преобразования и области обзора",
content: (
<div className="inspector-control-stack">
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
<Checker checked={false} disabled label="Области обзора камер" description="Камерный канал пока не подключён" onChange={() => undefined} />
</div>
),
},
{
id: "perception",
label: "Объекты и маски",
description: "Ожидают внешние обработчики",
content: (
<div className="inspector-control-stack">
<Checker checked={false} disabled label="Рамки 2D и 3D" description="Внешний обработчик не подключён" onChange={() => undefined} />
<Checker checked={false} disabled label="Маски сегментации" description="Внешний обработчик не подключён" onChange={() => undefined} />
<Checker checked={false} disabled label="Ключевые точки и треки" description="Внешний обработчик не подключён" onChange={() => undefined} />
</div>
),
},
]}
/>
</Window>
<Window
open={layoutWindowOpen}
title="Компоновка рабочей области"
subtitle="КОМПОНОВКА / ЧЕРНОВИК"
size="sm"
placement="end"
draggable
closeOnBackdrop={false}
closeOnEscape={false}
lockBodyScroll={false}
trapFocus={false}
className="scene-tool-window scene-tool-window--layout"
data-scene-window="layout"
data-scene-active={activeSceneWindow === "layout" ? "true" : undefined}
onPointerDown={() => activateSceneWindow("layout")}
onClose={() => closeSceneWindow("layout")}
footer={
<WindowFooterActions>
<Button onClick={() => closeSceneWindow("layout")}>Закрыть</Button>
<Button
variant="primary"
shape="pill"
disabled={!layoutName.trim()}
onClick={() => setLayoutDraftSaved(true)}
>
Зафиксировать черновик
</Button>
</WindowFooterActions>
}
>
<div className="modal-stack">
<TextField
label="Название компоновки"
value={layoutName}
onChange={(event) => {
setLayoutName(event.target.value);
setLayoutDraftSaved(false);
}}
placeholder="Название профиля"
/>
<div className="modal-contract-note" data-tone={layoutDraftSaved ? "success" : "warning"}>
<Icon name={layoutDraftSaved ? "check" : "save"} />
<div>
<strong>{layoutDraftSaved ? "Черновик зафиксирован в текущем сеансе" : "Экспорт RBL ещё не подключён"}</strong>
<p>
{layoutDraftSaved
? "Это состояние интерфейса без записи на диск. Будущий адаптер сохранит профиль Rerun рядом с кодом."
: "Окно и контракт сохранения готовы; запись файла не имитируется."}
</p>
</div>
</div>
</div>
</Window>
</>
);
}
-224
View File
@@ -1,224 +0,0 @@
export interface BleDevice {
device_id: string;
name?: string | null;
rssi?: number | null;
address?: string | null;
connectable?: boolean | null;
likely_k1?: boolean | null;
}
export type SourceMode = "idle" | "live" | "replay";
export interface K1Metrics {
mqtt_to_decode_ms?: number | null;
decode_ms?: number | null;
publish_ms?: number | null;
pipeline_ms?: number | null;
end_to_end_ms?: number | null;
frame_rate?: number | null;
frame_rate_hz?: number | null;
point_count?: number | null;
dropped_preview_frames?: number | null;
[key: string]: number | null | undefined;
}
export interface ConsoleState {
phase?: string | null;
message?: string | null;
devices?: BleDevice[];
selected_device_id?: string | null;
k1_ip?: string | null;
foxglove_ws_url?: string | null;
foxglove_viewer_url?: string | null;
rerun_grpc_url?: string | null;
viewer_settings?: ViewerSettings | null;
source_mode?: SourceMode | null;
metrics?: K1Metrics;
}
export interface ViewerSettings {
point_size: number;
color_mode: "intensity" | "height" | "distance" | "rgb" | "class";
palette: "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
custom_color: string;
accumulation_seconds: number;
show_points: boolean;
show_trajectory: boolean;
show_grid: boolean;
}
export interface HealthResponse {
ok?: boolean;
status?: string;
service?: string;
version?: string;
}
export interface ScanRequest {
duration_seconds?: number;
}
export interface ConnectRequest {
device_id: string;
ssid: string;
password: string;
}
export interface LiveRequest {
host?: string;
duration_seconds?: number;
}
export interface ReplayRequest {
path: string;
speed?: number;
loop?: boolean;
}
export class ApiError extends Error {
readonly status: number;
constructor(message: string, status = 0) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function unwrapState(payload: unknown): ConsoleState {
const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload;
if (!isRecord(value)) {
throw new ApiError("Локальный сервер вернул некорректное состояние.");
}
return value as ConsoleState;
}
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
let response: Response;
try {
response = await fetch(path, {
...init,
headers: {
Accept: "application/json",
...(init?.body ? { "Content-Type": "application/json" } : {}),
...init?.headers,
},
});
} catch {
throw new ApiError("Не удалось подключиться к локальному сервису устройства.");
}
const bodyText = await response.text();
let body: unknown;
if (bodyText) {
try {
body = JSON.parse(bodyText) as unknown;
} catch {
body = bodyText;
}
}
if (!response.ok) {
const detail =
isRecord(body) && typeof body.detail === "string"
? body.detail
: typeof body === "string" && body.trim()
? body.trim()
: `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`;
throw new ApiError(
detail || `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`,
response.status,
);
}
return body;
}
async function postState(path: string, body?: object): Promise<ConsoleState> {
const payload = await requestJson(path, {
method: "POST",
body: body ? JSON.stringify(body) : undefined,
});
if (payload === undefined) {
return api.getState();
}
return unwrapState(payload);
}
export const api = {
async getHealth(): Promise<HealthResponse> {
const payload = await requestJson("/api/health");
if (!isRecord(payload)) {
throw new ApiError("Локальный сервер вернул некорректный ответ проверки.");
}
return payload as HealthResponse;
},
async getState(): Promise<ConsoleState> {
return unwrapState(await requestJson("/api/state"));
},
scanBle(body: ScanRequest = {}): Promise<ConsoleState> {
return postState("/api/ble/scan", body);
},
connect(body: ConnectRequest): Promise<ConsoleState> {
return postState("/api/connect", body);
},
startLive(body: LiveRequest = {}): Promise<ConsoleState> {
return postState("/api/session/live", body);
},
startReplay(body: ReplayRequest): Promise<ConsoleState> {
return postState("/api/session/replay", body);
},
stopSession(): Promise<ConsoleState> {
return postState("/api/session/stop");
},
updateViewerSettings(body: ViewerSettings): Promise<ConsoleState> {
return postState("/api/viewer/settings", body);
},
};
export type EventSocketStatus = "connecting" | "open" | "closed" | "error";
function eventSocketUrl(): string {
const url = new URL("/api/events", window.location.href);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.toString();
}
export function openEventSocket(
onState: (state: ConsoleState) => void,
onStatus: (status: EventSocketStatus) => void,
): () => void {
onStatus("connecting");
const socket = new WebSocket(eventSocketUrl());
socket.addEventListener("open", () => onStatus("open"));
socket.addEventListener("message", (event) => {
try {
const payload = JSON.parse(String(event.data)) as unknown;
onState(unwrapState(payload));
} catch {
// The REST poll remains authoritative if an unrelated event is received.
}
});
socket.addEventListener("error", () => onStatus("error"));
socket.addEventListener("close", () => onStatus("closed"));
return () => socket.close(1000, "Пункт управления закрыт");
}
@@ -1,133 +0,0 @@
import { useEffect, useRef, useState } from "react";
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
export interface RerunSelection {
entityPath: string;
viewName?: string;
position?: [number, number, number];
}
export interface RerunViewportProps {
sourceUrl: string;
onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
onSelectionChange?: (selection: RerunSelection | null) => void;
}
export function RerunViewport({
sourceUrl,
onStatusChange,
onSelectionChange,
}: RerunViewportProps) {
const hostRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
useEffect(() => {
const normalizedSource = sourceUrl.trim();
if (!normalizedSource || !hostRef.current) {
setStatus("idle");
onStatusChange?.("idle");
onSelectionChange?.(null);
return;
}
let disposed = false;
let stopViewer: (() => void) | undefined;
const unsubscribers: Array<() => void> = [];
setStatus("loading");
onStatusChange?.("loading");
void import("@rerun-io/web-viewer")
.then(async ({ WebViewer }) => {
const viewer = new WebViewer();
stopViewer = () => {
// Remove the gRPC receiver explicitly before tearing down WASM. This
// closes the browser-side stream promptly so the local SDK server can
// release its port before the next device session starts.
try {
viewer.close(normalizedSource);
} catch {
// The viewer can already be stopped after a startup failure.
}
viewer.stop();
};
await viewer.start(
normalizedSource,
hostRef.current,
{
width: "100%",
height: "100%",
theme: "dark",
hide_welcome_screen: true,
enable_history: false,
allow_fullscreen: false,
},
null,
);
if (disposed) {
stopViewer();
return;
}
viewer.override_panel_state("top", "hidden");
viewer.override_panel_state("blueprint", "hidden");
viewer.override_panel_state("selection", "hidden");
viewer.override_panel_state("time", "hidden");
unsubscribers.push(
viewer.on("selection_change", (event) => {
const entity = event.items.find((item) => item.type === "entity");
if (!entity || entity.type !== "entity") {
onSelectionChange?.(null);
return;
}
onSelectionChange?.({
entityPath: entity.entity_path,
viewName: entity.view_name,
position: entity.position,
});
}),
);
setStatus("ready");
onStatusChange?.("ready");
})
.catch(() => {
if (disposed) return;
const message = "Не удалось запустить встроенный визуализатор.";
setStatus("error");
onStatusChange?.("error", message);
});
return () => {
disposed = true;
unsubscribers.forEach((unsubscribe) => unsubscribe());
stopViewer?.();
onSelectionChange?.(null);
};
}, [onSelectionChange, onStatusChange, sourceUrl]);
return (
<div className="rerun-viewport" data-status={status}>
<div ref={hostRef} className="rerun-viewport__canvas" />
{status === "loading" ? (
<div className="rerun-viewport__notice" role="status">
<span className="busy-indicator" aria-hidden="true" />
<div>
<strong>Подключаем визуальный источник</strong>
<span>Запуск встроенного визуализатора Rerun.</span>
</div>
</div>
) : null}
{status === "error" ? (
<div className="rerun-viewport__notice rerun-viewport__notice--error" role="alert">
<div>
<strong>Источник не открыт</strong>
<span>Проверьте адрес RRD или Rerun gRPC в настройках источника.</span>
</div>
</div>
) : null}
</div>
);
}
-30
View File
@@ -1,30 +0,0 @@
const runtimeMessageReplacements: Array<[RegExp, string]> = [
[/broker connection ended/gi, "соединение с брокером завершено"],
[/Unspecified error/gi, "неуказанная ошибка"],
[
/Device was not rediscovered; keep the K1 powered and nearby\.?/gi,
"Устройство не обнаружено повторно; оставьте его включённым и рядом.",
],
[/Foxglove/gi, "локальный мост визуализации"],
[/MacBook/gi, "компьютер"],
[/\bK1\b/g, "устройство"],
];
export function localizeRuntimeMessage(message: string | null | undefined): string | null {
if (!message) return null;
const localized = runtimeMessageReplacements.reduce(
(localized, [pattern, replacement]) => localized.replace(pattern, replacement),
message,
);
const withoutTechnicalTerms = localized.replace(
/\b(?:API|BLE|Bluetooth|gRPC|IP|JSON|K1MQTT|MQTT|Rerun|RRD|TSV|UUID|Wi-Fi)\b/gi,
"",
);
if (/[A-Za-z]{3,}/.test(withoutTechnicalTerms)) {
return "Операция не выполнена. Технические подробности сохранены в журнале сервера.";
}
return localized;
}
-203
View File
@@ -1,203 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
ApiError,
api,
openEventSocket,
type ConnectRequest,
type ConsoleState,
type EventSocketStatus,
type LiveRequest,
type ReplayRequest,
type ViewerSettings,
} from "./api";
import { localizeRuntimeMessage } from "./messages";
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer";
function messageFor(error: unknown): string {
if (error instanceof ApiError) {
const message = localizeRuntimeMessage(error.message) ?? error.message;
return error.status
? `${message} (HTTP ${error.status})`
: message;
}
return "Запрос к локальному сервису устройства завершился ошибкой.";
}
function measuredLatency(state: ConsoleState | null): number | null {
if (state?.source_mode !== "live") return null;
const metrics = state?.metrics;
if (!metrics) return null;
const reported = metrics.pipeline_ms ?? metrics.end_to_end_ms;
if (typeof reported === "number" && Number.isFinite(reported)) return reported;
const segments = [
metrics.mqtt_to_decode_ms,
metrics.publish_ms,
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
}
export function useK1Console() {
const [state, setState] = useState<ConsoleState | null>(null);
const [backendStatus, setBackendStatus] = useState<BackendStatus>("checking");
const [eventStatus, setEventStatus] = useState<EventSocketStatus>("connecting");
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
const [error, setError] = useState<string | null>(null);
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
const mounted = useRef(true);
const acceptState = useCallback((nextState: ConsoleState) => {
setState(nextState);
setBackendStatus("online");
}, []);
const refresh = useCallback(async (reportErrors = true) => {
const [healthResult, stateResult] = await Promise.allSettled([
api.getHealth(),
api.getState(),
]);
if (!mounted.current) return;
if (stateResult.status === "fulfilled") {
acceptState(stateResult.value);
if (reportErrors) setError(null);
}
if (healthResult.status === "fulfilled") {
const health = healthResult.value;
const healthy = health.ok !== false && health.status !== "error";
setBackendStatus(healthy && stateResult.status === "fulfilled" ? "online" : "degraded");
} else if (stateResult.status === "rejected") {
setBackendStatus("offline");
}
if (stateResult.status === "rejected" && reportErrors) {
setError(messageFor(stateResult.reason));
}
}, [acceptState]);
const run = useCallback(
async (action: PendingAction, operation: () => Promise<ConsoleState>) => {
setPendingAction(action);
setError(null);
try {
const nextState = await operation();
if (mounted.current) acceptState(nextState);
return true;
} catch (operationError) {
if (mounted.current) {
setError(messageFor(operationError));
if (operationError instanceof ApiError && operationError.status === 0) {
setBackendStatus("offline");
}
}
return false;
} finally {
if (mounted.current) setPendingAction(null);
}
},
[acceptState],
);
const scan = useCallback(
() => run("scan", () => api.scanBle({ duration_seconds: 6 })),
[run],
);
const connect = useCallback(
(request: ConnectRequest) => run("connect", () => api.connect(request)),
[run],
);
const startLive = useCallback(
(request: LiveRequest = {}) => run("live", () => api.startLive(request)),
[run],
);
const startReplay = useCallback(
(request: ReplayRequest) => run("replay", () => api.startReplay(request)),
[run],
);
const stop = useCallback(
() => run("stop", () => api.stopSession()),
[run],
);
const updateViewerSettings = useCallback(
(request: ViewerSettings) => run("viewer", () => api.updateViewerSettings(request)),
[run],
);
useEffect(() => {
mounted.current = true;
void refresh(true);
const poll = window.setInterval(() => void refresh(false), 4_000);
return () => {
mounted.current = false;
window.clearInterval(poll);
};
}, [refresh]);
useEffect(() => {
let dispose: (() => void) | undefined;
let retry: number | undefined;
let cancelled = false;
const connectEvents = () => {
if (cancelled) return;
dispose = openEventSocket(acceptState, (status) => {
if (cancelled) return;
setEventStatus(status);
if ((status === "closed" || status === "error") && retry === undefined) {
retry = window.setTimeout(() => {
retry = undefined;
connectEvents();
}, 3_000);
}
});
};
connectEvents();
return () => {
cancelled = true;
if (retry !== undefined) window.clearTimeout(retry);
dispose?.();
};
}, [acceptState]);
useEffect(() => {
const latency = measuredLatency(state);
if (latency === null) {
setLatencyHistory((values) => (values.length ? [] : values));
return;
}
setLatencyHistory((values) => [...values.slice(-23), latency]);
}, [state]);
return {
state,
backendStatus,
eventStatus,
pendingAction,
error,
latencyHistory,
refresh: () => refresh(true),
clearError: () => setError(null),
scan,
connect,
startLive,
startReplay,
stop,
updateViewerSettings,
};
}
@@ -1,550 +0,0 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Button,
Checker,
GlassSurface,
Icon,
SegmentedControl,
StatusBadge,
TextField,
type StatusTone,
} from "@nodedc/ui-react";
import type { BleDevice } from "../api";
import { MetricCard } from "../components/MetricCard";
import { localizeRuntimeMessage } from "../messages";
import {
backendLabel,
backendTone,
eventStatusLabel,
finiteMetric,
formatNumber,
phaseLabel,
phaseTone,
pipelineLatency,
sourceModeLabel,
} from "../presentation";
import type { useK1Console } from "../useK1Console";
type ConsoleController = ReturnType<typeof useK1Console>;
type SessionIntent = "live" | "replay";
const sessionItems = [
{ value: "live", label: "Реальное устройство" },
{ value: "replay", label: "Повтор записи" },
] satisfies Array<{ value: SessionIntent; label: string }>;
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="detail-row">
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
}
function WizardStep({
number,
title,
status,
tone = "neutral",
children,
}: {
number: string;
title: string;
status: string;
tone?: StatusTone;
children: ReactNode;
}) {
return (
<section className="wizard-step">
<div className="wizard-step__rail" aria-hidden="true">
<span>{number}</span>
</div>
<div className="wizard-step__content">
<header>
<h3>{title}</h3>
<StatusBadge tone={tone}>{status}</StatusBadge>
</header>
{children}
</div>
</section>
);
}
function DeviceRow({
device,
selected,
onSelect,
}: {
device: BleDevice;
selected: boolean;
onSelect: () => void;
}) {
return (
<div
className="device-row"
data-compatible={device.likely_k1 ? "true" : undefined}
data-selected={selected ? "true" : undefined}
>
<div className="device-row__identity">
<span className="device-row__signal" aria-hidden="true" />
<div>
<span className="device-row__name">
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
{device.likely_k1 ? <small>Совместимый профиль</small> : null}
</span>
<code>{device.device_id}</code>
</div>
</div>
<div className="device-row__action">
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
<Button
size="compact"
variant={selected ? "primary" : "secondary"}
disabled={device.connectable === false}
onClick={onSelect}
>
{selected ? "Выбрано" : "Выбрать"}
</Button>
</div>
</div>
);
}
function LatencyTrace({ values }: { values: number[] }) {
const ceiling = Math.max(16, ...values);
return (
<div className="latency-trace" aria-label="Последние измерения времени до публикации">
{values.length ? (
values.map((value, index) => (
<span
key={`${index}-${value}`}
style={{ height: `${Math.max(8, Math.min(100, (value / ceiling) * 100))}%` }}
title={`${value.toFixed(1)} мс`}
/>
))
) : (
<p>Измерений пока нет. График появится после получения реальных данных.</p>
)}
</div>
);
}
export function DeviceWorkspace({
console,
onOpenSpatialScene,
}: {
console: ConsoleController;
onOpenSpatialScene: () => void;
}) {
const {
state,
backendStatus,
eventStatus,
pendingAction,
error,
latencyHistory,
refresh,
clearError,
scan,
connect,
startLive,
startReplay,
stop,
} = console;
const [powerConfirmed, setPowerConfirmed] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
useEffect(() => {
if (state?.selected_device_id) {
setSelectedDeviceId(state.selected_device_id);
return;
}
if (
selectedDeviceId &&
state?.devices &&
!state.devices.some((device) => device.device_id === selectedDeviceId)
) {
setSelectedDeviceId("");
}
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const droppedFrames = finiteMetric(metrics?.dropped_preview_frames);
const devices = state?.devices ?? [];
const isBusy = pendingAction !== null;
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
const sourceLabel = sourceModeLabel(state?.source_mode);
const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId],
);
const submitConnect = async () => {
if (!canConnect) return;
const succeeded = await connect({
device_id: selectedDeviceId,
ssid: ssid.trim(),
password,
});
if (succeeded) setPassword("");
};
const submitLive = async () => {
const host = liveHost.trim();
const started = await startLive(host ? { host } : {});
if (started) onOpenSpatialScene();
};
const submitReplay = async () => {
const speed = Number(replaySpeed);
const started = await startReplay({
path: replayPath.trim(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
loop: replayLoop,
});
if (started) onOpenSpatialScene();
};
return (
<div className="device-workspace">
{error ? (
<aside className="error-banner" role="alert">
<span className="error-banner__dot" aria-hidden="true" />
<div>
<strong>Локальная операция завершилась ошибкой</strong>
<p>{localizeRuntimeMessage(error)}</p>
</div>
<div className="error-banner__actions">
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
Повторить
</Button>
<Button size="compact" variant="ghost" onClick={clearError}>
Закрыть
</Button>
</div>
</aside>
) : null}
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">РАБОЧИЙ АДАПТЕР УСТРОЙСТВА</span>
<h2>Подключение первого устройства</h2>
<p>
Этот путь уже работает физически, но остаётся изолированным адаптером. Парковая и
операторская модель от конкретного устройства не зависят.
</p>
</div>
<div className="workspace-lead__status">
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
</div>
</section>
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
<MetricCard
featured
eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)}
unit="мс"
detail="MQTT callback → Rerun SDK; без экрана"
/>
<MetricCard
eyebrow="ЧАСТОТА КАДРОВ"
value={formatNumber(frameRate)}
unit="кадр/с"
detail="Последнее измерение адаптера"
/>
<MetricCard
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Реальное число декодированных точек"
/>
<MetricCard
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Исходные данные при этом сохраняются"
/>
</section>
<div className="device-workspace__grid">
<GlassSurface className="connection-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</span>
<h2>Подключите устройство к сети</h2>
</div>
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
</header>
<div className="wizard-list">
<WizardStep
number="01"
title="Включите устройство"
status={powerConfirmed ? "Подтверждено" : "Ожидает"}
tone={powerConfirmed ? "success" : "warning"}
>
<Checker
checked={powerConfirmed}
label="Устройство включено, индикатор стабилен"
description="Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия."
onChange={setPowerConfirmed}
/>
</WizardStep>
<WizardStep
number="02"
title="Выберите Bluetooth-устройство"
status={
pendingAction === "scan"
? "Поиск…"
: selectedDeviceId
? "Устройство выбрано"
: `Найдено: ${devices.length}`
}
tone={
pendingAction === "scan"
? "accent"
: selectedDeviceId
? "success"
: "neutral"
}
>
<p className="step-copy">
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Совместимый
профиль только подсказка; окончательный выбор всегда делает оператор.
</p>
<Button
width="full"
variant="secondary"
icon={<Icon name="search" />}
disabled={!powerConfirmed || isBusy}
onClick={() => {
setSelectedDeviceId("");
void scan();
}}
>
{pendingAction === "scan"
? "Сканируем Bluetooth — 6 секунд…"
: "Показать все BLE-устройства"}
</Button>
<div className="device-list">
{devices.length ? (
devices.map((device) => (
<DeviceRow
key={device.device_id}
device={device}
selected={device.device_id === selectedDeviceId}
onSelect={() => setSelectedDeviceId(device.device_id)}
/>
))
) : (
<div className="empty-device-list">
Устройства пока не найдены. Проверьте питание и состояние индикатора, затем
повторите поиск.
</div>
)}
</div>
</WizardStep>
<WizardStep
number="03"
title="Передайте настройки Wi‑Fi"
status={state?.k1_ip ? "Подключено" : "Не подключено"}
tone={state?.k1_ip ? "success" : "neutral"}
>
<div className="field-stack">
<TextField
label="Название сети Wi‑Fi"
hint="SSID"
value={ssid}
onChange={(event) => setSsid(event.target.value)}
autoComplete="off"
spellCheck={false}
placeholder="Сеть локального контура"
/>
<TextField
label="Пароль WiFi"
hint="Только в оперативной памяти"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="off"
placeholder="Введите пароль"
/>
</div>
<div className="connection-summary">
<span>Устройство</span>
<strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong>
</div>
<Button
width="full"
variant="primary"
icon={<Icon name="network" />}
disabled={!canConnect}
onClick={() => void submitConnect()}
>
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
</Button>
<p className="safety-note">
Пароль передаётся только локальному сервису на этом компьютере, не сохраняется в браузере и
удаляется из формы после успешного подключения.
</p>
</WizardStep>
</div>
</GlassSurface>
<div className="device-workspace__side">
<GlassSurface className="session-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">
{sessionIntent === "live" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
</span>
<h2>{sessionIntent === "live" ? "Запустите поток" : "Повторите запись"}</h2>
</div>
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
{sourceLabel}
</StatusBadge>
</header>
<SegmentedControl
label="Источник данных"
value={sessionIntent}
items={sessionItems}
onChange={setSessionIntent}
/>
{sessionIntent === "live" ? (
<div className="session-form">
<TextField
label="Адрес устройства"
hint="Обычно определяется автоматически"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
spellCheck={false}
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !liveTargetReady}
onClick={() => void submitLive()}
>
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
</Button>
<p className="live-instruction">
{liveTargetReady
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
</p>
</div>
) : (
<div className="session-form session-form--replay">
<TextField
label="Путь к записи"
hint="Локальный файл исходных данных"
value={replayPath}
onChange={(event) => setReplayPath(event.target.value)}
spellCheck={false}
placeholder="sessions/.../capture.tsv"
/>
<TextField
label="Скорость повтора"
hint="Множитель"
type="number"
min="0.1"
step="0.1"
value={replaySpeed}
onChange={(event) => setReplaySpeed(event.target.value)}
/>
<Checker
checked={replayLoop}
label="Повторять по кругу"
description="После последнего кадра начать запись заново."
onChange={setReplayLoop}
/>
<Button
variant="primary"
icon={<Icon name="video" />}
disabled={isBusy || replayPath.trim().length === 0}
onClick={() => void submitReplay()}
>
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
</Button>
</div>
)}
<div className="session-footer">
<p>Статус изменится только после ответа локального сервиса.</p>
<Button
variant="secondary"
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
onClick={() => void stop()}
>
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
</Button>
</div>
</GlassSurface>
<div className="diagnostics-grid">
<GlassSurface className="status-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">ТЕКУЩЕЕ СОСТОЯНИЕ</span>
<h2>Локальный контур</h2>
</div>
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
</header>
<dl className="detail-list">
<DetailRow label="Канал событий">
<span className="inline-state" data-state={eventStatus}>
{eventStatusLabel(eventStatus)}
</span>
</DetailRow>
<DetailRow label="Источник">{sourceLabel}</DetailRow>
<DetailRow label="Адрес устройства">
<code>{state?.k1_ip || "Не получен"}</code>
</DetailRow>
</dl>
</GlassSurface>
<GlassSurface className="latency-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
<h2>Время до публикации</h2>
</div>
<strong className="latency-now">
{formatNumber(latency)} <span>мс</span>
</strong>
</header>
<LatencyTrace values={latencyHistory} />
<div className="latency-legend">
<span>Старые</span>
<span>Последние</span>
</div>
</GlassSurface>
</div>
</div>
</div>
</div>
);
}
@@ -1,574 +0,0 @@
import { useCallback, useMemo, useState } from "react";
import {
Button,
GlassSurface,
Icon,
StatusBadge,
} from "@nodedc/ui-react";
import type { ConsoleState } from "../api";
import { MetricCard } from "../components/MetricCard";
import { localizeRuntimeMessage } from "../messages";
import {
RerunViewport,
type RerunSelection,
type RerunViewportStatus,
} from "../components/RerunViewport";
import {
capabilityStatusLabel,
type CapabilityStatus,
type WorkspaceDefinition,
} from "../productModel";
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
import type { SceneSettings } from "../sceneSettings";
import type { BackendStatus } from "../useK1Console";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success";
if (status === "ready") return "accent";
if (status === "contract") return "warning";
return "neutral";
}
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
return (
<div className="feature-inventory">
{definition.groups.map((group) => (
<section className="feature-group" key={group.title}>
<header>
<span className="section-eyebrow">ФУНКЦИОНАЛЬНЫЙ БЛОК</span>
<h3>{group.title}</h3>
<p>{group.description}</p>
</header>
<div className="feature-list">
{group.capabilities.map((capability) => (
<div className="feature-row" key={capability.label} data-status={capability.status}>
<span className="feature-row__mark" aria-hidden="true" />
<div>
<strong>{capability.label}</strong>
<p>{capability.description}</p>
</div>
<StatusBadge tone={statusTone(capability.status)}>
{capabilityStatusLabel[capability.status]}
</StatusBadge>
</div>
))}
</div>
</section>
))}
</div>
);
}
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
return (
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
<p>{definition.description}</p>
</div>
{note ? <span className="workspace-lead__note">{note}</span> : null}
</section>
);
}
export interface WorkspaceNavigation {
openView: (viewId: string) => void;
openSource: () => void;
openDisplay: () => void;
openLayers: () => void;
}
export interface WorkspaceRendererProps {
definition: WorkspaceDefinition;
state: ConsoleState | null;
backendStatus: BackendStatus;
sourceUrl: string;
sceneSettings: SceneSettings;
navigation: WorkspaceNavigation;
}
function OverviewWorkspace({
definition,
state,
backendStatus,
navigation,
}: WorkspaceRendererProps) {
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const adapterOnline = backendStatus === "online";
const rerunReady = Boolean(state?.rerun_grpc_url);
return (
<div className="standard-workspace overview-workspace">
<WorkspaceLead
definition={definition}
note="Числа появляются только из реального локального контура"
/>
<section className="metrics-grid" aria-label="Оперативные показатели">
<MetricCard
featured
eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)}
unit="мс"
detail="MQTT callback → Rerun SDK; без экрана"
/>
<MetricCard
eyebrow="ЧАСТОТА"
value={formatNumber(frameRate)}
unit="кадр/с"
detail="Последнее измерение потока"
/>
<MetricCard
eyebrow="ТОЧЕК В КАДРЕ"
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
detail="Без синтетического заполнения"
/>
<MetricCard
eyebrow="РЕЖИМ"
value={sourceModeLabel(state?.source_mode)}
detail="Реальное время, повтор или ожидание"
/>
</section>
<div className="overview-grid">
<GlassSurface className="contour-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">КРИТИЧЕСКИЙ ПУТЬ</span>
<h2>Контур наблюдения</h2>
</div>
<StatusBadge tone={adapterOnline ? "success" : "danger"}>
{adapterOnline ? "Контур доступен" : "Нет связи"}
</StatusBadge>
</header>
<div className="pipeline-strip" aria-label="Путь данных">
<div data-state={state?.k1_ip ? "ready" : "idle"}>
<span>01</span>
<strong>Устройство</strong>
<small>{state?.k1_ip || "не назначено"}</small>
</div>
<Icon name="chevron-right" />
<div data-state={streamActive ? "ready" : "idle"}>
<span>02</span>
<strong>Адаптер</strong>
<small>{sourceModeLabel(state?.source_mode)}</small>
</div>
<Icon name="chevron-right" />
<div data-state={rerunReady ? "ready" : "idle"}>
<span>03</span>
<strong>Rerun</strong>
<small>{rerunReady ? "gRPC опубликован" : "ожидает поток"}</small>
</div>
<Icon name="chevron-right" />
<div data-state="ready">
<span>04</span>
<strong>Оператор</strong>
<small>интерфейс готов</small>
</div>
</div>
</GlassSurface>
<GlassSurface className="next-actions-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">БЫСТРЫЙ ПЕРЕХОД</span>
<h2>Продолжить работу</h2>
</div>
</header>
<div className="action-list">
<button type="button" onClick={() => navigation.openView("local-device")}>
<span><Icon name="network" /></span>
<div>
<strong>Подключить устройство</strong>
<small>Bluetooth, WiFi и запуск потока</small>
</div>
<Icon name="chevron-right" />
</button>
<button type="button" onClick={() => navigation.openView("spatial-scene")}>
<span><Icon name="globe" /></span>
<div>
<strong>Открыть пространственную сцену</strong>
<small>Облако точек, траектория и слои</small>
</div>
<Icon name="chevron-right" />
</button>
<button type="button" onClick={() => navigation.openView("streams")}>
<span><Icon name="activity" /></span>
<div>
<strong>Проверить потоки</strong>
<small>Транспорт, схемы и готовность</small>
</div>
<Icon name="chevron-right" />
</button>
</div>
</GlassSurface>
</div>
<FeatureInventory definition={definition} />
</div>
);
}
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
return (
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
<div className="empty-spatial-stage__grid" aria-hidden="true" />
<div className="spatial-axis" aria-hidden="true">
<span data-axis="x">X</span>
<span data-axis="z">Z</span>
</div>
<div className="empty-spatial-stage__message">
<span className="empty-spatial-stage__icon"><Icon name="globe" size={20} /></span>
<strong>Визуальный источник не назначен</strong>
<p>
Область сцены не подставляет демонстрационные точки. Назначьте RRD или Rerun gRPC, чтобы
открыть реальные данные.
</p>
</div>
</div>
);
}
function SpatialWorkspace({
state,
sourceUrl,
sceneSettings,
navigation,
}: WorkspaceRendererProps) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState("");
const [selection, setSelection] = useState<RerunSelection | null>(null);
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
setViewerStatus(status);
setViewerMessage(message ?? "");
}, []);
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
const viewerStatusLabel = {
idle: "Источник не назначен",
loading: "Подключение",
ready: "Визуализатор готов",
error: "Ошибка источника",
}[viewerStatus];
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
return (
<div className="spatial-workspace">
<div className="spatial-toolbar">
<div className="spatial-toolbar__mode">
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
</div>
<div className="spatial-toolbar__actions">
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
Источник
</Button>
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
Слои
</Button>
<Button size="compact" variant="secondary" icon={<Icon name="sliders" />} onClick={navigation.openDisplay}>
Отображение
</Button>
</div>
</div>
<div className="spatial-viewport-shell">
{sourceUrl.trim() ? (
<RerunViewport
sourceUrl={sourceUrl}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
/>
) : (
<EmptySpatialStage settings={sceneSettings} />
)}
<div className="scene-status scene-status--top-left">
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
{viewerMessage ? <small>{viewerMessage}</small> : null}
</div>
<div className="scene-metrics" aria-label="Метрики пространственной сцены">
<div>
<span>КАДР/С</span>
<strong>{formatNumber(frameRate)}</strong>
</div>
<div>
<span>Точек</span>
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
</div>
<div>
<span>До публикации</span>
<strong>{formatNumber(latency)}<small> мс</small></strong>
</div>
</div>
{state?.source_mode && state.source_mode !== "idle" && !sourceUrl.trim() ? (
<div className="scene-adapter-note">
<Icon name="alert" />
<span>
Локальный поток <strong>{sourceModeLabel(state.source_mode).toLocaleLowerCase("ru-RU")}</strong> активен,
Rerun-мост запускается и опубликует адрес автоматически.
</span>
</div>
) : null}
{selection ? (
<div className="scene-selection">
<span>Выбрано</span>
<strong>{selection.entityPath}</strong>
{selection.viewName ? <small>{selection.viewName}</small> : null}
</div>
) : null}
<div className="scene-timeline">
<Button size="compact" variant="ghost" disabled={viewerStatus !== "ready"} aria-label="Перейти к началу">
<Icon name="chevron-left" />
</Button>
<Button size="compact" variant="secondary" disabled={viewerStatus !== "ready"}>
Воспроизвести
</Button>
<div className="scene-timeline__track" data-disabled={viewerStatus !== "ready" ? "true" : undefined}>
<span style={{ width: viewerStatus === "ready" ? "8%" : "0%" }} />
</div>
<code>{viewerStatus === "ready" ? "00:00:00.000" : "—:—:—.———"}</code>
<span className="scene-timeline__follow" data-active={viewerStatus === "ready" ? "true" : undefined}>
ЭФИР
</span>
</div>
</div>
<div className="spatial-contract-strip">
<span><i data-state="ready" />Облако точек</span>
<span><i data-state="ready" />Траектория</span>
<span><i data-state="ready" />Преобразования</span>
<span><i data-state="contract" />Камеры в 3D</span>
<span><i data-state="contract" />Объекты / маски</span>
<span><i data-state="contract" />Компоновка</span>
</div>
</div>
);
}
function CamerasWorkspace({ definition }: WorkspaceRendererProps) {
const slots = ["Панорама", "Камера 01", "Камера 02", "Глубина"];
return (
<div className="standard-workspace cameras-workspace">
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
<div className="camera-grid">
{slots.map((slot, index) => (
<div className="camera-slot" key={slot} data-primary={index === 0 ? "true" : undefined}>
<div className="camera-slot__grid" aria-hidden="true" />
<header>
<span>{slot}</span>
<StatusBadge tone="neutral">Канал не назначен</StatusBadge>
</header>
<div className="camera-slot__empty">
<Icon name={index === 3 ? "image" : "video"} />
<span>{index === 3 ? "Ожидается карта глубины" : "Ожидается поток изображения"}</span>
</div>
<footer>
<span>{index === 0 ? "360° / эквидистантная" : index === 3 ? "глубина / метры" : "перспективная / калибровка"}</span>
<button type="button" disabled aria-label={`Настроить ${slot}`}><Icon name="sliders" /></button>
</footer>
</div>
))}
</div>
<FeatureInventory definition={definition} />
</div>
);
}
function MapWorkspace({ definition }: WorkspaceRendererProps) {
const isMissionRoute = definition.id === "routes";
return (
<div className="standard-workspace map-workspace">
<WorkspaceLead
definition={definition}
note={isMissionRoute ? "Схема интерфейса — координаты и командный канал не подключены" : "Картографический модуль не подключён"}
/>
<div className="map-layout">
<div className="map-canvas" data-route={isMissionRoute ? "true" : undefined}>
<div className="map-canvas__grid" aria-hidden="true" />
<div className="map-canvas__contours" aria-hidden="true">
<span /><span /><span /><span />
</div>
<svg viewBox="0 0 1000 560" role="img" aria-label="Схематический черновик маршрута без географических координат">
<path d="M165 420 C 235 350, 272 375, 340 292 S 480 235, 545 290 S 680 340, 765 210 S 845 125, 892 155" />
<circle cx="165" cy="420" r="8" />
<circle cx="340" cy="292" r="8" />
<circle cx="545" cy="290" r="8" />
<circle cx="765" cy="210" r="8" />
<circle cx="892" cy="155" r="8" />
</svg>
<div className="map-point-label" style={{ left: "15%", top: "72%" }}>A · старт</div>
<div className="map-point-label" style={{ left: "52%", top: "49%" }}>C · наблюдение</div>
<div className="map-point-label" style={{ left: "84%", top: "22%" }}>E · финиш</div>
<div className="map-canvas__notice">
<StatusBadge tone="warning">Схема интерфейса</StatusBadge>
<span>Не является картой местности и не содержит реальных координат.</span>
</div>
</div>
<GlassSurface className="map-inspector" padding="lg">
<span className="section-eyebrow">СЛОИ КАРТЫ</span>
<h3>Рабочая композиция</h3>
<div className="layer-summary-list">
<div><span data-tone="cyan" /><strong>Маршрут</strong><small>5 точек · схема</small></div>
<div><span data-tone="violet" /><strong>Геозоны</strong><small>не назначены</small></div>
<div><span data-tone="green" /><strong>Высота</strong><small>контракт GridMap</small></div>
<div><span data-tone="orange" /><strong>Проходимость</strong><small>обработчик не подключён</small></div>
</div>
<Button variant="secondary" width="full" disabled>Редактировать точки</Button>
</GlassSurface>
</div>
<FeatureInventory definition={definition} />
</div>
);
}
function TimelineWorkspace({ definition, state }: WorkspaceRendererProps) {
const rows = useMemo(() => [
{ label: "Устройство", value: state?.phase ? sourceModeLabel(state.source_mode) : "нет событий", active: Boolean(state?.phase) },
{ label: "Поток точек", value: state?.source_mode && state.source_mode !== "idle" ? "активен" : "ожидание", active: state?.source_mode !== "idle" && Boolean(state?.source_mode) },
{ label: "Поза", value: state?.metrics?.point_count ? "синхронно" : "нет данных", active: Boolean(state?.metrics?.point_count) },
{ label: "Камеры", value: "каналы не назначены", active: false },
{ label: "События", value: localizeRuntimeMessage(state?.message) || "нет событий", active: Boolean(state?.message) },
], [state]);
return (
<div className="standard-workspace timeline-workspace">
<WorkspaceLead definition={definition} note="Отображаются только доступные состояния" />
<GlassSurface className="timeline-panel" padding="lg">
<header className="timeline-panel__head">
<div>
<span className="section-eyebrow">СИНХРОННЫЕ КАНАЛЫ</span>
<h3>Текущий интервал</h3>
</div>
<div className="timeline-panel__controls">
<Button size="compact" variant="secondary" disabled>Воспроизвести</Button>
<code>::.</code>
</div>
</header>
<div className="timeline-ruler" aria-hidden="true">
{Array.from({ length: 11 }, (_, index) => <span key={index}>{index * 10}</span>)}
</div>
<div className="timeline-lanes">
{rows.map((row, index) => (
<div className="timeline-lane" key={row.label} data-active={row.active ? "true" : undefined}>
<strong>{row.label}</strong>
<div><span style={{ left: row.active ? `${18 + index * 9}%` : "0" }} /></div>
<small>{row.value}</small>
</div>
))}
</div>
</GlassSurface>
<FeatureInventory definition={definition} />
</div>
);
}
function MissionWorkspace({ definition }: WorkspaceRendererProps) {
const steps = [
{ id: "01", label: "Аппарат", value: "Не назначен" },
{ id: "02", label: "Зона", value: "Не задана" },
{ id: "03", label: "Маршрут", value: "Черновик · 0 точек" },
{ id: "04", label: "Наблюдение", value: "Облако точек" },
{ id: "05", label: "Завершение", value: "Безопасная остановка" },
];
return (
<div className="standard-workspace mission-workspace">
<WorkspaceLead definition={definition} note="Команды на физический аппарат отключены" />
<div className="mission-layout">
<GlassSurface className="mission-sequence" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">МИССИЯ / ЧЕРНОВИК</span>
<h2>Новая миссия</h2>
</div>
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
</header>
<div className="mission-steps">
{steps.map((step) => (
<button key={step.id} type="button" disabled>
<span>{step.id}</span>
<div><strong>{step.label}</strong><small>{step.value}</small></div>
<Icon name="chevron-right" />
</button>
))}
</div>
</GlassSurface>
<GlassSurface className="mission-summary" padding="lg">
<span className="section-eyebrow">ГОТОВНОСТЬ</span>
<div className="mission-readiness"><strong>0</strong><span>/ 5 блоков</span></div>
<p>Сохранение и отправка станут доступны после подключения исполнителя миссий и проверки безопасности.</p>
<div className="mission-summary__checks">
<span><i />Аппарат</span>
<span><i />Геометрия</span>
<span><i />Связь</span>
<span><i />Безопасность</span>
</div>
<Button variant="primary" width="full" disabled>Сохранить миссию</Button>
</GlassSurface>
</div>
<FeatureInventory definition={definition} />
</div>
);
}
function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
const total = definition.groups.reduce((count, group) => count + group.capabilities.length, 0);
const active = definition.groups.reduce(
(count, group) => count + group.capabilities.filter((capability) => capability.status === "active").length,
0,
);
const ready = definition.groups.reduce(
(count, group) => count + group.capabilities.filter((capability) => capability.status === "ready").length,
0,
);
return (
<div className="standard-workspace catalog-workspace">
<WorkspaceLead definition={definition} note="Архитектурный и интерфейсный контракт" />
<section className="capability-summary" aria-label="Готовность функционала">
<div><span>Всего функций</span><strong>{total}</strong></div>
<div><span>Работает сейчас</span><strong>{active}</strong></div>
<div><span>Готово к данным</span><strong>{ready}</strong></div>
<div><span>Без серверной логики</span><strong>{Math.max(0, total - active - ready)}</strong></div>
</section>
<FeatureInventory definition={definition} />
</div>
);
}
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
switch (props.definition.kind) {
case "overview":
return <OverviewWorkspace {...props} />;
case "spatial":
return <SpatialWorkspace {...props} />;
case "cameras":
return <CamerasWorkspace {...props} />;
case "map":
return <MapWorkspace {...props} />;
case "timeline":
return <TimelineWorkspace {...props} />;
case "missions":
return <MissionWorkspace {...props} />;
case "catalog":
return <CatalogWorkspace {...props} />;
case "device":
return null;
}
}
-32
View File
@@ -1,32 +0,0 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import wasm from "vite-plugin-wasm";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
return {
plugins: [react(), wasm()],
build: {
target: "esnext",
},
server: {
host: "127.0.0.1",
port: 5173,
strictPort: true,
proxy: {
"/api": {
target: apiTarget,
changeOrigin: false,
ws: true,
},
},
},
preview: {
host: "127.0.0.1",
port: 4173,
strictPort: true,
},
};
});
+13 -6
View File
@@ -1,6 +1,6 @@
# Technical audit
Status: live feasibility validated, 2026-07-15.
Status: live feasibility and camera-preview transport validated, 2026-07-16.
## Executive finding
@@ -11,9 +11,14 @@ physical-button scan and decoded both point-cloud and pose reports.
All 1,140 captured `lio_pcl` frames decoded as raw-LZ4 protobuf blocks, yielding
4,165,862 points. All 1,215 `lio_pose` messages decoded, and the resulting
approximately 1.566 m displacement matched the controlled movement. The
remaining feasibility question is the panoramic camera branch, not the core
realtime LiDAR/pose path.
approximately 1.566 m displacement matched the controlled movement. A later
owner-operated LixelGO/iPhone capture also observed left/right RTSP/H.264 camera
preview and the remote start/stop wire mapping. Mission Core now has a bounded
read-only receiver, physical live left/right UI acceptance, acquisition-owned
fMP4 archival and a generation-bound recorded player. The remaining camera gate
is a newly recorded real K1 session played end-to-end with its point/pose
timeline. Full-resolution/raw panorama, device-clock synchronization,
intrinsics/extrinsics and remote delivery are still unproven.
The supplied Bible is useful as an OSINT dossier. It is not an executable plan
for the actual stand because many experiments assume a phone and LixelGO. The
@@ -52,12 +57,14 @@ the local LAS alone: current LixelStudio materials describe K1 remote control
and realtime point-cloud streaming over USB/Wi-Fi. LixelStudio is Windows-only
and this does not disclose or guarantee access to the protocol from macOS. It
does, however, justify keeping realtime point cloud as the primary stream target.
No comparable official evidence proves an exportable raw panorama/camera stream.
The physical Lab 002 capture now provides stronger evidence than the prior
official-material inference: a compressed left/right preview leaves the device.
It still does not prove a full-resolution raw panorama stream.
### Still unsupported until measured
- Local `map.las` layout resembles the network stream.
- Raw or stitched panoramic camera frames leave the device.
- Full-resolution raw or pre-stitched panoramic camera frames leave the device.
- The upper 24 bits of point `rgbi` are usable packed RGB.
- MQTT application commands can be published safely without first reproducing
device/session header state and response handling.
+176 -10
View File
@@ -3,7 +3,7 @@
This plan supersedes the app-dependent experiment order in the reference Bible.
Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
## Live checkpoint — 2026-07-15
## Current checkpoint — 2026-07-24
| Stage | Result |
| --- | --- |
@@ -11,21 +11,147 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
| Gate 1 physical operation | GO — autonomous double-click start/stop verified |
| Stage 1 BLE discovery | GO — repeatable advertisement and GATT profile |
| Stage 2 BLE provisioning | GO — reviewed 99-byte profile, LAN association confirmed |
| Stage 2 local connection matrix | GO for Bridge/direct-LAN; LAB-ONLY for Quick Connect — firmware review proved the K1 3.0.2 AP credential is firmware-constant and the SSID follows the `XGR-` identity/MAC rule. One prepared Mac completed AP-enable, AP-ready, CoreWLAN association and the normal control lifecycle. The successful host had been seeded from the reviewed firmware archive; a copied build on a clean host cannot acquire that material automatically. Automatic firmware download, iPhone extraction and hard-coding were rejected. Bridge remains the product path; Direct Connect remains physically pending |
| Stage 3 application session | GO — MQTT 3.1.1 on confirmed K1 TCP 1883 |
| Stage 4 artifacts/flows | GO — bounded capture, hashes and negative control |
| Stage 5 point cloud | GO — raw-LZ4 protobuf, 1,140 live frames decoded |
| Stage 5 pose | GO — 1,215 live frames decoded and motion-correlated |
| Stage 5 camera | PAUSE — no independent frame/video stream observed |
| Stage 6 live viewer | GO — React console, Foxglove cloud/path and Mac latency metrics |
| Stage 5 modeling telemetry | GO (read-only) — bounded `ModelingReport` decoder and device-reported scan time/distance/speed are integrated before the preview queue |
| Stage 5 camera | GO (live) — left/right RTSP/H.264 preview observed, read-only runtime adapter and physical UI acceptance completed |
| Stage 6 live viewer | GO — React Control Station, embedded self-hosted Rerun cloud/trajectory, plugin-owned spatial controls and live device/Mac metrics |
| Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented |
| Stage 7 recorded cameras | GO — the accepted physical session sealed real fMP4 camera data, produced two digest-bound recorded sources and passed manifest/init/segment range admission alongside a 107 MB RRD; browser QA opened real frames from the 170 MB camera epoch at multiple shared-timeline positions |
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
| K1 application control | GO (physical staged cycle) — after fixing the PCAP-proven `sint64` time field, one explicit UI launch completed all 14 canonical operations on one control session, reached live `SCANNING + project + init_ready`, displayed real points, then one explicit STOP returned K1 to unbound `READY`. No retry or fallback command was sent. Native-project reuse through LixelGO/USB remains an independent verification |
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
| Simulation Polygon | SIM S0 GO — dedicated Docker-free D-only worker, exact accepted pins, stock Ackermann PX4/ROS 2/Gazebo telemetry, authoritative clock, pause/2 ms step, 1×/2× RTF/resource baselines, repeated clean lifecycle and all nine digest-bound evidence claims passed target doctor. S1 orchestrator/control, navigation/safety acceptance and real actuator authority remain absent |
USB project copying remains optional ground truth rather than a blocker for the
now-verified network path. MQTT control publishing remains deliberately deferred
because the physical button is a known-safe start/stop mechanism.
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
start/stop mapping and RTSP camera transport. The exact start/stop protobuf
encoder, response correlator and device-status state machine now run in the
separately gated interactive publisher; the legacy shadow publisher remains
disabled. Retained PCAP plus client static analysis prove that OpenAPI is one
private application-level value in this LixelGO build, not a per-scanner
credential. The selected BLE peripheral returns its own LAN IP; the initial
unbound `DeviceInfoRequest` then returns vendor ID, serial, model, activation and
version facts for that live K1. Mission Core's shadow bootstrap reproduces the
10 observed pre-START requests byte-for-byte across four response windows and
five observed publish groups: initial DeviceInfo binding; then the ordered
ModelingStatus/RTK and time-sync/DeviceInfo/RTK groups without an invented
response barrier between them; NTRIP read; then cloud/RTK/DeviceInfo reads. The first
ModelingStatus request has no mandatory synchronous response barrier; readiness
is separately attested through live DeviceStatus. A fixed macOS Keychain loader
now reads the exact 36-byte authority without environment/file/browser fallback.
The response orchestrator correlates required headers/results and rejects
identity/profile drift. The legacy shadow publisher remains a separate
write-disabled type that cannot call its injected sink. Its dormant coordinator
is wired into the XGRIDS facade only for redacted planning/state. Separately,
plugin v0.5.0 installs the reviewed physical-acceptance transport behind explicit
operator actions and one continuous background session owner. Each operation
key is consumed before publish, automatic reconnect and application retry are
forbidden, and any unknown outcome poisons the transport. Public state contains
only bounded lifecycle, counter and failure metadata; authority and device
identity remain private.
The one-time `k1link authority provision` command delegates secret entry to the
macOS Keychain TTY prompt and validates through the production loader without
receiving the value in argv/environment/file/browser state. During the first
physical attempt that CLI loader did not receive macOS approval, so a temporary
process-local Security.framework lab adapter supplied the already reviewed
value. The transport emitted bootstrap ordinals 16 and received all five
required responses through the old third window, but topic-only routing
misassociated the unbound and bound RTK responses and the orchestrator raised
`ApplicationBootstrapError`. No START was emitted;
live DeviceStatus stayed READY and the local point/pose counts remained zero.
The local capture was sealed and the temporary Keychain item was deleted.
The second physical attempt then correlated all ten bootstrap requests and one
START success response. It nevertheless did not reproduce the full LixelGO
lifecycle: all eleven publishes completed in 365 ms, the process did not retain
the control session through initialization, and it omitted the immediate and
post-initialization status reads present in both retained LixelGO START captures.
K1 reported `SCAN_STARTING` for about 24 seconds, emitted system error
`0x32040133` twice (the recovered `ALGORITHM_ERROR` namespace/value), returned
to `OTHER_STATUS`, produced zero point/pose frames and showed steady red. The
operator restored steady green through a normal power cycle. The legacy
`run_bootstrap()` path now rejects before emission. The offline acceptance path
maps requests 16 to connection, request 7 to explicit scan-workspace entry,
requests 810 to explicit project-prompt opening and request 11 to the operator's
START confirmation. Captured human delays are not replayed or treated as minimum
timeouts. After the immediate operation-12 read, operations 1314 wait for the
same bound K1 to report `SCANNING`, a bound project and `init_ready=true`.
The same transport is then serviced until an explicit STOP request; post-STOP
ownership waits for live unbound `READY`. System-error
and status reports are decoded into redacted safety state. The next staged
physical attempt, after correcting the protobuf `sint64` time field, completed
all 14 canonical operations, reached live scanning with real point/pose data and
then completed one explicit STOP. K1 returned to unbound `READY`; no retry or fallback device command was
sent. The durable local session is catalogued as ready/replayable, its RRD and
two camera sources pass digest-bound range admission, and physical double-click
remains the fallback. Reuse of the vendor-native project is still verified
separately through LixelGO or USB.
The Stage 6 alpha uses a bounded raw-first bridge: loss in the visualization
queue cannot discard MQTT evidence. Acceptance is replay of the full captured
scan followed by a live ideal-LAN run with measured host pipeline latency.
Sensor-to-display latency remains a separate clock-correlation test.
The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
queue cannot discard MQTT evidence. The queue holds four preview messages;
`ModelingReport` is consumed by a plugin-injected observer before that queue and
does not compete with point/pose frames. A project name is mandatory at
preparation, NFKC-normalized/trimmed on both sides, rejected for control
or surrogate characters or more than 96 characters, and retained only as
display metadata.
Stage 7 adds an independent durable observation-session lifecycle. New native
captures durably publish the clock origin before camera production, retain a
transport-scoped provisional envelope and atomically switch the capture-summary
pointer to a content-addressed session envelope after all producers stop.
Completed or recovered captures are prepared once by a bounded backend worker
into digest-bound RRD/cache-v9 and camera-manifest generations;
a browser replay request never performs conversion. The RRD materializes real
origin/end rows plus archived device-reported distance, speed and scan-time
series. The saved scene, controller and timeline remain hidden until the
complete RRD range and every declared camera pass admission. Sensor-to-display
latency and camera/LiDAR sensor-clock alignment remain separate correlation
tests.
The generic host clears a prior manual or archived spatial source only after a
plugin-owned live/file-replay start succeeds; a failed start preserves the
current scene. Once an acquisition is nonterminal, a fail-closed guard blocks
saved-session switching, persisted replay reattach and manual source
input/apply/reset until that acquisition has been finalized. The successful
automatic-source action is an internal start-result boundary and intentionally
does not masquerade as an operator source switch. The K1 scene-level stop
control is plugin-owned. A plugin-commanded acquisition sends one canonical STOP
and seals locally after protocol-reported standby; an operator-manual acquisition
still stops and seals only local reception and reports scanner state as unknown.
## Parallel branch — Simulation Polygon
The Polygon branch follows
[`docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md`](12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md)
and
[`ADR 0015`](adr/0015-simulation-polygon-qualification-boundary.md).
It does not reorder or weaken the K1 physical-evidence gates in this document.
Its first gate, SIM S0, is accepted:
- exact Windows 11 / dedicated `MissionCore-Sim` WSL2 Ubuntu 24.04 worker
profile;
- all mutable runtime bytes physically on D;
- ROS 2 Jazzy, Gazebo Harmonic, PX4 v1.17.0, matching `px4_msgs`,
Micro XRCE-DDS and Nav2 compatibility;
- authoritative Gazebo `/clock`;
- loopback port and deterministic process ownership;
- target-worker rover/telemetry/pause/2 ms step/1×/2× resource evidence;
- target doctor `GO` for profile SHA-256
`aeecf5005301db230e1340b85215cbbd862b72974a22b8c43767c28be8453e2f`.
The repository doctor is side-effect free:
```text
uv run missioncore-sim s0 doctor --json
```
Without the exact admitted target-worker evidence its correct result remains
`INCOMPLETE`. S1 starts from the accepted S0 version/storage/time/process
boundary and must add server-owned lifecycle, canonical rover commands,
TTL/watchdog/failsafe evidence and immutable qualification runs.
## Stage 0 — repository and host baseline
@@ -204,6 +330,40 @@ confirms it.
MVP GO: K1-to-Mac scan-correlated data can be captured reliably and at least one
useful stream is decoded or structurally identified.
## Stage 8 — replaceable external perception worker
Stages 67 established the raw-first live bridge and durable combined session
archive described above. Stage 8 introduces a replaceable GPU executor without
giving it K1 command authority or the authoritative archive.
Accepted recorded gate, 2026-07-19:
- the Mac validates one sealed camera epoch through the same digest and
ISO-BMFF timing inspector used by saved-session replay;
- `k1link compute prepare-camera-job` publishes an immutable
`missioncore.compute-job/v1` below ignored runtime storage;
- the first TEST007 job contains only one bounded 56-frame camera epoch, not the
MQTT archive or another K1 control path;
- Windows revalidates all 59 job files, reconstructs the exact canonical stream
digest and decodes all 56 frames;
- pinned Apache-2.0 YOLOX-S executes through pinned Triton 2.70.0 on the RTX
4090 and atomically publishes one content-addressed
`missioncore.compute-result/v1`;
- repeating the exact result identity returns the existing result without
increasing Triton inference count;
- Mission Core revalidates the returned generation, projects the exact camera
frames and boxes onto `session_time`, and exposes the optional native Rerun
view only after its complete RRF2 layer is accepted.
The accepted result is perception evidence only. The larger TEST007 epoch,
direct/routed worker transport and bounded live fan-out remain separate gates.
SSH/SCP is allowed only as the recorded laboratory bootstrap. The worker never
connects to K1.
See [ADR 0014](adr/0014-bounded-external-perception-worker.md), the
[worker contract](10_EXTERNAL_PERCEPTION_WORKER.md) and
[Lab 005](lab/005_FIRST_RECORDED_PERCEPTION_20260719.redacted.md).
## Deferred work
- ROS2/MCAP bridge;
@@ -211,4 +371,10 @@ useful stream is decoded or structurally identified.
- automated scan-button electronics;
- OpenWrt/monitor-mode infrastructure;
- firmware or internal-Linux analysis;
- camera branch if no external frame stream is evidenced.
- browser-visible end-to-end shared-timeline playback of the accepted archive
containing point cloud plus one selected K1 camera (backend preparation and
digest-bound byte admission are already accepted);
- OS-independent plugin/edge authority provider, PCAP-to-executor transcript
regression and runtime ownership integration;
- long-running large-session WebViewer/WASM memory telemetry;
- production retention, replication, encryption and cross-platform packaging.
+115 -6
View File
@@ -16,6 +16,35 @@ profile is not a generic XGRIDS protocol claim and must not be used for fuzzing.
- The application sends operator-supplied router credentials; it does not derive
them from the K1 and it does not require the password of the K1's own AP.
The 99-byte `7f01` profile is used when K1 joins an external network (Bridge or
controller hotspot). It is not the Quick Connect bootstrap. A live Mission Core
negative run on 2026-07-19 proved that host association alone cannot work while
the reviewed K1 remains in station mode: `7f02` still reported the existing LAN.
Re-review of the owner-supplied LixelGO QuickLink branch then recovered the
missing first step. After a real BLE connection, LixelGO sends one fixed
100-byte frame to the same `7f01` characteristic: bytes `0..98` are zero and
byte `99` is `0x01`. Its callback is the AP-launch result; only then does the
application passes that device record's `WiFiAP_SSID` and `WiFiAP_Password` to
the operating-system connector. Later analysis of the official K1 `3.0.2`
firmware recovered the upstream source: `lixel_nman` invokes the bundled
NetworkManager AP script, which assigns a firmware-constant WPA2 credential.
The persisted app fields are per-device records, but the reviewed firmware
material is not per-device. The reviewed app requests write-without-response.
Mission Core follows the live characteristic properties on macOS, where this K1
advertises write-with-response, and never retries either transport or command
automatically.
The browser/API carries no AP password. An optional laboratory importer authenticates the
exact official `3.0.2` archive, resolves the reviewed material without printing
it and installs one firmware-scoped credential source in the OS secure store.
Mission Core then binds a device-scoped host profile ID to the selected
BLE-advertised SSID. Before any device write, the macOS helper materializes that
profile entirely inside Keychain from the firmware-scoped source. The actual
secret never reaches the browser, API, argv, logs, manifests or evidence. The
bounded Python importer holds it only in a short-lived mutable buffer, sends it
through helper stdin and zeroizes the buffer.
Full UUIDs:
- service: `00007f00-0000-1000-8000-00805f9b34fb`;
@@ -38,6 +67,59 @@ There is no checksum, nonce, token, certificate, signature, or separate commit
command in this production call path. The implementation must never print,
persist, or accept the password as a command-line argument.
## Quick Connect AP-enable frame
The separately reviewed Quick Connect device action writes exactly 100 bytes:
| Offset | Length | Meaning |
| --- | ---: | --- |
| 0 | 99 | zero |
| 99 | 1 | enable device AP (`0x01`) |
It contains no SSID, password, device identifier or user input. The reviewed
client maps response byte 51 of `7f02` to its AP-ready flag. Mission Core
therefore requires all three observations before host association: mode
`WIFI_AP`, address `192.168.56.1`, and non-zero byte 51. A failed or ambiguous
attempt is not automatically repeated.
The first controlled AP attempt was physically accepted: one write-with-response
completed, `7f02` changed to `WIFI_AP / 192.168.56.1`, and a later exact
CoreWLAN scan found the SSID matching the selected BLE device. The Mac remained
on its previous LAN only because the former host adapter looked up an invalid
global profile. That profile inference and its APK importer were withdrawn.
A later physical attempt established another boundary: `7f02` can continue to
report `WIFI_AP / 192.168.56.1` with byte 51 zero after the exact SSID is no
longer beaconing. Mode and address alone are therefore not an idempotency or
readiness signal. Like the reviewed LixelGO action, each new explicit Mission
Core Quick Connect intent emits one AP-enable frame even when the baseline mode
already says `WIFI_AP`, then polls for the byte-51 ready flag for at most 15
seconds. It never retries the device write automatically.
Static review of the original client also established a lifecycle requirement:
LixelGO keeps the same BLE manager connected after AP-ready and invokes native
Wi-Fi association from that live session. Mission Core now retains the same
`BleakClient` while CoreWLAN performs bounded exact-SSID discovery and one
association. The discovery window may contain multiple read-only scans; it does
not repeat the BLE write or Wi-Fi association.
The next physical attempt exposed a CoreBluetooth lifecycle boundary before any
write: K1 appeared in the explicit six-second BLE scan, but a second lookup by
its macOS UUID failed moments later. The UUID is a transport-local selector, not
a durable rediscovery contract. The runtime now retains the live `BLEDevice`
handle from the operator's scan and connects that exact selected handle in the
following network action. A fallback lookup remains only for non-UI callers
that did not perform discovery first.
The 2026-07-20 prepared-host acceptance installed the exact firmware provider,
found one expected K1 candidate, emitted one AP-enable write, observed AP-ready
and completed one CoreWLAN association without an iPhone or manual credential.
Mission Core admitted `192.168.56.1`; the operator disconnected afterward only
to restore the external chat route. Full redacted evidence and artifact hashes
are recorded in `docs/lab/004_K1_FW302_AP_CREDENTIAL_PROVIDER_20260720.redacted.md`.
This proves the mechanism on that Mac, not automatic credential acquisition by
a copied application on a clean host.
The Android application unequivocally requests a write without response and
negotiates MTU 120, making the 99-byte frame one ATT command. A live read-only
CoreBluetooth check reports MTU 256 and a maximum write-without-response size of
@@ -53,11 +135,13 @@ mode. It never fragments or retries the payload automatically.
## Expected transition and evidence of acceptance
A completed GATT write only proves transport completion. It does not prove that
the K1 joined Wi-Fi. The application polls `7f02`; the observed response frame
contains a fixed-width mode slot, an address slot, and a status byte at offset
50. The current AP baseline reports mode `WIFI_AP` and address `192.168.56.1`.
the K1 joined Wi-Fi or began beaconing. The application polls `7f02`; the
observed response frame contains a fixed-width mode slot, an address slot, a
status byte at offset 50 and the AP-ready flag at offset 51. The stale AP
baseline reports `WIFI_AP / 192.168.56.1 / byte51=0`; the physically observed
ready transition reports the same mode/address with `byte51=1`.
For the controlled experiment, acceptance required at least one of:
For Bridge/Direct Connect, acceptance requires at least one of:
1. `7f02` reports a non-AP IPv4 address;
2. the same address appears as a new router/ARP client after the write;
@@ -66,10 +150,35 @@ For the controlled experiment, acceptance required at least one of:
Do not infer success from a write callback alone.
The Bridge/Direct Connect address is a DHCP lease, not configuration and not
device identity. Mission Core re-reads `7f02` without writing before every new
LAN control session, implicit-host acquisition and factory-calibration read.
If the value changes, it rotates `device_session_id`; it never retargets an
active acquisition. A correlated MQTT `DeviceInfo` response supplies the live
model/firmware/serial identity barrier.
The 2026-07-20 reboot/power-cycle check observed the startup race directly:
one read returned the earlier `.54` lease while that exact address had no ARP or
application endpoint; a later read returned `.52`, where exact probes found
MQTT 1883 and RTSP 8554. No subnet scan, route change, network-profile change or
VPN action was used. This is why no owner-LAN IPv4 value is a product constant
and why a BLE lease observation alone is not reported as live DeviceInfo.
For Quick Connect, host association is not admitted until the canonical
byte-51 ready flag is observed. CoreWLAN then searches only for the exact
device-profile SSID for at most 15 seconds and performs at most one association.
## Safety, recovery and stop conditions
- Perform one write per explicitly named attempt, using credentials for the LAN
already used by the Mac. Never retry automatically.
- Perform one write per explicitly named attempt. Never retry automatically.
- For Bridge/Direct Connect, use credentials for the operator-selected network.
- For Quick Connect, the exact `3.0.2` provider must already exist in the OS
secure store. Missing or mismatched firmware material fails before the AP
write. Never extrapolate this provider to another firmware or model.
- The macOS adapter materializes a device-scoped Keychain item from the exact
firmware source, then performs one association. Standard Wi-Fi Keychain and
native prompt paths remain compatibility fallbacks, not the reviewed
zero-touch path. It never asks the browser for a password.
- Do not alter Deco settings, scan the subnet, or guess any credential.
- If the status does not change, do not retry automatically.
- If the supplied credentials are wrong, reconnect over BLE and overwrite them
+190 -15
View File
@@ -129,22 +129,192 @@ orientation is exposed as `(x,y,z,w)`. Unknown tail fields are retained.
`PrePathArray` is exactly 16 little-endian float64 values.
## Modeling telemetry
`lixel/application/report/modeling` contains a bounded `ModelingReport`. Field 2
is PGO progress; field 3 is a nested scan status containing float32 move
distance, float32 move speed and an int64 scan-time counter. Retained physical
captures correlate that counter at exactly two ticks per second. Mission Core
consumes this status before the disposable preview queue and exposes the current
device-reported scan generation as elapsed seconds, route metres and metres per
second. When scan time and distance reset together, the old route is not added
to the new generation.
## Application control boundary
Static analysis identified the application start/stop topic as
Static analysis first identified the application start/stop topic as
`lixel/application/request/modeling`, QoS 2, with a protobuf
`ModelingRequest`. Start action is 1 and stop action is 2. A valid request also
contains a device/session/OpenAPI header and, for start, project/record/scan/mount
settings.
`ModelingRequest`. Start action is 1 and stop action is 2. The retained header
contains explicit device identity and OpenAPI value; its session relation is
literally `{device_id}:ModelingRequest`, not a caller-selected session. The
retained start request carries an operator project name, record mode 2, scan
mode 1 and mount mode 0; the retained stop request carries only header and
action. QoS is 2, retain is false, and correlated success uses numeric result
`302252033` with the same header identity and action.
Publishing is deliberately not implemented. The physical double-click provides
a verified autonomous start/stop path and avoids inventing session headers or
changing scan settings. A future publisher requires a separate reviewed profile,
explicit confirmation, response handling and rollback.
Lab 002 then observed both mappings in owner-operated LixelGO traffic and
correlated them with device success responses and physical start/stop of the
high-rate streams. This promotes the mapping from a static hint to descriptive
wire evidence; it does not authorize replay or publishing.
## Decoder and capture bounds
An offline bounded audit of the retained owner-controlled captures additionally
proved that the repository encoder reproduces the observed START and STOP
payloads byte-for-byte for this one K1 and exact firmware profile. It also
separated four values which must never be substituted for one another:
Default defensive limits are applied before allocation or iteration:
- Mission Core's provisional device UUID is local inventory identity and never
belongs in a vendor request;
- the vendor device ID comes from live K1 status and occupies the request header;
- the K1 serial is a separate live device-binding value;
- the OpenAPI value is private application-level material embedded by the
observed LixelGO build; it is shared across all retained request types and is
not derived from the current scanner while the application value is present.
Consequently, support for another K1 never replays captured payload bytes and
does not require a manually maintained scanner profile. BLE selection and its
provisioning response bind the transport to that unit; an initial
`DeviceInfoRequest` without device ID returns the live vendor ID, serial, model,
activation and version facts. Those values bind later requests and must agree
with the live status stream. Any mismatch, identity drift, malformed response,
replay-only evidence or unexpected lifecycle state fails closed.
The clean LixelGO cycle contains exactly ten requests before START, all QoS 2
and retain false: `DeviceInfo`, `ModelingStatus`, `GetRtkAdvance`, a bound
`DeviceConfig` time/timezone sync, then `DeviceInfo`, `GetRtkAdvance`,
`GetNtripProfile`, `GetCloudServerConfig`, `GetRtkAdvance`, `DeviceInfo`. The
special time-sync session is exactly
`${device_id}:DeviceConfigRequest:Publish_Proto_DeviceConfig_SetTime`. The
repository shadow bootstrap reproduces all ten retained payloads and their
topic order byte-for-byte. Time sync is the only mutation before START; the
other nine requests are reads. Its Unix timestamp is protobuf `sint64`, not
`int64`/`uint64`: the retained value `3568430060` ZigZag-decodes to
`1784215030` (`2026-07-16 15:17:10 UTC`). The runtime encoder must therefore
ZigZag-encode the current epoch before writing field 1 of the nested time
configuration.
The first ten requests contain five observed publish groups but only four
application-response windows. The initial DeviceInfo response is the identity
binding barrier; ordinals 26 then retain their exact publish order without an
invented response barrier between ordinals 3 and 4. Two independent owner-operated START
captures contain the same 14-operation order: the ten requests above, START,
one bound `ModelingStatus` read, then a bound `DeviceInfo` plus
`ModelingStatus` refresh about 2425 seconds after START. The first capture
reached START 202.423 seconds after its initial DeviceInfo; the faster capture
reached it after 54.130 seconds. This variation is operator/UI time, while the
stage order is invariant. In both captures the original control MQTT connection
remained open throughout initialization and the later refresh; no DISCONNECT or
UNSUBSCRIBE occurred. DeviceInfo response establishes identity before later
bound requests. Required response barriers are correlated by operation key, topic, exact
session, vendor identity, application authority and numeric success. The
initial and immediate post-START ModelingStatus reads are intentionally not
treated as mandatory synchronous responses. In both retained captures operation
12 has no application response; the single bound ModelingStatus response in
that part of the lifecycle arrives only after operation 14 and belongs to it.
An unanswered optional operation therefore cannot reserve FIFO ownership over
the required operation-14 response. Readiness belongs to live DeviceStatus plus
point/pose observation.
The UI/event correlation is also fixed by the retained capture. Requests 16
follow successful data connection. Entering the scan workspace causes request
7. Opening the record/project-name prompt causes reads 810. The project name is
not sent by a separate save mutation: it first appears in the START payload when
the operator confirms the prompt. K1 then reports `SCAN_STARTING`; completion is
the live transition to `SCANNING` with a bound project and `init_ready=true`.
Operations 1314 coincide with that state in both captures. The observed
2425-second initialization duration is therefore evidence/telemetry, not a
hard-coded transition delay.
The repository now contains an inert bounded encoder/response parser and a
fail-closed device-status state machine for this exact profile. A live-only
shadow planner accepts START only from `READY` with no project and STOP only
from `SCANNING` with a bound project. It emits only a non-executable digest and
wire metadata, uses QoS 2 with retain false, and declares automatic retry
forbidden. It has no MQTT publish dependency and cannot send a command.
Legacy shadow-plan publishing remains deliberately disabled. The exact 36-byte authority has a
read-only macOS Keychain loader with no environment, file, browser or API
fallback. A facade-owned dormant coordinator can arm it only after explicit
operator confirmation, a connected/attested K1, idle runtime and no active
acquisition. Its non-exportable process-memory lease is limited to 15300
seconds and is revoked by expiry, disarm, reprovision, acquisition preparation
or shutdown. The current one-shot boundary is structurally write-disabled,
exposes no emission method and cannot touch its injected sink.
A separate operator-driven physical-acceptance implementation is installed in
plugin v0.5.0 through the facade/runtime. Retained-PCAP re-audit fixes its MQTT
contract at 3.1.1,
`clean_session=false`, keepalive 60, the three ordered 9/5/42-topic control
subscription groups, separation from the point-cloud client, QoS 2
request/PUBCOMP completion and
`lixel/application/response/modeling`. It performs one connection attempt,
consumes every ordinal/action operation key before publish, never reconnects or
retries, rejects stale/duplicate/unexpected responses and poisons every unknown
post-publish outcome. A 15120 second permit requires explicit operator-present,
owner-controlled-device, LixelGO-closed, battery/storage and expected-state
confirmations. START additionally requires the complete four-window bootstrap;
STOP requires its own separate permit. No plugin state read, poll, navigation or
automatic lifecycle event may invoke either action. On 2026-07-18 its first
operator-present physical attempt made one
connection and emitted bootstrap ordinals 16. All six QoS 2 publishes completed
and five required responses were collected, but the old topic-only collector
misrouted one of the two `GetRtkAdvance` responses in its third window. The
executor stopped before building or publishing START;
K1 remained `READY`, project binding stayed absent and point/pose remained zero.
No automatic retry was attempted. A second explicitly permitted attempt then
correlated all ten bootstrap responses and one START success response, with 11
QoS2 completions, but it compressed the exchange into 365 ms and ended without
the retained post-START reads or durable control-session ownership. K1 remained
in `SCAN_STARTING` for about 24 seconds, emitted two identical
`report/system_error` values `0x32040133`, returned to `OTHER_STATUS`, produced
no point/pose frames and showed steady red. The bounded system-error decoder maps
the `0x32040000 + 307` value to the recovered `ALGORITHM_ERROR` state. A normal
operator power cycle restored steady green.
This disproves the earlier assumption that byte-matched pre-START payloads plus
a correlated START acknowledgement are sufficient acceptance. The legacy
collapsed `run_bootstrap()` executor now rejects before emission. Its
replacement is an operator-driven acceptance session owner. It requires the
observed connection, workspace-entry and project-prompt checkpoints instead of
wall-clock floors, sends the immediate post-START ModelingStatus read, services
the same MQTT socket until live `SCANNING + project + init_ready`, then emits the
DeviceInfo/ModelingStatus refresh. It continues servicing that socket until an
explicit STOP request and afterwards until live unbound `READY`. That device
status is the canonical standby evidence; no second visual acknowledgement is
required. System-error and DeviceStatus reports are decoded into redacted safety
state; a fault forbids further automatic action. Standalone START/STOP are
disabled. MQTT keepalive/report processing also continues while the operator is
between UI actions; the 15120-second command permit is created at the actual
START or STOP confirmation, not at connection time. Plugin v0.5.0 now exposes
this path through separate plugin-owned UI actions. Repository tests use only
in-memory transports; physical execution occurs only from an explicit operator
click against an already selected direct-LAN K1.
The one-time Keychain administration command uses `security -w` as the final
argument so Apple's own TTY prompt receives the authority. Mission Core never
accepts it through CLI argv and validates the resulting fixed item through the
production loader. The macOS CLI approval path timed out during the first lab
attempt; a temporary Security.framework-backed item was used only inside the
one-shot process and deleted immediately afterward. It is not the product
authority architecture. The acceptance executor now records only ordinal,
message type, response topic, payload size, SHA-256 and a safe correlation
reason; it never exposes response bytes or authority in diagnostics. The
retained clean-cycle now completes all nine bootstrap barriers offline while
ordinals 3 and 6 are simultaneously in flight and routes them by exact
session/device identity. After repairing the PCAP-proven protobuf `sint64` time
field, the next operator-run physical cycle completed all 14 canonical requests
on one socket, reached `SCANNING + project + init_ready`, delivered real
point/pose data and completed one correlated STOP. Streams quiesced, K1 returned
to unbound `READY`; no retry or fallback STOP was sent. The operator also
observed stable green, but that observation is not a second protocol gate. The
local archive sealed and passed digest-bound RRD and recorded-
camera admission. Appearance and reuse of the vendor-native project through
LixelGO or USB remains a separate gate. Physical double-click remains the
operational fallback.
## Decoder and raw-capture bounds
The semantic decoder applies these default defensive limits before allocation
or iteration:
- 2 MiB maximum MQTT payload;
- 1 MiB maximum compressed block;
@@ -153,12 +323,17 @@ Default defensive limits are applied before allocation or iteration:
- 250,000 points per frame;
- bounded protobuf field counts and exact LZ4 decoded length.
The raw-first evidence recorder has an independent 64 MiB default per-message
limit, configurable only up to 256 MiB. That larger transport ceiling does not
raise any semantic decoder allocation bound.
Per-frame failures do not justify firmware writes or speculative recovery.
Always preserve the raw MQTT record and report the decoder error separately.
## Current camera result
## Camera result outside MQTT
No independent camera, panorama, JPEG, H.264 or RTSP topic was observed in this
run. `lio_pcl` may carry intensity or packed color information, but that does not
prove access to the two raw panoramic camera streams. Camera discovery remains a
separate evidence gate.
No camera payload was observed in the report-topic MQTT run documented above.
The later LixelGO/iPhone experiment found the preview on a separate transport:
left/right RTSP sessions on TCP `8554` with interleaved RTP/TCP and H.264. This
proves compressed preview availability, not full-resolution raw frames or
camera calibration. See Lab 002.
+153 -50
View File
@@ -1,25 +1,31 @@
# K1 live console and embedded Rerun bridge
Status: the Rerun alpha milestone is implemented for the verified firmware-3
MQTT streams. Live capture and replay use real K1 point-cloud and pose messages;
the application does not generate a placeholder cloud, trajectory, camera frame
or latency value.
Status: the Rerun live and saved-session milestones are implemented for the
verified firmware-3 point/pose streams. Live and adapter file-replay use the
process-wide gRPC source; saved observation sessions use private digest-bound
RRD over same-origin HTTP. Left/right RTSP preview is delivered separately from
the spatial Rerun stream. The application does not generate placeholder cloud,
trajectory, camera frame or latency values.
## Active device-to-scene path
```text
K1 lio_pcl / lio_pose
K1 lio_pcl / lio_pose / ModelingReport
|
v
read-only MQTT subscription on TCP 1883
|
+--> raw .k1mqtt + JSONL + SHA-256 summary (written first)
+--> raw .k1mqtt + JSONL + durable clock origin (before camera/preview)
+--> transport/session envelope + SHA-256 summary (shutdown/seal)
|
+--> injected K1 ModelingReport observer
| `--> device scan time / distance / speed product metrics
|
v
bounded latest-wins preview queue (32 messages)
bounded latest-wins visual preview queue (4 messages)
|
v
reviewed raw-LZ4/protobuf decoders
reviewed raw-LZ4/protobuf point/pose normalizer
|
v
Rerun Points3D + Transform3D + LineStrips3D
@@ -30,16 +36,22 @@ Rerun gRPC/proxy on TCP 9876
+--> rerun_grpc_url in REST/WebSocket state
|
v
self-hosted @rerun-io/web-viewer inside NODE.DC Control Station
self-hosted @rerun-io/web-viewer inside Mission Core Control Station
React console <-- REST + WebSocket state --> FastAPI on 127.0.0.1:8000
```
The Paho MQTT callback does not decode or render the point cloud. It first
writes and flushes the raw frame and metadata, then enqueues a preview message.
If visualization cannot keep up, the oldest queued preview is discarded while
the raw capture continues. Rerun work stays on the dedicated publisher thread
and cannot block raw-first evidence capture.
appends the raw frame, flushes it to the operating-system page cache and stages
its aligned metadata, then enqueues a preview message. Raw bytes and metadata
become a durable pair at the bounded group-commit boundary (at most 0.5 s,
4 MiB or 32 messages), not at every callback. If visualization cannot keep up,
the oldest queued preview is discarded while capture continues. Rerun work
stays on the dedicated publisher thread and cannot block that raw-first path.
The four-message queue size is independent of the 32-message evidence group
commit. A plugin-injected observer consumes bounded K1 `ModelingReport` messages
after raw persistence but before visual admission, so status telemetry cannot
evict a point-cloud or pose preview.
The current live/replay runtime instantiates only `RerunBridge`. The former
Foxglove implementation is not a parallel runtime and does not listen on TCP
@@ -52,8 +64,8 @@ Prerequisites are the repository-local Python environment and Node.js 20.19+ or
```bash
uv sync --group dev
cd apps/k1-viewer
npm install
cd apps/control-station
npm ci
npm run typecheck
npm run build
cd ../..
@@ -103,27 +115,57 @@ directly and use their sibling metadata receive timestamps when present.
complete visible-device list.
4. Enter the existing router SSID/password and explicitly authorize the reviewed
provisioning write. The backend does not retry the write automatically.
5. When K1 reports a non-AP private address, start live reception.
6. Wait until the UI reports that the local Rerun bridge is ready. No manual
viewer URL is needed.
7. Open **Наблюдение → Пространственная сцена**.
8. Double-click the physical K1 button to start scanning. Real point frames and
pose/trajectory updates then appear in the embedded viewport.
9. Double-click K1 again to stop physical scanning, wait for steady green, then
stop the local session so captures and summaries are finalized.
5. Enter the required project name, confirm operator presence, closed LixelGO,
storage/power and steady green, then choose **Запустить сканирование и
локальный приём** once.
6. Mission Core emits operations 16, waits for their correlated device
responses, then performs operation 7 and operations 810 behind the same
operator intent. No captured human pause is treated as a timer and no device
command is automatically retried.
7. Mission Core prepares evidence reception before emitting the single
canonical START. The project name is carried only by that START; there is no
separate K1 project-name write. Mission Core opens
**Наблюдение → Пространственная сцена** and shows calibration while the same
control socket waits for `SCANNING + project + init_ready`.
8. Real point frames and pose/trajectory updates then appear in the embedded
viewport. Do not use the physical button or open LixelGO during this cycle.
9. When K1 emits `ModelingReport`, the same plugin block shows its current scan
time, route distance and speed. These are device reports, not values inferred
from a browser timer or accumulated across a device counter reset.
10. Choose **Остановить устройство и запись** in the scene. The
plugin-commanded path emits one canonical STOP on the original socket,
waits for live unbound READY, then finalizes capture, camera archive and
summaries automatically. The UI does not ask for a redundant green-indicator
confirmation because READY plus cleared project binding is already the
scanner's protocol evidence.
Each live run creates an ignored `sessions/<UTC>_viewer_live/` directory with a
redacted manifest, operator notes, raw MQTT frames, per-message metadata and a
hash summary. The connector subscribes to the fixed report-topic allowlist and
does not publish an application request or modeling command.
Each new live run creates a direct child below `MISSIONCORE_EVIDENCE_DIR`, or
`.runtime/mission-core/evidence/sessions/` by default, with raw MQTT frames,
per-message metadata and a hash summary. Repository-level
`sessions/*_viewer_live` is legacy import-only evidence and is never selected by
the current writer. The data connector remains subscribe-only. Application
requests are isolated in the separate canonical control-session owner and can
be emitted only after the explicit launch or STOP intent and the corresponding
live response gate.
The recovered command substrate byte-matches the ten
observed pre-START requests plus START/STOP, correlates DeviceInfo and modeling
responses, and classifies live device states before any command transition. One
private application-level OpenAPI authority is kept
separate from the transient vendor ID/serial returned by the BLE-selected K1.
The fixed Keychain loader and dormant shadow coordinator remain available for
inspection. Plugin v0.5.0 additionally installs a continuous interactive MQTT
owner: one UI launch intent advances operations 16, 7, 810 and START through
their live response barriers; STOP remains a separate explicit action. Physical START/STOP and
durable native-project completion still require operator acceptance.
## Automatic Rerun source and lifecycle
On the first live or replay session, `RerunBridge`:
On the first live or adapter file-replay session, `RerunBridge`:
- creates an explicit `RecordingStream("nodedc_device_spatial")`;
- creates an explicit `RecordingStream("nodedc_mission_core_spatial")`;
- installs the default spatial blueprint;
- starts the gRPC/proxy server on TCP 9876 with a 512 MiB late-client buffer;
- starts the gRPC/proxy server on TCP 9876 with a 32 MiB late-client buffer;
- reports `rerun+http://127.0.0.1:9876/proxy` only after the server is ready;
- accepts the local development and production browser origins used by this
repository;
@@ -143,6 +185,16 @@ served over HTTP(S). No externally hosted viewer UI is involved:
application. FastAPI carries control state only; the browser reads the point
stream directly from the Rerun gRPC/proxy endpoint.
`activateAutomaticSpatialSource` is a generic host action, not a scanner-control
command. A plugin invokes it only after its live or adapter-replay start returns
success, then opens the spatial scene; a failed start preserves the old scene
and source. The successful action releases any host-selected archive/manual URL
so the new backend source becomes authoritative. After an acquisition becomes
nonterminal, a fail-closed host guard blocks saved-session switching, persisted
replay reattach and manual RRD/Rerun source input/application/reset until the
acquisition ends. This successful-start transition intentionally does not
consult the operator-switch guard, and the guard never stops equipment.
## Rerun entities
| Entity | Rerun archetype | Meaning |
@@ -150,11 +202,14 @@ stream directly from the Rerun gRPC/proxy endpoint.
| `/world` | `ViewCoordinates` | right-handed Z-up display convention |
| `/world/points` | `Points3D` | decoded metric XYZ with computed or available RGB colors |
| `/world/sensor_pose` | `Transform3D` + `TransformAxes3D` | current decoded translation, xyzw quaternion and pose axes |
| `/world/trajectory` | `LineStrips3D` | bounded path of up to 20,000 decoded poses |
| `/world/trajectory` | `LineStrips3D` | bounded path of up to 2,000 decoded poses |
Point count, frame rate, queue drops and measured pipeline time remain product
metrics in the outer Control Station; they are not logged as Rerun entities and
therefore cannot create additional automatic viewer panes.
metrics in the outer Control Station. Live K1 scan time, route distance and
speed also remain product metrics in the plugin-owned scene block. Saved-session
export separately logs those three device-reported values below
`/metrics/device` as Rerun scalar time series; this does not add vendor parsing
to the generic live bridge.
Point coordinates remain `(x/scaler, y/scaler, z/scaler)` exactly as decoded.
No axis swap, quaternion normalization, scanner-to-vehicle extrinsic or SLAM
@@ -189,18 +244,61 @@ shows only the current frame. RGB mode falls back to the verified scalar
coloring path when the active point format has no RGB fields. The current
`class`/custom behavior is a selected solid color, not semantic segmentation.
Projection switching, a custom playback timeline, semantic object/mask layers,
camera frustums and persisted RBL/layout profiles are not wired yet and must not
be inferred from the implemented controls above.
Projection switching, semantic object/mask layers and camera frustums are not
wired yet. The host-owned `observation.spatial` layout profile does persist the
implemented scene controls, tool-window state, dynamic source visibility and
normalized floating-window geometry. It is separate from Rerun's internal
blueprint and from observation evidence.
## Saved observation sessions
Every completed native live run is indexed by the local host session store. A
valid crash prefix without its final summary is indexed as `interrupted`. The
**Сохранённые сессии** control shows the three newest runs and launches an
opaque same-origin RRD rather than starting a second live bridge.
Sealing, recovery or legacy import makes the session eligible for the bounded
backend preparation worker. The worker reads every native message once and
atomically publishes a private cache-v9 RRD using the zero-based `session_time`
duration timeline. New sessions bind that zero/end to the durable capture-clock
envelope; older valid evidence retains the first/last-message compatibility
fallback. A SHA sidecar binds the generation to native evidence.
Replay-open never performs conversion: it returns HTTP 202 and a preparation
handle or reuses the verified artifact. The embedded viewer opens the exact
generation through Rerun's native incremental HTTP receiver. The bottom Control
Station timeline then controls play/pause and seek directly; it does not
approximate time with a React timer.
The native `.k1mqtt`, aligned metadata and capture-clock envelope remain the
spatial evidence master. RRD generation does not use the bounded live-preview
queue, so it retains every point/pose frame accepted by the reviewed normalizer
and logs every valid `ModelingReport` as distance, speed and elapsed-time scalar
rows. See
[`09_OBSERVATION_SESSIONS.md`](09_OBSERVATION_SESSIONS.md) for storage,
recovery and HTTP Range details.
## Time and latency semantics
The Rerun `capture_time` timeline uses
`StreamMessage.received_at_epoch_ns`: Unix time when the Mac received the MQTT
message. The K1 header timestamp epoch is not proven, so `capture_time` is not
a sensor acquisition timestamp. The viewer accumulation window uses
`stream_time`, assigned when the current live/replay run publishes a message;
therefore replayed historical timestamps do not mix two sequential sessions.
The Rerun `capture_time` timeline uses Mac host-clock epoch values. Data rows use
`StreamMessage.received_at_epoch_ns`, while new archived sessions also contain a
real origin row at the capture-envelope start and a real end row at the sealed
session completion. Their zero-based `session_time` is derived from the same
monotonic envelope; it can therefore cover a camera fragment before the first
MQTT packet or after the last spatial packet without inventing an RRD range.
Legacy evidence without an envelope falls back to first/last message bounds.
The K1 header timestamp epoch is not proven, so `capture_time` is not a sensor
acquisition timestamp. The live viewer accumulation window uses `stream_time`,
assigned when the current live/replay run publishes a message; therefore
replayed historical timestamps do not mix two sequential sessions.
Retained read-only physical captures support the profile-scoped telemetry units:
in five of six runs, `ScanTime / 2` tracks host monotonic duration within about
0.17 seconds across 762,294-second spans; the remaining older run contains a
counter/distance reset and is treated as a new scan generation. In two moving
runs, `ModelingReport.MoveDistance` matches the independent
`lio_pose.distance` values at 41.521 m and 26.631 m, while reported peak speeds
of 1.382 m/s and 1.291 m/s are physically plausible. This evidence does not turn
the counters into a cross-firmware contract.
For live MQTT only, `pipeline_ms` / `mqtt_to_publish_ms` uses the Mac monotonic
clock from MQTT callback receipt through raw disk write, bounded queue wait,
@@ -228,7 +326,7 @@ and host firewall as appropriate, and never forward it directly to the public
Internet or cellular WAN. Remote operation requires an authenticated TLS reverse
proxy or another reviewed secure transport before deployment.
The 512 MiB Rerun server buffer limits retained late-client data but is not an
The 32 MiB Rerun server buffer limits retained late-client data but is not an
access-control mechanism. The listener and embedded viewer intentionally remain
ready between acquisition sessions. Stop `k1link serve` when the network
listener and its process memory must be closed unconditionally.
@@ -236,10 +334,14 @@ listener and its process memory must be closed unconditionally.
## Current boundaries
- Raw panoramic camera frames were absent from the observed MQTT report topics.
This milestone contains point cloud and pose/trajectory; operational metrics
are rendered by the outer Control Station.
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
publisher needs a separately reviewed state-changing profile.
The Rerun spatial recording contains point cloud and pose/trajectory;
left/right compressed RTSP preview and acquisition-owned fMP4 archive use a
separate generic media path. Historical sessions predating that archive have
no recoverable video.
- Physical double-click remains the independently verified device-local fallback.
Mission Core also contains the installed response-gated operator-present
acceptance transport for the exact K1/A4/FW 3.0.2 profile; its full
START-to-STOP durable-save cycle was physically accepted on 2026-07-18/19.
- No terrain map, elevation model, obstacle segmentation, localization fusion,
mission planner or vehicle control is implemented by this viewer milestone.
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting
@@ -264,12 +366,13 @@ embedded Web Viewer path without a viewer-side substitute:
The 38 point frames and 42 pose frames account for all 80 observed messages.
This proves current real-device decoding, bridge publication and embedded-viewer
delivery for point cloud plus trajectory. It does not prove a camera channel or
sensor-to-screen latency.
delivery for point cloud plus trajectory. It does not prove archived playback
of one selected camera together with the point cloud or sensor-to-screen
latency.
## Legacy Foxglove regression module
`src/k1link/viewer/foxglove_bridge.py` and its tests remain in the repository
`src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py` and its tests remain in the repository
to compare decoder/packing behavior and preserve earlier evidence. They are not
instantiated by `VisualizationRuntime`, are not the Control Station source, and
do not make TCP 8765 part of the current operator path.
+313
View File
@@ -0,0 +1,313 @@
# NODEDC MISSION CORE monorepo
This document maps the current repository to the target Mission Core
architecture without claiming that deferred package and process boundaries
already exist. The device-lifecycle decision is recorded in
[`ADR 0004`](adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md),
and the bounded laboratory runtime seam in
[`ADR 0011`](adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md).
## Current layout
| Path | Current responsibility | Target responsibility |
| --- | --- | --- |
| `apps/control-station/` | React shell, model catalog, plugin host slots, spatial scene | Vendor-neutral operator application |
| `apps/control-station/src/core/device-plugins/frontendSdk.ts` | Public React host surface for reviewed frontend contributions | Versioned frontend Plugin SDK package surface |
| `packages/plugin-sdk/` | Installable v0alpha2 Pydantic contracts and JSON Schema export | Portable host/plugin identity, lifecycle, stream and evidence boundary |
| `plugins/xgrids-k1/` | v1alpha2 manifest, exact profile/loader and plugin-owned React connection/acquisition UI | Independently versioned XGRIDS device plugin |
| `plugins/xgrids-k1/frontend/` | XGRIDS provisioning, project/acquisition/replay, optional spatial controls, diagnostics, metrics, runtime mapping and scoped CSS | Plugin-owned reviewed frontend contribution |
| `src/k1link/web/` | Local FastAPI host, fail-closed runtime handshake/transport seam, in-memory operation/acquisition lifecycle and generic session API | Generic host APIs plus isolated plugin supervisor |
| `src/k1link/device_plugins/xgrids_k1/` | Physically isolated K1 BLE/MQTT/protobuf/LZ4/camera/replay/CLI compatibility implementation | Independently built XGRIDS device plugin process |
| `src/k1link/data_plane/` | Transport-neutral decoded in-process consumer views | Local projections hydrated from portable SDK envelopes |
| `src/k1link/viewer/` | Vendor-neutral Rerun consumer, metrics and recorded blueprint | Replaceable canonical scene sink and presentation adapters |
| `src/k1link/sessions/` | Host-owned SQLite catalog, evidence discovery/recovery, background preparation and derived-cache lifecycle | Device-neutral observation archive service |
| `src/k1link/web/session_api.py` | Opaque saved-session, immutable RRD/media and workspace-layout API | Versioned Control/Edge observation contract |
| `apps/control-station/src/core/observation/` | Session selection, replay admission, recorded-source and layout state | Device-neutral observation UI runtime |
| `docs/domain-model/` | Versioned experimental Mission Core vocabulary | Promotion source for meanings proven across devices/services |
## Implemented boundary
The current K1 live/replay data path is:
```text
raw K1 transport
-> raw-first evidence capture
-> injected K1 ModelingReport observer (device time/distance/speed)
-> bounded four-message visual preview queue
-> XGRIDS normalizer injected by the plugin facade
-> DecodedPointCloudView / DecodedPoseView
-> Rerun bridge
-> embedded local viewer
```
The host-owned recorded path is separate from the disposable live preview:
```text
sealed or recovered native observation session
-> SQLite catalog and bounded single-worker preparation
-> capture-clock-bound atomic RRD cache v9 + immutable recorded-media manifest v2
-> generation-bound same-origin HTTP
-> aggregate RRD/camera admission
-> native Rerun HTTP receiver + recorded fMP4 player
```
New K1 source generations durably publish
`captures/mqtt_live/mqtt.timeline.origin.json` before camera production. MQTT
shutdown adds provisional transport envelope `mqtt.timeline.json`; after camera
and MQTT/runtime shutdown, the acquisition owner publishes content-addressed
`mqtt.timeline.session-<sha256>.json` and atomically switches the capture-summary
pointer while still holding the session lease. Only that `session` scope is
eligible to advertise combined media replay. Cache v9 materializes both session
envelope endpoints as real RRD rows and logs archived device-reported scan time,
route distance and speed as scalar time series. A crash-recovered origin-only
point/pose prefix falls back to its last validated message and cannot advertise
camera media. Older valid sessions without the clock contract keep their
first/last-message fallback and cannot acquire camera evidence retroactively.
Rerun does not import K1 protocol code, inspect MQTT topics or receive raw
payloads. `VisualizationRuntime` cannot choose a vendor decoder implicitly; its
composition owner injects one. The local `Decoded*View` classes are in-process
consumer projections, not the portable Plugin SDK wire envelopes.
The Plugin SDK v0alpha2 separately defines model/device/session identity,
operation events, acquisition-related session state, canonical stream
envelopes, payload handles, evidence lineage and compatibility assessments. It
is importable through the root editable path dependency and independently
buildable from `packages/plugin-sdk`. A runtime-owned descriptor must match the
manifest ID, version, host API and exact action set, then complete the
`plugin-runtime/v0alpha1` handshake before its lifecycle health becomes ready.
Backend actions instantiate immutable SDK `RuntimeActionInvocation` and
`RuntimeActionResult` envelopes through the replaceable transport seam, and the
host rejects uncorrelated results. The current transport is still in-process.
The live spatial path does not yet instantiate portable SDK stream envelopes or
use the SDK `EvidenceStore` protocol.
Frontend device workflows follow the same ownership rule. The generic host
selects a manifest model and mounts its `device.connection` component. Concrete
XGRIDS source lives under `plugins/xgrids-k1/frontend`, imports the host only via
`@mission-core/plugin-sdk`, and is connected by the single reviewed import in
`apps/control-station/src/composition/devicePlugins.ts`. BLE/Wi-Fi provisioning,
exact-profile confirmation, project metadata, acquisition/replay UI and the
optional K1 spatial-scene control block are not Core components. The host owns
only the optional slot contract and generic actions such as opening the scene or
releasing a prior manual/archive selection after a plugin source successfully
starts.
A generic fail-closed guard blocks manual source input/apply/reset,
saved-session switching and persisted replay reattach whenever the runtime
reports a nonterminal acquisition. Unknown future acquisition states block by
default. A plugin clears the previous host-selected source only after its start
returns success, preserving the current scene on failure. That internal
success-result transition is distinct from an operator switch. The guard does
not stop equipment or invent a lifecycle transition; the acquisition must
finish through its owning plugin.
## Experimental vocabulary, not Platform Ontology
The local vocabulary distinguishes:
```text
model != device != transport alias != device session
acquisition != operation != source/channel != evidence
observation session != preparation job != replay launch != viewer instance
workspace layout != sensor evidence
acknowledgement != completion
raw evidence != decoded data != viewer state
```
These meanings remain versioned under `docs/domain-model/` and do not mutate or
create a runtime dependency on NODE.DC Platform Ontology. Promotion requires a
second device family or another platform service using the same stable meaning,
an owner, lifecycle, compatibility policy and migration tests.
## Plugin manifest and compatibility
The catalog accepts existing v1alpha1 manifests and additive v1alpha2 manifests.
v1alpha1 remains a one-model contract; v1alpha2 accepts one or more models and
requires every model to be covered by a reviewed, plugin-local, path-confined
compatibility profile. The current XGRIDS profile matches exactly:
- XGRIDS LixelKity K1;
- wire-level platform type `A4`;
- firmware `3.0.2`;
- direct-LAN topology;
- one retained physical laboratory evidence scope.
Unknown or mismatched firmware fails closed. The UI selects the profile and the
canonical bootstrap verifies model, platform type, activation and firmware from
a correlated live `DeviceInfo` before START. The profile independently records observation, decode, replay,
physical-verification and write evidence. Loading it cannot authorize a
transport mutation. BLE Wi-Fi provisioning and application control retain
separate explicit operator gates.
An exact K1 application-control substrate now lives inside the vendor
plugin. It byte-matches the retained ten-request pre-START dialogue plus
START/STOP, correlates live DeviceInfo/modeling responses and maps bounded
device-status values into a fail-closed state machine. The OpenAPI value
is one private application-level authority; vendor ID and serial come from the
BLE-selected live K1 and are cross-checked against status. Plugin v0.5.0 owns a
single interactive MQTT session from connection through STOP/standby, with one
explicit UI action per recorded lifecycle boundary and no automatic retry. A
fixed Keychain loader and dormant shadow coordinator remain present. The staged
physical START/live/STOP/unbound-READY cycle is accepted on the reviewed unit;
write capability is exposed only while that interactive socket owner is active.
## Semantic lifecycle
The transitional facade now creates separate provisional device,
device-session, acquisition and operation IDs. Acquisition can be prepared,
wait for receiver readiness, then wait for an external physical start, and become acquiring only after real point
data. Plugin-commanded STOP finalizes automatically after the same bound control
session reports unbound READY. Operator-manual capture-only stop still reports
the K1 physical state as unknown.
Preparation requires a project name. The frontend and backend both apply NFKC
normalization plus surrounding-whitespace trimming, reject control/surrogate
characters and names above 96 Unicode characters, and store the result as
session/catalog display metadata. It is not a path component. The accepted
interactive START carries this exact validated value to K1.
The K1 contribution may mount a `SpatialControlsView` beside the host-owned
viewport. It presents plugin lifecycle wording, the stop action and live
`ModelingReport` scan time, route distance and speed. In plugin-commanded mode
the stop action sends the canonical device STOP; in operator-manual mode it
seals only local reception. `ModelingReport` is consumed before the visual preview queue through
an injected observer, preserving the generic normalizer/Rerun boundary.
The operation journal is bounded and in memory. It records IDs, idempotency,
declared deadlines, progress and terminal results without action parameters or secrets.
It is not durable, distributed or recoverable after process restart.
Semantic acquisition actions coexist with legacy `stream.*` actions for
v1alpha1/UI compatibility. On the exact accepted profile, a plugin-commanded
acquisition owns canonical START and STOP plus local reception. Physical
double-click remains the independently verified device-local fallback.
Left/right camera preview transport, endpoint paths and H.264 framing are now
observed under the exact compatibility profile. The local read-only adapter
copy-remuxes one selected RTSP producer into bounded fMP4/WebSocket delivery for
the generic MSE UI. New acquisitions archive selected-camera init/segments/index
independently of browser delivery. The generic recorded player, manifest-v2
validation and shared `session_time` controls are connected; historical sessions
created before this archive contract contain no recoverable video. The TEST007
recorded-camera package and browser playback are physically accepted. Portable FFmpeg packaging, disk-backed
browser buffering, fan-out and remote delivery also remain open. Device
calibration command and sensor-to-vehicle extrinsics are unavailable.
The 2026-07-17 physical acceptance gate confirmed continuously updating point
clouds, a matching live trajectory, and both camera selections in the same
Mission Core acquisition. The embedded Rerun blueprint owns live-edge following
on `stream_time`; the React shell selects the timeline once and does not drive it
with a timer. Raw captures, camera frames, device identity and network details
remain outside Git. The bounded acquisition subset of `DeviceStatusReport` and
`ModelingReport` route telemetry are decoded for the exact profile; heartbeat
and nested system/RTK status payloads remain raw observed evidence. See
[`ADR 0007`](adr/0007-k1-camera-preview-copy-remux-gateway.md)
for the measured gate and remaining limits.
## Remaining extraction order
Native replay parity, exact-profile physical point/pose regression, immutable
SDK runtime-action envelopes, fail-closed runtime handshake and physical
extraction of K1 transports/codecs are complete. Portable SDK stream/evidence
envelopes remain separate from the allocation-conscious in-process preview
views.
1. When a real deployment or second execution target requires it, implement a
subprocess transport behind the current seam, then add heartbeat timeout,
restart policy and resource limits as separately accepted behavior.
2. Replace compatibility routes and singleton state with multi-device session
routing.
3. Split Edge execution from the Control Station behind authenticated transport.
4. Physically accept a newly archived left/right K1 session, then package the
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
caching and evolve same-host MSE delivery toward an authenticated Edge media
plane. Keep modeling commands limited to the installed operator-present,
exact-profile acceptance transport until durable-save confirmation is
physically accepted.
Complex equipment will likely be assembled from separately useful component
plugins into configured hardware packs, while standalone equipment remains
available for engineering work. That direction is intentionally not a current
domain schema: pack ownership, roles, compatibility and lifecycle must be
derived from real additional component families rather than inferred from the
single K1 vertical.
## Invariants
- Raw bytes are appended and OS-flushed before preview work; aligned raw and
metadata become durable together at the bounded group-commit boundary and
are never overwritten by a decode result.
- Mission Core starts when the XGRIDS manifest is absent. Formal installed
plugin quarantine remains a future supervisor feature.
- Core code does not branch on XGRIDS IDs, fields, topics or firmware.
- A plugin cannot add global navigation or arbitrary CSS.
- A transport alias never becomes a stable device identity merely because it
appears in a vendor header.
- Acknowledgement, first data, operator confirmation and physical completion
remain distinct evidence levels.
- Vendor writes require a declared capability, exact compatibility evidence,
host authorization and an operator gate. Manifest metadata and profiles alone
grant none of these.
- The visualization engine can be replaced without changing vendor transports
or codecs.
- Real captures, credentials, device identities, maps and router metadata remain
outside Git.
## Deferred, not implemented
- isolated/signed plugin processes and crash containment;
- durable operation journal, restart recovery, vault and RBAC;
- persistent device identity and concurrent multi-device routing;
- complete SDK-envelope/EvidenceStore hot-path integration;
- remote Edge split, authenticated WAN relay and fleet orchestration;
- automatic K1 start/stop/calibration commands;
- device-reported camera capability discovery beyond the reviewed K1 profile;
- physical acceptance of shared-timeline point-cloud plus one selected-camera
playback on a newly archived K1 session;
- frame-accurate camera/LiDAR calibration, panoramic stitching, disk-backed
browser media cache and remote multi-consumer delivery;
- production retention, replication, encryption and long-run WebViewer/WASM
memory acceptance for large observation sessions.
## Workspace-repeatable locked architecture gate
From the repository root:
```bash
uv sync --frozen --group dev
uv run pytest \
tests/test_plugin_sdk_v0alpha2_contracts.py \
tests/test_plugin_runtime.py \
tests/test_plugin_catalog.py \
tests/test_xgrids_compatibility_profile.py \
tests/test_device_lifecycle.py \
tests/test_xgrids_acquisition_lifecycle.py \
tests/test_canonical_pipeline.py \
tests/test_rerun_bridge.py \
tests/test_web_validation_security.py
uv run ruff check .
uv run mypy
uv run mypy --strict packages/plugin-sdk/python/missioncore_plugin_sdk
uv run python plugins/xgrids-k1/profile_loader.py
uv run python -m missioncore_plugin_sdk.v0alpha2 \
> /tmp/missioncore-plugin-sdk-v0alpha2.schemas.json
cd apps/control-station
npm ci
npm run test:unit
npm run typecheck
npm run build
```
These checks perform no BLE scan, network capture, router mutation or K1
application write. The next physical and communication gates are specified in
ADR 0004.
This gate is repeatable in the current workspace, not standalone reproducible.
The frontend consumes mutable sibling `file:` dependencies from
`NODEDC_DESIGN_GUIDELINE`; its lockfile does not pin the donor checkout's Git
revision or content hash. CI and portable packaging remain blocked until those
packages are published or vendored, or an immutable donor revision and content
verification are enforced. The current root Python wheel also omits the plugin
manifests/profile loader and built frontend, so it is not a deployment bundle.
+76
View File
@@ -0,0 +1,76 @@
# LixelGO / iPhone observation runbook
This runbook prepares the Mac without full Xcode and defines the first bounded,
read-only capture. The accepted decision is ADR 0005. Do not start the iPhone
experiment until the exact-profile physical regression in ADR 0004 passes.
## Current Mac baseline
- macOS 26.5.1, Apple Silicon;
- Xcode Command Line Tools 16.4 are present;
- full Xcode and `rvictl` are absent;
- built-in `tcpdump` and `uv` are present;
- Wireshark/tshark are not required for capture;
- the isolated lab uses Python 3.12 and `pymobiledevice3==9.36.0`.
## Preparation
```bash
uv sync --project plugins/xgrids-k1/lab/iphone-capture --frozen
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
pymobiledevice3 version
```
Expected version: `9.36.0`.
Connect one unlocked iPhone using a data cable, approve the Mac accessory prompt,
and tap **Trust This Computer**. Do not paste a UDID into Terminal or notes.
```bash
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
python plugins/xgrids-k1/lab/iphone-capture/preflight.py
```
## First smoke without K1
After the iPhone preflight succeeds, run a 10-second idle capture only after the
current source milestone is committed or with an explicitly recorded dirty-tree
fingerprint:
```bash
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
python plugins/xgrids-k1/lab/iphone-capture/capture.py \
--duration 10
```
The tool retains the original metadata-bearing `PCAPNG` and creates a compatible
classic `PCAP`. Validate the latter with built-in `tcpdump`:
```bash
tcpdump -nn -r sessions/iphone-k1-observation/<session>/captures/iphone-network.pcap -c 1
```
After the capture and operator timeline are both closed, seal the complete
session without rewriting the capture manifest:
```bash
uv run python plugins/xgrids-k1/lab/iphone-capture/session_integrity.py \
sessions/iphone-k1-observation/<session>
```
The derived private inventory verifies all manifest hashes and also hashes the
late operator-event artifact when it exists.
## Full baseline sequence
1. Complete the ADR 0004 physical K1 regression without LixelGO.
2. Close unrelated iPhone apps and pause unrelated network activity.
3. Keep the iPhone on the K1/shared Wi-Fi while USB remains connected to Mac.
4. Start a bounded 60180 second network capture.
5. Perform one predefined LixelGO action at a recorded monotonic offset.
6. Stop and retain the raw capture; analyze only offline.
7. Record endpoints, ports, sizes, timing and correlation before interpreting a
payload as a command, image, or model frame.
The first baseline does not install the Bluetooth logging profile. BLE/HCI is a
separate operator-approved experiment.
+586
View File
@@ -0,0 +1,586 @@
# Observation sessions, playback and workspace layout
Status: implemented for native K1 point/pose evidence and host-side camera
archival. Valid K1 `ModelingReport` scan time, distance and speed are also
materialized as recorded Rerun time series. Recorded spatial playback is exposed
in the Mission Core observation workspace. The camera archive/player contract
is implemented and covered by tests. TEST007 physically accepted shared-timeline
playback of the saved cameras and point cloud. It also carries the first optional
external-perception result projected into the same Rerun recording; older
sessions without canonical camera evidence or an admitted compute result remain
fully usable with only their available modalities.
## Operator path
1. Enter the required project name and start a normal acquisition from **Парк →
Локальное устройство**. The NFKC-normalized/trimmed value is local display
metadata, not a filesystem path, and becomes the saved-session catalog name.
A normal acquisition has no duration deadline and runs until the operator
explicitly stops it. Compatibility clients may still request a positive
finite duration, without an application-level maximum.
2. Open **Наблюдение → Пространственная сцена**. Live point cloud, trajectory
and the selected camera remain live-only while acquisition is running.
3. Stop acquisition normally, or allow the local service to recover an
unexpected interruption on its next start. Session recording is automatic;
the disk action is not required.
4. Open **Сохранённые сессии** in the observation header. The menu shows up to
100 indexed runs, their date, duration, state, modalities and
background preparation state.
5. Choose a replayable run. Opening a replay never performs conversion in the
request. A ready recording opens immediately; otherwise the client receives
HTTP 202, keeps the current scene mounted and polls the preparation status.
Only after the server returns a verified launch descriptor does Mission Core
pause and unload the previous viewer.
6. The client then receives and decodes the complete RRD. The new viewport and
timeline stay hidden until the declared recording range is fully buffered
(`fullyBuffered`). Playback starts once at that point. Partial frames are
never shown. If the run contains canonical camera archives, its recorded
camera sources replace all live device overlays.
7. Use **Воспроизвести / Пауза**, the scrubber, **К началу** and **К концу** on
the right half of the bottom timeline. The left half changes point-cloud
accumulation. The recorded timeline is zero-based `session_time`.
8. If a validated derived camera result exists, **Распознавание** appears in the
scene toolbar. It switches the active native Rerun view to camera frames and
boxes; **Облако точек** returns to the 3D view. No button is shown for an
absent or rejected result, and the base recording remains usable.
9. Use the first disk button to save the current workspace layout. The next
opening restores display settings and dynamic sensor-source window
positions. The transient **Движок / Слои / Отображение** tool windows are
scoped to the mounted spatial workspace and are never persisted. This action
saves no sensor evidence.
Mission Core does not switch an active acquisition to another spatial source.
Every nonterminal or unknown acquisition state fail-closes saved-session replay,
persisted replay reattach and manual RRD/Rerun input/apply/reset with
**Завершите текущий приём перед сменой источника.** A plugin-owned automatic
source action clears the older source and opens the scene only after its start
returns success; failure leaves the current scene mounted. This internal
success-result transition is not an operator switch. The guard does not stop the
scanner or local receiver automatically.
Display controls have no separate Apply/Reset transaction. Boolean controls
commit immediately; sliders, colors and selects commit on release/blur or after
a short quiet period, and concurrent commits collapse to the newest value. A
layout save flushes and awaits this queue before serializing the confirmed
settings. Recorded display updates reuse stable Rerun scene identifiers and do
not mutate playback state, so changing accumulation, size, color or visibility
does not pause the active replay or replace its camera view.
Completed and recovered sessions are discovered on startup and by the catalog
reconciler. The first scan is only a historical baseline: it never enqueues the
existing archive. A session that becomes finalized after that baseline is
deduplicated into the bounded, single-worker preparation queue. The worker
validates the native source, exports point/pose data, verifies source stability
and both SHA-256 digests, finalizes once, and atomically publishes the
digest-bound derived RRD plus its cache sidecar. Before the same job becomes
`ready`, it also parses and validates every archived camera index and publishes
the immutable media-manifest sidecar. The catalog exposes preparation state and
progress while this runs; a recording URL and camera descriptors are returned
only after the complete launch generation has passed validation.
Preparation is a backend lifecycle, not a viewer computation. Reopening,
seeking, refreshing the catalog or changing the workspace never reruns a
published conversion. After a process restart, a catalog/replay read restores a
complete compatible RRD and camera package from its durable sidecars using
bounded schema and stat-identity checks; it does not hash native evidence, parse
camera indexes or invoke the exporter. A historical session whose package is
missing, stale or from an incompatible cache schema remains cold. Only an
explicit replay/RRD request schedules that one session for preparation; listing
the catalog and finalizing another session do not schedule it.
The session menu uses three operator indicators:
| Indicator | Catalog/preparation state | Meaning |
| --- | --- | --- |
| Solid green + `Готово` | `ready` | Verified RRD is available. |
| Pulsing green + `Обработка` | `queued`, `validating`, `exporting`, `finalizing` | Background preparation is active. |
| Solid gray + `Подготовить` | replayable session with no compatible published package | No work is running; choosing the row schedules this session only. |
| Dim gray + `Ошибка` | failed, cancelled, non-replayable or invalid session | No launchable recording; inspect the message or retry. |
Saved-session status never uses yellow or red. Switching sessions aborts only
the obsolete browser poll; it does not cancel the process-owned conversion.
A queued job may legitimately wait behind another export and therefore uses the
overall preparation deadline rather than the active-export heartbeat timeout.
## Immutable LAB instances
An accepted experiment is published as a new saved-session identity instead of
changing the AI result selected for its source recording. A LAB instance binds:
- a stable `LAB E…` marker and opaque session id;
- the immutable source observation session;
- the exact result, source-result and configuration SHA-256 identities;
- run and publication timestamps plus JSON provenance;
- a content-addressed compute job, LiDAR pack and integrated visual result.
The source evidence remains the only raw sensor master. Full-session LAB replay
caches and unchanged camera inputs use hard links on the same filesystem; no
second copy of the source RRD, video or native K1 capture is created. A bounded
experiment instead stores a small derived RRD slice with exact start/end markers
and omits the source's unbounded recorded-video descriptor. Its integrated
perception overlay owns the matching bounded camera. This prevents Rerun from
holding the final AI frame while a longer source cloud continues to play.
Derived arrays are stored only when the run did not already persist a
viewer-ready form. Deleting a LAB catalog entry cannot delete the referenced
source session, and a source session with published LAB instances fails closed
on deletion.
The visual result is resolved by the LAB session id, so `LAB E19`, `LAB E21`
and the derived temporal comparison `LAB E22` can coexist over `RAVNOVES00`
without “latest accepted result” replacing an earlier experiment. The operator
can open any row in **Сохранённые сессии** and use the same
**Объекты 2D / Сегментация / Кубы 3D** controls. First publication must also
warm the result-specific Rerun overlay; later opens reuse that verified cache
and do not rerun AI inference.
E21 is a bounded special case: its worker persisted 121 semantic mask hashes,
not duplicate mask pixels. Its visual publication materializes a mask only
after an exact SHA-256 match against the immutable accepted E19 semantic
reference. The seven detector frames replaced by the bounded latest-wins queue
remain explicit empty frames. This preserves the measured near-real-time
behavior rather than presenting a fabricated gap-free run. The saved-session
duration and base RRD range must equal the E21 visual result range; attaching
the 60-second result to the complete 8:55 source replay is an invalid
presentation because latest-at image data would appear static after the E21
window ends.
E22 is also bounded to the exact E21 60-second range, but it resolves its
catalog provenance directly to the physical source session rather than chaining
one LAB entry to another. Its temporal result remains a separate immutable
integrated overlay. Raw point-cloud, camera, calibration and E21 inference
payloads are not replaced. Short held boxes are provenance-marked diagnostic
presentation/world-state values, not fresh sensor measurements.
The first preparation of a long capture can take time because every decodable
point/pose message and every valid K1 `ModelingReport` is projected into RRD.
Later openings reuse a verified, digest-bound cache. Derived RRD cache v9 is
intentionally incompatible with v8 and older generations. In addition to plugin
and ordered-artifact identity, it binds the durable clock origin and active
envelope and materializes real `session_time = 0` origin and sealed completion
rows. Older generations remain cold after startup and are rebuilt only when an
operator explicitly opens that session.
The browser may use the strict `source_url` with `If-Match`, while the embedded
Rerun loader uses the canonical `viewer_source_url` whose lowercase SHA-256
`generation` query is bound to the same launch descriptor. A missing or stale
generation fails with `412`; a matching URL is served with exact length, strong
ETag and private immutable/no-transform caching. Every cache pin is released when the
HTTP response completes, disconnects or fails, so an interrupted switch cannot
make a derived recording permanently non-evictable. A bounded 120-second launch
reservation also protects the artifact between the verified launch response and
the WebViewer's subsequent RRD request, including a cold WASM startup; it
expires automatically if that request never arrives.
## Storage roots
By default, both catalog state and new source evidence are private to the
checkout:
```text
.runtime/mission-core/
├── mission-core.sqlite3
├── mission-core.sqlite3-shm
├── mission-core.sqlite3-wal
├── evidence/
│ └── sessions/
│ ├── .current_session # present only while a writer owns the root
│ └── <UTC>_viewer_live/
│ ├── manifest.redacted.json # normalized local project display metadata
│ ├── captures/
│ │ └── mqtt_live/
│ │ ├── mqtt.raw.k1mqtt
│ │ ├── mqtt.metadata.jsonl
│ │ ├── mqtt.timeline.origin.json
│ │ ├── mqtt.timeline.json # provisional transport envelope
│ │ ├── mqtt.timeline.session-<sha256>.json # sealed generation
│ │ └── mqtt.summary.json # atomic active-envelope pointer
│ └── media/ # canonical camera archives
├── recordings/
├── .export.lock # cross-process conversion/eviction lock
└── <opaque-session-id>/
├── scene.rrd
└── scene.rrd.cache.json
└── perception-overlays/
└── <opaque-session-id>/<result-id>/<recording-id>.rrd
```
`MISSIONCORE_DATA_DIR` relocates the catalog and derived cache. Unless it is
overridden separately, new evidence is written to
`$MISSIONCORE_DATA_DIR/evidence/sessions`. `MISSIONCORE_EVIDENCE_DIR` can select
another absolute MQTT evidence root. In the current K1 integration the camera
gateway additionally confines its recording directory to the repository
checkout, so a full point-plus-camera acquisition must keep the evidence root
inside that checkout. An external evidence volume currently supports MQTT
capture/cataloging but camera archival will fail closed until the gateway gets
a separately attested storage root:
```bash
export MISSIONCORE_DATA_DIR=/absolute/private/path/mission-core
# Full K1 point-plus-camera evidence must currently remain below the checkout.
export MISSIONCORE_EVIDENCE_DIR=/absolute/path/to/NODEDC_MISSION_CORE/.runtime/mission-core/evidence/sessions
# Optional operator retention quota; unset means no application byte quota.
# export MISSIONCORE_RRD_CACHE_MAX_BYTES=8589934592
export MISSIONCORE_RRD_FREE_SPACE_RESERVE_BYTES=2147483648
uv run k1link serve
```
The directories and derived recording cache use owner-only permissions where
the host filesystem permits them. A new acquisition writer is assigned only a
direct child of the private evidence root; it no longer writes a new run to the
repository-level `sessions/` directory.
Repository-level `sessions/*_viewer_live` runs are a legacy, import-only source
for the catalog. They are discovered and confined in place: refresh does not
copy, move or migrate their large payloads, and no current writer is assigned
that root. Startup may recovery-seal an already existing, incomplete canonical
camera epoch there by preserving its valid segment prefix and writing an
`interrupted` summary; this is evidence recovery, not a new acquisition write.
Never add `.runtime/`, `sessions/`, raw captures, RRD files or camera media to
Git. They can contain mapped interiors, trajectories and identifiable images.
The recorded perception importer reads repo-local ignored
`.runtime/compute-jobs` and `.runtime/compute-results`; those derived handoff
artifacts are likewise private and never become browser paths or Git inputs.
## Session source of record
For the current K1 profile:
```text
MQTT callback
├─ durable native .k1mqtt + aligned metadata (source of record)
├─ durable clock origin (before camera production)
├─ provisional + content-addressed envelopes (transport/session bounds)
├─ ModelingReport -> live product metrics (before visual preview queue)
└─ bounded latest-wins Rerun live preview (disposable)
selected RTSP producer
├─ durable init + fMP4 segments + JSONL index (camera evidence)
└─ bounded WebSocket/MSE preview (disposable)
```
The live preview is intentionally allowed to drop frames under load. Native
point/pose evidence and camera archive writes do not traverse that queue.
Native `.k1mqtt` bytes with aligned metadata, the capture-clock artifacts and
canonical camera fMP4 archives are the evidence source of truth. The derived RRD
contains every decodable point and pose frame plus every valid `ModelingReport`
distance/speed/scan-time sample from that source, but remains a rebuildable view
rather than an evidence master. A cache entry is eligible for rebuild only when
native source identity/digests change or an incompatible derived-data export
revision is introduced. Historical entries are not rebuilt during startup or
because another session is finalized; an explicit open schedules the affected
entry. A UI blueprint or workspace-layout revision never invalidates or rewrites
the data RRD. Cache v8 and older payloads are not reusable as v9 because they do
not bind both real capture-envelope endpoints.
`ModelingReport` time is the device's `ScanTime` counter at two ticks per second;
distance and speed are the reported `MoveDistance`/`MoveSpeed` values. Live state
keeps the current device scan generation and does not accumulate an earlier
distance after the device resets scan time and route distance. These values are
not reconstructed from pose integration or a browser timer.
Expensive cache misses run through the single-worker preparation queue and one
global cross-process export gate to cap concurrent RAM, CPU and temporary-disk
use. Crash leftovers from candidates, exporter temporary files and staged replay
prefixes are scavenged under that lock before capacity accounting. Ready cache
hits and active response leases do not wait behind that gate. The derived cache
has no application byte quota by default, so a single multi-hour RRD is not
rejected at 8 GiB. It still preserves a 2 GiB default filesystem reserve. An
operator may set `MISSIONCORE_RRD_CACHE_MAX_BYTES` to enable LRU eviction of
derived RRDs only; native evidence is never deleted.
## Capture-clock envelope
New K1 captures first publish bounded schema-1
`captures/mqtt_live/mqtt.timeline.origin.json` with start epoch and monotonic
nanoseconds. The writer creates and synchronizes this immutable artifact after
its raw/metadata files are open and before camera production is armed. A crash
therefore cannot leave retained camera evidence whose intended session zero
existed only in memory.
MQTT shutdown then publishes schema-1 `mqtt.timeline.json` with the same start
plus transport completion. Capture-summary schema 2 initially points to it with
`capture_clock_scope: transport`, and binds both origin/envelope names and
SHA-256 values. That provisional file is not rewritten.
After the camera archive and MQTT/runtime stop, the acquisition owner creates
`mqtt.timeline.session-<sha256>.json` with the extended session completion and
atomically switches `mqtt.summary.json` to that content-addressed name, digest
and `capture_clock_scope: session` while still holding the active-session lease.
Publication order is sealed file first, summary pointer second, so a crash leaves
the previous pointer valid and the idempotent seal can converge on retry. The
same summary update records `session_elapsed_seconds`. Files and containing
directories are synchronized at their publication boundaries.
Discovery validates the summary-selected filename, both digests, the shared
origin and the envelope bounds, then catalogs that exact artifact. The
materializer passes its exact staged path to the exporter; it never chooses a
sealed generation by glob or “latest file” ordering. Direct/legacy export keeps
the fixed provisional-name fallback only for evidence outside the cataloged
session contract, and orphan sealed candidates not selected by the validated
summary are ignored.
A provisional `transport` scope is never advertised as combined camera replay.
An interrupted origin-only point/pose prefix may use the durable origin and its
last validated message as a compatibility end, but it cannot advertise camera
media. If the origin, active envelope, summary pointer or digest relationship is
missing/corrupt, new-schema discovery fails closed instead of inventing a
session boundary.
For a sealed generation, `session_time = 0` is the envelope start and the RRD
end is the envelope completion. Cache v9 logs real rows at
`/__mission_core/session_origin` and `/__mission_core/session_end`, so a camera
fragment accepted before the first MQTT message or after the last point/pose
message can remain inside the declared RRD interval. Valid legacy sessions
without this artifact keep their first/last-message compatibility fallback and
cannot gain historical camera coverage.
## Camera archive contract
New acquisitions archive each selected source and codec epoch below the same
private evidence session:
```text
<evidence-root>/sessions/<session-id>/media/<stable-source-id>/epoch-1/
├── init.mp4
├── segments/
│ ├── 1.m4s
│ └── ...
├── index.jsonl
└── summary.json
```
Each index row binds a segment sequence, byte length, SHA-256 and host arrival
epoch/monotonic timestamps. Camera durability uses a
`per-segment-fsync` policy: `init.mp4` and every complete media segment are
individually fsynced and their directory entries synchronized before the
matching JSONL index row is committed. The index and an atomic `interrupted`
checkpoint summary are fsynced before the append returns. The old interval and
byte constructor options are compatibility-only and cannot weaken this
segment-bound RPO. A power failure may still lose or leave uncommitted the
fragment currently being produced; it cannot make a committed index row point
past durable media. Camera windows may close or reconnect without terminating
archival while the owning acquisition remains active.
Synchronization is `host-arrival-best-effort`: LiDAR/MQTT and camera segments
share the Mac host clock boundary, but K1 sensor exposure time and LiDAR firing
time are not proven to use a shared device clock. Do not infer frame-accurate
calibration from the playback timeline.
Finalized media is prepared once by the same process-owned background job that
materializes the RRD. JSONL indexes are read incrementally and no total index,
segment-count or archive-byte ceiling is used. The gateway records host time
only after a complete
`moof+mdat` fragment has arrived, so that timestamp is an availability/end
anchor, never a fragment-start timestamp. Preparation reads and SHA-verifies
every fragment, parses bounded ISO-BMFF timing tables (`mdhd`, `trex`, `tfhd`,
`trun`), anchors the epoch at
`max(0, first_arrival - first_fragment_duration)`, and sets its end to that start
plus the checked sum of every decoded fragment duration. The declared interval
therefore has exactly the duration MSE is expected to expose and is not stretched
by host scheduling jitter.
Epoch arrivals must be strictly monotonic; epoch intervals must be finite,
monotonic and non-overlapping. Replay v2 also requires every media interval to
fit inside the spatial RRD interval with a 50 ms numeric tolerance; it fails
preparation instead of clamping unreachable evidence. A future replay v3 must
separate `spatial_range` from a session-wide union range before out-of-RRD camera
coverage can be navigated. A missing, ambiguous, oversized or otherwise
unparseable timing table fails preparation; the archive remains evidence but is
never advertised as seekable media.
The durable path-free v2 descriptor and full source stat identity (native raw
and timing metadata plus every camera summary, index, init and segment file) are
written under the private derived cache with a schema, generation and checksum.
Publication uses a private temporary file, file and directory `fsync`, and atomic
rename; startup scavenges crash-left temporary files. A restart reuses this
sidecar after confined O(n) stat validation, without rereading, hashing or parsing
media. A missing, corrupt or stale sidecar remains cold until an explicit open
schedules the complete session package in the background worker. No catalog,
status, manifest or payload request itself performs conversion or recalculates
these intervals.
Every derived RRD contains a real `session_time = 0` row at the internal
`/__mission_core/session_origin` entity. The anchor is deliberately outside
`/world`, so it establishes the actual recording time range without creating a
3D layer or drawable scene object. Preparation verifies the derived recording
against the declared spatial range rather than relying on summary metadata
alone.
Sessions made before this archive contract have no recoverable video even if a
camera preview was visible at the time. In particular, the 2026-07-16 browser
camera acceptance run retained point/pose evidence only.
## Crash and interruption behavior
- SQLite uses WAL, foreign keys and full synchronous durability.
- Before creating a session directory, the acquisition takes an exclusive,
cross-process lease by atomically creating and locking
`<evidence-root>/.current_session`. The marker contains only the direct child
session name. While the lock is held, a second writer fails closed and
discovery excludes that in-progress session from replay.
- A process exit releases the operating-system lock. On the next service start,
stale-marker recovery removes the marker only after it can take the lock and
revalidate the marker inode without following symlinks. It never deletes the
interrupted session directory; normal catalog recovery then evaluates the
durable prefix. A locked/live or suspicious marker is left untouched.
- Native MQTT writes use a bounded group commit: at most 0.5 seconds, 4 MiB or
32 messages per group. Raw bytes are fsynced before their metadata rows are
written and fsynced. This is a bounded RPO, not a zero-loss guarantee: a hard
process or power failure may discard the final uncommitted group. Raw bytes
from an interrupted commit window may survive beyond the durable metadata;
replay uses only the last validated metadata-aligned raw boundary.
- New capture-summary schema 2 binds the durable origin and its active envelope
by name and SHA-256. A normal combined replay requires the owner-sealed,
content-addressed `session` scope. A crash can leave an origin-only or
provisional `transport` generation, but discovery will not use either to
advertise camera coverage. A mismatched origin/envelope/summary fails closed.
- A native capture with a missing final summary is accepted only when the raw
and metadata prefix is aligned and structurally valid. Recovery streams the
metadata JSONL one row at a time with no total-byte or message-count ceiling;
it is cataloged as `interrupted`, never silently promoted to `ready`.
- A non-newline metadata crash tail can be ignored. Newline-terminated or
mid-file corruption fails closed.
- Camera recovery retains a contiguous valid segment prefix, quarantines
non-contiguous/orphan fragments instead of deleting them, rebuilds the index
when necessary and writes an `interrupted` summary. Unindexed bytes are not
presented as valid media.
- If a valid normal summary appears later, repeat discovery updates the same
session to `ready`.
- Materialization detects a native capture changing during export and refuses
to publish the derived RRD. It reopens the prepared raw source with no symlink
following inside the cataloged session roots, and interrupted recordings are
materialized from the last validated raw/metadata boundary only.
- Internal preparation cancellation is cooperative. A queued job cancels
immediately; an active job stops at a safe checkpoint, removes its candidate
and never publishes a partial RRD. The operator UI does not cancel automatic
preparation when switching sessions or closing a tab: preparation belongs to
the application worker, not to an HTTP request.
- `failed` and `cancelled` are terminal, retryable states. The API exposes a
sanitized error, and an explicit retry creates a fresh job for the current
source identity. A stalled poll or failed switch leaves the current scene
mounted; the latest operator selection wins over obsolete responses.
- A lifecycle shutdown marks only its own interrupted work for automatic
reconciliation after restart. An operator cancellation and a genuine failed
export remain terminal and are never retried forever by the reconciler.
These rules provide crash recovery, not replication. A single host disk failure
can still destroy local data. Vehicle deployment must add independent onboard
and control-station copies, capacity monitoring and a documented retention
policy.
## HTTP API
All paths are same-origin and expose opaque identifiers only:
```text
GET /api/v1/observation-sessions?limit=3
GET /api/v1/observation-sessions/{id}
POST /api/v1/observation-sessions/{id}/replay
GET /api/v1/observation-sessions/{id}/recording-preparation
DELETE /api/v1/observation-sessions/{id}/recording-preparation
GET /api/v1/observation-sessions/{id}/recording.rrd
POST /api/v1/observation-sessions/{id}/blueprint.rrd
GET /api/v1/observation-sessions/{id}/media/{artifact}/manifest
GET /api/v1/observation-sessions/{id}/media/{artifact}/epochs/{n}/init.mp4
GET /api/v1/observation-sessions/{id}/media/{artifact}/epochs/{n}/segments/{m}.m4s
GET /api/v1/observation-sessions/{id}/media/{artifact}/epochs/{n}/recording.mp4?generation=<sha256>
GET /api/v1/workspace-layouts/observation.spatial
PUT /api/v1/workspace-layouts/observation.spatial
```
The catalog embeds each replayable session's preparation state and progress.
`POST .../replay` returns either a verified replay v2 launch document or HTTP
202 with the preparation v1 document, `Location`, `Retry-After`, an exact quoted
preparation `ETag` and a same-origin status URL. Polling
`GET .../recording-preparation` sends that value as `If-Match` and returns HTTP
202 while the job is active, HTTP 409 for retryable `failed`/`cancelled` states,
and the launch document only when the same job and artifact are ready; every
response echoes the same `ETag`. `DELETE` also requires `If-Match`, so a stale
tab cannot cancel a replacement job. Conversion is never executed synchronously
by a replay-open request. Playback speed and loop are request-local launch
policy; they are not stored on or shared through a preparation job.
Production `recording.rrd` access is bound to the launch generation. The client
can use the strict query-free URL with exact strong
`If-Match: "sha256:<launch.sha256>"`; a request with neither an `If-Match` nor a
generation returns 428. The embedded Rerun receiver instead gets only the
canonical `viewer_source_url = source_url + "?generation=<launch.sha256>"`.
The server returns 412 for a malformed or replaced generation before opening
the body. A successful response echoes the strong ETag, exact `Content-Length`,
`application/vnd.rerun.rrd` and immutable private/no-transform cache policy.
Rerun's native HTTP receiver owns incremental decoding; `LogChannel.send_rrd`
is reserved for independently complete RRD payloads such as the small generated
blueprint and must never receive arbitrary HTTP byte slices. The canvas,
timeline, controller and autoplay remain closed until the decoded spatial range
exactly matches the launch descriptor and every declared camera is ready. API
documents never contain local paths. Layout updates require the quoted current
revision in `If-Match`; stale writers receive HTTP 412 rather than overwriting
another saved profile.
Recorded media routes expose only opaque catalog identifiers and ordinal codec
epochs. The public compact
`missioncore.observation-recorded-media/v3` manifest carries a strong
`generation_sha256`, exact JS-safe aggregate `byte_length`, and finite
`timeline_start_seconds` / `timeline_end_seconds` for every epoch. Epoch ends
participate in the generation digest. Each epoch declares one generation-bound
`stream_url`, media type and aggregate byte length; thousands of internal
segment rows never enter browser memory. The launch source repeats the same
aggregate `byte_length` and uses exactly `max(epoch.timeline_end_seconds)` as
its end; the spatial RRD end must never pad camera coverage. The browser
cross-checks launch and manifest identity, but applies no duration, per-source
byte or aggregate-session byte admission ceiling.
The `<video>` element reads the immutable virtual fMP4 through native HTTP Range
requests. The server maps each requested interval onto init/segment files,
opens them through confined descriptors with no symlink following and verifies
the digest of each touched component. It never assembles the full video in
backend or JavaScript memory. Responses carry exact `Content-Length` /
`Content-Range`, a generation-and-epoch ETag, `Accept-Ranges: bytes` and private
immutable `no-transform` caching. The manifest ETag is the exact generation and
its GET requires the matching `If-Match`; the stream URL binds that same
generation in its query. The older init/segment routes remain internal
compatibility surfaces. Physical source ids, RTSP addresses and storage paths
never cross the API boundary.
## Current synchronization boundary
The spatial RRD, trajectory, archived device-metric series and archived fMP4
cameras use the same operator scrubber now. Camera epochs are aligned to
zero-based `session_time` from the shared host-arrival monotonic clock and
rendered through the browser's native fMP4/Range pipeline. The metric tab carries
device-reported route distance,
speed and scan time at their `ModelingReport` receive times. The client selects
a camera epoch only inside its declared inclusive interval and verifies that the
decoded native-media seekable duration covers that interval. This remains best-effort
correlation: codec PTS, K1 sensor exposure time and LiDAR firing time are not
proven to share a device clock. A codec epoch whose init segment does not expose
a browser-supported codec or whose timing cannot be proven remains retained
evidence and fails closed in the UI/background preparation.
Historical sessions with no canonical camera archive honestly show no recorded
video. Recorded blueprints can change accumulation, grid visibility, point and
trajectory visibility, point radius and a uniform custom point color without
rewriting the recording. The client deliberately has no progressive recorded
mode: the viewport, timeline and autoplay gate remain closed until the complete
declared RRD has been received and decoded. Height, intensity, distance and RGB
palettes remain baked into current RRD rows; fully dynamic recoloring requires
exporting the corresponding scalar components in a future recording schema.
## Verification boundary — 2026-07-17
The current automated gate covers project-name validation, source-switch
fail-closure, optional plugin scene controls, bounded `ModelingReport` decode,
inert start/stop encoding and correlation, capture-clock publication/validation,
cache-v9 origin/end materialization and archived metric series. The standard
repository gate remains Python tests/Ruff/mypy plus frontend unit tests,
TypeScript checking and the Vite production build; exact pass counts belong to
the commit's CI/pre-push result rather than this durable architecture contract.
Vite still reports its expected large-chunk warning for the embedded Rerun
viewer/WASM payload. That is a packaging optimization item, not a failed gate.
No retained physical K1 session contains the new canonical camera archive, so a
real point-cloud plus one-camera recorded playback remains an explicit hardware
acceptance test. Automated protocol tests also do not authorize K1 modeling
publishing: operator-owned Keychain item provisioning, operator-present
physical acceptance and durable save remain separate physical/security gates.
+488
View File
@@ -0,0 +1,488 @@
# External perception worker contract
## Boundary
```text
K1
-> XGRIDS device plugin / Mission Core Edge
-> authoritative raw session on Mac
-> bounded missioncore.compute-job/v1
-> replaceable GPU worker / Triton
-> content-addressed missioncore.compute-result/v1
-> optional validated Rerun derived layer
```
The GPU worker cannot discover, provision, start or stop a K1. It receives an
observation package and returns derived observations. The Mac archive is never
rewritten when a job is prepared or a result is received.
## Recorded camera job v1
`k1link compute prepare-camera-job` accepts a sealed cataloged session, one
camera source and one physical codec epoch. It performs the canonical camera
checks before and after copying into a private staging directory:
- summary schema/source/epoch and per-segment durability contract;
- exact ordered JSONL index and its summary SHA-256;
- init SHA-256, every segment length/SHA-256 and complete stream SHA-256;
- continuous ISO-BMFF decode timeline and finite media duration;
- source session-time mapping through the captured host clock origin.
Publication atomically renames this layout below ignored storage:
```text
<jobs>/<job-id>/
├── job.json
└── input/camera/<source-id>/epoch-N/
├── summary.json
├── index.jsonl
├── init.mp4
└── segments/*.m4s
```
`input_sha256` is SHA-256 over canonical JSON for the complete path-free input
descriptor. `job_id` includes its first 96 bits and validators require the full
digest, so a prefix collision fails closed.
## Recorded result v1
The accepted worker profile verifies every transferred file, reconstructs the
stream outside the job, decodes frames and preserves strictly increasing
best-effort timestamps in session seconds. Its result identity is canonical JSON
covering:
- job and full input generation;
- pipeline ID/version;
- model ID/version/weight SHA-256;
- score/NMS thresholds, tensor shape, color order and letterbox policy.
The identity's full SHA-256 names an immutable `result-<sha256>` directory.
The result manifest binds the detection artifact's length and SHA-256. A repeat
with the same identity validates the existing directory and returns it without
calling Triton.
## Full-epoch panoptic result v2
`missioncore.recorded-perception-result/v2` is the complete recorded-camera
profile. It is separate from the YOLOX detector proof and processes every
admitted frame without sampling. Its immutable identity binds the complete
compute job, factory-calibration generation and camera slot, exact model
revisions/weight files, thresholds, alpha values, runner SHA-256 and publication
encoder.
The current research configuration produces:
- TorchVision Mask R-CNN ResNet50-FPN v2 instance masks and COCO labels;
- Microsoft BEiT ADE20K-150 semantic masks at the original 800x600 frame size;
- a seekable H.264 MP4 with the two overlays combined for operator playback;
- lossless instance and semantic mask PNGs for later calibrated fusion;
- ordered per-frame JSONL and one-second GPU telemetry;
- a machine-readable run report with input/config/model identities, decode,
inference, encode and end-to-end timing, latency percentiles, throughput,
CUDA peak allocation/reservation, process peak RSS, system load and GPU
utilization/VRAM/temperature/power samples.
The worker runs with `--network none` and cached model weights. A preflight
revalidates the complete transferred payload, CUDA execution and both cached
model generations before decoding a long epoch. The run then repeats payload
validation inside the inference container, requires exactly the declared frame
count and a strictly increasing session-time row for every frame, and publishes
only by atomic rename after every output digest is sealed.
There is no recorded-duration, frame-count or aggregate-video-byte admission
ceiling in this profile. Resource use therefore scales with the real input and
is reported, not hidden behind an arbitrary eight-minute laboratory limit.
## Native panoptic playback
Full raster masks are not copied into the RRD. That would turn a long video into
a multi-gigabyte browser-memory object. Instead Mission Core validates the v2
result and exposes its MP4 through the same generation-bound, seekable HTTP
Range contract as a recorded camera. Replay advertises an additional opaque
source such as `recorded.perception.right`; the Control Station opens it in a
native video window on the shared `session_time` timeline. A one-, three- or
ten-hour video remains disk/range streamed and does not have to fit in RAM.
The lossless masks remain private derived evidence. A host-side calibrated
fusion step samples them at K1 KB4 LiDAR projections and publishes compact
semantic `Points3D`, support-gated `Boxes3D` and diagnostic distances as a
separate replaceable generation. Missing or rejected v2/fusion results never
replace or invalidate the base raw point-cloud recording.
## RAVNOVES00 qualification · 2026-07-20
The first full recorded run admitted all 4,489 frames from
`sensor.camera.right` without sampling, failures or skips. The sealed input was
363,235,615 bytes over `35.421857292484.144857292` session seconds. Its
immutable result is
`result-f4cebdea8a82698a5b8a65d2c3fbdb0428b88b9dc49fe45f8cb37d740ed83d02`.
Measured RTX 4090 worker results:
- inference: 2,674.722 s and 1.678 frames/s;
- end to end: 2,816.349 s and 1.594 frames/s;
- instance latency: 89.826 ms p50, 121.344 ms p95, 915.325 ms max;
- semantic latency: 249.678 ms p50, 284.826 ms p95, 406.594 ms max;
- GPU utilization: 67% p50, 82% p95, 90% max over 2,675 one-second samples;
- process CUDA peak: 2,230.8 MiB allocated and 2,872 MiB reserved;
- total GPU memory observed, including the worker's shared resident services:
13,373 MiB p50 and 13,388 MiB max;
- GPU power/temperature: 182.46 W p50, 190.10 W p95 and 49 C p50, 54 C max;
- process peak RSS: 2,375.9 MiB;
- publication: 31.229 s decode, 3.397 s NVENC, 81,109,627-byte H.264 MP4,
60,326,719-byte lossless mask archive.
The factory-calibrated full fusion generation
`fusion-0b1be23128ebd3d230562cffd96491169e99f0e839e56b812661c974e4fdc00b`
matched LiDAR and pose within the admitted 250 ms host-arrival window for 4,323
frames and declared 166 frames `depth-unavailable`. It produced 6,124,145
semantic points and 13,496 support-gated diagnostic boxes in 77.708 s. The
compact fusion payload is 43 MiB.
Native browser QA opened `RAVNOVES00`, played the raw and panoptic 800x600
videos together at ready-state 4, and measured approximately 12 ms between
their media clocks. The Rerun scene showed the synchronized semantic points and
distance-labeled diagnostic boxes. The current baseline is deliberately not an
accuracy or safety acceptance: generic perspective-trained models produce
large fisheye false positives in 916 frames, and timing/distance have not been
ground-truthed. The next A/B should compare an admitted undistort/ROI transform
before inference rather than silently hiding these observations.
## E1 valid-FOV preprocessing qualification · 2026-07-20
The first post-baseline A/B uses two immutable inputs derived from the same
RAVNOVES00 job and factory calibration:
- valid-FOV generation
`valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2`;
- qualification-slice generation
`qualification-slice-2394070b4f3e38f1b8c483e878fcc11fd3c29f751f3d4cd7e3a2553304c5c142`.
The mask is not estimated from each image. It is bound to the exact calibration
SHA, `sensor.camera.right`, `camera_1`, the admitted 800x600 linear-resize
profile and the KB4 principal point `(396.319, 301.496)`. A four-pixel inner
margin produces a 293.504-pixel radius, 270,606 valid pixels (56.37625%) and an
exclusive crop rectangle `[103, 8, 690, 595]`. Repeated preparation reuses the
same content-addressed PNG and manifest.
The slice selects 256 exact frame indices uniformly across all 4,489 frames,
including both endpoints. The same loaded FP32 Mask R-CNN and BEiT generations
were interleaved per frame across three variants: unmodified baseline, fixed
valid-FOV fill, and valid-FOV crop remapped into the original pixel coordinates.
The sealed result is
`qualification-result-98a2fee3d22979f3e18847719667cc76bdeacf25904c0fd1da4c5202253b3940`.
The run completed in 318.969 s and emitted 12 preview frames per variant. GPU
telemetry recorded 319 one-second samples: utilization was 74% p50 and 82% p95,
power was 199.34 W p50 and 206.96 W p95, and temperature was 50 C p50 and 55 C
max. The qualification process peaked at 1,849.9 MiB CUDA allocated, 2,256 MiB
reserved and 2,298.2 MiB RSS.
Measured mean model paths, excluding the frame decode shared by all variants:
| Variant | Mask R-CNN path | BEiT path | Combined |
|---|---:|---:|---:|
| baseline | 106.292 ms | 278.064 ms | 384.356 ms |
| valid-FOV fill | 107.027 ms | 280.762 ms | 387.789 ms |
| valid-FOV crop | 104.055 ms | 282.840 ms | 386.895 ms |
The crop reduced Mask R-CNN forward time by 6.38%, but BEiT still receives its
fixed 640x640 tensor and became 1.18% slower. With mask/crop preprocessing
included, neither variant improved the combined path; fill was 0.89% slower and
crop was 0.66% slower than baseline. A binary mask improves admission quality,
but multiplying an unchanged tensor by it does not remove dense neural FLOPs.
The quality proxies are useful but are not ground truth. Baseline produced 58
instance masks and 58 boxes larger than half the admitted comparison area; both
masked variants produced zero. The fraction of raw predicted instance-mask
pixels outside the canonical FOV fell from 41.263% to 0.077% for fill and 0.911%
for crop before the final output clamp. Inside the valid circle, mean BEiT
disagreement with baseline was 8.713% for fill and 11.547% for crop. This is a
measure of change, not accuracy.
E1 therefore accepts the immutable valid-FOV artifact and the 256-frame gate.
Fixed fill is the conservative next accuracy baseline because it preserves the
800x600 geometry, removes the exterior lens region and changes the semantic
result less than crop. Crop remains an experimental model-specific option, not
a general speed optimization. The next run needs human labels/ground truth and
must compare native KB4 input, calibrated virtual views and fisheye-trained
models before promoting any preprocessing profile to a full-epoch result.
## E2 evaluation pack and annotation gate · 2026-07-20
LAB E2 starts from the same immutable RAVNOVES00 compute job, E1 qualification
slice and factory-calibrated valid-FOV generation. All eight contact sheets,
covering the 256 uniformly distributed E1 candidates, were reviewed before
selection. The sealed evaluation generation is
`evaluation-pack-7a983bba75d46c7c260252cb2d461e1384dcb92cda9e164397e841e6ebb37789`.
The pack contains 64 exact 800x600 images: 48 reviewed full-epoch anchors from
the E1 slice and four four-frame consecutive clips for temporal measurements.
The clips cover a person with a stroller, a close moving car, vehicle
occlusion/relative motion and a near building/terrace scene with a partially
visible carried laptop. The anchors retain the recording's road, sidewalk,
ground, grass, woody vegetation, buildings, sky, people, cars, trucks, lens
boundary and hard-negative diversity.
Every image is stored both as the raw decoded RGB frame and as the accepted E1
fixed-valid-FOV-fill input. The identity binds the job/input SHA, source, codec
epoch, selected segment SHA, decoded session timestamp, E1 qualification,
valid-FOV generation, calibration SHA, camera slot, FFmpeg 7.1.1 generation and
the raw/fill RGB pixel hashes. It also binds the reviewed selection-document
SHA and both producer-code hashes. The pack has 130 hashed payload artifacts
plus its manifest and occupies approximately 67 MiB locally.
One earlier local preparation generation, `evaluation-pack-b6d9215a…`, was not
promoted because its identity omitted the producer-code and selection-document
hashes. It remains a superseded diagnostic artifact and is not an accepted E2
input.
The immutable pack is deliberately `unannotated`. Its annotation contract
defines 15 robotics-oriented thing/stuff classes, label 0 for the excluded lens
exterior, label 255 for genuinely unresolved pixels, two-pass human review and
required semantic, instance, safety-proxy and temporal metrics. The empty
annotation template must be copied to a review workspace; it must never be
edited inside the sealed pack. Model-generated prelabels may accelerate review
but are not accepted as ground truth without a human pass.
No AP, mIoU or model-ranking claim is attached to E2 yet. The next gate is to
complete and seal the reviewed annotations. Only then may candidate models be
ranked on this pack; a full 4,489-frame run remains prohibited until one
configuration passes both the accuracy and throughput gates.
The first model-assisted draft is sealed separately as
`evaluation-prelabels-4ba26bbf6eb8a49631f5caf984267e0445958540aeda2b5b0d82ca6440835cf1`.
It reuses the exact E0 Mask R-CNN and BEiT weights and maps their COCO/ADE
classes into the E2 taxonomy. It is explicitly marked
`unreviewed-model-draft`; it never mutates the evaluation pack or annotation
template.
The isolated RTX 4090 run processed 64/64 frames in 31.551 s. Mean forward time
was 57.539 ms for Mask R-CNN, 215.164 ms for BEiT and 272.703 ms combined. The
process peaked at 1,842.8 MiB CUDA allocated, 2,768 MiB reserved and 2,239.9 MiB
RSS. Across 32 one-second samples, GPU utilization was 51% p50 / 71% p95, power
167.73 W p50 / 186.89 W p95 and temperature 42 C p50 / 47 C max. Shared Triton,
Frigate and Ollama services remained running and healthy.
The draft emitted 775 mapped instances: 640 car, 58 static obstacle, 45 person,
25 heavy vehicle, five bicycle and one each motorcycle/animal. Sixteen previews
were reviewed. They confirm that the fixed-FOV exterior stays clean and that
the draft is useful for annotation assistance, but also expose the expected E0
domain errors: duplicated/distant car boxes, unstable small instances, coarse
fisheye boundaries and excessive static-obstacle proposals on planters. These
counts are workload indicators for review, not precision or recall.
The first prelabel attempt stopped before model loading because the container
mountpoint `/evaluation-pack` was incorrectly required to equal the
content-addressed generation basename. The path-name check was removed while
all manifest, artifact and identity hashes remained mandatory. No failed result
was published; the second attempt completed and 147 payload artifacts were
reverified locally with zero digest/length mismatches.
The review handoff is sealed separately as
`annotation-workspace-9a950d1c37d56dc12cc285b13c5addd7795285879cbcb1fbb2d5811c3c69821a`.
It contains a deterministic 64-image upload, a 775-instance COCO RLE draft, a
dense CVAT Segmentation Mask archive, an exact frame/timestamp map, the fixed
valid-FOV mask, the 15-class label specification and an unreviewed two-pass
checklist. The three ZIP archives passed both the workspace validator and
independent ZIP integrity checks. The 11 payload artifacts occupy 31,861,798
bytes. This workspace remains `ground_truth=false`; the two synchronized CVAT
tasks must be reviewed and their accepted exports sealed as a separate
generation before any AP or mIoU claim is allowed.
The real prelabel generation also exposed one identity-serialization defect in
its producer: `target_categories` used integer dictionary keys while hashing,
but JSON reloads them as strings and changes their sorted order. The stored
artifact files and all 147 recorded payload hashes are unchanged. The workspace
validator admits only this exact reversible legacy representation and binds the
serialized `result.json` SHA separately. The worker producer now emits string
keys before hashing, so subsequent prelabel identities are stable across a JSON
round trip. The existing result was neither rewritten nor renamed.
Prepare or reproduce the review inputs with:
```console
.venv/bin/python experiments/perception/prepare_e2_evaluation_pack.py candidates \
--job-root .runtime/compute-jobs/<job-id> \
--qualification-root .runtime/compute-experiments/e1/qualification-slices/<generation> \
--output-root .runtime/compute-experiments/e2/<candidate-review>
.venv/bin/python experiments/perception/prepare_e2_evaluation_pack.py seal \
--job-root .runtime/compute-jobs/<job-id> \
--qualification-root .runtime/compute-experiments/e1/qualification-slices/<generation> \
--valid-fov-root .runtime/compute-experiments/e1/valid-fov/<generation> \
--selection .runtime/compute-experiments/e2/selection-e2.json \
--output-root .runtime/compute-experiments/e2/evaluation-packs
.venv/bin/python experiments/perception/prepare_e2_annotation_workspace.py prepare \
--evaluation-pack .runtime/compute-experiments/e2/evaluation-packs/<generation> \
--prelabels .runtime/compute-experiments/e2/prelabels/<generation> \
--valid-fov-root .runtime/compute-experiments/e1/valid-fov/<generation> \
--output-root .runtime/compute-experiments/e2/annotation-workspaces
```
## E4 full-session semantic playback · 2026-07-21
LAB E4 promotes the plain EoMT valid-FOV control from LAB E3 into the first
complete saved-session semantic playback. It consumed all 4,489 frames of
RAVNOVES00 (`20260720T065719Z_viewer_live`) from
`sensor.camera.right`, using factory calibration slot `camera_1`, FP16
autocast, batch size one and no sampling. CLAHE, five-view rectification and the
instance branch were deliberately disabled.
The immutable published result is
`result-793785170472c519486ccd666be102fb04d169d92383acda3fcc29eecf045d30`.
It contains an 800x600 H.264 semantic-overlay video, 4,489 semantic masks,
4,489 timestamp rows, one-second GPU telemetry and a run report. FFprobe and the
recorded-perception validator independently confirmed the exact frame count,
448.723-second timeline, artifact hashes and input/job/calibration binding.
The inference loop ran for 1,447.565 seconds at 3.101 FPS. Full extraction,
inference, publication, hashing and validation took 1,668.259 seconds at 2.691
FPS. GPU utilization was 73.05% mean / 89% p95, E4 process CUDA allocation
peaked at 2,099.8 MiB, process RSS at 2,009.3 MiB, power at 253.23 W and
temperature at 58 C. The exact configuration is about 3.72 times slower than
the source recording rate and is therefore an offline baseline, not a live
configuration.
All task-controlled worker paths remained under `D:\NDC_MISSIONCORE`. The
orchestrator enforced a 360 GiB free-space floor and a 17.195 GiB conservative
working-set reserve. Final free space after exact task-temporary cleanup was
379.395 GiB; C: was not used or mounted by the task.
Mission Core exposes the result in **Сохранённые сессии → RAVNOVES00 →
Источники данных сцены → Сегментация · камера right**. Browser acceptance
confirmed the exact result source, 800x600 dimensions, full duration, no media
error and advancing playback time. The detailed configuration, timing tables,
artifact hashes, disk checkpoints, limitations and next gates are recorded in
`experiments/perception/LAB_E4_REPORT_2026-07-21.md` and Ops card MISSIONCOR-18.
E4 remains `ground_truth=false` and semantic-only. It makes no claim about live
latency, instances, tracking, 3D cuboids, LiDAR association, distance accuracy,
point-cloud labels or safety fitness.
## Recorded Rerun projection
Mission Core discovers only results whose validated job names the opened
session. Before projection it revalidates the complete job/result binding,
artifact digests, ordered timestamps and result metrics. It then reconstructs
the exact fMP4 epoch, verifies decoded dimensions/frame count with `ffprobe`,
decodes exactly the admitted number of RGB frames and rejects boxes outside the
image.
The optional `perception.rrd` endpoint returns either HTTP 204 or one complete,
bounded RRF2 stream for the opened Rerun recording ID. The browser never splits
that stream and never replaces the base recording when the optional layer is
absent or rejected. Accepted frames are logged under `/perception/camera` on
the canonical zero-based `session_time` timeline; `Image` and `Boxes2D` rows
therefore seek with the point-cloud archive. Generated overlays are a private,
content-addressed cache and remain rebuildable from the immutable job/result.
In **Наблюдение → Пространственная сцена**, a **Распознавание** button appears
only after the layer has been admitted. It switches the active native Rerun view
between the camera/detections and the point cloud. TEST007's accepted fragment
occupies approximately `01:07.35101:12.848`; Rerun's latest-at query retains
the last admitted frame after that short fragment.
## Accepted model profile
- YOLOX-S official ONNX from upstream release `0.1.1rc0`;
- Apache-2.0;
- SHA-256
`c5c2d13e59ae883e6af3b45daea64af4833a4951c92d116ec270d9ddbe998063`;
- FP32 input `[1,3,640,640]`, BGR, bilinear top-left letterbox, pad 114;
- FP32 output `[1,8400,85]`, official grid/stride decode, COCO-80;
- Triton 2.70.0 / ONNX Runtime GPU backend in the pinned 26.06 image.
This generic model is useful for proving the contract and timing path. It is not
accepted for obstacle avoidance, free-space estimation or safety decisions.
## Open gates
1. Qualify TEST007's 206-second epoch and record throughput/resource telemetry.
2. Replace SSH/SCP with a reviewed authenticated worker transport.
3. Add bounded live queues, sampling/drop policy and acquisition-isolation
tests.
4. Introduce tracking, segmentation/free-space, calibration and point-cloud
models as separate versioned pipelines.
## E5 recorded instance tracking qualification · 2026-07-21
LAB E5 establishes the first measured temporal object-identity baseline on a
preselected 601-frame, 60.069-second RAVNOVES00 interval. It uses the official
Apache-2.0 YOLOX-S ONNX release through the existing Triton service, the
immutable `camera_1`-bound valid-FOV mask, and a ByteTrack-style two-stage IoU
tracker. The accepted profile, runner, model and configuration are pinned by
SHA-256; output remains `ground_truth=false` and qualification-only.
The immutable result is
`e5-tracking-88aace13ef9963f8dc07f85228e530f9d28c2b49aca9192409f7975512b058f6`.
It contains an 800x600 H.264 ID-overlay video, exactly 601 timestamped detection
and track rows, one-second GPU telemetry, a contact sheet and the complete run
report. The result validator rehashed all artifacts and verified the exact job,
input, session, source, clip and timeline binding. FFprobe independently
confirmed 601 declared/read frames and 60.068948 seconds.
The frame loop ran for 88.093 seconds at 6.822 FPS; the complete worker run took
166.336 seconds at 3.613 FPS. Mean per-frame time was 12.359 ms in Triton,
0.247 ms in tracking, 30.132 ms decoding and 87.363 ms writing overlays. The
detector and tracker are therefore not the main live-rate bottleneck; artifact
I/O must be decoupled before a live path is admitted. GPU utilization was
51.65% mean / 56% max, power 144.78 W mean / 148.40 W max, temperature 43 C max
and process RSS 126.199 MiB.
The run admitted 4,049 detections and emitted 3,320 observations across 167
confirmed IDs. Useful clear-view persistence is proven, including vehicle
tracks lasting 100212 frames. The result also exposes real limitations: an ID
fragments during the close woman/stroller occlusion, the stroller can be called
`motorcycle`, and parked vehicles can flicker between `car` and `truck`. There
is no identity ground truth, so IDF1, HOTA, MOTA and true ID-switch counts are
not claimed.
All task-controlled worker paths remained under `D:\NDC_MISSIONCORE`; the
orchestrator enforced the 360 GiB floor and a 4.402 GiB working-set reserve.
Free space after exact temporary cleanup was 380.595 GiB. The YOLOX model was
loaded only for the run and restored to its prior unloaded state. Detailed
provenance, pilot history, thresholds, timing tables, artifacts, hashes,
quality findings and the LAB E6 gate are in
`experiments/perception/LAB_E5_REPORT_2026-07-21.md` and Ops card
MISSIONCOR-19.
The next bounded gate is calibrated 2D-track/LiDAR association on the same
601-frame interval: robust point support, metric range, coarse 3D cuboids and a
qualification-only Rerun recording. Full-session and live promotion remain
blocked on measured identity quality and a bounded queue/drop design.
## E6 factory-calibrated tracked LiDAR fusion · 2026-07-21
LAB E6 closes the recorded 2D-track/LiDAR association gate on the exact E5
601-frame interval. It reuses the immutable E4 semantic masks, E5 track IDs,
raw K1 point/pose capture and the XGRIDS `camera_1` factory KB4 calibration.
Fused observations must satisfy 100 ms camera/point and pose/point gates.
Nearest-depth buffering, semantic support, depth splitting, 3D connected
components, robust range history and plausible-size gates prevent unsupported
image rectangles from becoming fabricated 3D boxes.
The immutable result is
`e6-fusion-b4e4226674a66f6196c033785eb307c255a7bdd8493ef9809dfb1d6e5bd68eaa`.
It processed 601/601 frames, fused 526, emitted 918 point-supported oriented
cuboids across 56 track IDs and failed closed for depth on 75 frames outside
the strict timing gate. LiDAR/camera absolute delta was 29.389 ms mean,
70.794 ms p95 and 96.107 ms maximum. Accepted range spans 1.23937.749 m with a
10.189 m median.
The single-process local geometry/artifact path took 14.774 seconds, peaked at
240.469 MiB RSS and is faster than the source rate. This is not an end-to-end
live result because E4 and E5 were precomputed. The external worker and both
worker disks were untouched. The output includes an 800x600 H.264 overlay,
timestamped JSONL/NPZ data, a contact sheet and a standalone 60 MiB Rerun
recording with camera, map-frame cloud, support points and translucent
`Boxes3D`. Visual QA confirmed that these are real oriented Rerun primitives;
they remain visible-surface envelopes, not ground-truthed complete object
volumes.
Detailed provenance, profile freeze, pilot failures, thresholds, timing,
rejection counts, artifact hashes, limitations and the LAB E7 gate are in
`experiments/perception/LAB_E6_REPORT_2026-07-21.md`. Ops synchronization is
pending restoration of the direct `nodedc-ops-agent` tasker tools; the legacy
Ops API was not used.

Some files were not shown because too many files have changed in this diff Show More