feat: activate self-hosted lidar spatial scene

This commit is contained in:
DCCONSTRUCTIONS 2026-07-16 10:36:51 +03:00
parent 6402601d6d
commit 904c6ea13a
27 changed files with 2009 additions and 646 deletions

View File

@ -7,8 +7,10 @@ or speculative writes.
Current status: live proof completed on firmware 3.0.2. The Mac provisioned the 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 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 scan-correlated point-cloud and pose streams, and decoded both successfully.
The repository also contains a local React control console and a Foxglove live The repository also contains a local React control console, an automatic Rerun
bridge for the verified point-cloud and trajectory topics. 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 repository now contains one narrowly gated state-changing command: The repository now contains one narrowly gated state-changing command:
`ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning `ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning
@ -88,25 +90,45 @@ 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 Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not
use an external hosted viewer UI. use an external hosted viewer UI.
There is one important current boundary: the automatic K1 MQTT → Rerun The first K1 live session or replay in a `k1link serve` process creates one local
RRD/gRPC contract is **not implemented yet**. Starting a K1 live or replay Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes
session updates real state and metrics, but it does not by itself populate the the resulting URL through control-plane state. Later sessions reset their
embedded Rerun viewport. The scene controls for point size, gradients, session-local scene and metrics and reuse that process-wide stream; this avoids
accumulation, layers, timeline and layout are presently a product UI contract; restarting the native listener while the embedded browser remains connected.
their Rerun Blueprint/playback adapter is also still pending. No synthetic Unless an operator has entered a manual source, the React application assigns
point cloud, trajectory, camera frame or latency value is generated to conceal that URL to the embedded viewer. The complete runtime path is K1 MQTT → raw-first evidence
these missing links. capture → bounded latest-wins preview queue → reviewed protobuf/LZ4 decoders →
Rerun `Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer.
The existing Python MQTT → Foxglove pipeline remains in place as the verified The default Rerun blueprint shows a 12-second sliding accumulation of real point
legacy visualization adapter. It still publishes `/k1/points`, `/k1/pose`, frames. Product controls are connected for point size, intensity/height/distance
`/k1/trajectory` and `/k1/metrics`, and preserves the measured or available RGB coloring, Turbo/Viridis/Plasma/grayscale/custom palettes,
MQTT-receive-to-Foxglove-publish latency plus bounded latest-wins preview point and trajectory visibility, and the scene grid. Projection, custom
dropping. It is retained for evidence, regression and diagnostics, not as the timeline transport and saved layout remain later product work. No synthetic
target Control Station interface. The historical point cloud, trajectory, camera frame or latency value is generated.
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records that proven path and its
timing semantics; the current frontend contract is documented in 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.
The old Foxglove implementation is retained only in
`src/k1link/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/k1-viewer/README.md`](apps/k1-viewer/README.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
reported source URL contains `127.0.0.1`. It has no connector-level
authentication or TLS. Use it only on a trusted laboratory LAN, do not expose
9876 to the public Internet or a cellular WAN, and add an authenticated secure
proxy before any remote deployment. Stopping acquisition keeps the local scene
server and its URL available for the next session. Stop `k1link serve` to close
the listener and release its retained memory.
`doctor` is intentionally non-invasive. It checks the local Python environment `doctor` is intentionally non-invasive. It checks the local Python environment
and reports external tools; it does not request Bluetooth permission, scan the and reports external tools; it does not request Bluetooth permission, scan the
LAN, touch the K1, alter Homebrew, or change capture permissions. LAN, touch the K1, alter Homebrew, or change capture permissions.
@ -147,7 +169,7 @@ present.
- [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md) - [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md)
- [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md) - [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md)
- [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md) - [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md)
- [Live console and Foxglove runbook](docs/06_K1_LIVE_VIEWER.md) - [Live console and embedded Rerun runbook](docs/06_K1_LIVE_VIEWER.md)
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md) - [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
- [Session manifest schema](schemas/session-manifest.schema.json) - [Session manifest schema](schemas/session-manifest.schema.json)
- [Reference input provenance](docs/reference/README.md) - [Reference input provenance](docs/reference/README.md)

View File

@ -7,8 +7,9 @@ device adapter, но структура интерфейса от него не
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer. Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
переход во внешний облачный интерфейс. Сам браузерный компонент, однако, должен переход во внешний облачный интерфейс. При запуске K1 live/replay backend сам
получить совместимый источник RRD или Rerun gRPC. создаёт Rerun gRPC/proxy source; ручной адрес нужен только для другого Rerun
потока или совместимой RRD-записи.
## Текущее состояние ## Текущее состояние
@ -18,10 +19,10 @@ device adapter, но структура интерфейса от него не
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. | | Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. | | K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. | | K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
| Legacy Foxglove adapter | Реализован | Текущий Python runtime публикует декодированные K1 данные в локальный Foxglove WebSocket. Это проверенный диагностический backend, а не продуктовый UI. | | Автоматический MQTT → Rerun | Реализован | Первый live/replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие сессии сбрасывают сцену и метрики и переиспользуют его. |
| Встроенный Rerun Viewer | Реализован | Открывает назначенный RRD по HTTP(S) или совместимый Rerun gRPC/proxy source внутри Control Station. | | Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
| Автоматический MQTT → Rerun | **Не реализован** | Запуск K1 live/replay пока не создаёт RRD и не подаёт данные во встроенный Rerun viewport. Нужен отдельный stream adapter. | | Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория и сетка. Проекция, собственный timeline и сохранённые layout-профили ещё не подключены. |
| Контролы сцены → Rerun Blueprint | **Не реализованы** | Размер точек, палитра, накопление, слои, проекция, timeline и компоновка пока являются честным UI-контрактом и состоянием текущего сеанса. | | Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
| Камеры, карты и миссии | Интерфейс готов | Серверная логика и реальные каналы для этих рабочих поверхностей ещё не подключены. | | Камеры, карты и миссии | Интерфейс готов | Серверная логика и реальные каналы для этих рабочих поверхностей ещё не подключены. |
Приложение не генерирует демонстрационное облако, траекторию, кадры или Приложение не генерирует демонстрационное облако, траекторию, кадры или
@ -31,28 +32,30 @@ device adapter, но структура интерфейса от него не
## Архитектура данных ## Архитектура данных
```text ```text
NODE.DC Control Station в браузере K1 MQTT :1883, read-only
├── control plane └── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
│ └── REST /api/* + WebSocket /api/events └── bounded latest-wins preview queue (32 сообщения)
│ └── FastAPI на 127.0.0.1:8000 └── проверенный protobuf/LZ4 decoder
│ ├── CoreBluetooth: discovery и reviewed Wi-Fi provisioning └── Rerun Points3D + Transform3D + LineStrips3D
│ └── live/replay session runtime └── gRPC/proxy TCP 9876
└── rerun_grpc_url в REST/WebSocket state
├── текущий K1 preview path — legacy adapter └── встроенный @rerun-io/web-viewer
│ └── K1 MQTT :1883, read-only └── пространственная сцена NODE.DC
│ ├── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
│ └── protobuf/LZ4 decoder React Control Station ←→ REST /api/* + WebSocket /api/events
│ └── Foxglove bridge → локальный WebSocket FastAPI на 127.0.0.1:8000
CoreBluetooth + live/replay runtime
└── продуктовая пространственная сцена
└── встроенный @rerun-io/web-viewer
└── RRD URL или Rerun gRPC/proxy source
``` ```
Между декодером K1 и Rerun Viewer сейчас нет автоматической стрелки. Поля В момент готовности `RerunBridge` backend публикует адрес вида
`foxglove_ws_url` и `foxglove_viewer_url` ещё присутствуют в API только потому, `rerun+http://127.0.0.1:9876/proxy`. Frontend автоматически назначает его сцене,
что Python runtime продолжает поднимать проверенный legacy adapter. Пункт если оператор не указал ручной source. После остановки приёма URL и встроенный
управления не использует их как основную поверхность визуализации. viewer остаются активны, а следующая сессия сбрасывает session-local геометрию,
траекторию и метрики и использует тот же listener. Он закрывается вместе с
процессом `k1link serve`.
Поля `foxglove_ws_url` и `foxglove_viewer_url` пока остаются в API как
совместимость со старым контрактом, но текущий runtime держит их пустыми.
## Фиксированная оболочка ## Фиксированная оболочка
@ -70,8 +73,10 @@ Shell собран из локальных NODE.DC UI packages и сохраня
Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя, Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя,
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
Control Station. Это ещё не означает, что все продуктовые контролы уже связаны Control Station. Размер точек, способ окрашивания и палитра, 12-секундное по
с API Rerun: текущая граница явно показана статусом `Интерфейс готов`. умолчанию накопление, видимость облака и траектории и сетка связаны с backend и
Rerun Blueprint. Кнопки собственного timeline, смена 2D/3D/карты и сохранение
layout пока остаются интерфейсным контрактом.
## Архитектурные разделы ## Архитектурные разделы
@ -93,7 +98,7 @@ Control Station. Это ещё не означает, что все продук
## Установка, проверка, сборка и запуск ## Установка, проверка, сборка и запуск
Требуются Node.js 20 или новее, `uv` и соседний checkout Требуются Node.js 20.19+ либо 22.12+, `uv` и соседний checkout
`NODEDC_DESIGN_GUIDELINE`: зависимости `@nodedc/*` подключены к нему через `NODEDC_DESIGN_GUIDELINE`: зависимости `@nodedc/*` подключены к нему через
локальные `file:` пути. Python устанавливается только в `.venv` репозитория. локальные `file:` пути. Python устанавливается только в `.venv` репозитория.
@ -142,18 +147,22 @@ Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/
6. Запустить live-приём по определённому адресу K1 либо replay локального 6. Запустить live-приём по определённому адресу K1 либо replay локального
`.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и `.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и
останавливается подтверждённым двойным нажатием кнопки устройства. останавливается подтверждённым двойным нажатием кнопки устройства.
7. Состояние, частота, число точек, задержка и пропуски preview появятся только 7. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
после прихода реальных данных. state. Ручной source вводить не требуется.
8. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
частота, число точек, задержка и пропуски preview появятся после прихода
сообщений K1.
Этот путь запускает существующий MQTT/Foxglove runtime. Чтобы увидеть тот же В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений:
живой K1 поток во встроенной пространственной сцене, нужно сначала реализовать и 38 кадров `lio_pcl`, 42 кадра `lio_pose`, 2 775 точек в последнем облаке и
запустить MQTT→Rerun stream adapter; одного запуска live-сессии сейчас 0 ошибок декодирования.
недостаточно.
## Источники Rerun ## Источники Rerun
В **Наблюдение → Пространственная сцена → Источник** принимается URL, который Live/replay runtime автоматически назначает первый URL из примера. В
понимает `@rerun-io/web-viewer` версии, зафиксированной в `package.json`: **Наблюдение → Пространственная сцена → Источник** можно оставить ручное поле
пустым либо указать другой адрес, который понимает `@rerun-io/web-viewer`
зафиксированной в `package.json` версии:
```text ```text
rerun+http://127.0.0.1:9876/proxy rerun+http://127.0.0.1:9876/proxy
@ -165,14 +174,23 @@ https://example.internal/recording.rrd
proxy; proxy;
- `http(s)://…/recording.rrd` — RRD-запись по HTTP(S); - `http(s)://…/recording.rrd` — RRD-запись по HTTP(S);
- версия RRD должна быть совместима с версией Web Viewer; - версия RRD должна быть совместима с версией Web Viewer;
- URL хранится только в состоянии текущей страницы; - ручной URL хранится только в состоянии текущей страницы и имеет приоритет над
автоматическим source;
- ошибка запуска показывается в viewport, без подстановки фиктивных данных. - ошибка запуска показывается в viewport, без подстановки фиктивных данных.
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
оболочку. Продуктовые настройки отображения, custom timeline, переключатель оболочку. Backend принимает и применяет к Rerun размер точки `0.512.0`, режимы
2D/3D/карты и сохранение RBL пока не вызывают Blueprint/playback API. Черновик цвета `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 ## Контракт локального API
@ -185,12 +203,14 @@ https://example.internal/recording.rrd
| `POST` | `/api/session/live` | Опционально `{ "host", "duration_seconds" }`. | | `POST` | `/api/session/live` | Опционально `{ "host", "duration_seconds" }`. |
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }`. | | `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }`. |
| `POST` | `/api/session/stop` | Остановка активного источника. | | `POST` | `/api/session/stop` | Остановка активного источника. |
| `POST` | `/api/viewer/settings` | Размер/цвет точек, накопление, видимость облака, траектории и сетки. |
| `WS` | `/api/events` | Периодические snapshots для live UI. | | `WS` | `/api/events` | Периодические snapshots для live UI. |
`/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или `/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`, как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`,
`selected_device_id`, `k1_ip`, `source_mode`, `metrics` и legacy-поля `selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
`foxglove_ws_url`/`foxglove_viewer_url`. OpenAPI доступен по `/api/docs`. `viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
пустыми. OpenAPI доступен по `/api/docs`.
## Карта исходников frontend ## Карта исходников frontend
@ -211,6 +231,12 @@ https://example.internal/recording.rrd
## Safety и чувствительные данные ## Safety и чувствительные данные
- Control Station и credential endpoint доступны только на loopback. - 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, - Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
очищается после успешного ответа и не сохраняется в URL/local storage. очищается после успешного ответа и не сохраняется в URL/local storage.
- BLE-подключение выполняет только отдельно рассмотренную provisioning-запись; - BLE-подключение выполняет только отдельно рассмотренную provisioning-запись;
@ -219,6 +245,12 @@ https://example.internal/recording.rrd
физического сканирования остаются за кнопкой K1. физического сканирования остаются за кнопкой K1.
- Live-сессии сначала сохраняют сырые сообщения, затем формируют preview. При - Live-сессии сначала сохраняют сырые сообщения, затем формируют preview. При
перегрузке preview может быть отброшен, raw evidence сохраняется. перегрузке 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 и может содержать адреса, идентификаторы, - `sessions/` игнорируется Git и может содержать адреса, идентификаторы,
траекторию и карту помещения. В репозиторий попадают только redacted manifests траекторию и карту помещения. В репозиторий попадают только redacted manifests
и безопасная документация. и безопасная документация.

View File

@ -1,10 +1,11 @@
import { useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
AdminNavigationPanel, AdminNavigationPanel,
AppHeader, AppHeader,
ApplicationPanel, ApplicationPanel,
ApplicationShell, ApplicationShell,
Button, Button,
Checker,
ColorField, ColorField,
ControlRow, ControlRow,
HeaderAvatar, HeaderAvatar,
@ -17,7 +18,6 @@ import {
RangeControl, RangeControl,
Select, Select,
StatusBadge, StatusBadge,
Switch,
TextField, TextField,
Window, Window,
WindowFooterActions, WindowFooterActions,
@ -26,6 +26,7 @@ import {
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
import { LandingStage } from "./components/LandingStage"; import { LandingStage } from "./components/LandingStage";
import type { ViewerSettings } from "./api";
import { import {
rootById, rootById,
roots, roots,
@ -44,6 +45,9 @@ import {
import { useK1Console } from "./useK1Console"; import { useK1Console } from "./useK1Console";
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace"; import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { WorkspaceRenderer } from "./workspaces/Workspaces"; 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 }> = [ const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" }, { value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
@ -61,6 +65,46 @@ const paletteOptions: Array<{ value: PointPalette; label: string; description: s
{ value: "custom", 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() { export default function App() {
const console = useK1Console(); const console = useK1Console();
const workspace = useApplicationWorkspace<string>({ const workspace = useApplicationWorkspace<string>({
@ -74,6 +118,7 @@ export default function App() {
const [sourceWindowOpen, setSourceWindowOpen] = useState(false); const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
const [displayWindowOpen, setDisplayWindowOpen] = useState(false); const [displayWindowOpen, setDisplayWindowOpen] = useState(false);
const [layerInspectorOpen, setLayerInspectorOpen] = useState(false); const [layerInspectorOpen, setLayerInspectorOpen] = useState(false);
const [sceneWindowOrder, setSceneWindowOrder] = useState<SceneToolWindowId[]>([]);
const [layoutWindowOpen, setLayoutWindowOpen] = useState(false); const [layoutWindowOpen, setLayoutWindowOpen] = useState(false);
const [layoutName, setLayoutName] = useState("Операторская сцена"); const [layoutName, setLayoutName] = useState("Операторская сцена");
const [layoutDraftSaved, setLayoutDraftSaved] = useState(false); const [layoutDraftSaved, setLayoutDraftSaved] = useState(false);
@ -83,6 +128,48 @@ export default function App() {
const currentRoot = rootById(activeRoot); const currentRoot = rootById(activeRoot);
const activeDefinition = workspaceById(workspace.activeView); const activeDefinition = workspaceById(workspace.activeView);
const rootWorkspaces = workspacesForRoot(activeRoot); 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) => { const selectRoot = (rootId: RootId) => {
setActiveRoot(rootId); setActiveRoot(rootId);
@ -100,11 +187,41 @@ export default function App() {
const openSource = () => { const openSource = () => {
setSourceDraft(sourceUrl); setSourceDraft(sourceUrl);
setSourceWindowOpen(true); setSourceWindowOpen(true);
activateSceneWindow("sources");
}; };
const openDisplay = () => { const openDisplay = () => {
setDisplayDraft(sceneSettings); setDisplayDraft(sceneSettings);
setDisplayWindowOpen(true); 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 contentActions = useMemo<ApplicationPanelUtilityAction[]>(() => {
@ -122,17 +239,14 @@ export default function App() {
actions.push( actions.push(
{ label: "Настроить источник", icon: "network", onClick: openSource }, { label: "Настроить источник", icon: "network", onClick: openSource },
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay }, { label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
{ label: "Открыть слои", icon: "list", onClick: () => setLayerInspectorOpen(true) }, { label: "Открыть слои", icon: "list", onClick: openLayers },
); );
} }
actions.push({ actions.push({
label: "Сохранить компоновку", label: "Сохранить компоновку",
icon: "save", icon: "save",
onClick: () => { onClick: openLayout,
setLayoutDraftSaved(false);
setLayoutWindowOpen(true);
},
}); });
return actions; return actions;
}, [activeDefinition?.kind, console, sceneSettings, sourceUrl]); }, [activeDefinition?.kind, console, sceneSettings, sourceUrl]);
@ -231,8 +345,8 @@ export default function App() {
{phaseLabel(console.state?.phase)} {phaseLabel(console.state?.phase)}
</StatusBadge> </StatusBadge>
) : activeDefinition.kind === "spatial" ? ( ) : activeDefinition.kind === "spatial" ? (
<StatusBadge tone={sourceUrl ? "accent" : "warning"}> <StatusBadge tone={effectiveSourceUrl ? "accent" : "warning"}>
{sourceUrl ? "Источник назначен" : "Без источника"} {effectiveSourceUrl ? "Источник назначен" : "Без источника"}
</StatusBadge> </StatusBadge>
) : ( ) : (
<StatusBadge tone="warning">Интерфейс готов</StatusBadge> <StatusBadge tone="warning">Интерфейс готов</StatusBadge>
@ -242,20 +356,22 @@ export default function App() {
onClose={workspace.closeView} onClose={workspace.closeView}
> >
{activeDefinition.kind === "device" ? ( {activeDefinition.kind === "device" ? (
<DeviceWorkspace console={console} /> <DeviceWorkspace
console={console}
onOpenSpatialScene={() => openView("spatial-scene")}
/>
) : ( ) : (
<WorkspaceRenderer <WorkspaceRenderer
definition={activeDefinition} definition={activeDefinition}
state={console.state} state={console.state}
backendStatus={console.backendStatus} backendStatus={console.backendStatus}
sourceUrl={sourceUrl} sourceUrl={effectiveSourceUrl}
sceneSettings={sceneSettings} sceneSettings={sceneSettings}
onSceneSettingsChange={setSceneSettings}
navigation={{ navigation={{
openView, openView,
openSource, openSource,
openDisplay, openDisplay,
openLayers: () => setLayerInspectorOpen(true), openLayers,
}} }}
/> />
)} )}
@ -265,10 +381,19 @@ export default function App() {
<Window <Window
open={sourceWindowOpen} open={sourceWindowOpen}
title="Источник визуальных данных" title="Источники"
subtitle="ИСТОЧНИКИ RRD И RERUN GRPC" subtitle="Потоки и записи пространственной сцены"
size="md" placement="end"
onClose={() => setSourceWindowOpen(false)} 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={ footer={
<WindowFooterActions> <WindowFooterActions>
<Button <Button
@ -276,10 +401,9 @@ export default function App() {
onClick={() => { onClick={() => {
setSourceDraft(""); setSourceDraft("");
setSourceUrl(""); setSourceUrl("");
setSourceWindowOpen(false);
}} }}
> >
Очистить источник Сбросить адрес
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
@ -287,48 +411,107 @@ export default function App() {
disabled={!sourceDraft.trim()} disabled={!sourceDraft.trim()}
onClick={() => { onClick={() => {
setSourceUrl(sourceDraft.trim()); setSourceUrl(sourceDraft.trim());
setSourceWindowOpen(false);
}} }}
> >
Подключить Применить адрес
</Button> </Button>
</WindowFooterActions> </WindowFooterActions>
} }
> >
<div className="modal-stack"> <Inspector
<div className="modal-contract-note" data-tone="accent"> defaultOpen={["connection"]}
<Icon name="network" /> singleOpen
<div> sections={[
<strong>Встроенный открытый визуализатор</strong> {
<p> id: "connection",
Источник открывается внутри интерфейса, без перехода во внешний сервис. Версия label: "Подключение",
RRD должна совпадать с версией визуального движка. description: "Адрес потока или записи",
</p> content: (
</div> <div className="inspector-control-stack">
</div> <ControlRow label="Состояние">
<TextField <span className="scene-window-state">
label="Адрес источника" <i className="api-dot" data-status={effectiveSourceUrl ? "online" : "checking"} aria-hidden="true" />
hint="URL" {effectiveSourceUrl ? "Источник назначен" : "Источник не назначен"}
value={sourceDraft} </span>
onChange={(event) => setSourceDraft(event.target.value)} </ControlRow>
spellCheck={false} {effectiveSourceUrl ? (
placeholder="rerun+http://127.0.0.1:9876/proxy или https://…/recording.rrd" <ControlRow label="Активный адрес" layout="stack">
description="Текущий локальный MQTT-поток пока требует отдельного адаптера потока Rerun." <code className="scene-window-code">{effectiveSourceUrl}</code>
/> </ControlRow>
<div className="source-format-list"> ) : null}
<div><code>rerun+http://…/proxy</code><span>Живой gRPC источник</span></div> <ControlRow label="Автоматический" layout="stack">
<div><code>http://…/recording.rrd</code><span>Запись по HTTP</span></div> <code className="scene-window-code">
<div><code>https://…/recording.rrd</code><span>Удалённая запись</span></div> {automaticSourceUrl || "Локальный источник ещё не опубликован"}
</div> </code>
</div> </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>
<Window <Window
open={displayWindowOpen} open={displayWindowOpen}
title="Параметры отображения" title="Отображение"
subtitle="ПРОСТРАНСТВЕННАЯ СЦЕНА" subtitle="Параметры пространственной сцены"
size="md" placement="end"
onClose={() => setDisplayWindowOpen(false)} 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={ footer={
<WindowFooterActions> <WindowFooterActions>
<Button variant="ghost" onClick={() => setDisplayDraft(defaultSceneSettings)}> <Button variant="ghost" onClick={() => setDisplayDraft(defaultSceneSettings)}>
@ -337,143 +520,138 @@ export default function App() {
<Button <Button
variant="primary" variant="primary"
shape="pill" shape="pill"
onClick={() => { disabled={console.pendingAction === "viewer"}
setSceneSettings(displayDraft); onClick={() => void applyDisplaySettings()}
setDisplayWindowOpen(false);
}}
> >
Применить к профилю {console.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"}
</Button> </Button>
</WindowFooterActions> </WindowFooterActions>
} }
> >
<div className="modal-stack"> <Inspector
<div className="modal-contract-note" data-tone="warning"> defaultOpen={["points"]}
<Icon name="alert" /> singleOpen
<div> sections={[
<strong>Профиль визуализации подготовлен</strong> {
<p> id: "points",
Параметры уже собраны в продуктовой форме. Они начнут управлять компоновкой Rerun label: "Облако точек",
после подключения адаптера потока и профиля; сейчас меняется только интерфейсный черновик. description: "Размер и способ окрашивания",
</p> content: (
</div> <div className="inspector-control-stack">
</div> <RangeControl
<Inspector label="Размер точки"
defaultOpen={["points", "history", "scene"]} value={displayDraft.pointSize}
sections={[ min={0.5}
{ max={12}
id: "points", step={0.5}
label: "Облако точек", formatValue={(value) => `${value.toFixed(1)} пкс`}
description: "Размер, атрибут и градиент", onChange={(pointSize) => setDisplayDraft((current) => ({ ...current, pointSize }))}
group: "ВИЗУАЛЬНЫЙ ПРОФИЛЬ", />
content: ( <ControlRow label="Атрибут цвета">
<div className="inspector-control-stack"> <Select
<RangeControl variant="split"
label="Размер точки" label="Атрибут цвета"
value={displayDraft.pointSize} value={displayDraft.colorMode}
min={0.5} options={colorModeOptions}
max={12} onChange={(colorMode) => setDisplayDraft((current) => ({ ...current, colorMode }))}
step={0.5}
formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => setDisplayDraft((current) => ({ ...current, pointSize }))}
/> />
<ControlRow label="Атрибут цвета" layout="stack"> </ControlRow>
<Select <ControlRow label="Палитра">
label="Атрибут цвета" <Select
value={displayDraft.colorMode} variant="split"
options={colorModeOptions} label="Палитра"
onChange={(colorMode) => setDisplayDraft((current) => ({ ...current, colorMode }))} 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> </ControlRow>
<ControlRow label="Палитра" layout="stack"> ) : null}
<Select </div>
label="Палитра" ),
value={displayDraft.palette} },
options={paletteOptions} {
onChange={(palette) => setDisplayDraft((current) => ({ ...current, palette }))} id: "history",
/> label: "Накопление и время",
</ControlRow> description: "История облака и траектория",
{displayDraft.palette === "custom" ? ( content: (
<ControlRow label="Цвет точек"> <div className="inspector-control-stack">
<ColorField <RangeControl
label="Цвет точек" label="Окно накопления"
value={displayDraft.customColor} value={displayDraft.accumulationSeconds}
onChange={(customColor) => setDisplayDraft((current) => ({ ...current, customColor }))} min={0}
/> max={120}
</ControlRow> step={1}
) : null} formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
</div> onChange={(accumulationSeconds) => setDisplayDraft((current) => ({ ...current, accumulationSeconds }))}
), />
}, <Checker
{ checked={displayDraft.showTrajectory}
id: "history", label="Показывать траекторию"
label: "Накопление и время", description="Линия пути устройства в координатах сцены"
description: "История облака и траектория", onChange={(showTrajectory) => setDisplayDraft((current) => ({ ...current, showTrajectory }))}
group: "ВИЗУАЛЬНЫЙ ПРОФИЛЬ", />
content: ( </div>
<div className="inspector-control-stack"> ),
<RangeControl },
label="Окно накопления" {
value={displayDraft.accumulationSeconds} id: "scene",
min={0} label: "Окружение сцены",
max={120} description: "Сетка, подписи и камеры",
step={1} content: (
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)} <div className="inspector-control-stack">
onChange={(accumulationSeconds) => setDisplayDraft((current) => ({ ...current, accumulationSeconds }))} <Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => setDisplayDraft((current) => ({ ...current, showGrid }))} />
/> <Checker checked={false} disabled label="Подписи сущностей" description="Появятся после подключения семантических сущностей" onChange={() => undefined} />
<Switch <Checker checked={false} disabled label="Области обзора камер" description="Камерный канал пока не подключён" onChange={() => undefined} />
checked={displayDraft.showTrajectory} </div>
label="Показывать траекторию" ),
onChange={(showTrajectory) => setDisplayDraft((current) => ({ ...current, showTrajectory }))} },
/> ]}
</div> />
),
},
{
id: "scene",
label: "Сцена",
description: "Сетка, подписи и области обзора камер",
group: "ВИЗУАЛЬНЫЙ ПРОФИЛЬ",
content: (
<div className="inspector-control-stack">
<Switch checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => setDisplayDraft((current) => ({ ...current, showGrid }))} />
<Switch checked={displayDraft.showLabels} label="Подписи сущностей" onChange={(showLabels) => setDisplayDraft((current) => ({ ...current, showLabels }))} />
<Switch checked={displayDraft.showCameraFrustums} label="Области обзора камер в 3D" onChange={(showCameraFrustums) => setDisplayDraft((current) => ({ ...current, showCameraFrustums }))} />
</div>
),
},
]}
/>
</div>
</Window> </Window>
<Window <Window
open={layerInspectorOpen} open={layerInspectorOpen}
title="Слои сцены" title="Слои сцены"
subtitle="ДЕРЕВО СУЩНОСТЕЙ / ПЕРЕОПРЕДЕЛЕНИЯ" subtitle="Сущности пространственной сцены"
placement="end" placement="end"
draggable draggable
onClose={() => setLayerInspectorOpen(false)} 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"> <div className="layer-inspector-intro">
<StatusBadge tone={sourceUrl ? "accent" : "neutral"}> <span className="scene-window-state">
{sourceUrl ? "Источник назначен" : "Без источника"} <i className="api-dot" data-status={effectiveSourceUrl ? "online" : "checking"} aria-hidden="true" />
</StatusBadge> {effectiveSourceUrl ? "Источник назначен" : "Источник не назначен"}
</span>
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p> <p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
</div> </div>
<Inspector <Inspector
defaultOpen={["geometry", "frames", "perception"]} defaultOpen={["geometry"]}
singleOpen
sections={[ sections={[
{ {
id: "geometry", id: "geometry",
label: "Геометрия", label: "Геометрия",
description: "Облако точек и путь", description: "Облако точек и путь",
group: "СЦЕНА",
tone: "accent",
content: ( content: (
<div className="inspector-control-stack"> <div className="inspector-control-stack">
<Switch checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => setSceneSettings((current) => ({ ...current, showPoints }))} /> <Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" description="Основной поток геометрии лидара" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
<Switch checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => setSceneSettings((current) => ({ ...current, showTrajectory }))} /> <Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" description="Положение и ориентация устройства во времени" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
</div> </div>
), ),
}, },
@ -481,11 +659,10 @@ export default function App() {
id: "frames", id: "frames",
label: "Координаты и камеры", label: "Координаты и камеры",
description: "Преобразования и области обзора", description: "Преобразования и области обзора",
group: "СЦЕНА",
content: ( content: (
<div className="inspector-control-stack"> <div className="inspector-control-stack">
<Switch checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => setSceneSettings((current) => ({ ...current, showGrid }))} /> <Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
<Switch checked={sceneSettings.showCameraFrustums} label="Области обзора камер в 3D" onChange={(showCameraFrustums) => setSceneSettings((current) => ({ ...current, showCameraFrustums }))} /> <Checker checked={false} disabled label="Области обзора камер" description="Камерный канал пока не подключён" onChange={() => undefined} />
</div> </div>
), ),
}, },
@ -493,12 +670,11 @@ export default function App() {
id: "perception", id: "perception",
label: "Объекты и маски", label: "Объекты и маски",
description: "Ожидают внешние обработчики", description: "Ожидают внешние обработчики",
group: "СЦЕНА",
content: ( content: (
<div className="layer-placeholder-list"> <div className="inspector-control-stack">
<span><i />Рамки 2D / 3D<small>не подключено</small></span> <Checker checked={false} disabled label="Рамки 2D и 3D" description="Внешний обработчик не подключён" onChange={() => undefined} />
<span><i />Маски сегментации<small>не подключено</small></span> <Checker checked={false} disabled label="Маски сегментации" description="Внешний обработчик не подключён" onChange={() => undefined} />
<span><i />Ключевые точки / треки<small>не подключено</small></span> <Checker checked={false} disabled label="Ключевые точки и треки" description="Внешний обработчик не подключён" onChange={() => undefined} />
</div> </div>
), ),
}, },
@ -511,10 +687,20 @@ export default function App() {
title="Компоновка рабочей области" title="Компоновка рабочей области"
subtitle="КОМПОНОВКА / ЧЕРНОВИК" subtitle="КОМПОНОВКА / ЧЕРНОВИК"
size="sm" size="sm"
onClose={() => setLayoutWindowOpen(false)} 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={ footer={
<WindowFooterActions> <WindowFooterActions>
<Button onClick={() => setLayoutWindowOpen(false)}>Закрыть</Button> <Button onClick={() => closeSceneWindow("layout")}>Закрыть</Button>
<Button <Button
variant="primary" variant="primary"
shape="pill" shape="pill"

View File

@ -30,10 +30,23 @@ export interface ConsoleState {
k1_ip?: string | null; k1_ip?: string | null;
foxglove_ws_url?: string | null; foxglove_ws_url?: string | null;
foxglove_viewer_url?: string | null; foxglove_viewer_url?: string | null;
rerun_grpc_url?: string | null;
viewer_settings?: ViewerSettings | null;
source_mode?: SourceMode | null; source_mode?: SourceMode | null;
metrics?: K1Metrics; 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 { export interface HealthResponse {
ok?: boolean; ok?: boolean;
status?: string; status?: string;
@ -174,6 +187,10 @@ export const api = {
stopSession(): Promise<ConsoleState> { stopSession(): Promise<ConsoleState> {
return postState("/api/session/stop"); return postState("/api/session/stop");
}, },
updateViewerSettings(body: ViewerSettings): Promise<ConsoleState> {
return postState("/api/viewer/settings", body);
},
}; };
export type EventSocketStatus = "connecting" | "open" | "closed" | "error"; export type EventSocketStatus = "connecting" | "open" | "closed" | "error";

View File

@ -23,19 +23,6 @@ export function LandingStage({
}: LandingStageProps) { }: LandingStageProps) {
return ( return (
<section className="landing-stage" data-root={root?.id ?? "home"}> <section className="landing-stage" data-root={root?.id ?? "home"}>
<div className="landing-stage__field" aria-hidden="true">
<span className="landing-stage__grid" />
<span className="landing-stage__orbit landing-stage__orbit--one" />
<span className="landing-stage__orbit landing-stage__orbit--two" />
<span className="landing-stage__orbit landing-stage__orbit--three" />
<span className="landing-stage__sweep" />
<span className="landing-stage__node landing-stage__node--one" />
<span className="landing-stage__node landing-stage__node--two" />
<span className="landing-stage__node landing-stage__node--three" />
<span className="landing-stage__node landing-stage__node--four" />
</div>
<div className="landing-stage__shade" />
<div className="landing-stage__copy"> <div className="landing-stage__copy">
<span className="section-eyebrow">{root?.eyebrow ?? "NODE.DC / ПУНКТ УПРАВЛЕНИЯ"}</span> <span className="section-eyebrow">{root?.eyebrow ?? "NODE.DC / ПУНКТ УПРАВЛЕНИЯ"}</span>
<h1>{root?.title ?? "Пункт управления"}</h1> <h1>{root?.title ?? "Пункт управления"}</h1>
@ -44,7 +31,7 @@ export function LandingStage({
"Наблюдение, планирование и корректировка миссий в одной модульной рабочей области."} "Наблюдение, планирование и корректировка миссий в одной модульной рабочей области."}
</p> </p>
<div className="landing-stage__actions"> <div className="landing-stage__actions">
<Button variant="accent" icon={<Icon name="globe" />} onClick={onOpenObservation}> <Button variant="primary" icon={<Icon name="globe" />} onClick={onOpenObservation}>
Пространственная сцена Пространственная сцена
</Button> </Button>
<Button variant="secondary" icon={<Icon name="network" />} onClick={onOpenDevice}> <Button variant="secondary" icon={<Icon name="network" />} onClick={onOpenDevice}>

View File

@ -32,7 +32,6 @@ export function RerunViewport({
} }
let disposed = false; let disposed = false;
let started = false;
let stopViewer: (() => void) | undefined; let stopViewer: (() => void) | undefined;
const unsubscribers: Array<() => void> = []; const unsubscribers: Array<() => void> = [];
setStatus("loading"); setStatus("loading");
@ -41,7 +40,17 @@ export function RerunViewport({
void import("@rerun-io/web-viewer") void import("@rerun-io/web-viewer")
.then(async ({ WebViewer }) => { .then(async ({ WebViewer }) => {
const viewer = new WebViewer(); const viewer = new WebViewer();
stopViewer = () => viewer.stop(); 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( await viewer.start(
normalizedSource, normalizedSource,
@ -56,10 +65,8 @@ export function RerunViewport({
}, },
null, null,
); );
started = true;
if (disposed) { if (disposed) {
viewer.stop(); stopViewer();
return; return;
} }
@ -86,9 +93,9 @@ export function RerunViewport({
setStatus("ready"); setStatus("ready");
onStatusChange?.("ready"); onStatusChange?.("ready");
}) })
.catch((error: unknown) => { .catch(() => {
if (disposed) return; if (disposed) return;
const message = error instanceof Error ? error.message : "Не удалось запустить визуальный движок."; const message = "Не удалось запустить встроенный визуализатор.";
setStatus("error"); setStatus("error");
onStatusChange?.("error", message); onStatusChange?.("error", message);
}); });
@ -96,7 +103,7 @@ export function RerunViewport({
return () => { return () => {
disposed = true; disposed = true;
unsubscribers.forEach((unsubscribe) => unsubscribe()); unsubscribers.forEach((unsubscribe) => unsubscribe());
if (started) stopViewer?.(); stopViewer?.();
onSelectionChange?.(null); onSelectionChange?.(null);
}; };
}, [onSelectionChange, onStatusChange, sourceUrl]); }, [onSelectionChange, onStatusChange, sourceUrl]);

View File

@ -13,8 +13,18 @@ const runtimeMessageReplacements: Array<[RegExp, string]> = [
export function localizeRuntimeMessage(message: string | null | undefined): string | null { export function localizeRuntimeMessage(message: string | null | undefined): string | null {
if (!message) return null; if (!message) return null;
return runtimeMessageReplacements.reduce( const localized = runtimeMessageReplacements.reduce(
(localized, [pattern, replacement]) => localized.replace(pattern, replacement), (localized, [pattern, replacement]) => localized.replace(pattern, replacement),
message, 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;
} }

View File

@ -67,11 +67,10 @@ export function pipelineLatency(metrics: K1Metrics | undefined): number | null {
const segments = [ const segments = [
finiteMetric(metrics.mqtt_to_decode_ms), finiteMetric(metrics.mqtt_to_decode_ms),
finiteMetric(metrics.decode_ms),
finiteMetric(metrics.publish_ms), finiteMetric(metrics.publish_ms),
].filter((value): value is number => value !== null); ].filter((value): value is number => value !== null);
return segments.length ? segments.reduce((total, value) => total + value, 0) : null; return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
} }
export function formatNumber(value: number | null, digits = 1): string { export function formatNumber(value: number | null, digits = 1): string {

View File

@ -393,7 +393,7 @@ export const workspaces: WorkspaceDefinition[] = [
title: "Временные представления", title: "Временные представления",
description: "Встроенные классы для чисел, состояний и таблиц.", description: "Встроенные классы для чисел, состояний и таблиц.",
capabilities: [ capabilities: [
active("Метрики потока в реальном времени", "Задержка, частота кадров, число точек и пропуски предпросмотра."), active("Метрики потока в реальном времени", "Время до Rerun SDK, частота кадров, число точек и пропуски предпросмотра."),
contract("Временные ряды (TimeSeriesView)", "Скалярные серии, несколько линий и диапазоны."), contract("Временные ряды (TimeSeriesView)", "Скалярные серии, несколько линий и диапазоны."),
contract("Шкала состояний (StateTimelineView)", "Переходы режимов, ошибки и статусы подсистем."), contract("Шкала состояний (StateTimelineView)", "Переходы режимов, ошибки и статусы подсистем."),
contract("Таблица данных (DataframeView)", "Табличная диагностика и атрибуты сущностей."), contract("Таблица данных (DataframeView)", "Табличная диагностика и атрибуты сущностей."),

View File

@ -17,6 +17,19 @@ body {
margin: 0; margin: 0;
} }
#root[data-nodedc-theme="dark"],
body {
--nodedc-accent-rgb: 247 248 244;
--nodedc-on-accent-rgb: 8 8 10;
--nodedc-focus-surface: rgb(255 255 255 / 0.09);
--nodedc-primary-action-bg: rgb(247 248 244 / 0.96);
--nodedc-primary-action-hover: rgb(255 255 255);
--nodedc-primary-action-color: rgb(8 8 10 / 0.96);
--nodedc-primary-action-shadow: none;
--nodedc-modal-surface: rgb(15 15 17 / 0.97);
--nodedc-floating-surface: rgb(15 15 17 / 0.98);
}
button, button,
input, input,
textarea, textarea,
@ -32,16 +45,100 @@ code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
} }
.control-station { .control-station,
body {
--station-panel: #151517; --station-panel: #151517;
--station-panel-soft: #0d0d0f; --station-panel-soft: #0d0d0f;
--station-stage: #08090b; --station-stage: #08090b;
--station-hairline: rgb(255 255 255 / 0.08); --station-hairline: rgb(255 255 255 / 0.08);
--station-hairline-strong: rgb(255 255 255 / 0.13); --station-hairline-strong: rgb(255 255 255 / 0.13);
--station-accent-soft: rgb(var(--nodedc-accent-rgb) / 0.11); --station-accent-soft: rgb(255 255 255 / 0.045);
--station-success-soft: rgb(var(--nodedc-success-rgb) / 0.1); --station-success-soft: rgb(255 255 255 / 0.045);
--station-warning-soft: rgb(var(--nodedc-warning-rgb) / 0.1); --station-warning-soft: rgb(255 255 255 / 0.045);
--station-danger-soft: rgb(var(--nodedc-danger-rgb) / 0.1); --station-danger-soft: rgb(255 255 255 / 0.045);
}
.nodedc-button[data-variant="accent"] {
--nodedc-button-bg: var(--nodedc-primary-action-bg);
--nodedc-button-color: var(--nodedc-primary-action-color);
}
.nodedc-button[data-variant="accent"]:hover:not(:disabled) {
background: var(--nodedc-primary-action-hover);
}
.nodedc-button[data-variant="danger"] {
color: var(--nodedc-text-primary);
}
.nodedc-button[data-variant="danger"]:hover:not(:disabled) {
background: var(--nodedc-glass-control-hover);
}
.nodedc-status {
min-height: auto;
justify-content: flex-start;
gap: 0.42rem;
border-radius: 0;
background: transparent;
color: var(--nodedc-text-primary);
padding: 0;
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-medium);
}
.nodedc-status::before {
width: 0.42rem;
height: 0.42rem;
flex: 0 0 0.42rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.nodedc-status[data-tone="success"],
.nodedc-status[data-tone="warning"],
.nodedc-status[data-tone="danger"],
.nodedc-status[data-tone="accent"] {
background: transparent;
color: var(--nodedc-text-primary);
}
.nodedc-status[data-tone="success"]::before {
background: rgb(var(--nodedc-success-rgb));
}
.nodedc-status[data-tone="warning"]::before {
background: rgb(var(--nodedc-warning-rgb));
}
.nodedc-status[data-tone="danger"]::before {
background: rgb(var(--nodedc-danger-rgb));
}
.nodedc-status[data-tone="accent"]::before {
background: var(--nodedc-text-primary);
}
.nodedc-inspector__section-trigger[data-open="true"],
.nodedc-inspector__section-trigger[data-active="true"],
.nodedc-inspector__section-trigger[data-tone="accent"] {
background: var(--nodedc-glass-control-hover);
color: var(--nodedc-text-primary);
}
.nodedc-inspector__section-trigger[data-open="true"] .nodedc-inspector__section-description,
.nodedc-inspector__section-trigger[data-active="true"] .nodedc-inspector__section-description,
.nodedc-inspector__section-trigger[data-tone="accent"] .nodedc-inspector__section-description {
color: var(--nodedc-text-muted);
}
.nodedc-glass-material::before {
display: none;
}
.nodedc-header__avatar {
background: rgb(226 226 226);
} }
.control-station .nodedc-application-panel__body { .control-station .nodedc-application-panel__body {
@ -146,9 +243,7 @@ code {
} }
.metric-card[data-featured="true"] { .metric-card[data-featured="true"] {
background: background: var(--station-panel);
radial-gradient(circle at 90% 0%, rgb(var(--nodedc-accent-rgb) / 0.16), transparent 44%),
var(--station-panel);
} }
.metric-card__reading { .metric-card__reading {
@ -194,15 +289,15 @@ code {
} }
.modal-contract-note[data-tone="accent"] { .modal-contract-note[data-tone="accent"] {
background: var(--station-accent-soft); background: rgb(255 255 255 / 0.045);
} }
.modal-contract-note[data-tone="warning"] { .modal-contract-note[data-tone="warning"] {
background: var(--station-warning-soft); background: rgb(255 255 255 / 0.045);
} }
.modal-contract-note[data-tone="success"] { .modal-contract-note[data-tone="success"] {
background: var(--station-success-soft); background: rgb(255 255 255 / 0.045);
} }
.modal-contract-note > svg { .modal-contract-note > svg {

View File

@ -25,14 +25,18 @@
align-items: center; align-items: center;
gap: 0.9rem; gap: 0.9rem;
border-radius: 1rem; border-radius: 1rem;
background: var(--station-danger-soft); background: rgb(255 255 255 / 0.045);
color: rgb(var(--nodedc-danger-rgb)); color: var(--nodedc-text-primary);
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
} }
.error-banner > svg { .error-banner__dot {
width: 0.46rem;
height: 0.46rem;
align-self: start; align-self: start;
margin-top: 0.12rem; margin-top: 0.28rem;
border-radius: 50%;
background: rgb(var(--nodedc-danger-rgb));
} }
.error-banner strong, .error-banner strong,
@ -159,8 +163,8 @@
} }
.device-row[data-selected="true"] { .device-row[data-selected="true"] {
background: var(--station-accent-soft); background: rgb(255 255 255 / 0.065);
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.18); box-shadow: none;
} }
.device-row__identity, .device-row__identity,
@ -191,10 +195,7 @@
.device-row__name small { .device-row__name small {
flex: 0 0 auto; flex: 0 0 auto;
border-radius: 999px; color: var(--nodedc-text-muted);
background: var(--station-accent-soft);
color: rgb(var(--nodedc-accent-rgb));
padding: 0.18rem 0.38rem;
font-size: 0.47rem; font-size: 0.47rem;
font-weight: 750; font-weight: 750;
letter-spacing: 0.04em; letter-spacing: 0.04em;
@ -223,12 +224,10 @@
flex: 0 0 0.58rem; flex: 0 0 0.58rem;
border-radius: 50%; border-radius: 50%;
background: var(--nodedc-text-muted); background: var(--nodedc-text-muted);
box-shadow: 0 0 0 0.24rem rgb(255 255 255 / 0.035);
} }
.device-row[data-compatible="true"] .device-row__signal { .device-row[data-compatible="true"] .device-row__signal {
background: rgb(var(--nodedc-success-rgb)); background: rgb(var(--nodedc-success-rgb));
box-shadow: 0 0 0 0.24rem var(--station-success-soft);
} }
.device-row__action > span { .device-row__action > span {
@ -388,10 +387,7 @@
gap: 0.2rem; gap: 0.2rem;
margin-top: 1rem; margin-top: 1rem;
border-radius: 0.85rem; border-radius: 0.85rem;
background: background: rgb(255 255 255 / 0.018);
linear-gradient(to top, rgb(255 255 255 / 0.035) 1px, transparent 1px),
rgb(255 255 255 / 0.018);
background-size: 100% 25%;
padding: 0.7rem; padding: 0.7rem;
} }
@ -399,7 +395,7 @@
min-width: 0.16rem; min-width: 0.16rem;
flex: 1 1 0; flex: 1 1 0;
border-radius: 999px 999px 0.12rem 0.12rem; border-radius: 999px 999px 0.12rem 0.12rem;
background: linear-gradient(to top, rgb(var(--nodedc-accent-rgb) / 0.35), rgb(var(--nodedc-accent-rgb))); background: var(--nodedc-text-primary);
} }
.latency-trace p { .latency-trace p {

View File

@ -120,15 +120,6 @@
font-size: clamp(2.8rem, 14vw, 5rem); font-size: clamp(2.8rem, 14vw, 5rem);
} }
.landing-stage__field {
opacity: 0.62;
}
.landing-stage__orbit,
.landing-stage__sweep {
left: 74%;
}
.landing-stage__status { .landing-stage__status {
top: auto; top: auto;
right: 1rem; right: 1rem;
@ -235,11 +226,12 @@
flex-direction: column; flex-direction: column;
} }
.error-banner > svg { .error-banner {
display: none; grid-template-columns: auto minmax(0, 1fr);
} }
.error-banner__actions { .error-banner__actions {
grid-column: 2;
justify-content: flex-end; justify-content: flex-end;
} }
@ -252,7 +244,7 @@
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
} }
.feature-row > .nodedc-status-badge { .feature-row > .nodedc-status {
grid-column: 2; grid-column: 2;
justify-self: start; justify-self: start;
} }

View File

@ -0,0 +1,83 @@
.scene-tool-window {
--scene-tool-window-top: 5.75rem;
--scene-tool-window-right: 27rem;
position: fixed;
top: var(--scene-tool-window-top);
right: var(--scene-tool-window-right);
max-height: calc(100vh - var(--scene-tool-window-top) - 1.5rem);
}
.scene-tool-window--display {
--scene-tool-window-top: 7.75rem;
--scene-tool-window-right: 28.5rem;
}
.scene-tool-window--layers {
--scene-tool-window-top: 9.75rem;
--scene-tool-window-right: 30rem;
}
.scene-tool-window--layout {
--scene-tool-window-top: 11.75rem;
--scene-tool-window-right: 31.5rem;
}
.nodedc-overlay:has(> .scene-tool-window[data-scene-active="true"]) {
z-index: calc(var(--nodedc-layer-overlay) + 1);
}
.nodedc-overlay[data-placement="center"] {
z-index: calc(var(--nodedc-layer-overlay) + 2);
}
.scene-window-state {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.5rem;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.3;
}
.scene-window-code {
display: block;
overflow: hidden;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-field-bg);
color: var(--nodedc-text-secondary);
padding: 0.72rem 0.8rem;
font-size: var(--nodedc-font-size-xs);
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.scene-window-note {
margin: 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.5;
}
@media (max-width: 44rem) {
.scene-tool-window,
.scene-tool-window--display,
.scene-tool-window--layers,
.scene-tool-window--layout {
--scene-tool-window-top: 4.75rem;
--scene-tool-window-right: 0.875rem;
max-height: calc(100vh - 5.625rem);
}
}
@media (min-width: 44.01rem) and (max-width: 70rem) {
.scene-tool-window,
.scene-tool-window--display,
.scene-tool-window--layers,
.scene-tool-window--layout {
--scene-tool-window-top: 13.5rem;
--scene-tool-window-right: 1.5rem;
max-height: calc(100vh - 15rem);
}
}

View File

@ -18,7 +18,6 @@
.api-dot[data-status="online"] { .api-dot[data-status="online"] {
background: rgb(var(--nodedc-success-rgb)); background: rgb(var(--nodedc-success-rgb));
box-shadow: 0 0 0 0.28rem rgb(var(--nodedc-success-rgb) / 0.09);
} }
.api-dot[data-status="checking"], .api-dot[data-status="checking"],
@ -31,123 +30,12 @@
} }
.landing-stage { .landing-stage {
--landing-accent: var(--nodedc-accent-rgb);
position: relative; position: relative;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
border-radius: var(--nodedc-radius-card); border-radius: var(--nodedc-radius-card);
background: background: var(--station-stage);
linear-gradient(115deg, rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.5)),
var(--station-stage);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
}
.landing-stage[data-root="center"] { --landing-accent: 84 177 255; }
.landing-stage[data-root="fleet"] { --landing-accent: 81 215 179; }
.landing-stage[data-root="observation"] { --landing-accent: 255 79 163; }
.landing-stage[data-root="missions"] { --landing-accent: 151 104 255; }
.landing-stage[data-root="data"] { --landing-accent: 93 192 255; }
.landing-stage[data-root="system"] { --landing-accent: 242 175 76; }
.landing-stage__field,
.landing-stage__shade,
.landing-stage__grid,
.landing-stage__orbit,
.landing-stage__sweep {
position: absolute;
inset: 0;
}
.landing-stage__field {
overflow: hidden;
background:
radial-gradient(circle at 68% 42%, rgb(var(--landing-accent) / 0.1), transparent 24%),
radial-gradient(circle at 76% 48%, rgb(255 255 255 / 0.035), transparent 42%);
}
.landing-stage__grid {
inset: -40% -25% -60% 25%;
opacity: 0.42;
background-image:
linear-gradient(rgb(255 255 255 / 0.055) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.055) 1px, transparent 1px);
background-position: center;
background-size: 4.2rem 4.2rem;
mask-image: radial-gradient(circle at center, #000 0%, transparent 68%);
transform: perspective(42rem) rotateX(62deg) rotateZ(-8deg) scale(1.3) translateY(15%);
}
.landing-stage__orbit {
top: 50%;
left: 69%;
border: 1px solid rgb(var(--landing-accent) / 0.2);
border-radius: 50%;
transform: translate(-50%, -50%);
}
.landing-stage__orbit--one {
width: min(22vw, 24rem);
height: min(22vw, 24rem);
animation: station-orbit 18s linear infinite;
}
.landing-stage__orbit--two {
width: min(34vw, 38rem);
height: min(34vw, 38rem);
border-color: rgb(var(--landing-accent) / 0.12);
animation: station-orbit-reverse 26s linear infinite;
}
.landing-stage__orbit--three {
width: min(49vw, 56rem);
height: min(49vw, 56rem);
border-color: rgb(255 255 255 / 0.06);
}
.landing-stage__orbit::before,
.landing-stage__orbit::after {
position: absolute;
width: 0.36rem;
height: 0.36rem;
border-radius: 50%;
background: rgb(var(--landing-accent));
box-shadow: 0 0 1.1rem rgb(var(--landing-accent) / 0.6);
content: "";
}
.landing-stage__orbit::before { top: -0.18rem; left: 50%; }
.landing-stage__orbit::after { right: 11%; bottom: 14%; opacity: 0.55; }
.landing-stage__sweep {
left: 69%;
width: min(32vw, 34rem);
height: min(32vw, 34rem);
margin: auto;
border-radius: 50%;
background: conic-gradient(from 220deg, transparent 0deg 318deg, rgb(var(--landing-accent) / 0.14) 344deg, transparent 360deg);
transform: translateX(-50%);
animation: station-sweep 7s linear infinite;
}
.landing-stage__node {
position: absolute;
width: 0.48rem;
height: 0.48rem;
border-radius: 50%;
background: rgb(var(--landing-accent));
box-shadow: 0 0 1.3rem rgb(var(--landing-accent) / 0.85);
}
.landing-stage__node--one { top: 29%; left: 62%; }
.landing-stage__node--two { top: 42%; left: 78%; animation: station-pulse 3.2s ease-in-out infinite; }
.landing-stage__node--three { top: 61%; left: 69%; animation: station-pulse 4.1s ease-in-out infinite 0.8s; }
.landing-stage__node--four { top: 52%; left: 87%; animation: station-pulse 3.6s ease-in-out infinite 1.4s; }
.landing-stage__shade {
background:
linear-gradient(90deg, rgb(5 5 6 / 0.96) 0%, rgb(5 5 6 / 0.76) 34%, transparent 67%),
linear-gradient(0deg, rgb(5 5 6 / 0.86) 0%, transparent 28%);
} }
.landing-stage__copy { .landing-stage__copy {
@ -226,28 +114,3 @@
font-weight: 780; font-weight: 780;
letter-spacing: 0.12em; letter-spacing: 0.12em;
} }
@keyframes station-orbit {
to { transform: translate(-50%, -50%) rotate(360deg); }
}
@keyframes station-orbit-reverse {
to { transform: translate(-50%, -50%) rotate(-360deg); }
}
@keyframes station-sweep {
to { transform: translateX(-50%) rotate(360deg); }
}
@keyframes station-pulse {
0%, 100% { opacity: 0.34; transform: scale(0.75); }
50% { opacity: 1; transform: scale(1.2); }
}
@media (prefers-reduced-motion: reduce) {
.landing-stage__orbit,
.landing-stage__sweep,
.landing-stage__node {
animation: none;
}
}

View File

@ -51,6 +51,11 @@
height: 100% !important; height: 100% !important;
} }
.rerun-viewport:is([data-status="loading"], [data-status="error"])
.rerun-viewport__canvas > div {
visibility: hidden;
}
.rerun-viewport__notice { .rerun-viewport__notice {
position: absolute; position: absolute;
top: 50%; top: 50%;
@ -82,7 +87,7 @@
} }
.rerun-viewport__notice--error { .rerun-viewport__notice--error {
background: rgb(var(--nodedc-danger-rgb) / 0.12); background: rgb(10 11 14 / 0.9);
} }
.busy-indicator { .busy-indicator {
@ -90,7 +95,7 @@
height: 1rem; height: 1rem;
flex: 0 0 1rem; flex: 0 0 1rem;
border: 2px solid rgb(255 255 255 / 0.12); border: 2px solid rgb(255 255 255 / 0.12);
border-top-color: rgb(var(--nodedc-accent-rgb)); border-top-color: var(--nodedc-text-primary);
border-radius: 50%; border-radius: 50%;
animation: viewer-spin 900ms linear infinite; animation: viewer-spin 900ms linear infinite;
} }
@ -103,26 +108,11 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
overflow: hidden; overflow: hidden;
background: background: #06070a;
radial-gradient(circle at 68% 41%, rgb(var(--nodedc-accent-rgb) / 0.075), transparent 28%),
linear-gradient(125deg, #08090c, #040508);
} }
.empty-spatial-stage__grid { .empty-spatial-stage__grid {
position: absolute; display: none;
inset: -25% -15% -85%;
opacity: 0;
background-image:
linear-gradient(rgb(255 255 255 / 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.07) 1px, transparent 1px);
background-position: center;
background-size: 3.7rem 3.7rem;
mask-image: radial-gradient(circle at center, #000 0%, transparent 70%);
transform: perspective(40rem) rotateX(66deg) rotateZ(-5deg) scale(1.45) translateY(8%);
}
.empty-spatial-stage[data-grid="true"] .empty-spatial-stage__grid {
opacity: 0.52;
} }
.spatial-axis { .spatial-axis {
@ -150,9 +140,9 @@
.spatial-axis::before { transform: rotate(-24deg); } .spatial-axis::before { transform: rotate(-24deg); }
.spatial-axis::after { transform: rotate(-90deg); } .spatial-axis::after { transform: rotate(-90deg); }
.spatial-axis span { position: absolute; } .spatial-axis span { position: absolute; }
.spatial-axis span[data-axis="x"] { right: 0; bottom: 0; color: #ff6a7a; } .spatial-axis span[data-axis="x"] { right: 0; bottom: 0; color: var(--nodedc-text-muted); }
.spatial-axis span[data-axis="y"] { top: 0; left: 0.55rem; color: #75d98a; } .spatial-axis span[data-axis="y"] { top: 0; left: 0.55rem; color: var(--nodedc-text-muted); }
.spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: #67a9ff; } .spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: var(--nodedc-text-muted); }
.empty-spatial-stage__message { .empty-spatial-stage__message {
position: absolute; position: absolute;
@ -256,7 +246,7 @@
transform: translate(-50%, 5.2rem); transform: translate(-50%, 5.2rem);
} }
.scene-adapter-note > svg { flex: 0 0 auto; color: rgb(var(--nodedc-warning-rgb)); } .scene-adapter-note > svg { flex: 0 0 auto; color: var(--nodedc-text-secondary); }
.scene-selection { .scene-selection {
right: 0.85rem; right: 0.85rem;
@ -296,24 +286,39 @@
display: block; display: block;
height: 100%; height: 100%;
border-radius: inherit; border-radius: inherit;
background: rgb(var(--nodedc-accent-rgb)); background: var(--nodedc-text-primary);
} }
.scene-timeline__track[data-disabled="true"] { opacity: 0.46; } .scene-timeline__track[data-disabled="true"] { opacity: 0.46; }
.scene-timeline code { color: var(--nodedc-text-muted); font-size: 0.57rem; } .scene-timeline code { color: var(--nodedc-text-muted); font-size: 0.57rem; }
.scene-timeline__follow { .scene-timeline__follow {
display: inline-flex;
align-items: center;
gap: 0.38rem;
border-radius: var(--nodedc-radius-circle); border-radius: var(--nodedc-radius-circle);
background: rgb(255 255 255 / 0.055); background: transparent;
color: var(--nodedc-text-muted); color: var(--nodedc-text-muted);
padding: 0.3rem 0.45rem; padding: 0.3rem 0.45rem;
font-size: 0.52rem; font-size: 0.52rem;
font-weight: 820; font-weight: 820;
} }
.scene-timeline__follow::before {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.scene-timeline__follow[data-active="true"] { .scene-timeline__follow[data-active="true"] {
background: var(--station-success-soft); background: transparent;
color: rgb(var(--nodedc-success-rgb)); color: var(--nodedc-text-primary);
}
.scene-timeline__follow[data-active="true"]::before {
background: rgb(var(--nodedc-success-rgb));
} }
.spatial-contract-strip { .spatial-contract-strip {
@ -339,7 +344,7 @@
background: var(--nodedc-text-muted); background: var(--nodedc-text-muted);
} }
.spatial-contract-strip i[data-state="ready"] { background: rgb(var(--nodedc-accent-rgb)); } .spatial-contract-strip i[data-state="ready"] { background: var(--nodedc-text-primary); }
.spatial-contract-strip i[data-state="contract"] { background: rgb(var(--nodedc-warning-rgb)); } .spatial-contract-strip i[data-state="contract"] { background: rgb(var(--nodedc-warning-rgb)); }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

View File

@ -64,17 +64,14 @@
.feature-row[data-status="active"] .feature-row__mark { .feature-row[data-status="active"] .feature-row__mark {
background: rgb(var(--nodedc-success-rgb)); background: rgb(var(--nodedc-success-rgb));
box-shadow: 0 0 0 0.25rem var(--station-success-soft);
} }
.feature-row[data-status="ready"] .feature-row__mark { .feature-row[data-status="ready"] .feature-row__mark {
background: rgb(var(--nodedc-accent-rgb)); background: var(--nodedc-text-primary);
box-shadow: 0 0 0 0.25rem var(--station-accent-soft);
} }
.feature-row[data-status="contract"] .feature-row__mark { .feature-row[data-status="contract"] .feature-row__mark {
background: rgb(var(--nodedc-warning-rgb)); background: rgb(var(--nodedc-warning-rgb));
box-shadow: 0 0 0 0.25rem var(--station-warning-soft);
} }
.feature-row strong, .feature-row strong,
@ -149,6 +146,7 @@
} }
.pipeline-strip > div { .pipeline-strip > div {
position: relative;
display: grid; display: grid;
min-width: 0; min-width: 0;
min-height: 6rem; min-height: 6rem;
@ -159,12 +157,23 @@
padding: 0.8rem; padding: 0.8rem;
} }
.pipeline-strip > div[data-state="ready"] { .pipeline-strip > div::before {
background: var(--station-success-soft); position: absolute;
top: 0.7rem;
right: 0.7rem;
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
} }
.pipeline-strip > div[data-state="contract"] { .pipeline-strip > div[data-state="ready"]::before {
background: var(--station-warning-soft); background: rgb(var(--nodedc-success-rgb));
}
.pipeline-strip > div[data-state="contract"]::before {
background: rgb(var(--nodedc-warning-rgb));
} }
.pipeline-strip > svg { .pipeline-strip > svg {
@ -271,12 +280,7 @@
.camera-slot__grid { .camera-slot__grid {
position: absolute; position: absolute;
inset: 0; inset: 0;
opacity: 0.22; background: rgb(255 255 255 / 0.012);
background-image:
linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px);
background-size: 3rem 3rem;
mask-image: radial-gradient(circle at center, #000, transparent 77%);
} }
.camera-slot header, .camera-slot header,
@ -342,9 +346,7 @@
min-height: 30rem; min-height: 30rem;
overflow: hidden; overflow: hidden;
border-radius: 1rem; border-radius: 1rem;
background: background: #0a0b0d;
radial-gradient(circle at 72% 38%, rgb(var(--nodedc-accent-rgb) / 0.08), transparent 28%),
#0a0b0d;
box-shadow: inset 0 0 0 1px var(--station-hairline); box-shadow: inset 0 0 0 1px var(--station-hairline);
} }
@ -355,11 +357,7 @@
} }
.map-canvas__grid { .map-canvas__grid {
opacity: 0.45; background: rgb(255 255 255 / 0.012);
background-image:
linear-gradient(rgb(255 255 255 / 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.045) 1px, transparent 1px);
background-size: 2.5rem 2.5rem;
} }
.map-canvas__contours span { .map-canvas__contours span {
@ -383,15 +381,14 @@
.map-canvas svg path { .map-canvas svg path {
fill: none; fill: none;
stroke: rgb(var(--nodedc-accent-rgb)); stroke: var(--nodedc-text-secondary);
stroke-linecap: round; stroke-linecap: round;
stroke-width: 3; stroke-width: 3;
filter: drop-shadow(0 0 8px rgb(var(--nodedc-accent-rgb) / 0.34));
} }
.map-canvas svg circle { .map-canvas svg circle {
fill: #0a0b0d; fill: #0a0b0d;
stroke: rgb(var(--nodedc-accent-rgb)); stroke: var(--nodedc-text-secondary);
stroke-width: 4; stroke-width: 4;
} }
@ -448,7 +445,7 @@
} }
.layer-summary-list > div > span[data-tone="cyan"] { background: #35d7c1; } .layer-summary-list > div > span[data-tone="cyan"] { background: #35d7c1; }
.layer-summary-list > div > span[data-tone="violet"] { background: #9a72ff; } .layer-summary-list > div > span[data-tone="violet"] { background: var(--nodedc-text-primary); }
.layer-summary-list > div > span[data-tone="green"] { background: #a6dc74; } .layer-summary-list > div > span[data-tone="green"] { background: #a6dc74; }
.layer-summary-list > div > span[data-tone="orange"] { background: #f2af4c; } .layer-summary-list > div > span[data-tone="orange"] { background: #f2af4c; }
@ -529,8 +526,7 @@
width: 0.6rem; width: 0.6rem;
height: 0.6rem; height: 0.6rem;
border-radius: 50%; border-radius: 50%;
background: rgb(var(--nodedc-accent-rgb)); background: var(--nodedc-text-primary);
box-shadow: 0 0 0 0.26rem var(--station-accent-soft);
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }

View File

@ -9,11 +9,12 @@ import {
type EventSocketStatus, type EventSocketStatus,
type LiveRequest, type LiveRequest,
type ReplayRequest, type ReplayRequest,
type ViewerSettings,
} from "./api"; } from "./api";
import { localizeRuntimeMessage } from "./messages"; import { localizeRuntimeMessage } from "./messages";
export type BackendStatus = "checking" | "online" | "degraded" | "offline"; export type BackendStatus = "checking" | "online" | "degraded" | "offline";
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop"; export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer";
function messageFor(error: unknown): string { function messageFor(error: unknown): string {
if (error instanceof ApiError) { if (error instanceof ApiError) {
@ -26,6 +27,7 @@ function messageFor(error: unknown): string {
} }
function measuredLatency(state: ConsoleState | null): number | null { function measuredLatency(state: ConsoleState | null): number | null {
if (state?.source_mode !== "live") return null;
const metrics = state?.metrics; const metrics = state?.metrics;
if (!metrics) return null; if (!metrics) return null;
@ -34,11 +36,10 @@ function measuredLatency(state: ConsoleState | null): number | null {
const segments = [ const segments = [
metrics.mqtt_to_decode_ms, metrics.mqtt_to_decode_ms,
metrics.decode_ms,
metrics.publish_ms, metrics.publish_ms,
].filter((value): value is number => typeof value === "number" && Number.isFinite(value)); ].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return segments.length ? segments.reduce((total, value) => total + value, 0) : null; return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
} }
export function useK1Console() { export function useK1Console() {
@ -130,6 +131,11 @@ export function useK1Console() {
[run], [run],
); );
const updateViewerSettings = useCallback(
(request: ViewerSettings) => run("viewer", () => api.updateViewerSettings(request)),
[run],
);
useEffect(() => { useEffect(() => {
mounted.current = true; mounted.current = true;
void refresh(true); void refresh(true);
@ -171,7 +177,10 @@ export function useK1Console() {
useEffect(() => { useEffect(() => {
const latency = measuredLatency(state); const latency = measuredLatency(state);
if (latency === null) return; if (latency === null) {
setLatencyHistory((values) => (values.length ? [] : values));
return;
}
setLatencyHistory((values) => [...values.slice(-23), latency]); setLatencyHistory((values) => [...values.slice(-23), latency]);
}, [state]); }, [state]);
@ -189,5 +198,6 @@ export function useK1Console() {
startLive, startLive,
startReplay, startReplay,
stop, stop,
updateViewerSettings,
}; };
} }

View File

@ -101,7 +101,7 @@ function DeviceRow({
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span> <span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
<Button <Button
size="compact" size="compact"
variant={selected ? "accent" : "secondary"} variant={selected ? "primary" : "secondary"}
disabled={device.connectable === false} disabled={device.connectable === false}
onClick={onSelect} onClick={onSelect}
> >
@ -116,7 +116,7 @@ function LatencyTrace({ values }: { values: number[] }) {
const ceiling = Math.max(16, ...values); const ceiling = Math.max(16, ...values);
return ( return (
<div className="latency-trace" aria-label="Последние измерения задержки потока"> <div className="latency-trace" aria-label="Последние измерения времени до публикации">
{values.length ? ( {values.length ? (
values.map((value, index) => ( values.map((value, index) => (
<span <span
@ -132,7 +132,13 @@ function LatencyTrace({ values }: { values: number[] }) {
); );
} }
export function DeviceWorkspace({ console }: { console: ConsoleController }) { export function DeviceWorkspace({
console,
onOpenSpatialScene,
}: {
console: ConsoleController;
onOpenSpatialScene: () => void;
}) {
const { const {
state, state,
backendStatus, backendStatus,
@ -173,7 +179,8 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
} }
}, [selectedDeviceId, state?.devices, state?.selected_device_id]); }, [selectedDeviceId, state?.devices, state?.selected_device_id]);
const metrics = state?.metrics; const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics); const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz); const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count); const points = finiteMetric(metrics?.point_count);
@ -200,25 +207,27 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
if (succeeded) setPassword(""); if (succeeded) setPassword("");
}; };
const submitLive = () => { const submitLive = async () => {
const host = liveHost.trim(); const host = liveHost.trim();
void startLive(host ? { host } : {}); const started = await startLive(host ? { host } : {});
if (started) onOpenSpatialScene();
}; };
const submitReplay = () => { const submitReplay = async () => {
const speed = Number(replaySpeed); const speed = Number(replaySpeed);
void startReplay({ const started = await startReplay({
path: replayPath.trim(), path: replayPath.trim(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 1, speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
loop: replayLoop, loop: replayLoop,
}); });
if (started) onOpenSpatialScene();
}; };
return ( return (
<div className="device-workspace"> <div className="device-workspace">
{error ? ( {error ? (
<aside className="error-banner" role="alert"> <aside className="error-banner" role="alert">
<Icon name="alert" size={18} /> <span className="error-banner__dot" aria-hidden="true" />
<div> <div>
<strong>Локальная операция завершилась ошибкой</strong> <strong>Локальная операция завершилась ошибкой</strong>
<p>{localizeRuntimeMessage(error)}</p> <p>{localizeRuntimeMessage(error)}</p>
@ -252,10 +261,10 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
<section className="metrics-grid" aria-label="Метрики потока в реальном времени"> <section className="metrics-grid" aria-label="Метрики потока в реальном времени">
<MetricCard <MetricCard
featured featured
eyebrow="ЗАДЕРЖКА КОНТУРА" eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)} value={formatNumber(latency)}
unit="мс" unit="мс"
detail="Приём MQTT → публикация предпросмотра" detail="MQTT callback → Rerun SDK; без экрана"
/> />
<MetricCard <MetricCard
eyebrow="ЧАСТОТА КАДРОВ" eyebrow="ЧАСТОТА КАДРОВ"
@ -387,7 +396,7 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
</div> </div>
<Button <Button
width="full" width="full"
variant="accent" variant="primary"
icon={<Icon name="network" />} icon={<Icon name="network" />}
disabled={!canConnect} disabled={!canConnect}
onClick={() => void submitConnect()} onClick={() => void submitConnect()}
@ -434,10 +443,10 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
placeholder={state?.k1_ip || "Сначала подключите устройство к WiFi"} placeholder={state?.k1_ip || "Сначала подключите устройство к WiFi"}
/> />
<Button <Button
variant="accent" variant="primary"
icon={<Icon name="activity" />} icon={<Icon name="activity" />}
disabled={isBusy || !liveTargetReady} disabled={isBusy || !liveTargetReady}
onClick={submitLive} onClick={() => void submitLive()}
> >
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"} {pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
</Button> </Button>
@ -473,10 +482,10 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
onChange={setReplayLoop} onChange={setReplayLoop}
/> />
<Button <Button
variant="accent" variant="primary"
icon={<Icon name="video" />} icon={<Icon name="video" />}
disabled={isBusy || replayPath.trim().length === 0} disabled={isBusy || replayPath.trim().length === 0}
onClick={submitReplay} onClick={() => void submitReplay()}
> >
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"} {pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
</Button> </Button>
@ -521,7 +530,7 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
<header className="panel-heading panel-heading--compact"> <header className="panel-heading panel-heading--compact">
<div> <div>
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span> <span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
<h2>Задержка потока</h2> <h2>Время до публикации</h2>
</div> </div>
<strong className="latency-now"> <strong className="latency-now">
{formatNumber(latency)} <span>мс</span> {formatNumber(latency)} <span>мс</span>

View File

@ -3,7 +3,6 @@ import {
Button, Button,
GlassSurface, GlassSurface,
Icon, Icon,
SegmentedControl,
StatusBadge, StatusBadge,
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
@ -21,15 +20,9 @@ import {
type WorkspaceDefinition, type WorkspaceDefinition,
} from "../productModel"; } from "../productModel";
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation"; import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
import type { SceneProjection, SceneSettings } from "../sceneSettings"; import type { SceneSettings } from "../sceneSettings";
import type { BackendStatus } from "../useK1Console"; import type { BackendStatus } from "../useK1Console";
const projectionItems = [
{ value: "3d", label: "3D" },
{ value: "2d", label: "2D" },
{ value: "map", label: "Карта" },
] satisfies Array<{ value: SceneProjection; label: string }>;
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" { function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success"; if (status === "active") return "success";
if (status === "ready") return "accent"; if (status === "ready") return "accent";
@ -93,7 +86,6 @@ export interface WorkspaceRendererProps {
backendStatus: BackendStatus; backendStatus: BackendStatus;
sourceUrl: string; sourceUrl: string;
sceneSettings: SceneSettings; sceneSettings: SceneSettings;
onSceneSettingsChange: (settings: SceneSettings) => void;
navigation: WorkspaceNavigation; navigation: WorkspaceNavigation;
} }
@ -103,12 +95,13 @@ function OverviewWorkspace({
backendStatus, backendStatus,
navigation, navigation,
}: WorkspaceRendererProps) { }: WorkspaceRendererProps) {
const metrics = state?.metrics; const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics); const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz); const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count); const points = finiteMetric(metrics?.point_count);
const adapterOnline = backendStatus === "online"; const adapterOnline = backendStatus === "online";
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay"; const rerunReady = Boolean(state?.rerun_grpc_url);
return ( return (
<div className="standard-workspace overview-workspace"> <div className="standard-workspace overview-workspace">
@ -119,10 +112,10 @@ function OverviewWorkspace({
<section className="metrics-grid" aria-label="Оперативные показатели"> <section className="metrics-grid" aria-label="Оперативные показатели">
<MetricCard <MetricCard
featured featured
eyebrow="ЗАДЕРЖКА" eyebrow="ДО ПУБЛИКАЦИИ"
value={formatNumber(latency)} value={formatNumber(latency)}
unit="мс" unit="мс"
detail="Локальный путь приёма и публикации" detail="MQTT callback → Rerun SDK; без экрана"
/> />
<MetricCard <MetricCard
eyebrow="ЧАСТОТА" eyebrow="ЧАСТОТА"
@ -166,10 +159,10 @@ function OverviewWorkspace({
<small>{sourceModeLabel(state?.source_mode)}</small> <small>{sourceModeLabel(state?.source_mode)}</small>
</div> </div>
<Icon name="chevron-right" /> <Icon name="chevron-right" />
<div data-state="contract"> <div data-state={rerunReady ? "ready" : "idle"}>
<span>03</span> <span>03</span>
<strong>Rerun</strong> <strong>Rerun</strong>
<small>ожидает источник</small> <small>{rerunReady ? "gRPC опубликован" : "ожидает поток"}</small>
</div> </div>
<Icon name="chevron-right" /> <Icon name="chevron-right" />
<div data-state="ready"> <div data-state="ready">
@ -227,7 +220,6 @@ function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
<div className="empty-spatial-stage__grid" aria-hidden="true" /> <div className="empty-spatial-stage__grid" aria-hidden="true" />
<div className="spatial-axis" aria-hidden="true"> <div className="spatial-axis" aria-hidden="true">
<span data-axis="x">X</span> <span data-axis="x">X</span>
<span data-axis="y">Y</span>
<span data-axis="z">Z</span> <span data-axis="z">Z</span>
</div> </div>
<div className="empty-spatial-stage__message"> <div className="empty-spatial-stage__message">
@ -246,13 +238,13 @@ function SpatialWorkspace({
state, state,
sourceUrl, sourceUrl,
sceneSettings, sceneSettings,
onSceneSettingsChange,
navigation, navigation,
}: WorkspaceRendererProps) { }: WorkspaceRendererProps) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle"); const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState(""); const [viewerMessage, setViewerMessage] = useState("");
const [selection, setSelection] = useState<RerunSelection | null>(null); const [selection, setSelection] = useState<RerunSelection | null>(null);
const metrics = state?.metrics; const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics); const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz); const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count); const points = finiteMetric(metrics?.point_count);
@ -276,12 +268,7 @@ function SpatialWorkspace({
<div className="spatial-workspace"> <div className="spatial-workspace">
<div className="spatial-toolbar"> <div className="spatial-toolbar">
<div className="spatial-toolbar__mode"> <div className="spatial-toolbar__mode">
<SegmentedControl <span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
label="Проекция сцены"
value={sceneSettings.projection}
items={projectionItems}
onChange={(projection) => onSceneSettingsChange({ ...sceneSettings, projection })}
/>
</div> </div>
<div className="spatial-toolbar__actions"> <div className="spatial-toolbar__actions">
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}> <Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
@ -323,7 +310,7 @@ function SpatialWorkspace({
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong> <strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
</div> </div>
<div> <div>
<span>Задержка</span> <span>До публикации</span>
<strong>{formatNumber(latency)}<small> мс</small></strong> <strong>{formatNumber(latency)}<small> мс</small></strong>
</div> </div>
</div> </div>
@ -333,7 +320,7 @@ function SpatialWorkspace({
<Icon name="alert" /> <Icon name="alert" />
<span> <span>
Локальный поток <strong>{sourceModeLabel(state.source_mode).toLocaleLowerCase("ru-RU")}</strong> активен, Локальный поток <strong>{sourceModeLabel(state.source_mode).toLocaleLowerCase("ru-RU")}</strong> активен,
но Rerun-адаптер пока не назначен. Rerun-мост запускается и опубликует адрес автоматически.
</span> </span>
</div> </div>
) : null} ) : null}
@ -366,7 +353,7 @@ function SpatialWorkspace({
<div className="spatial-contract-strip"> <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="ready" />Траектория</span>
<span><i data-state="contract" />Преобразования</span> <span><i data-state="ready" />Преобразования</span>
<span><i data-state="contract" />Камеры в 3D</span> <span><i data-state="contract" />Камеры в 3D</span>
<span><i data-state="contract" />Объекты / маски</span> <span><i data-state="contract" />Объекты / маски</span>
<span><i data-state="contract" />Компоновка</span> <span><i data-state="contract" />Компоновка</span>
@ -532,7 +519,7 @@ function MissionWorkspace({ definition }: WorkspaceRendererProps) {
<span><i />Связь</span> <span><i />Связь</span>
<span><i />Безопасность</span> <span><i />Безопасность</span>
</div> </div>
<Button variant="accent" width="full" disabled>Сохранить миссию</Button> <Button variant="primary" width="full" disabled>Сохранить миссию</Button>
</GlassSurface> </GlassSurface>
</div> </div>
<FeatureInventory definition={definition} /> <FeatureInventory definition={definition} />

View File

@ -1,9 +1,11 @@
# K1 live console and Foxglove bridge # K1 live console and embedded Rerun bridge
Status: alpha implementation for the verified firmware-3 MQTT streams. It uses Status: the Rerun alpha milestone is implemented for the verified firmware-3
real K1 data and does not generate a placeholder cloud, pose or latency value. 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.
## Architecture ## Active device-to-scene path
```text ```text
K1 lio_pcl / lio_pose K1 lio_pcl / lio_pose
@ -11,139 +13,263 @@ K1 lio_pcl / lio_pose
v v
read-only MQTT subscription on TCP 1883 read-only MQTT subscription on TCP 1883
| |
+--> raw .k1mqtt + JSONL + SHA-256 summary (first) +--> raw .k1mqtt + JSONL + SHA-256 summary (written first)
| |
v v
bounded latest-wins preview queue (32 messages) bounded latest-wins preview queue (32 messages)
| |
v v
raw-LZ4/protobuf decoder --> foxglove.PointCloud / Pose / SceneUpdate reviewed raw-LZ4/protobuf decoders
| |
v v
ws://127.0.0.1:8765 --> Foxglove 3D Rerun Points3D + Transform3D + LineStrips3D
|
v
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
React console <-- REST + WebSocket state --> FastAPI on 127.0.0.1:8000 React console <-- REST + WebSocket state --> FastAPI on 127.0.0.1:8000
``` ```
The Paho MQTT callback never decodes the point cloud. It writes and flushes the The Paho MQTT callback does not decode or render the point cloud. It first
raw frame and metadata, then enqueues a preview reference. If visualization cannot writes and flushes the raw frame and metadata, then enqueues a preview message.
keep up, the oldest queued preview is discarded while the raw capture continues. 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.
The current live/replay runtime instantiates only `RerunBridge`. The former
Foxglove implementation is not a parallel runtime and does not listen on TCP
8765; it is retained only as a legacy regression module and test oracle.
## Start the local application ## Start the local application
Prerequisites are the repository-local Python environment and Node.js 20 or Prerequisites are the repository-local Python environment and Node.js 20.19+ or
newer. No global Python package or system component is installed. 22.12+. No global Python package or system component is installed.
```bash ```bash
uv sync --group dev uv sync --group dev
cd apps/k1-viewer cd apps/k1-viewer
npm install npm install
npm run typecheck
npm run build npm run build
cd ../.. cd ../..
uv run k1link serve uv run k1link serve
``` ```
Open `http://127.0.0.1:8000`. `k1link serve` intentionally exposes no LAN bind Open `http://127.0.0.1:8000`. The static Control Station, REST API, WebSocket
option because its provisioning endpoint temporarily receives a Wi-Fi password. state channel and credential endpoint bind to loopback. The Rerun gRPC server is
The password is accepted only in the POST body, is never logged or persisted by a separate listener with a different network boundary described under
the connector, and is cleared from the React form after success. [Network and security boundary](#network-and-security-boundary).
## Replay the captured proof ## Replay a reviewed capture
1. In **Session**, select **Replay capture**. 1. In **Парк → Локальное устройство**, choose the replay mode.
2. Enter the repository-relative path: 2. Enter a native `mqtt.raw.k1mqtt` capture or a reviewed four-column TSV,
for example:
```text ```text
sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv
``` ```
3. Use speed `1` and enable loop for initial viewer setup. 3. Select replay speed and optional looping, then start the session.
4. Start replay, then select **Open Foxglove 3D**. 4. The runtime starts the process-wide Rerun `RecordingStream` and its
5. In a Foxglove 3D panel, enable `/k1/points`, `/k1/pose` and gRPC/proxy server on TCP 9876 before the first replay. Later sessions reuse it.
`/k1/trajectory`. 5. Open **Наблюдение → Пространственная сцена**. With the manual source field
6. For the cloud, choose `intensity` as the color field and a Turbo/Rainbow empty, the embedded viewer automatically uses:
colormap or custom two-color gradient. Foxglove can also color by `x`, `y`,
`z` and its derived `<distance>`.
The reviewed TSV reader accepts exactly four columns: receive timestamp, topic, ```text
declared payload length and hex payload. It verifies timestamp, UTF-8 topic, rerun+http://127.0.0.1:9876/proxy
declared length, hex encoding and allocation bounds. Native ```
`mqtt.raw.k1mqtt` captures are supported directly and use their sibling metadata
timestamps when present. 6. Use **Отображение** and **Слои** to adjust the real cloud and trajectory.
7. Stop the session when finished. Acquisition and session metrics stop, while
the embedded viewer and `rerun_grpc_url` remain ready. A later session resets
scene-local state and reuses the same listener without reloading the page.
The TSV reader accepts exactly receive timestamp, topic, declared payload length
and hexadecimal payload. It verifies timestamp, UTF-8 topic, declared length,
hex encoding and allocation bounds. Native `.k1mqtt` captures are supported
directly and use their sibling metadata receive timestamps when present.
## Connect and stream live ## Connect and stream live
1. Power K1 to its normal steady-green standby state. 1. Power K1 to its normal steady-green standby state.
2. Confirm the manual power checklist in **Connect**. 2. Confirm the manual power checklist in **Парк → Локальное устройство**.
3. Run the real six-second BLE scan and select the K1 candidate. 3. Run the real six-second BLE scan and select the intended device from the
4. Enter the existing router SSID/password and press **Provision Wi-Fi & complete visible-device list.
connect**. That button is the explicit authorization for one reviewed 99-byte 4. Enter the existing router SSID/password and explicitly authorize the reviewed
provisioning write; the backend never retries automatically. provisioning write. The backend does not retry the write automatically.
5. When K1 reports a non-AP private address, press **Start live stream**. 5. When K1 reports a non-AP private address, start live reception.
6. Open Foxglove before scanning if convenient. 6. Wait until the UI reports that the local Rerun bridge is ready. No manual
7. Double-click the physical K1 button to start scanning. Double-click again to viewer URL is needed.
stop, wait for the LED to return to steady green, then stop the local session. 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.
Each live run creates an ignored `sessions/<UTC>_viewer_live/` directory with a 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 redacted manifest, operator notes, raw MQTT frames, per-message metadata and a
hash summary. No application request topic is published. hash summary. The connector subscribes to the fixed report-topic allowlist and
does not publish an application request or modeling command.
## Published topics ## Automatic Rerun source and lifecycle
| Topic | Foxglove schema | Meaning | On the first live or replay session, `RerunBridge`:
- creates an explicit `RecordingStream("nodedc_device_spatial")`;
- installs the default spatial blueprint;
- starts the gRPC/proxy server on TCP 9876 with a 512 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;
- keeps the recording and URL alive until `k1link serve` shuts down.
At every session start it resets the trajectory, current point count, metrics,
blueprint and session-local visible time, then feeds the new source through the
same recording. This process-wide lifecycle is intentional: the Rerun 0.34.1
browser receiver can remain connected after canvas teardown, so restarting the
native listener on the same port is not a reliable session boundary.
The frontend prefers an operator-entered source when one is present; otherwise
it automatically uses `rerun_grpc_url` from backend state. A manual field can
still open another compatible `rerun+http://.../proxy` source or an RRD file
served over HTTP(S). No externally hosted viewer UI is involved:
`@rerun-io/web-viewer` and its WebAssembly runtime are bundled with the local
application. FastAPI carries control state only; the browser reads the point
stream directly from the Rerun gRPC/proxy endpoint.
## Rerun entities
| Entity | Rerun archetype | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `/k1/points` | `foxglove.PointCloud` | metric XYZ float32 + uint8 intensity, stride 16 | | `/world` | `ViewCoordinates` | right-handed Z-up display convention |
| `/k1/pose` | `foxglove.PoseInFrame` | current decoded K1 pose in the `map` frame | | `/world/points` | `Points3D` | decoded metric XYZ with computed or available RGB colors |
| `/k1/trajectory` | `foxglove.SceneUpdate` | bounded cyan line strip, throttled while growing | | `/world/sensor_pose` | `Transform3D` + `TransformAxes3D` | current decoded translation, xyzw quaternion and pose axes |
| `/k1/metrics` | JSON schema | latency, decode/publish time, FPS, points and drops | | `/world/trajectory` | `LineStrips3D` | bounded path of up to 20,000 decoded poses |
Point coordinates remain `(x/scaler, y/scaler, z/scaler)` exactly as verified. Point count, frame rate, queue drops and measured pipeline time remain product
No axis swap, quaternion normalization or vehicle extrinsic is silently applied. metrics in the outer Control Station; they are not logged as Rerun entities and
Only the low byte of `rgbi` is labeled intensity; interpreting the upper bytes therefore cannot create additional automatic viewer panes.
as RGB remains unverified.
## Latency semantics Point coordinates remain `(x/scaler, y/scaler, z/scaler)` exactly as decoded.
No axis swap, quaternion normalization, scanner-to-vehicle extrinsic or SLAM
post-processing is silently applied. The point entity is in `/world` rather
than under the pose transform, so an already map-frame cloud is not transformed
twice. The physical K1 axes and a scanner-to-vehicle transform still require
calibration before vehicle mounting.
`pipeline_ms` / `mqtt_to_publish_ms` is measured with the host monotonic clock Only the low byte of firmware-3 `rgbi` has been verified as intensity. The
from MQTT callback receipt through raw disk write, bounded queue wait, decode, upper bytes are not labeled RGB. Legacy frames use RGB only when those decoded
packing and Foxglove channel publish. Rolling p50/p95 values use the last 512 fields are actually present.
published decoded messages.
This is the exact Mac pipeline latency, not yet sensor-photon-to-screen latency. ## Scene controls
The K1 header timestamp epoch has not been proven, Foxglove rendering time is
outside the Python publisher, and display latency is not observable without a The current product controls call `POST /api/viewer/settings`; backend state is
clock-correlated device timestamp or high-speed-camera experiment. Foxglove authoritative and the bridge applies the updated settings to subsequent
message time therefore uses host receive Unix time while durations use messages.
`monotonic_ns`.
| Control | Implemented behavior |
| --- | --- |
| Point size | Rerun UI-point radius from 0.5 to 12.0; default 2.5 |
| Color attribute | intensity, height Z, distance from the point-coordinate origin, available RGB, or solid class/custom color |
| Palette | Turbo, Viridis, Plasma, grayscale or operator-selected custom color |
| Accumulation | sliding session-local `stream_time` window from 0 to 120 seconds; default 12 seconds |
| Point layer | shows or clears `/world/points` |
| Trajectory | shows or clears the decoded path; publication is throttled while it grows |
| Grid | updates visibility of the Rerun 3D line grid through the active blueprint |
The default 12-second accumulation is a viewer time range over real point
frames; it does not duplicate or alter the raw capture. An accumulation of zero
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.
## 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.
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,
decode, Rerun packing and the SDK log call. Replay and idle states expose no
latency value. Browser fetch, WebAssembly ingestion, GPU rendering and display
scanout are outside this measurement.
The displayed value is therefore Mac pipeline latency, not sensor-photon-to-
screen latency. End-to-end display latency requires a proven device clock or a
separate clock-correlated/high-speed-camera experiment.
## Network and security boundary
There are two different listeners:
- FastAPI, the WebSocket control plane and Wi-Fi credential endpoint bind only
to `127.0.0.1:8000`;
- Rerun gRPC/proxy on TCP 9876 currently binds **all network interfaces**, even
though the URL returned to the local browser contains `127.0.0.1`.
The Rerun endpoint has no connector-level authentication and no TLS. It may
expose point clouds, trajectory and mapped interiors to any host that can reach
the port. Use it only on a trusted laboratory LAN, block TCP 9876 at the router
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
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.
## Current boundaries ## Current boundaries
- Raw panoramic camera frames were not present on the observed MQTT report - Raw panoramic camera frames were absent from the observed MQTT report topics.
topics; this milestone intentionally ships point cloud plus trajectory only. This milestone contains point cloud and pose/trajectory; operational metrics
- Physical double-click remains the start/stop control. A future MQTT modeling 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. publisher needs a separately reviewed state-changing profile.
- The React application opens the official Foxglove viewer over a local - No terrain map, elevation model, obstacle segmentation, localization fusion,
WebSocket. It does not embed or fork the proprietary modern viewer; Foxglove mission planner or vehicle control is implemented by this viewer milestone.
account/seat terms apply to that viewer independently of this connector. - Exact coordinate axes and the scanner-to-vehicle transform remain a mounting
- Exact path axes and a scanner-to-vehicle transform must be calibrated before calibration task.
mounting on the unmanned platform. - A single local runtime owns TCP 9876. A port collision causes a visible bridge
startup error rather than silently selecting another source. The Rerun native
listener lives for the lifetime of `k1link serve` and is reused by later
sessions in that process; shutdown closes it explicitly.
## Verification checkpoint — 2026-07-15 ## Verification checkpoint — 2026-07-16
The full captured 180-second scan was passed through the production Foxglove A powered K1 capture was passed through the active MQTT → decoder → Rerun gRPC →
bridge without a viewer-side substitute: embedded Web Viewer path without a viewer-side substitute:
| Check | Result | | Check | Result |
| --- | ---: | | --- | ---: |
| MQTT messages read | 2,836 | | real MQTT messages processed | 80 |
| `lio_pcl` frames published | 1,140 | | `lio_pcl` frames published | 38 |
| `lio_pose` frames published | 1,215 | | `lio_pose` frames published | 42 |
| points packed and published | 4,165,862 | | points in the last cloud | 2,775 |
| decoder errors | 0 | | decoder errors | 0 |
| final trajectory poses | 1,215 |
The same replay was then started through the FastAPI/React contract at `10x`. The 38 point frames and 42 pose frames account for all 80 observed messages.
The API exposed the loopback Foxglove URL, live point/pose metrics and non-zero This proves current real-device decoding, bridge publication and embedded-viewer
preview-drop accounting under deliberate acceleration, and released TCP 8765 delivery for point cloud plus trajectory. It does not prove a camera channel or
after the stop request. This validates replay, overload behavior and lifecycle; sensor-to-screen latency.
the next checkpoint is the powered K1 ideal-LAN live run.
## Legacy Foxglove regression module
`src/k1link/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.

View File

@ -16,6 +16,7 @@ dependencies = [
"foxglove-sdk==0.25.3", "foxglove-sdk==0.25.3",
"lz4>=4.4,<5", "lz4>=4.4,<5",
"paho-mqtt>=2.1,<3", "paho-mqtt>=2.1,<3",
"rerun-sdk==0.34.1",
"rich>=13.9,<15", "rich>=13.9,<15",
"typer>=0.15,<1", "typer>=0.15,<1",
"uvicorn[standard]>=0.35,<1", "uvicorn[standard]>=0.35,<1",

View File

@ -0,0 +1,386 @@
from __future__ import annotations
import math
import time
from collections import deque
from collections.abc import Callable
from dataclasses import asdict, dataclass
from typing import Literal
import numpy as np
import rerun as rr
from rerun import blueprint as rrb
from k1link.protocol.streams import (
LegacyPointCloudFrame,
LegacyPoseFrame,
LioPointCloudFrame,
LioPoseFrame,
StreamDecodeError,
decode_legacy_pointcloud,
decode_legacy_pose,
decode_lio_pcl,
decode_lio_pose,
)
from k1link.viewer.foxglove_bridge import BridgeMetrics
from k1link.viewer.messages import StreamMessage
PointColorMode = Literal["intensity", "height", "distance", "rgb", "class"]
PointPalette = Literal["turbo", "viridis", "plasma", "grayscale", "custom"]
MAX_TRAJECTORY_POSES = 20_000
DEFAULT_GRPC_PORT = 9876
DEFAULT_CORS_ORIGINS = (
"http://127.0.0.1:5173",
"http://localhost:5173",
"http://127.0.0.1:4173",
"http://localhost:4173",
"http://127.0.0.1:8000",
"http://localhost:8000",
)
@dataclass(frozen=True, slots=True)
class RerunSceneSettings:
point_size: float = 2.5
color_mode: PointColorMode = "intensity"
palette: PointPalette = "turbo"
custom_color: str = "#f7f8f4"
accumulation_seconds: float = 12.0
show_points: bool = True
show_trajectory: bool = True
show_grid: bool = True
def as_dict(self) -> dict[str, object]:
return asdict(self)
SettingsProvider = Callable[[], RerunSceneSettings]
class RerunBridge:
"""Decode verified device topics into a self-hosted Rerun recording stream."""
def __init__(
self,
*,
grpc_port: int = DEFAULT_GRPC_PORT,
metrics: BridgeMetrics | None = None,
settings_provider: SettingsProvider | None = None,
cors_allow_origin: tuple[str, ...] = DEFAULT_CORS_ORIGINS,
recording_factory: Callable[[str], rr.RecordingStream] | None = None,
) -> None:
self.metrics = metrics or BridgeMetrics()
self._settings_provider = settings_provider or RerunSceneSettings
self._settings = self._settings_provider()
self._recording = (recording_factory or rr.RecordingStream)(
"nodedc_device_spatial"
)
blueprint = _blueprint(self._settings)
self._url = self._recording.serve_grpc(
grpc_port=grpc_port,
default_blueprint=blueprint,
server_memory_limit="512MiB",
newest_first=True,
cors_allow_origin=list(cors_allow_origin),
)
self._recording.send_blueprint(
blueprint,
make_active=True,
make_default=True,
)
self._recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
self._recording.log(
"/world/sensor_pose",
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
static=True,
)
self._path: deque[tuple[float, float, float]] = deque(
maxlen=MAX_TRAJECTORY_POSES
)
self._last_trajectory_publish_ns = 0
self._last_point_count = 0
self._closed = False
@property
def grpc_url(self) -> str:
return self._url
def begin_session(self, metrics: BridgeMetrics | None = None) -> None:
"""Reset session-local state while keeping the process-wide server alive."""
if self._closed:
raise RuntimeError("Rerun bridge is already closed")
if metrics is not None:
self.metrics = metrics
self._settings = self._settings_provider()
self._path.clear()
self._last_trajectory_publish_ns = 0
self._last_point_count = 0
self._recording.set_time("stream_time", timestamp=time.time())
self._recording.log("/world", rr.Clear(recursive=True))
self._recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
self._recording.log(
"/world/sensor_pose",
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
static=True,
)
self._recording.send_blueprint(
_blueprint(self._settings),
make_active=True,
make_default=True,
)
def process(self, message: StreamMessage) -> None:
started_ns = time.monotonic_ns()
self.metrics.received(len(message.payload))
self._apply_latest_settings()
self._set_message_time(message)
try:
if message.topic.endswith("/lio_pcl"):
self._publish_lio_pcl(decode_lio_pcl(message.payload))
point_frame = True
elif message.topic == "RealtimePointcloud":
self._publish_legacy_pcl(decode_legacy_pointcloud(message.payload))
point_frame = True
elif message.topic.endswith("/lio_pose"):
self._publish_lio_pose(decode_lio_pose(message.payload))
point_frame = False
elif message.topic == "RealtimePath":
self._publish_legacy_pose(decode_legacy_pose(message.payload))
point_frame = False
else:
return
except StreamDecodeError:
self.metrics.decode_error()
return
published_ns = time.monotonic_ns()
decode_publish_ms = (published_ns - started_ns) / 1_000_000
if point_frame:
self.metrics.published_pcl(
self._last_point_count,
published_ns,
decode_publish_ms,
)
else:
self.metrics.published_pose(
published_ns,
decode_publish_ms,
len(self._path),
)
if message.source == "live_mqtt" and message.received_monotonic_ns is not None:
self.metrics.record_latency(
(published_ns - message.received_monotonic_ns) / 1_000_000
)
def close(self) -> None:
if self._closed:
return
self._closed = True
recording = self._recording
try:
recording.flush(timeout_sec=5.0)
finally:
recording.disconnect()
# The Python wrapper owns the native gRPC server. Release it immediately
# instead of waiting for the publisher thread frame to be collected.
del self._recording
def _set_message_time(self, message: StreamMessage) -> None:
self._recording.set_time("stream_time", timestamp=time.time())
self._recording.set_time(
"capture_time",
timestamp=message.received_at_epoch_ns / 1_000_000_000,
)
self._recording.set_time("message_sequence", sequence=message.sequence)
def _apply_latest_settings(self) -> None:
settings = self._settings_provider()
if settings == self._settings:
return
self._settings = settings
self._recording.send_blueprint(_blueprint(settings))
def _publish_lio_pcl(self, frame: LioPointCloudFrame) -> None:
count = len(frame.points)
positions = np.empty((count, 3), dtype=np.float32)
intensities = np.empty(count, dtype=np.uint8)
scaler = frame.header.scaler
for index, point in enumerate(frame.points):
positions[index] = point.scaled_xyz(scaler)
intensities[index] = point.intensity
self._publish_points(positions, intensities, rgb=None)
def _publish_legacy_pcl(self, frame: LegacyPointCloudFrame) -> None:
count = len(frame.points)
positions = np.empty((count, 3), dtype=np.float32)
intensities = np.empty(count, dtype=np.uint8)
rgb = np.empty((count, 3), dtype=np.uint8)
for index, point in enumerate(frame.points):
positions[index] = (point.x, point.y, point.z)
intensities[index] = point.intensity
rgb[index] = (point.r, point.g, point.b)
self._publish_points(positions, intensities, rgb=rgb)
def _publish_points(
self,
positions: np.ndarray,
intensities: np.ndarray,
*,
rgb: np.ndarray | None,
) -> None:
self._last_point_count = int(positions.shape[0])
if not self._settings.show_points:
self._recording.log("/world/points", rr.Clear(recursive=False))
return
colors = _point_colors(positions, intensities, rgb, self._settings)
self._recording.log(
"/world/points",
rr.Points3D(
positions,
colors=colors,
radii=rr.Radius.ui_points(self._settings.point_size),
),
)
def _publish_lio_pose(self, frame: LioPoseFrame) -> None:
self._publish_pose(frame.position_xyz, frame.orientation_xyzw)
def _publish_legacy_pose(self, frame: LegacyPoseFrame) -> None:
self._publish_pose(frame.position_xyz, frame.orientation_xyzw)
def _publish_pose(
self,
position_xyz: tuple[float, float, float],
orientation_xyzw: tuple[float, float, float, float],
) -> None:
self._recording.log(
"/world/sensor_pose",
rr.Transform3D(
translation=position_xyz,
quaternion=rr.Quaternion(xyzw=orientation_xyzw),
),
)
self._path.append(position_xyz)
if not self._settings.show_trajectory:
self._recording.log("/world/trajectory", rr.Clear(recursive=False))
return
now_ns = time.monotonic_ns()
if (
len(self._path) > 2
and len(self._path) % 20 != 0
and now_ns - self._last_trajectory_publish_ns < 200_000_000
):
return
self._last_trajectory_publish_ns = now_ns
self._recording.log(
"/world/trajectory",
rr.LineStrips3D(
[list(self._path)],
colors=[247, 248, 244, 255],
radii=rr.Radius.ui_points(2.0),
),
)
def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint:
accumulation = max(0.0, settings.accumulation_seconds)
time_range = rr.VisibleTimeRange(
"stream_time",
start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
end=rr.TimeRangeBoundary.cursor_relative(),
)
return rrb.Blueprint(
rrb.Spatial3DView(
origin="/world",
name="Пространственная сцена",
background=[7, 8, 10, 255],
line_grid=rrb.LineGrid3D(
visible=settings.show_grid,
color=[86, 91, 99, 110],
stroke_width=0.75,
),
time_ranges=[time_range],
),
auto_layout=False,
auto_views=False,
collapse_panels=True,
)
def _point_colors(
positions: np.ndarray,
intensities: np.ndarray,
rgb: np.ndarray | None,
settings: RerunSceneSettings,
) -> np.ndarray:
if settings.color_mode == "rgb" and rgb is not None:
return rgb
if settings.color_mode == "class":
color = _parse_hex_color(settings.custom_color)
return np.tile(np.asarray(color, dtype=np.uint8), (positions.shape[0], 1))
if settings.color_mode == "height":
values = _normalize(positions[:, 2])
elif settings.color_mode == "distance":
values = _normalize(np.linalg.norm(positions, axis=1))
else:
values = intensities.astype(np.float32) / 255.0
return _apply_palette(values, settings.palette, settings.custom_color)
def _normalize(values: np.ndarray) -> np.ndarray:
if values.size == 0:
return values.astype(np.float32)
minimum = float(np.min(values))
maximum = float(np.max(values))
if not math.isfinite(minimum) or not math.isfinite(maximum) or maximum <= minimum:
return np.full(values.shape, 0.5, dtype=np.float32)
return ((values - minimum) / (maximum - minimum)).astype(np.float32)
def _apply_palette(values: np.ndarray, palette: PointPalette, custom: str) -> np.ndarray:
clipped = np.clip(values, 0.0, 1.0)
if palette == "custom":
color = _parse_hex_color(custom)
return np.tile(np.asarray(color, dtype=np.uint8), (values.size, 1))
if palette == "grayscale":
channel = np.rint(clipped * 255).astype(np.uint8)
return np.column_stack((channel, channel, channel))
stops = {
"turbo": (
(0.00, (48, 18, 59)),
(0.25, (40, 126, 231)),
(0.50, (42, 240, 154)),
(0.75, (246, 214, 55)),
(1.00, (180, 4, 38)),
),
"viridis": (
(0.00, (68, 1, 84)),
(0.25, (59, 82, 139)),
(0.50, (33, 145, 140)),
(0.75, (94, 201, 98)),
(1.00, (253, 231, 37)),
),
"plasma": (
(0.00, (13, 8, 135)),
(0.25, (126, 3, 168)),
(0.50, (204, 71, 120)),
(0.75, (248, 149, 64)),
(1.00, (240, 249, 33)),
),
}[palette]
positions = np.asarray([item[0] for item in stops], dtype=np.float32)
colors = np.asarray([item[1] for item in stops], dtype=np.float32)
channels = [np.interp(clipped, positions, colors[:, index]) for index in range(3)]
return np.rint(np.column_stack(channels)).astype(np.uint8)
def _parse_hex_color(value: str) -> tuple[int, int, int]:
candidate = value.removeprefix("#")
if len(candidate) != 6:
return 247, 248, 244
try:
return tuple(int(candidate[index : index + 2], 16) for index in (0, 2, 4)) # type: ignore[return-value]
except ValueError:
return 247, 248, 244

View File

@ -11,9 +11,10 @@ from typing import Literal, TypedDict
from k1link.artifacts import utc_now_iso, write_json_atomic from k1link.artifacts import utc_now_iso, write_json_atomic
from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
from k1link.viewer.foxglove_bridge import BridgeMetrics, FoxgloveBridge, MetricsSnapshot from k1link.viewer.foxglove_bridge import BridgeMetrics, MetricsSnapshot
from k1link.viewer.messages import StreamMessage from k1link.viewer.messages import StreamMessage
from k1link.viewer.replay import iter_replay_messages from k1link.viewer.replay import iter_replay_messages
from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings
RuntimePhase = Literal[ RuntimePhase = Literal[
"idle", "idle",
@ -25,6 +26,7 @@ RuntimePhase = Literal[
] ]
SourceMode = Literal["idle", "live", "replay"] SourceMode = Literal["idle", "live", "replay"]
StateCallback = Callable[[], None] StateCallback = Callable[[], None]
BridgeFactory = Callable[..., RerunBridge]
class RuntimeSnapshot(TypedDict): class RuntimeSnapshot(TypedDict):
@ -33,13 +35,21 @@ class RuntimeSnapshot(TypedDict):
source_mode: SourceMode source_mode: SourceMode
foxglove_ws_url: str | None foxglove_ws_url: str | None
foxglove_viewer_url: str | None foxglove_viewer_url: str | None
rerun_grpc_url: str | None
viewer_settings: dict[str, object]
metrics: MetricsSnapshot metrics: MetricsSnapshot
class VisualizationRuntime: class VisualizationRuntime:
"""Own one bounded live/replay source and one deterministic publisher thread.""" """Own one bounded live/replay source and one deterministic publisher thread."""
def __init__(self, *, on_state_change: StateCallback | None = None) -> None: def __init__(
self,
*,
on_state_change: StateCallback | None = None,
grpc_port: int = DEFAULT_GRPC_PORT,
bridge_factory: BridgeFactory | None = None,
) -> None:
self._lock = threading.Lock() self._lock = threading.Lock()
self._on_state_change = on_state_change self._on_state_change = on_state_change
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
@ -49,6 +59,12 @@ class VisualizationRuntime:
self._source_mode: SourceMode = "idle" self._source_mode: SourceMode = "idle"
self._foxglove_ws_url: str | None = None self._foxglove_ws_url: str | None = None
self._foxglove_viewer_url: str | None = None self._foxglove_viewer_url: str | None = None
self._rerun_grpc_url: str | None = None
self._grpc_port = grpc_port
self._bridge_factory = bridge_factory or RerunBridge
self._bridge: RerunBridge | None = None
self._closed = False
self._scene_settings = RerunSceneSettings()
self._metrics = BridgeMetrics() self._metrics = BridgeMetrics()
def snapshot(self) -> RuntimeSnapshot: def snapshot(self) -> RuntimeSnapshot:
@ -59,9 +75,17 @@ class VisualizationRuntime:
"source_mode": self._source_mode, "source_mode": self._source_mode,
"foxglove_ws_url": self._foxglove_ws_url, "foxglove_ws_url": self._foxglove_ws_url,
"foxglove_viewer_url": self._foxglove_viewer_url, "foxglove_viewer_url": self._foxglove_viewer_url,
"rerun_grpc_url": self._rerun_grpc_url,
"viewer_settings": self._scene_settings.as_dict(),
"metrics": self._metrics.snapshot(), "metrics": self._metrics.snapshot(),
} }
def update_scene_settings(self, settings: RerunSceneSettings) -> RuntimeSnapshot:
with self._lock:
self._scene_settings = settings
self._notify()
return self.snapshot()
def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None: def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
resolved = path.expanduser().resolve() resolved = path.expanduser().resolve()
if not resolved.is_file(): if not resolved.is_file():
@ -123,6 +147,34 @@ class VisualizationRuntime:
assert thread is not None assert thread is not None
thread.join(timeout=wait_seconds) thread.join(timeout=wait_seconds)
def close(self, *, wait_seconds: float = 5.0) -> None:
"""Stop the active source and release the process-wide visual bridge."""
with self._lock:
self._closed = True
thread = self._thread
if thread is not None and thread.is_alive():
self._phase = "stopping"
self._message = "Завершаем локальный поток и визуальный мост."
self._stop_event.set()
else:
self._phase = "idle"
self._source_mode = "idle"
self._message = "Локальный поток завершён."
self._notify()
if thread is not None and thread.is_alive():
thread.join(timeout=wait_seconds)
with self._lock:
thread_alive = self._thread is not None and self._thread.is_alive()
bridge = None if thread_alive else self._bridge
if not thread_alive:
self._bridge = None
self._rerun_grpc_url = None
if bridge is not None:
bridge.close()
self._notify()
def _start( def _start(
self, self,
*, *,
@ -132,6 +184,8 @@ class VisualizationRuntime:
target: Callable[[], None], target: Callable[[], None],
) -> None: ) -> None:
with self._lock: with self._lock:
if self._closed:
raise RuntimeError("runtime завершён; перезапустите локальный сервер")
if self._thread is not None and self._thread.is_alive(): if self._thread is not None and self._thread.is_alive():
raise RuntimeError("поток уже запущен; сначала остановите текущую сессию") raise RuntimeError("поток уже запущен; сначала остановите текущую сессию")
self._stop_event = threading.Event() self._stop_event = threading.Event()
@ -221,6 +275,7 @@ class VisualizationRuntime:
messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=32) messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=32)
source_done = threading.Event() source_done = threading.Event()
publisher_ready = threading.Event() publisher_ready = threading.Event()
publisher_aborted = threading.Event()
publisher_error: list[BaseException] = [] publisher_error: list[BaseException] = []
def enqueue(message: StreamMessage) -> None: def enqueue(message: StreamMessage) -> None:
@ -241,19 +296,41 @@ class VisualizationRuntime:
self._metrics.preview_dropped() self._metrics.preview_dropped()
def publish() -> None: def publish() -> None:
bridge: FoxgloveBridge | None = None bridge: RerunBridge | None = None
try: try:
bridge = FoxgloveBridge(metrics=self._metrics) bridge = self._bridge
if bridge is None:
candidate = self._bridge_factory(
grpc_port=self._grpc_port,
metrics=self._metrics,
settings_provider=self._current_scene_settings,
)
with self._lock:
if self._closed:
publisher_aborted.set()
else:
self._bridge = candidate
bridge = candidate
if publisher_aborted.is_set():
candidate.close()
publisher_ready.set()
return
assert bridge is not None
bridge.begin_session(self._metrics)
with self._lock: with self._lock:
self._foxglove_ws_url = bridge.websocket_url if self._closed:
self._foxglove_viewer_url = bridge.viewer_url publisher_aborted.set()
if self._phase != "stopping": else:
self._rerun_grpc_url = bridge.grpc_url
if not self._closed and self._phase != "stopping":
self._phase = running_phase self._phase = running_phase
self._message = ( self._message = (
"Локальный мост визуализации готов; источник данных запущен." "Локальный Rerun-мост готов; источник данных запущен."
) )
publisher_ready.set() publisher_ready.set()
self._notify() self._notify()
if publisher_aborted.is_set():
return
while not source_done.is_set() or not messages.empty(): while not source_done.is_set() or not messages.empty():
if self._stop_event.is_set() and source_done.is_set() and messages.empty(): if self._stop_event.is_set() and source_done.is_set() and messages.empty():
break break
@ -268,19 +345,43 @@ class VisualizationRuntime:
if self._metrics.snapshot()["messages_received"] % 10 == 0: if self._metrics.snapshot()["messages_received"] % 10 == 0:
self._notify() self._notify()
except BaseException as exc: except BaseException as exc:
publisher_error.append(exc) with self._lock:
closed = self._closed
if not closed:
publisher_error.append(exc)
else:
publisher_aborted.set()
publisher_ready.set() publisher_ready.set()
self._stop_event.set() self._stop_event.set()
finally: finally:
if bridge is not None: if bridge is not None:
bridge.close() with self._lock:
close_bridge = self._closed and self._bridge is bridge
if close_bridge:
self._bridge = None
self._rerun_grpc_url = None
if close_bridge:
bridge.close()
publisher = threading.Thread(target=publish, name="k1-foxglove-publisher", daemon=True) def join_publisher() -> None:
# Never orphan a publisher: the session thread remains its owner.
# On process shutdown both are daemon threads, so an irrecoverably
# blocked native call cannot prevent the operating system from exit.
while publisher.is_alive():
publisher.join(timeout=0.25)
publisher = threading.Thread(target=publish, name="k1-rerun-publisher", daemon=True)
publisher.start() publisher.start()
if not publisher_ready.wait(timeout=15.0): if not publisher_ready.wait(timeout=15.0):
self._finish_error("Локальный мост визуализации не запустился за 15 секунд.") self._finish_error("Локальный мост визуализации не запустился за 15 секунд.")
source_done.set() source_done.set()
self._stop_event.set() self._stop_event.set()
join_publisher()
return
if publisher_aborted.is_set():
source_done.set()
self._stop_event.set()
join_publisher()
return return
if publisher_error: if publisher_error:
self._finish_error( self._finish_error(
@ -288,6 +389,7 @@ class VisualizationRuntime:
f"{type(publisher_error[0]).__name__}: {publisher_error[0]}" f"{type(publisher_error[0]).__name__}: {publisher_error[0]}"
) )
source_done.set() source_done.set()
join_publisher()
return return
final_message = "Поток остановлен." final_message = "Поток остановлен."
@ -299,11 +401,8 @@ class VisualizationRuntime:
self._finish_error(f"Ошибка источника: {type(exc).__name__}: {exc}") self._finish_error(f"Ошибка источника: {type(exc).__name__}: {exc}")
finally: finally:
source_done.set() source_done.set()
publisher.join(timeout=15.0) join_publisher()
if publisher.is_alive(): if publisher_error:
self._stop_event.set()
self._finish_error("Очередь публикации не завершилась за 15 секунд.")
elif publisher_error:
self._finish_error( self._finish_error(
f"Ошибка публикации: {type(publisher_error[0]).__name__}: {publisher_error[0]}" f"Ошибка публикации: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
) )
@ -334,6 +433,10 @@ class VisualizationRuntime:
self._foxglove_viewer_url = None self._foxglove_viewer_url = None
self._notify() self._notify()
def _current_scene_settings(self) -> RerunSceneSettings:
with self._lock:
return self._scene_settings
def _notify(self) -> None: def _notify(self) -> None:
callback = self._on_state_change callback = self._on_state_change
if callback is not None: if callback is not None:
@ -360,7 +463,7 @@ def _write_live_session_preamble(out_dir: Path, host: str, duration_seconds: flo
"schema_version": 1, "schema_version": 1,
"started_at_utc": started_at_utc, "started_at_utc": started_at_utc,
"started_monotonic_ns": time.monotonic_ns(), "started_monotonic_ns": time.monotonic_ns(),
"operation": "k1_live_mqtt_to_foxglove", "operation": "k1_live_mqtt_to_rerun",
"target": "owner-controlled K1 at redacted RFC1918 address", "target": "owner-controlled K1 at redacted RFC1918 address",
"requested_duration_seconds": duration_seconds, "requested_duration_seconds": duration_seconds,
"raw_capture": "captures/mqtt_live/mqtt.raw.k1mqtt", "raw_capture": "captures/mqtt_live/mqtt.raw.k1mqtt",
@ -370,7 +473,7 @@ def _write_live_session_preamble(out_dir: Path, host: str, duration_seconds: flo
notes = ( notes = (
"# K1 live visualization session\n\n" "# K1 live visualization session\n\n"
f"Started UTC: {started_at_utc}\n\n" f"Started UTC: {started_at_utc}\n\n"
"The local control API started a read-only MQTT subscription and Foxglove " "The local control API started a read-only MQTT subscription and Rerun "
"preview. Raw MQTT evidence is written before preview decoding. The target " "preview. Raw MQTT evidence is written before preview decoding. The target "
f"was a validated private IPv4 address ({host.rsplit('.', 1)[0]}.x).\n" f"was a validated private IPv4 address ({host.rsplit('.', 1)[0]}.x).\n"
) )

View File

@ -2,10 +2,11 @@ from __future__ import annotations
import asyncio import asyncio
import threading import threading
from collections.abc import Mapping from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Literal
from bleak.exc import BleakError from bleak.exc import BleakError
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
@ -17,6 +18,7 @@ from k1link.artifacts import write_json_atomic
from k1link.ble.scanner import scan from k1link.ble.scanner import scan
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
from k1link.mqtt import validate_private_ipv4 from k1link.mqtt import validate_private_ipv4
from k1link.viewer.rerun_bridge import RerunSceneSettings
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
REPOSITORY_ROOT = Path(__file__).resolve().parents[3] REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
@ -43,6 +45,17 @@ class ReplayRequest(BaseModel):
loop: bool = False loop: bool = False
class ViewerSettingsRequest(BaseModel):
point_size: float = Field(default=2.5, ge=0.5, le=12.0)
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
accumulation_seconds: float = Field(default=12.0, ge=0.0, le=120.0)
show_points: bool = True
show_trajectory: bool = True
show_grid: bool = True
class ConsoleService: class ConsoleService:
def __init__(self, repository_root: Path) -> None: def __init__(self, repository_root: Path) -> None:
self.repository_root = repository_root.resolve() self.repository_root = repository_root.resolve()
@ -100,6 +113,8 @@ class ConsoleService:
"k1_ip": k1_ip, "k1_ip": k1_ip,
"foxglove_ws_url": runtime["foxglove_ws_url"], "foxglove_ws_url": runtime["foxglove_ws_url"],
"foxglove_viewer_url": runtime["foxglove_viewer_url"], "foxglove_viewer_url": runtime["foxglove_viewer_url"],
"rerun_grpc_url": runtime["rerun_grpc_url"],
"viewer_settings": runtime["viewer_settings"],
"source_mode": runtime["source_mode"], "source_mode": runtime["source_mode"],
"metrics": { "metrics": {
"pipeline_ms": metrics["mqtt_to_publish_ms"], "pipeline_ms": metrics["mqtt_to_publish_ms"],
@ -213,6 +228,21 @@ class ConsoleService:
self.runtime.stop() self.runtime.stop()
return self.state() return self.state()
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
self.runtime.update_scene_settings(
RerunSceneSettings(
point_size=request.point_size,
color_mode=request.color_mode,
palette=request.palette,
custom_color=request.custom_color,
accumulation_seconds=request.accumulation_seconds,
show_points=request.show_points,
show_trajectory=request.show_trajectory,
show_grid=request.show_grid,
)
)
return self.state()
def _set_operation(self, phase: str, message: str) -> None: def _set_operation(self, phase: str, message: str) -> None:
with self._lock: with self._lock:
self._operation_phase = phase self._operation_phase = phase
@ -220,12 +250,23 @@ class ConsoleService:
service = ConsoleService(REPOSITORY_ROOT) service = ConsoleService(REPOSITORY_ROOT)
@asynccontextmanager
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
try:
yield
finally:
service.runtime.close()
app = FastAPI( app = FastAPI(
title="NODE.DC Device Control API", title="NODE.DC Device Control API",
version=__version__, version=__version__,
docs_url="/api/docs", docs_url="/api/docs",
redoc_url=None, redoc_url=None,
openapi_url="/api/openapi.json", openapi_url="/api/openapi.json",
lifespan=app_lifespan,
) )
@ -284,6 +325,11 @@ def stop_session() -> dict[str, Any]:
return service.stop() return service.stop()
@app.post("/api/viewer/settings")
def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
return service.update_viewer_settings(request)
@app.websocket("/api/events") @app.websocket("/api/events")
async def events(websocket: WebSocket) -> None: async def events(websocket: WebSocket) -> None:
await websocket.accept() await websocket.accept()

281
tests/test_rerun_bridge.py Normal file
View File

@ -0,0 +1,281 @@
from __future__ import annotations
import json
import socket
import struct
import threading
import time
from pathlib import Path
import numpy as np
import pytest
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.viewer.messages import StreamMessage
from k1link.viewer.rerun_bridge import RerunBridge, RerunSceneSettings, _point_colors
from k1link.viewer.runtime import VisualizationRuntime
class FakeRecording:
def __init__(self) -> None:
self.logs: list[tuple[str, object, bool]] = []
self.times: list[tuple[str, dict[str, object]]] = []
self.blueprints: list[object] = []
self.disconnected = False
def serve_grpc(self, **_: object) -> str:
return "rerun+http://127.0.0.1:9876/proxy"
def log(self, path: str, entity: object, *, static: bool = False) -> None:
self.logs.append((path, entity, static))
def set_time(self, timeline: str, **value: object) -> None:
self.times.append((timeline, value))
def send_blueprint(self, blueprint: object, **_: object) -> None:
self.blueprints.append(blueprint)
def disconnect(self) -> None:
self.disconnected = True
def flush(self, **_: object) -> None:
return
def _message(topic: str, payload: bytes, *, sequence: int = 7) -> StreamMessage:
return StreamMessage(
sequence=sequence,
topic=topic,
payload=payload,
received_at_epoch_ns=1_784_124_315_186_225_000,
received_monotonic_ns=None,
source="test",
)
def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
recording = FakeRecording()
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
point_payload = struct.pack("<III", 16, 0, 0) + struct.pack(
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
)
pose_payload = struct.pack(
"<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3
)
bridge.process(_message("RealtimePointcloud", point_payload))
bridge.process(_message("RealtimePath", pose_payload, sequence=8))
paths = [path for path, _, _ in recording.logs]
snapshot = bridge.metrics.snapshot()
assert bridge.grpc_url == "rerun+http://127.0.0.1:9876/proxy"
assert "/world/points" in paths
assert "/world/sensor_pose" in paths
assert "/world/trajectory" in paths
assert snapshot["pcl_frames"] == 1
assert snapshot["pose_frames"] == 1
assert snapshot["last_point_count"] == 1
assert {timeline for timeline, _ in recording.times} == {
"capture_time",
"message_sequence",
"stream_time",
}
bridge.close()
assert recording.disconnected is True
def test_bad_frame_is_counted_without_publishing() -> None:
recording = FakeRecording()
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
bridge.process(_message("RealtimePointcloud", b"short"))
snapshot = bridge.metrics.snapshot()
assert snapshot["messages_received"] == 1
assert snapshot["decode_errors"] == 1
assert not any(path == "/world/points" for path, _, _ in recording.logs)
def test_palettes_are_deterministic_and_custom_color_is_exact() -> None:
positions = np.asarray([[0, 0, 0], [0, 0, 10]], dtype=np.float32)
intensities = np.asarray([0, 255], dtype=np.uint8)
height = _point_colors(
positions,
intensities,
None,
RerunSceneSettings(color_mode="height", palette="viridis"),
)
custom = _point_colors(
positions,
intensities,
None,
RerunSceneSettings(color_mode="class", custom_color="#102030"),
)
assert height.shape == (2, 3)
assert height.dtype == np.uint8
assert height[0].tolist() == [68, 1, 84]
assert height[1].tolist() == [253, 231, 37]
assert custom.tolist() == [[16, 32, 48], [16, 32, 48]]
def test_real_grpc_server_releases_its_port() -> None:
probe = socket.socket()
probe.bind(("127.0.0.1", 0))
port = int(probe.getsockname()[1])
probe.close()
bridge = RerunBridge(
grpc_port=port,
cors_allow_origin=("http://127.0.0.1:8000",),
)
assert bridge.grpc_url == f"rerun+http://127.0.0.1:{port}/proxy"
bridge.close()
deadline = time.monotonic() + 2.0
while True:
available = socket.socket()
try:
available.bind(("127.0.0.1", port))
break
except OSError:
if time.monotonic() >= deadline:
raise
time.sleep(0.02)
finally:
available.close()
def test_runtime_exposes_rerun_url_and_stops_cleanly(tmp_path: Path) -> None:
capture = tmp_path / "mqtt.raw.k1mqtt"
point_topic = "RealtimePointcloud"
pose_topic = "RealtimePath"
point_payload = struct.pack("<III", 16, 0, 0) + struct.pack(
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
)
pose_payload = struct.pack(
"<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3
)
frames = bytearray(RAW_MAGIC)
for topic, payload in ((point_topic, point_payload), (pose_topic, pose_payload)):
topic_raw = topic.encode()
frames.extend(FRAME_HEADER.pack(len(topic_raw), len(payload)))
frames.extend(topic_raw)
frames.extend(payload)
capture.write_bytes(frames)
metadata = [
{
"record_type": "message",
"sequence": 1,
"received_at_epoch_ns": 1_000_000_000,
"received_monotonic_ns": 1_000_000_000,
},
{
"record_type": "message",
"sequence": 2,
"received_at_epoch_ns": 11_000_000_000,
"received_monotonic_ns": 11_000_000_000,
},
]
(tmp_path / "mqtt.metadata.jsonl").write_text(
"".join(json.dumps(item) + "\n" for item in metadata),
encoding="utf-8",
)
recording = FakeRecording()
created: list[RerunBridge] = []
def bridge_factory(**kwargs: object) -> RerunBridge:
bridge = RerunBridge(
recording_factory=lambda _: recording, # type: ignore[arg-type]
**kwargs, # type: ignore[arg-type]
)
created.append(bridge)
return bridge
runtime = VisualizationRuntime(
bridge_factory=bridge_factory,
)
runtime.start_replay(capture, speed=1.0)
deadline = time.monotonic() + 5.0
snapshot = runtime.snapshot()
while time.monotonic() < deadline:
snapshot = runtime.snapshot()
if snapshot["rerun_grpc_url"] and snapshot["metrics"]["pcl_frames"] == 1:
break
time.sleep(0.05)
assert snapshot["rerun_grpc_url"] == "rerun+http://127.0.0.1:9876/proxy"
assert snapshot["metrics"]["pcl_frames"] == 1
assert snapshot["metrics"]["mqtt_to_publish_ms"] is None
runtime.stop(wait_seconds=5.0)
assert runtime.snapshot()["phase"] == "idle"
assert runtime.snapshot()["rerun_grpc_url"] == "rerun+http://127.0.0.1:9876/proxy"
assert recording.disconnected is False
runtime.start_replay(capture, speed=0.0)
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if runtime.snapshot()["metrics"]["pcl_frames"] == 1:
break
time.sleep(0.05)
runtime.stop(wait_seconds=5.0)
assert len(created) == 1
assert runtime.snapshot()["metrics"]["pcl_frames"] == 1
runtime.close()
assert runtime.snapshot()["rerun_grpc_url"] is None
assert recording.disconnected is True
def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) -> None:
capture = tmp_path / "mqtt.raw.k1mqtt"
topic = b"RealtimePointcloud"
payload = struct.pack("<III", 16, 0, 0) + struct.pack(
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
)
capture.write_bytes(
RAW_MAGIC + FRAME_HEADER.pack(len(topic), len(payload)) + topic + payload
)
(tmp_path / "mqtt.metadata.jsonl").write_text(
json.dumps(
{
"record_type": "message",
"sequence": 1,
"received_at_epoch_ns": 1_000_000_000,
"received_monotonic_ns": 1_000_000_000,
}
)
+ "\n",
encoding="utf-8",
)
factory_entered = threading.Event()
release_factory = threading.Event()
recording = FakeRecording()
def blocked_factory(**kwargs: object) -> RerunBridge:
factory_entered.set()
assert release_factory.wait(timeout=5.0)
return RerunBridge(
recording_factory=lambda _: recording, # type: ignore[arg-type]
**kwargs, # type: ignore[arg-type]
)
runtime = VisualizationRuntime(bridge_factory=blocked_factory)
runtime.start_replay(capture, speed=0.0)
assert factory_entered.wait(timeout=2.0)
runtime.close(wait_seconds=0.01)
release_factory.set()
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline and not recording.disconnected:
time.sleep(0.01)
runtime.close(wait_seconds=1.0)
assert recording.disconnected is True
assert runtime.snapshot()["rerun_grpc_url"] is None
with pytest.raises(RuntimeError, match="runtime завершён"):
runtime.start_replay(capture, speed=0.0)

View File

@ -46,3 +46,30 @@ def test_ble_scan_exposes_every_device_and_only_labels_likely_k1(
] ]
assert [device["likely_k1"] for device in state["devices"]] == [True, False] assert [device["likely_k1"] for device in state["devices"]] == [True, False]
def test_viewer_settings_are_validated_and_exposed(tmp_path: Path) -> None:
service = web_app.ConsoleService(tmp_path)
request = web_app.ViewerSettingsRequest(
point_size=4.0,
color_mode="height",
palette="viridis",
custom_color="#102030",
accumulation_seconds=18,
show_points=True,
show_trajectory=False,
show_grid=False,
)
state = service.update_viewer_settings(request)
assert state["rerun_grpc_url"] is None
assert state["viewer_settings"] == {
"point_size": 4.0,
"color_mode": "height",
"palette": "viridis",
"custom_color": "#102030",
"accumulation_seconds": 18.0,
"show_points": True,
"show_trajectory": False,
"show_grid": False,
}

97
uv.lock
View File

@ -33,6 +33,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
] ]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]] [[package]]
name = "bleak" name = "bleak"
version = "3.0.2" version = "3.0.2"
@ -269,6 +278,7 @@ dependencies = [
{ name = "foxglove-sdk" }, { name = "foxglove-sdk" },
{ name = "lz4" }, { name = "lz4" },
{ name = "paho-mqtt" }, { name = "paho-mqtt" },
{ name = "rerun-sdk" },
{ name = "rich" }, { name = "rich" },
{ name = "typer" }, { name = "typer" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
@ -288,6 +298,7 @@ requires-dist = [
{ name = "foxglove-sdk", specifier = "==0.25.3" }, { name = "foxglove-sdk", specifier = "==0.25.3" },
{ name = "lz4", specifier = ">=4.4,<5" }, { name = "lz4", specifier = ">=4.4,<5" },
{ name = "paho-mqtt", specifier = ">=2.1,<3" }, { name = "paho-mqtt", specifier = ">=2.1,<3" },
{ name = "rerun-sdk", specifier = "==0.34.1" },
{ name = "rich", specifier = ">=13.9,<15" }, { name = "rich", specifier = ">=13.9,<15" },
{ name = "typer", specifier = ">=0.15,<1" }, { name = "typer", specifier = ">=0.15,<1" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" },
@ -300,6 +311,25 @@ dev = [
{ name = "ruff", specifier = ">=0.11,<1" }, { name = "ruff", specifier = ">=0.11,<1" },
] ]
[[package]]
name = "numpy"
version = "2.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" },
{ url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" },
{ url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" },
{ url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" },
{ url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" },
{ url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" },
{ url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" },
{ url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" },
{ url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.2" version = "26.2"
@ -327,6 +357,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
] ]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@ -336,6 +383,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "psutil"
version = "7.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
[[package]]
name = "pyarrow"
version = "25.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/27/f3/95428098d1fa7d04432fb750eed06b41304c2f6a5d3319985e64db2d9d41/pyarrow-25.0.0.tar.gz", hash = "sha256:d2d697008b5ec06d75952ef260c2e9a8a0f6ccfce24266c04c9c8ade927cb3b4", size = 1199181, upload-time = "2026-07-10T08:29:50.116Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/44/fdd3a4377807b7dcabe2d4b5aa99dbbc98e2e5df3f1ca4e7f0aec492d987/pyarrow-25.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:149730a3d1f0fb59d663a0b8aa210adfd9c17c27cd94a0d143e60daea8320d4e", size = 35850884, upload-time = "2026-07-10T08:26:47.357Z" },
{ url = "https://files.pythonhosted.org/packages/bf/71/9f053177a7709b8c90abb00a2375b916286f9f0d6cfb21a5cadd4ef811e8/pyarrow-25.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:0721332c30fdd453fdd1fc203b2ac1f4c9db5aea28fa38d41f2574c4b068b9ec", size = 37616197, upload-time = "2026-07-10T08:26:53.564Z" },
{ url = "https://files.pythonhosted.org/packages/95/1a/22bfb6597dcdc861fa83c39c06e1457cb56f698940eff42fbb25de30e8e5/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fa1482b3da10cac2d4db6e26b81da543e237616af2ef6d466018b31ca586496f", size = 46841966, upload-time = "2026-07-10T08:27:07.685Z" },
{ url = "https://files.pythonhosted.org/packages/55/0e/cd705c042bc4fe7022478db577fcab4abdcfabb9bc37ab7a75556b3fcb2b/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5d1dbf24e151042f2fa3c129563f65d66674128868496fb008c4272b16bdf778", size = 50088993, upload-time = "2026-07-10T08:27:14.268Z" },
{ url = "https://files.pythonhosted.org/packages/98/ee/d822e1ee31fe31ec5d057210e0605c950b975dcd8d9a332976cc859a9df8/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:20887a762dd61dcc530f93a140840ab1f6aa7836b33270e42d627ab3cf11e537", size = 49941005, upload-time = "2026-07-10T08:27:21.274Z" },
{ url = "https://files.pythonhosted.org/packages/33/1b/207a90cc64619a095eb75a263ae069735f2810056d43c667befd573ec083/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58d1ab556b0cea1c93fdb799b24ad58adb2f2a2788dbce782a94f64ae1a5cc9b", size = 53112355, upload-time = "2026-07-10T08:27:27.911Z" },
{ url = "https://files.pythonhosted.org/packages/7e/fe/81d1e5f8beed15c01e98649d5c6e2167b67fd395884a2488f18bf1cf0dba/pyarrow-25.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f356afe61186395c861d5cd63dc21ff7d5fa335012a4668d979257df7fea0f5", size = 27945954, upload-time = "2026-07-10T08:27:32.903Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.4" version = "2.13.4"
@ -480,6 +558,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
] ]
[[package]]
name = "rerun-sdk"
version = "0.34.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "numpy" },
{ name = "pillow" },
{ name = "psutil" },
{ name = "pyarrow" },
{ name = "typing-extensions" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/37/41422eca73b5f933872ad073d17034f47ccac730fc4a29b9064d73c74424/rerun_sdk-0.34.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:32d3fb3e9f46eb84433427dc6add6ef7508bbd36295dcc80d693dccacf936e6b", size = 133584987, upload-time = "2026-07-07T17:43:14.934Z" },
{ url = "https://files.pythonhosted.org/packages/24/f7/c2e5097f0138a3c4d70e2fe1c24781fac9aec351324c2ea5b15f4d6971b8/rerun_sdk-0.34.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:57585f9c77af6d8a5ee0342bd79b87431b632bf7158e9b8dd4756d6ccd7b7feb", size = 142889929, upload-time = "2026-07-07T17:43:20.293Z" },
{ url = "https://files.pythonhosted.org/packages/18/fa/f87899a8c0cc36901d32069072340b894654fa6f3b3a2a74913565c3661a/rerun_sdk-0.34.1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:60c35adb49f04bd64bf1a426b59f61aeee8312fa7cc2624878b43186e817b2d4", size = 147505605, upload-time = "2026-07-07T17:43:25.397Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3a/07125af48ef024c573acadf1bfedd406bcc838ffa1af0d7b20bd79895267/rerun_sdk-0.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:3448b34d385994a32caaa4e3ffd42b69069ab7791382d8936089e4928ca24cd6", size = 126554967, upload-time = "2026-07-07T17:43:30.61Z" },
]
[[package]] [[package]]
name = "rich" name = "rich"
version = "14.3.4" version = "14.3.4"