feat: activate self-hosted lidar spatial scene
This commit is contained in:
parent
6402601d6d
commit
904c6ea13a
60
README.md
60
README.md
|
|
@ -7,8 +7,10 @@ or speculative writes.
|
|||
Current status: live proof completed on firmware 3.0.2. The Mac provisioned the
|
||||
K1 onto an existing LAN without LixelGO, connected to its MQTT broker, captured
|
||||
the scan-correlated point-cloud and pose streams, and decoded both successfully.
|
||||
The repository also contains a local React control console and a Foxglove live
|
||||
bridge for the verified point-cloud and trajectory topics.
|
||||
The repository also contains a local React control console, an automatic Rerun
|
||||
gRPC bridge for the verified point-cloud and trajectory streams, and a
|
||||
self-hosted Rerun Web Viewer embedded in that console. The former Foxglove
|
||||
bridge remains only as a legacy regression module.
|
||||
|
||||
The repository now contains one narrowly gated state-changing command:
|
||||
`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
|
||||
use an external hosted viewer UI.
|
||||
|
||||
There is one important current boundary: the automatic K1 MQTT → Rerun
|
||||
RRD/gRPC contract is **not implemented yet**. Starting a K1 live or replay
|
||||
session updates real state and metrics, but it does not by itself populate the
|
||||
embedded Rerun viewport. The scene controls for point size, gradients,
|
||||
accumulation, layers, timeline and layout are presently a product UI contract;
|
||||
their Rerun Blueprint/playback adapter is also still pending. No synthetic
|
||||
point cloud, trajectory, camera frame or latency value is generated to conceal
|
||||
these missing links.
|
||||
The first K1 live session or replay in a `k1link serve` process creates one local
|
||||
Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes
|
||||
the resulting URL through control-plane state. Later sessions reset their
|
||||
session-local scene and metrics and reuse that process-wide stream; this avoids
|
||||
restarting the native listener while the embedded browser remains connected.
|
||||
Unless an operator has entered a manual source, the React application assigns
|
||||
that URL to the embedded viewer. The complete runtime path is K1 MQTT → raw-first evidence
|
||||
capture → bounded latest-wins preview queue → reviewed protobuf/LZ4 decoders →
|
||||
Rerun `Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer.
|
||||
|
||||
The existing Python MQTT → Foxglove pipeline remains in place as the verified
|
||||
legacy visualization adapter. It still publishes `/k1/points`, `/k1/pose`,
|
||||
`/k1/trajectory` and `/k1/metrics`, and preserves the measured
|
||||
MQTT-receive-to-Foxglove-publish latency plus bounded latest-wins preview
|
||||
dropping. It is retained for evidence, regression and diagnostics, not as the
|
||||
target Control Station interface. The historical
|
||||
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records that proven path and its
|
||||
timing semantics; the current frontend contract is documented in
|
||||
The default Rerun blueprint shows a 12-second sliding accumulation of real point
|
||||
frames. Product controls are connected for point size, intensity/height/distance
|
||||
or available RGB coloring, Turbo/Viridis/Plasma/grayscale/custom palettes,
|
||||
point and trajectory visibility, and the scene grid. Projection, custom
|
||||
timeline transport and saved layout remain later product work. No synthetic
|
||||
point cloud, trajectory, camera frame or latency value is generated.
|
||||
|
||||
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).
|
||||
|
||||
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
|
||||
and reports external tools; it does not request Bluetooth permission, scan the
|
||||
LAN, touch the K1, alter Homebrew, or change capture permissions.
|
||||
|
|
@ -147,7 +169,7 @@ present.
|
|||
- [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md)
|
||||
- [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md)
|
||||
- [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md)
|
||||
- [Live console and 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)
|
||||
- [Session manifest schema](schemas/session-manifest.schema.json)
|
||||
- [Reference input provenance](docs/reference/README.md)
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ device adapter, но структура интерфейса от него не
|
|||
|
||||
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
|
||||
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
|
||||
переход во внешний облачный интерфейс. Сам браузерный компонент, однако, должен
|
||||
получить совместимый источник RRD или Rerun gRPC.
|
||||
переход во внешний облачный интерфейс. При запуске K1 live/replay backend сам
|
||||
создаёт Rerun gRPC/proxy source; ручной адрес нужен только для другого Rerun
|
||||
потока или совместимой RRD-записи.
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
|
|
@ -18,10 +19,10 @@ device adapter, но структура интерфейса от него не
|
|||
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
|
||||
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
|
||||
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
|
||||
| Legacy Foxglove adapter | Реализован | Текущий Python runtime публикует декодированные K1 данные в локальный Foxglove WebSocket. Это проверенный диагностический backend, а не продуктовый UI. |
|
||||
| Встроенный Rerun Viewer | Реализован | Открывает назначенный RRD по HTTP(S) или совместимый Rerun gRPC/proxy source внутри Control Station. |
|
||||
| Автоматический MQTT → Rerun | **Не реализован** | Запуск K1 live/replay пока не создаёт RRD и не подаёт данные во встроенный Rerun viewport. Нужен отдельный stream adapter. |
|
||||
| Контролы сцены → Rerun Blueprint | **Не реализованы** | Размер точек, палитра, накопление, слои, проекция, timeline и компоновка пока являются честным UI-контрактом и состоянием текущего сеанса. |
|
||||
| Автоматический MQTT → Rerun | Реализован | Первый live/replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие сессии сбрасывают сцену и метрики и переиспользуют его. |
|
||||
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
|
||||
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория и сетка. Проекция, собственный timeline и сохранённые layout-профили ещё не подключены. |
|
||||
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
|
||||
| Камеры, карты и миссии | Интерфейс готов | Серверная логика и реальные каналы для этих рабочих поверхностей ещё не подключены. |
|
||||
|
||||
Приложение не генерирует демонстрационное облако, траекторию, кадры или
|
||||
|
|
@ -31,28 +32,30 @@ device adapter, но структура интерфейса от него не
|
|||
## Архитектура данных
|
||||
|
||||
```text
|
||||
NODE.DC Control Station в браузере
|
||||
├── control plane
|
||||
│ └── REST /api/* + WebSocket /api/events
|
||||
│ └── FastAPI на 127.0.0.1:8000
|
||||
│ ├── CoreBluetooth: discovery и reviewed Wi-Fi provisioning
|
||||
│ └── live/replay session runtime
|
||||
│
|
||||
├── текущий K1 preview path — legacy adapter
|
||||
│ └── K1 MQTT :1883, read-only
|
||||
│ ├── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
|
||||
│ └── protobuf/LZ4 decoder
|
||||
│ └── Foxglove bridge → локальный WebSocket
|
||||
│
|
||||
└── продуктовая пространственная сцена
|
||||
K1 MQTT :1883, read-only
|
||||
└── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
|
||||
└── bounded latest-wins preview queue (32 сообщения)
|
||||
└── проверенный protobuf/LZ4 decoder
|
||||
└── Rerun Points3D + Transform3D + LineStrips3D
|
||||
└── gRPC/proxy TCP 9876
|
||||
└── rerun_grpc_url в REST/WebSocket state
|
||||
└── встроенный @rerun-io/web-viewer
|
||||
└── RRD URL или Rerun gRPC/proxy source
|
||||
└── пространственная сцена NODE.DC
|
||||
|
||||
React Control Station ←→ REST /api/* + WebSocket /api/events
|
||||
FastAPI на 127.0.0.1:8000
|
||||
CoreBluetooth + live/replay runtime
|
||||
```
|
||||
|
||||
Между декодером K1 и Rerun Viewer сейчас нет автоматической стрелки. Поля
|
||||
`foxglove_ws_url` и `foxglove_viewer_url` ещё присутствуют в API только потому,
|
||||
что Python runtime продолжает поднимать проверенный legacy adapter. Пункт
|
||||
управления не использует их как основную поверхность визуализации.
|
||||
В момент готовности `RerunBridge` backend публикует адрес вида
|
||||
`rerun+http://127.0.0.1:9876/proxy`. Frontend автоматически назначает его сцене,
|
||||
если оператор не указал ручной source. После остановки приёма URL и встроенный
|
||||
viewer остаются активны, а следующая сессия сбрасывает session-local геометрию,
|
||||
траекторию и метрики и использует тот же listener. Он закрывается вместе с
|
||||
процессом `k1link serve`.
|
||||
|
||||
Поля `foxglove_ws_url` и `foxglove_viewer_url` пока остаются в API как
|
||||
совместимость со старым контрактом, но текущий runtime держит их пустыми.
|
||||
|
||||
## Фиксированная оболочка
|
||||
|
||||
|
|
@ -70,8 +73,10 @@ Shell собран из локальных NODE.DC UI packages и сохраня
|
|||
|
||||
Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя,
|
||||
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
|
||||
Control Station. Это ещё не означает, что все продуктовые контролы уже связаны
|
||||
с API Rerun: текущая граница явно показана статусом `Интерфейс готов`.
|
||||
Control Station. Размер точек, способ окрашивания и палитра, 12-секундное по
|
||||
умолчанию накопление, видимость облака и траектории и сетка связаны с 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/*` подключены к нему через
|
||||
локальные `file:` пути. Python устанавливается только в `.venv` репозитория.
|
||||
|
||||
|
|
@ -142,18 +147,22 @@ Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/
|
|||
6. Запустить live-приём по определённому адресу K1 либо replay локального
|
||||
`.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и
|
||||
останавливается подтверждённым двойным нажатием кнопки устройства.
|
||||
7. Состояние, частота, число точек, задержка и пропуски preview появятся только
|
||||
после прихода реальных данных.
|
||||
7. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
|
||||
state. Ручной source вводить не требуется.
|
||||
8. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
|
||||
частота, число точек, задержка и пропуски preview появятся после прихода
|
||||
сообщений K1.
|
||||
|
||||
Этот путь запускает существующий MQTT/Foxglove runtime. Чтобы увидеть тот же
|
||||
живой K1 поток во встроенной пространственной сцене, нужно сначала реализовать и
|
||||
запустить MQTT→Rerun stream adapter; одного запуска live-сессии сейчас
|
||||
недостаточно.
|
||||
В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений:
|
||||
38 кадров `lio_pcl`, 42 кадра `lio_pose`, 2 775 точек в последнем облаке и
|
||||
0 ошибок декодирования.
|
||||
|
||||
## Источники Rerun
|
||||
|
||||
В **Наблюдение → Пространственная сцена → Источник** принимается URL, который
|
||||
понимает `@rerun-io/web-viewer` версии, зафиксированной в `package.json`:
|
||||
Live/replay runtime автоматически назначает первый URL из примера. В
|
||||
**Наблюдение → Пространственная сцена → Источник** можно оставить ручное поле
|
||||
пустым либо указать другой адрес, который понимает `@rerun-io/web-viewer`
|
||||
зафиксированной в `package.json` версии:
|
||||
|
||||
```text
|
||||
rerun+http://127.0.0.1:9876/proxy
|
||||
|
|
@ -165,14 +174,23 @@ https://example.internal/recording.rrd
|
|||
proxy;
|
||||
- `http(s)://…/recording.rrd` — RRD-запись по HTTP(S);
|
||||
- версия RRD должна быть совместима с версией Web Viewer;
|
||||
- URL хранится только в состоянии текущей страницы;
|
||||
- ручной URL хранится только в состоянии текущей страницы и имеет приоритет над
|
||||
автоматическим source;
|
||||
- ошибка запуска показывается в viewport, без подстановки фиктивных данных.
|
||||
|
||||
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
|
||||
оболочку. Продуктовые настройки отображения, custom timeline, переключатель
|
||||
2D/3D/карты и сохранение RBL пока не вызывают Blueprint/playback API. Черновик
|
||||
компоновки фиксируется только в памяти текущей страницы и не записывается на
|
||||
диск.
|
||||
оболочку. Backend принимает и применяет к Rerun размер точки `0.5–12.0`, режимы
|
||||
цвета `intensity`, `height`, `distance`, `rgb`, `class`, палитры Turbo, Viridis,
|
||||
Plasma, grayscale и custom, накопление `0–120` секунд, а также видимость облака,
|
||||
траектории и сетки. Значение по умолчанию — 12 секунд истории реальных кадров.
|
||||
Для текущего firmware-3 `lio_pcl` доказана только интенсивность из младшего
|
||||
байта `rgbi`; RGB используется лишь когда он действительно присутствует в
|
||||
декодированном формате. Custom/class сейчас означает выбранный сплошной цвет,
|
||||
а не готовую семантическую классификацию.
|
||||
|
||||
Custom timeline, переключатель 2D/3D/карты, семантические слои и сохранение RBL
|
||||
пока не вызывают Blueprint/playback API. Черновик компоновки фиксируется только
|
||||
в памяти текущей страницы и не записывается на диск.
|
||||
|
||||
## Контракт локального API
|
||||
|
||||
|
|
@ -185,12 +203,14 @@ https://example.internal/recording.rrd
|
|||
| `POST` | `/api/session/live` | Опционально `{ "host", "duration_seconds" }`. |
|
||||
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }`. |
|
||||
| `POST` | `/api/session/stop` | Остановка активного источника. |
|
||||
| `POST` | `/api/viewer/settings` | Размер/цвет точек, накопление, видимость облака, траектории и сетки. |
|
||||
| `WS` | `/api/events` | Периодические snapshots для live UI. |
|
||||
|
||||
`/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
|
||||
как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`,
|
||||
`selected_device_id`, `k1_ip`, `source_mode`, `metrics` и legacy-поля
|
||||
`foxglove_ws_url`/`foxglove_viewer_url`. OpenAPI доступен по `/api/docs`.
|
||||
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
|
||||
`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
|
||||
пустыми. OpenAPI доступен по `/api/docs`.
|
||||
|
||||
## Карта исходников frontend
|
||||
|
||||
|
|
@ -211,6 +231,12 @@ https://example.internal/recording.rrd
|
|||
## Safety и чувствительные данные
|
||||
|
||||
- Control Station и credential endpoint доступны только на loopback.
|
||||
- Исключение: Rerun gRPC/proxy на TCP 9876 сейчас слушает все сетевые интерфейсы,
|
||||
хотя автоматически возвращаемый URL содержит `127.0.0.1`. На нём нет
|
||||
connector-level authentication или TLS. Этот порт допустим только в доверенной
|
||||
лабораторной LAN; его нельзя пробрасывать в публичный Интернет или cellular
|
||||
WAN без аутентифицированного TLS reverse proxy. Listener намеренно остаётся
|
||||
активным между сессиями; для его закрытия нужно остановить `k1link serve`.
|
||||
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
|
||||
очищается после успешного ответа и не сохраняется в URL/local storage.
|
||||
- BLE-подключение выполняет только отдельно рассмотренную provisioning-запись;
|
||||
|
|
@ -219,6 +245,12 @@ https://example.internal/recording.rrd
|
|||
физического сканирования остаются за кнопкой K1.
|
||||
- Live-сессии сначала сохраняют сырые сообщения, затем формируют preview. При
|
||||
перегрузке preview может быть отброшен, raw evidence сохраняется.
|
||||
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
|
||||
накопления viewer использует session-local `stream_time`. `capture_time` — не
|
||||
доказанный timestamp сенсора K1 и не photon-to-screen latency.
|
||||
- Панорамный камерный поток в наблюдавшихся MQTT report topics отсутствует;
|
||||
текущая Rerun-сцена содержит только облако точек и позу/траекторию, а
|
||||
операционные метрики отображаются внешней оболочкой Control Station.
|
||||
- `sessions/` игнорируется Git и может содержать адреса, идентификаторы,
|
||||
траекторию и карту помещения. В репозиторий попадают только redacted manifests
|
||||
и безопасная документация.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AdminNavigationPanel,
|
||||
AppHeader,
|
||||
ApplicationPanel,
|
||||
ApplicationShell,
|
||||
Button,
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
HeaderAvatar,
|
||||
|
|
@ -17,7 +18,6 @@ import {
|
|||
RangeControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
Switch,
|
||||
TextField,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LandingStage } from "./components/LandingStage";
|
||||
import type { ViewerSettings } from "./api";
|
||||
import {
|
||||
rootById,
|
||||
roots,
|
||||
|
|
@ -44,6 +45,9 @@ import {
|
|||
import { useK1Console } from "./useK1Console";
|
||||
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
||||
import { WorkspaceRenderer } from "./workspaces/Workspaces";
|
||||
import "./styles/scene-windows.css";
|
||||
|
||||
type SceneToolWindowId = "sources" | "display" | "layers" | "layout";
|
||||
|
||||
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
|
||||
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
|
||||
|
|
@ -61,6 +65,46 @@ const paletteOptions: Array<{ value: PointPalette; label: string; description: s
|
|||
{ value: "custom", label: "Свой цвет", description: "Один назначенный цвет" },
|
||||
];
|
||||
|
||||
function toViewerSettings(settings: SceneSettings): ViewerSettings {
|
||||
return {
|
||||
point_size: settings.pointSize,
|
||||
color_mode: settings.colorMode,
|
||||
palette: settings.palette,
|
||||
custom_color: settings.customColor,
|
||||
accumulation_seconds: settings.accumulationSeconds,
|
||||
show_points: settings.showPoints,
|
||||
show_trajectory: settings.showTrajectory,
|
||||
show_grid: settings.showGrid,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeViewerSettings(
|
||||
current: SceneSettings,
|
||||
remote: ViewerSettings,
|
||||
): SceneSettings {
|
||||
const next = {
|
||||
...current,
|
||||
pointSize: remote.point_size,
|
||||
colorMode: remote.color_mode,
|
||||
palette: remote.palette,
|
||||
customColor: remote.custom_color,
|
||||
accumulationSeconds: remote.accumulation_seconds,
|
||||
showPoints: remote.show_points,
|
||||
showTrajectory: remote.show_trajectory,
|
||||
showGrid: remote.show_grid,
|
||||
};
|
||||
const unchanged =
|
||||
current.pointSize === next.pointSize &&
|
||||
current.colorMode === next.colorMode &&
|
||||
current.palette === next.palette &&
|
||||
current.customColor === next.customColor &&
|
||||
current.accumulationSeconds === next.accumulationSeconds &&
|
||||
current.showPoints === next.showPoints &&
|
||||
current.showTrajectory === next.showTrajectory &&
|
||||
current.showGrid === next.showGrid;
|
||||
return unchanged ? current : next;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const console = useK1Console();
|
||||
const workspace = useApplicationWorkspace<string>({
|
||||
|
|
@ -74,6 +118,7 @@ export default function App() {
|
|||
const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
|
||||
const [displayWindowOpen, setDisplayWindowOpen] = useState(false);
|
||||
const [layerInspectorOpen, setLayerInspectorOpen] = useState(false);
|
||||
const [sceneWindowOrder, setSceneWindowOrder] = useState<SceneToolWindowId[]>([]);
|
||||
const [layoutWindowOpen, setLayoutWindowOpen] = useState(false);
|
||||
const [layoutName, setLayoutName] = useState("Операторская сцена");
|
||||
const [layoutDraftSaved, setLayoutDraftSaved] = useState(false);
|
||||
|
|
@ -83,6 +128,48 @@ export default function App() {
|
|||
const currentRoot = rootById(activeRoot);
|
||||
const activeDefinition = workspaceById(workspace.activeView);
|
||||
const rootWorkspaces = workspacesForRoot(activeRoot);
|
||||
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
|
||||
const automaticSourceUrl = console.state?.rerun_grpc_url?.trim() ?? "";
|
||||
const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
|
||||
|
||||
useEffect(() => {
|
||||
const remote = console.state?.viewer_settings;
|
||||
if (!remote) return;
|
||||
setSceneSettings((current) => mergeViewerSettings(current, remote));
|
||||
if (!displayWindowOpen) {
|
||||
setDisplayDraft((current) => mergeViewerSettings(current, remote));
|
||||
}
|
||||
}, [console.state?.viewer_settings, displayWindowOpen]);
|
||||
|
||||
const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||
setSceneWindowOrder((current) => [
|
||||
...current.filter((candidate) => candidate !== windowId),
|
||||
windowId,
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||
if (windowId === "sources") setSourceWindowOpen(false);
|
||||
if (windowId === "display") setDisplayWindowOpen(false);
|
||||
if (windowId === "layers") setLayerInspectorOpen(false);
|
||||
if (windowId === "layout") setLayoutWindowOpen(false);
|
||||
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSceneWindow) return;
|
||||
|
||||
const closeActiveWindow = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
if (document.querySelector(".nodedc-dropdown-surface")) return;
|
||||
if (document.querySelector('.nodedc-overlay[data-placement="center"]')) return;
|
||||
event.preventDefault();
|
||||
closeSceneWindow(activeSceneWindow);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", closeActiveWindow);
|
||||
return () => document.removeEventListener("keydown", closeActiveWindow);
|
||||
}, [activeSceneWindow, closeSceneWindow]);
|
||||
|
||||
const selectRoot = (rootId: RootId) => {
|
||||
setActiveRoot(rootId);
|
||||
|
|
@ -100,11 +187,41 @@ export default function App() {
|
|||
const openSource = () => {
|
||||
setSourceDraft(sourceUrl);
|
||||
setSourceWindowOpen(true);
|
||||
activateSceneWindow("sources");
|
||||
};
|
||||
|
||||
const openDisplay = () => {
|
||||
setDisplayDraft(sceneSettings);
|
||||
setDisplayWindowOpen(true);
|
||||
activateSceneWindow("display");
|
||||
};
|
||||
|
||||
const openLayers = () => {
|
||||
setLayerInspectorOpen(true);
|
||||
activateSceneWindow("layers");
|
||||
};
|
||||
|
||||
const openLayout = () => {
|
||||
setLayoutDraftSaved(false);
|
||||
setLayoutWindowOpen(true);
|
||||
activateSceneWindow("layout");
|
||||
};
|
||||
|
||||
const applyDisplaySettings = async () => {
|
||||
const applied = await console.updateViewerSettings(toViewerSettings(displayDraft));
|
||||
if (applied) setSceneSettings(displayDraft);
|
||||
};
|
||||
|
||||
const applyScenePatch = async (patch: Partial<SceneSettings>) => {
|
||||
const previous = sceneSettings;
|
||||
const next = { ...sceneSettings, ...patch };
|
||||
setSceneSettings(next);
|
||||
setDisplayDraft((current) => (displayWindowOpen ? { ...current, ...patch } : next));
|
||||
const applied = await console.updateViewerSettings(toViewerSettings(next));
|
||||
if (!applied) {
|
||||
setSceneSettings(previous);
|
||||
if (!displayWindowOpen) setDisplayDraft(previous);
|
||||
}
|
||||
};
|
||||
|
||||
const contentActions = useMemo<ApplicationPanelUtilityAction[]>(() => {
|
||||
|
|
@ -122,17 +239,14 @@ export default function App() {
|
|||
actions.push(
|
||||
{ label: "Настроить источник", icon: "network", onClick: openSource },
|
||||
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
|
||||
{ label: "Открыть слои", icon: "list", onClick: () => setLayerInspectorOpen(true) },
|
||||
{ label: "Открыть слои", icon: "list", onClick: openLayers },
|
||||
);
|
||||
}
|
||||
|
||||
actions.push({
|
||||
label: "Сохранить компоновку",
|
||||
icon: "save",
|
||||
onClick: () => {
|
||||
setLayoutDraftSaved(false);
|
||||
setLayoutWindowOpen(true);
|
||||
},
|
||||
onClick: openLayout,
|
||||
});
|
||||
return actions;
|
||||
}, [activeDefinition?.kind, console, sceneSettings, sourceUrl]);
|
||||
|
|
@ -231,8 +345,8 @@ export default function App() {
|
|||
{phaseLabel(console.state?.phase)}
|
||||
</StatusBadge>
|
||||
) : activeDefinition.kind === "spatial" ? (
|
||||
<StatusBadge tone={sourceUrl ? "accent" : "warning"}>
|
||||
{sourceUrl ? "Источник назначен" : "Без источника"}
|
||||
<StatusBadge tone={effectiveSourceUrl ? "accent" : "warning"}>
|
||||
{effectiveSourceUrl ? "Источник назначен" : "Без источника"}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
|
|
@ -242,20 +356,22 @@ export default function App() {
|
|||
onClose={workspace.closeView}
|
||||
>
|
||||
{activeDefinition.kind === "device" ? (
|
||||
<DeviceWorkspace console={console} />
|
||||
<DeviceWorkspace
|
||||
console={console}
|
||||
onOpenSpatialScene={() => openView("spatial-scene")}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceRenderer
|
||||
definition={activeDefinition}
|
||||
state={console.state}
|
||||
backendStatus={console.backendStatus}
|
||||
sourceUrl={sourceUrl}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
sceneSettings={sceneSettings}
|
||||
onSceneSettingsChange={setSceneSettings}
|
||||
navigation={{
|
||||
openView,
|
||||
openSource,
|
||||
openDisplay,
|
||||
openLayers: () => setLayerInspectorOpen(true),
|
||||
openLayers,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -265,10 +381,19 @@ export default function App() {
|
|||
|
||||
<Window
|
||||
open={sourceWindowOpen}
|
||||
title="Источник визуальных данных"
|
||||
subtitle="ИСТОЧНИКИ RRD И RERUN GRPC"
|
||||
size="md"
|
||||
onClose={() => setSourceWindowOpen(false)}
|
||||
title="Источники"
|
||||
subtitle="Потоки и записи пространственной сцены"
|
||||
placement="end"
|
||||
draggable
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
lockBodyScroll={false}
|
||||
trapFocus={false}
|
||||
className="scene-tool-window scene-tool-window--sources"
|
||||
data-scene-window="sources"
|
||||
data-scene-active={activeSceneWindow === "sources" ? "true" : undefined}
|
||||
onPointerDown={() => activateSceneWindow("sources")}
|
||||
onClose={() => closeSceneWindow("sources")}
|
||||
footer={
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
|
|
@ -276,10 +401,9 @@ export default function App() {
|
|||
onClick={() => {
|
||||
setSourceDraft("");
|
||||
setSourceUrl("");
|
||||
setSourceWindowOpen(false);
|
||||
}}
|
||||
>
|
||||
Очистить источник
|
||||
Сбросить адрес
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
|
|
@ -287,48 +411,107 @@ export default function App() {
|
|||
disabled={!sourceDraft.trim()}
|
||||
onClick={() => {
|
||||
setSourceUrl(sourceDraft.trim());
|
||||
setSourceWindowOpen(false);
|
||||
}}
|
||||
>
|
||||
Подключить
|
||||
Применить адрес
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
>
|
||||
<div className="modal-stack">
|
||||
<div className="modal-contract-note" data-tone="accent">
|
||||
<Icon name="network" />
|
||||
<div>
|
||||
<strong>Встроенный открытый визуализатор</strong>
|
||||
<p>
|
||||
Источник открывается внутри интерфейса, без перехода во внешний сервис. Версия
|
||||
RRD должна совпадать с версией визуального движка.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Inspector
|
||||
defaultOpen={["connection"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "connection",
|
||||
label: "Подключение",
|
||||
description: "Адрес потока или записи",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<ControlRow label="Состояние">
|
||||
<span className="scene-window-state">
|
||||
<i className="api-dot" data-status={effectiveSourceUrl ? "online" : "checking"} aria-hidden="true" />
|
||||
{effectiveSourceUrl ? "Источник назначен" : "Источник не назначен"}
|
||||
</span>
|
||||
</ControlRow>
|
||||
{effectiveSourceUrl ? (
|
||||
<ControlRow label="Активный адрес" layout="stack">
|
||||
<code className="scene-window-code">{effectiveSourceUrl}</code>
|
||||
</ControlRow>
|
||||
) : null}
|
||||
<ControlRow label="Автоматический" layout="stack">
|
||||
<code className="scene-window-code">
|
||||
{automaticSourceUrl || "Локальный источник ещё не опубликован"}
|
||||
</code>
|
||||
</ControlRow>
|
||||
<TextField
|
||||
label="Адрес источника"
|
||||
hint="URL"
|
||||
label="Ручной адрес"
|
||||
hint="необязательно"
|
||||
value={sourceDraft}
|
||||
onChange={(event) => setSourceDraft(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="rerun+http://127.0.0.1:9876/proxy или https://…/recording.rrd"
|
||||
description="Текущий локальный MQTT-поток пока требует отдельного адаптера потока Rerun."
|
||||
placeholder="rerun+http://127.0.0.1:9876/proxy"
|
||||
description="Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."
|
||||
/>
|
||||
<div className="source-format-list">
|
||||
<div><code>rerun+http://…/proxy</code><span>Живой gRPC источник</span></div>
|
||||
<div><code>http://…/recording.rrd</code><span>Запись по HTTP</span></div>
|
||||
<div><code>https://…/recording.rrd</code><span>Удалённая запись</span></div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "formats",
|
||||
label: "Поддерживаемые адреса",
|
||||
description: "Rerun gRPC и RRD",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<ControlRow label="Живой поток" layout="stack">
|
||||
<code className="scene-window-code">rerun+http://…/proxy</code>
|
||||
</ControlRow>
|
||||
<ControlRow label="Локальная запись" layout="stack">
|
||||
<code className="scene-window-code">http://…/recording.rrd</code>
|
||||
</ControlRow>
|
||||
<ControlRow label="Удалённая запись" layout="stack">
|
||||
<code className="scene-window-code">https://…/recording.rrd</code>
|
||||
</ControlRow>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "adapter",
|
||||
label: "Локальный адаптер",
|
||||
description: "Транспорт устройства",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<ControlRow label="Контур">
|
||||
<span className="scene-window-state">
|
||||
<i className="api-dot" data-status={console.backendStatus} aria-hidden="true" />
|
||||
{backendLabel(console.backendStatus)}
|
||||
</span>
|
||||
</ControlRow>
|
||||
<p className="scene-window-note">
|
||||
Встроенный адаптер публикует локальный Rerun gRPC автоматически после запуска
|
||||
живого приёма или повтора записи. Ручной адрес для этого не требуется.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={displayWindowOpen}
|
||||
title="Параметры отображения"
|
||||
subtitle="ПРОСТРАНСТВЕННАЯ СЦЕНА"
|
||||
size="md"
|
||||
onClose={() => setDisplayWindowOpen(false)}
|
||||
title="Отображение"
|
||||
subtitle="Параметры пространственной сцены"
|
||||
placement="end"
|
||||
draggable
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
lockBodyScroll={false}
|
||||
trapFocus={false}
|
||||
className="scene-tool-window scene-tool-window--display"
|
||||
data-scene-window="display"
|
||||
data-scene-active={activeSceneWindow === "display" ? "true" : undefined}
|
||||
onPointerDown={() => activateSceneWindow("display")}
|
||||
onClose={() => closeSceneWindow("display")}
|
||||
footer={
|
||||
<WindowFooterActions>
|
||||
<Button variant="ghost" onClick={() => setDisplayDraft(defaultSceneSettings)}>
|
||||
|
|
@ -337,35 +520,22 @@ export default function App() {
|
|||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
setSceneSettings(displayDraft);
|
||||
setDisplayWindowOpen(false);
|
||||
}}
|
||||
disabled={console.pendingAction === "viewer"}
|
||||
onClick={() => void applyDisplaySettings()}
|
||||
>
|
||||
Применить к профилю
|
||||
{console.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
}
|
||||
>
|
||||
<div className="modal-stack">
|
||||
<div className="modal-contract-note" data-tone="warning">
|
||||
<Icon name="alert" />
|
||||
<div>
|
||||
<strong>Профиль визуализации подготовлен</strong>
|
||||
<p>
|
||||
Параметры уже собраны в продуктовой форме. Они начнут управлять компоновкой Rerun
|
||||
после подключения адаптера потока и профиля; сейчас меняется только интерфейсный черновик.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Inspector
|
||||
defaultOpen={["points", "history", "scene"]}
|
||||
defaultOpen={["points"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "points",
|
||||
label: "Облако точек",
|
||||
description: "Размер, атрибут и градиент",
|
||||
group: "ВИЗУАЛЬНЫЙ ПРОФИЛЬ",
|
||||
description: "Размер и способ окрашивания",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<RangeControl
|
||||
|
|
@ -377,16 +547,18 @@ export default function App() {
|
|||
formatValue={(value) => `${value.toFixed(1)} пкс`}
|
||||
onChange={(pointSize) => setDisplayDraft((current) => ({ ...current, pointSize }))}
|
||||
/>
|
||||
<ControlRow label="Атрибут цвета" layout="stack">
|
||||
<ControlRow label="Атрибут цвета">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Атрибут цвета"
|
||||
value={displayDraft.colorMode}
|
||||
options={colorModeOptions}
|
||||
onChange={(colorMode) => setDisplayDraft((current) => ({ ...current, colorMode }))}
|
||||
/>
|
||||
</ControlRow>
|
||||
<ControlRow label="Палитра" layout="stack">
|
||||
<ControlRow label="Палитра">
|
||||
<Select
|
||||
variant="split"
|
||||
label="Палитра"
|
||||
value={displayDraft.palette}
|
||||
options={paletteOptions}
|
||||
|
|
@ -409,7 +581,6 @@ export default function App() {
|
|||
id: "history",
|
||||
label: "Накопление и время",
|
||||
description: "История облака и траектория",
|
||||
group: "ВИЗУАЛЬНЫЙ ПРОФИЛЬ",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<RangeControl
|
||||
|
|
@ -421,9 +592,10 @@ export default function App() {
|
|||
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
||||
onChange={(accumulationSeconds) => setDisplayDraft((current) => ({ ...current, accumulationSeconds }))}
|
||||
/>
|
||||
<Switch
|
||||
<Checker
|
||||
checked={displayDraft.showTrajectory}
|
||||
label="Показывать траекторию"
|
||||
description="Линия пути устройства в координатах сцены"
|
||||
onChange={(showTrajectory) => setDisplayDraft((current) => ({ ...current, showTrajectory }))}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -431,49 +603,55 @@ export default function App() {
|
|||
},
|
||||
{
|
||||
id: "scene",
|
||||
label: "Сцена",
|
||||
description: "Сетка, подписи и области обзора камер",
|
||||
group: "ВИЗУАЛЬНЫЙ ПРОФИЛЬ",
|
||||
label: "Окружение сцены",
|
||||
description: "Сетка, подписи и камеры",
|
||||
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 }))} />
|
||||
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => setDisplayDraft((current) => ({ ...current, showGrid }))} />
|
||||
<Checker checked={false} disabled label="Подписи сущностей" description="Появятся после подключения семантических сущностей" onChange={() => undefined} />
|
||||
<Checker checked={false} disabled label="Области обзора камер" description="Камерный канал пока не подключён" onChange={() => undefined} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
open={layerInspectorOpen}
|
||||
title="Слои сцены"
|
||||
subtitle="ДЕРЕВО СУЩНОСТЕЙ / ПЕРЕОПРЕДЕЛЕНИЯ"
|
||||
subtitle="Сущности пространственной сцены"
|
||||
placement="end"
|
||||
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">
|
||||
<StatusBadge tone={sourceUrl ? "accent" : "neutral"}>
|
||||
{sourceUrl ? "Источник назначен" : "Без источника"}
|
||||
</StatusBadge>
|
||||
<span className="scene-window-state">
|
||||
<i className="api-dot" data-status={effectiveSourceUrl ? "online" : "checking"} aria-hidden="true" />
|
||||
{effectiveSourceUrl ? "Источник назначен" : "Источник не назначен"}
|
||||
</span>
|
||||
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
|
||||
</div>
|
||||
<Inspector
|
||||
defaultOpen={["geometry", "frames", "perception"]}
|
||||
defaultOpen={["geometry"]}
|
||||
singleOpen
|
||||
sections={[
|
||||
{
|
||||
id: "geometry",
|
||||
label: "Геометрия",
|
||||
description: "Облако точек и путь",
|
||||
group: "СЦЕНА",
|
||||
tone: "accent",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Switch checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => setSceneSettings((current) => ({ ...current, showPoints }))} />
|
||||
<Switch checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => setSceneSettings((current) => ({ ...current, showTrajectory }))} />
|
||||
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" description="Основной поток геометрии лидара" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
|
||||
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" description="Положение и ориентация устройства во времени" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
@ -481,11 +659,10 @@ export default function App() {
|
|||
id: "frames",
|
||||
label: "Координаты и камеры",
|
||||
description: "Преобразования и области обзора",
|
||||
group: "СЦЕНА",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<Switch checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => setSceneSettings((current) => ({ ...current, showGrid }))} />
|
||||
<Switch checked={sceneSettings.showCameraFrustums} label="Области обзора камер в 3D" onChange={(showCameraFrustums) => setSceneSettings((current) => ({ ...current, showCameraFrustums }))} />
|
||||
<Checker disabled={console.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
|
||||
<Checker checked={false} disabled label="Области обзора камер" description="Камерный канал пока не подключён" onChange={() => undefined} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
@ -493,12 +670,11 @@ export default function App() {
|
|||
id: "perception",
|
||||
label: "Объекты и маски",
|
||||
description: "Ожидают внешние обработчики",
|
||||
group: "СЦЕНА",
|
||||
content: (
|
||||
<div className="layer-placeholder-list">
|
||||
<span><i />Рамки 2D / 3D<small>не подключено</small></span>
|
||||
<span><i />Маски сегментации<small>не подключено</small></span>
|
||||
<span><i />Ключевые точки / треки<small>не подключено</small></span>
|
||||
<div className="inspector-control-stack">
|
||||
<Checker checked={false} disabled label="Рамки 2D и 3D" description="Внешний обработчик не подключён" onChange={() => undefined} />
|
||||
<Checker checked={false} disabled label="Маски сегментации" description="Внешний обработчик не подключён" onChange={() => undefined} />
|
||||
<Checker checked={false} disabled label="Ключевые точки и треки" description="Внешний обработчик не подключён" onChange={() => undefined} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
@ -511,10 +687,20 @@ export default function App() {
|
|||
title="Компоновка рабочей области"
|
||||
subtitle="КОМПОНОВКА / ЧЕРНОВИК"
|
||||
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={
|
||||
<WindowFooterActions>
|
||||
<Button onClick={() => setLayoutWindowOpen(false)}>Закрыть</Button>
|
||||
<Button onClick={() => closeSceneWindow("layout")}>Закрыть</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
|
|
|
|||
|
|
@ -30,10 +30,23 @@ export interface ConsoleState {
|
|||
k1_ip?: string | null;
|
||||
foxglove_ws_url?: string | null;
|
||||
foxglove_viewer_url?: string | null;
|
||||
rerun_grpc_url?: string | null;
|
||||
viewer_settings?: ViewerSettings | null;
|
||||
source_mode?: SourceMode | null;
|
||||
metrics?: K1Metrics;
|
||||
}
|
||||
|
||||
export interface ViewerSettings {
|
||||
point_size: number;
|
||||
color_mode: "intensity" | "height" | "distance" | "rgb" | "class";
|
||||
palette: "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
||||
custom_color: string;
|
||||
accumulation_seconds: number;
|
||||
show_points: boolean;
|
||||
show_trajectory: boolean;
|
||||
show_grid: boolean;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
ok?: boolean;
|
||||
status?: string;
|
||||
|
|
@ -174,6 +187,10 @@ export const api = {
|
|||
stopSession(): Promise<ConsoleState> {
|
||||
return postState("/api/session/stop");
|
||||
},
|
||||
|
||||
updateViewerSettings(body: ViewerSettings): Promise<ConsoleState> {
|
||||
return postState("/api/viewer/settings", body);
|
||||
},
|
||||
};
|
||||
|
||||
export type EventSocketStatus = "connecting" | "open" | "closed" | "error";
|
||||
|
|
|
|||
|
|
@ -23,19 +23,6 @@ export function LandingStage({
|
|||
}: LandingStageProps) {
|
||||
return (
|
||||
<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">
|
||||
<span className="section-eyebrow">{root?.eyebrow ?? "NODE.DC / ПУНКТ УПРАВЛЕНИЯ"}</span>
|
||||
<h1>{root?.title ?? "Пункт управления"}</h1>
|
||||
|
|
@ -44,7 +31,7 @@ export function LandingStage({
|
|||
"Наблюдение, планирование и корректировка миссий в одной модульной рабочей области."}
|
||||
</p>
|
||||
<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 variant="secondary" icon={<Icon name="network" />} onClick={onOpenDevice}>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ export function RerunViewport({
|
|||
}
|
||||
|
||||
let disposed = false;
|
||||
let started = false;
|
||||
let stopViewer: (() => void) | undefined;
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
setStatus("loading");
|
||||
|
|
@ -41,7 +40,17 @@ export function RerunViewport({
|
|||
void import("@rerun-io/web-viewer")
|
||||
.then(async ({ 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(
|
||||
normalizedSource,
|
||||
|
|
@ -56,10 +65,8 @@ export function RerunViewport({
|
|||
},
|
||||
null,
|
||||
);
|
||||
started = true;
|
||||
|
||||
if (disposed) {
|
||||
viewer.stop();
|
||||
stopViewer();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -86,9 +93,9 @@ export function RerunViewport({
|
|||
setStatus("ready");
|
||||
onStatusChange?.("ready");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
.catch(() => {
|
||||
if (disposed) return;
|
||||
const message = error instanceof Error ? error.message : "Не удалось запустить визуальный движок.";
|
||||
const message = "Не удалось запустить встроенный визуализатор.";
|
||||
setStatus("error");
|
||||
onStatusChange?.("error", message);
|
||||
});
|
||||
|
|
@ -96,7 +103,7 @@ export function RerunViewport({
|
|||
return () => {
|
||||
disposed = true;
|
||||
unsubscribers.forEach((unsubscribe) => unsubscribe());
|
||||
if (started) stopViewer?.();
|
||||
stopViewer?.();
|
||||
onSelectionChange?.(null);
|
||||
};
|
||||
}, [onSelectionChange, onStatusChange, sourceUrl]);
|
||||
|
|
|
|||
|
|
@ -13,8 +13,18 @@ const runtimeMessageReplacements: Array<[RegExp, string]> = [
|
|||
export function localizeRuntimeMessage(message: string | null | undefined): string | null {
|
||||
if (!message) return null;
|
||||
|
||||
return runtimeMessageReplacements.reduce(
|
||||
const localized = runtimeMessageReplacements.reduce(
|
||||
(localized, [pattern, replacement]) => localized.replace(pattern, replacement),
|
||||
message,
|
||||
);
|
||||
|
||||
const withoutTechnicalTerms = localized.replace(
|
||||
/\b(?:API|BLE|Bluetooth|gRPC|IP|JSON|K1MQTT|MQTT|Rerun|RRD|TSV|UUID|Wi-Fi)\b/gi,
|
||||
"",
|
||||
);
|
||||
if (/[A-Za-z]{3,}/.test(withoutTechnicalTerms)) {
|
||||
return "Операция не выполнена. Технические подробности сохранены в журнале сервера.";
|
||||
}
|
||||
|
||||
return localized;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,11 +67,10 @@ export function pipelineLatency(metrics: K1Metrics | undefined): number | null {
|
|||
|
||||
const segments = [
|
||||
finiteMetric(metrics.mqtt_to_decode_ms),
|
||||
finiteMetric(metrics.decode_ms),
|
||||
finiteMetric(metrics.publish_ms),
|
||||
].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 {
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||
title: "Временные представления",
|
||||
description: "Встроенные классы для чисел, состояний и таблиц.",
|
||||
capabilities: [
|
||||
active("Метрики потока в реальном времени", "Задержка, частота кадров, число точек и пропуски предпросмотра."),
|
||||
active("Метрики потока в реальном времени", "Время до Rerun SDK, частота кадров, число точек и пропуски предпросмотра."),
|
||||
contract("Временные ряды (TimeSeriesView)", "Скалярные серии, несколько линий и диапазоны."),
|
||||
contract("Шкала состояний (StateTimelineView)", "Переходы режимов, ошибки и статусы подсистем."),
|
||||
contract("Таблица данных (DataframeView)", "Табличная диагностика и атрибуты сущностей."),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,19 @@ body {
|
|||
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,
|
||||
input,
|
||||
textarea,
|
||||
|
|
@ -32,16 +45,100 @@ code {
|
|||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.control-station {
|
||||
.control-station,
|
||||
body {
|
||||
--station-panel: #151517;
|
||||
--station-panel-soft: #0d0d0f;
|
||||
--station-stage: #08090b;
|
||||
--station-hairline: rgb(255 255 255 / 0.08);
|
||||
--station-hairline-strong: rgb(255 255 255 / 0.13);
|
||||
--station-accent-soft: rgb(var(--nodedc-accent-rgb) / 0.11);
|
||||
--station-success-soft: rgb(var(--nodedc-success-rgb) / 0.1);
|
||||
--station-warning-soft: rgb(var(--nodedc-warning-rgb) / 0.1);
|
||||
--station-danger-soft: rgb(var(--nodedc-danger-rgb) / 0.1);
|
||||
--station-accent-soft: rgb(255 255 255 / 0.045);
|
||||
--station-success-soft: rgb(255 255 255 / 0.045);
|
||||
--station-warning-soft: rgb(255 255 255 / 0.045);
|
||||
--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 {
|
||||
|
|
@ -146,9 +243,7 @@ code {
|
|||
}
|
||||
|
||||
.metric-card[data-featured="true"] {
|
||||
background:
|
||||
radial-gradient(circle at 90% 0%, rgb(var(--nodedc-accent-rgb) / 0.16), transparent 44%),
|
||||
var(--station-panel);
|
||||
background: var(--station-panel);
|
||||
}
|
||||
|
||||
.metric-card__reading {
|
||||
|
|
@ -194,15 +289,15 @@ code {
|
|||
}
|
||||
|
||||
.modal-contract-note[data-tone="accent"] {
|
||||
background: var(--station-accent-soft);
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
}
|
||||
|
||||
.modal-contract-note[data-tone="warning"] {
|
||||
background: var(--station-warning-soft);
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
}
|
||||
|
||||
.modal-contract-note[data-tone="success"] {
|
||||
background: var(--station-success-soft);
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
}
|
||||
|
||||
.modal-contract-note > svg {
|
||||
|
|
|
|||
|
|
@ -25,14 +25,18 @@
|
|||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--station-danger-soft);
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.error-banner > svg {
|
||||
.error-banner__dot {
|
||||
width: 0.46rem;
|
||||
height: 0.46rem;
|
||||
align-self: start;
|
||||
margin-top: 0.12rem;
|
||||
margin-top: 0.28rem;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.error-banner strong,
|
||||
|
|
@ -159,8 +163,8 @@
|
|||
}
|
||||
|
||||
.device-row[data-selected="true"] {
|
||||
background: var(--station-accent-soft);
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.18);
|
||||
background: rgb(255 255 255 / 0.065);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.device-row__identity,
|
||||
|
|
@ -191,10 +195,7 @@
|
|||
|
||||
.device-row__name small {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: var(--station-accent-soft);
|
||||
color: rgb(var(--nodedc-accent-rgb));
|
||||
padding: 0.18rem 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.47rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
|
|
@ -223,12 +224,10 @@
|
|||
flex: 0 0 0.58rem;
|
||||
border-radius: 50%;
|
||||
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 {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
box-shadow: 0 0 0 0.24rem var(--station-success-soft);
|
||||
}
|
||||
|
||||
.device-row__action > span {
|
||||
|
|
@ -388,10 +387,7 @@
|
|||
gap: 0.2rem;
|
||||
margin-top: 1rem;
|
||||
border-radius: 0.85rem;
|
||||
background:
|
||||
linear-gradient(to top, rgb(255 255 255 / 0.035) 1px, transparent 1px),
|
||||
rgb(255 255 255 / 0.018);
|
||||
background-size: 100% 25%;
|
||||
background: rgb(255 255 255 / 0.018);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
|
|
@ -399,7 +395,7 @@
|
|||
min-width: 0.16rem;
|
||||
flex: 1 1 0;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -120,15 +120,6 @@
|
|||
font-size: clamp(2.8rem, 14vw, 5rem);
|
||||
}
|
||||
|
||||
.landing-stage__field {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.landing-stage__orbit,
|
||||
.landing-stage__sweep {
|
||||
left: 74%;
|
||||
}
|
||||
|
||||
.landing-stage__status {
|
||||
top: auto;
|
||||
right: 1rem;
|
||||
|
|
@ -235,11 +226,12 @@
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.error-banner > svg {
|
||||
display: none;
|
||||
.error-banner {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.error-banner__actions {
|
||||
grid-column: 2;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
|
@ -252,7 +244,7 @@
|
|||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.feature-row > .nodedc-status-badge {
|
||||
.feature-row > .nodedc-status {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,6 @@
|
|||
|
||||
.api-dot[data-status="online"] {
|
||||
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"],
|
||||
|
|
@ -31,123 +30,12 @@
|
|||
}
|
||||
|
||||
.landing-stage {
|
||||
--landing-accent: var(--nodedc-accent-rgb);
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background:
|
||||
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%);
|
||||
background: var(--station-stage);
|
||||
}
|
||||
|
||||
.landing-stage__copy {
|
||||
|
|
@ -226,28 +114,3 @@
|
|||
font-weight: 780;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,11 @@
|
|||
height: 100% !important;
|
||||
}
|
||||
|
||||
.rerun-viewport:is([data-status="loading"], [data-status="error"])
|
||||
.rerun-viewport__canvas > div {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.rerun-viewport__notice {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
|
@ -82,7 +87,7 @@
|
|||
}
|
||||
|
||||
.rerun-viewport__notice--error {
|
||||
background: rgb(var(--nodedc-danger-rgb) / 0.12);
|
||||
background: rgb(10 11 14 / 0.9);
|
||||
}
|
||||
|
||||
.busy-indicator {
|
||||
|
|
@ -90,7 +95,7 @@
|
|||
height: 1rem;
|
||||
flex: 0 0 1rem;
|
||||
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%;
|
||||
animation: viewer-spin 900ms linear infinite;
|
||||
}
|
||||
|
|
@ -103,26 +108,11 @@
|
|||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 68% 41%, rgb(var(--nodedc-accent-rgb) / 0.075), transparent 28%),
|
||||
linear-gradient(125deg, #08090c, #040508);
|
||||
background: #06070a;
|
||||
}
|
||||
|
||||
.empty-spatial-stage__grid {
|
||||
position: absolute;
|
||||
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;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spatial-axis {
|
||||
|
|
@ -150,9 +140,9 @@
|
|||
.spatial-axis::before { transform: rotate(-24deg); }
|
||||
.spatial-axis::after { transform: rotate(-90deg); }
|
||||
.spatial-axis span { position: absolute; }
|
||||
.spatial-axis span[data-axis="x"] { right: 0; bottom: 0; color: #ff6a7a; }
|
||||
.spatial-axis span[data-axis="y"] { top: 0; left: 0.55rem; color: #75d98a; }
|
||||
.spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: #67a9ff; }
|
||||
.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: var(--nodedc-text-muted); }
|
||||
.spatial-axis span[data-axis="z"] { right: 0.55rem; bottom: 2.2rem; color: var(--nodedc-text-muted); }
|
||||
|
||||
.empty-spatial-stage__message {
|
||||
position: absolute;
|
||||
|
|
@ -256,7 +246,7 @@
|
|||
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 {
|
||||
right: 0.85rem;
|
||||
|
|
@ -296,24 +286,39 @@
|
|||
display: block;
|
||||
height: 100%;
|
||||
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 code { color: var(--nodedc-text-muted); font-size: 0.57rem; }
|
||||
|
||||
.scene-timeline__follow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: rgb(255 255 255 / 0.055);
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.52rem;
|
||||
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"] {
|
||||
background: var(--station-success-soft);
|
||||
color: rgb(var(--nodedc-success-rgb));
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.scene-timeline__follow[data-active="true"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.spatial-contract-strip {
|
||||
|
|
@ -339,7 +344,7 @@
|
|||
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)); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
|
|
|||
|
|
@ -64,17 +64,14 @@
|
|||
|
||||
.feature-row[data-status="active"] .feature-row__mark {
|
||||
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 {
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
box-shadow: 0 0 0 0.25rem var(--station-accent-soft);
|
||||
background: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.feature-row[data-status="contract"] .feature-row__mark {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
box-shadow: 0 0 0 0.25rem var(--station-warning-soft);
|
||||
}
|
||||
|
||||
.feature-row strong,
|
||||
|
|
@ -149,6 +146,7 @@
|
|||
}
|
||||
|
||||
.pipeline-strip > div {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 6rem;
|
||||
|
|
@ -159,12 +157,23 @@
|
|||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.pipeline-strip > div[data-state="ready"] {
|
||||
background: var(--station-success-soft);
|
||||
.pipeline-strip > div::before {
|
||||
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"] {
|
||||
background: var(--station-warning-soft);
|
||||
.pipeline-strip > div[data-state="ready"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.pipeline-strip > div[data-state="contract"]::before {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
.pipeline-strip > svg {
|
||||
|
|
@ -271,12 +280,7 @@
|
|||
.camera-slot__grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.22;
|
||||
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%);
|
||||
background: rgb(255 255 255 / 0.012);
|
||||
}
|
||||
|
||||
.camera-slot header,
|
||||
|
|
@ -342,9 +346,7 @@
|
|||
min-height: 30rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background:
|
||||
radial-gradient(circle at 72% 38%, rgb(var(--nodedc-accent-rgb) / 0.08), transparent 28%),
|
||||
#0a0b0d;
|
||||
background: #0a0b0d;
|
||||
box-shadow: inset 0 0 0 1px var(--station-hairline);
|
||||
}
|
||||
|
||||
|
|
@ -355,11 +357,7 @@
|
|||
}
|
||||
|
||||
.map-canvas__grid {
|
||||
opacity: 0.45;
|
||||
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;
|
||||
background: rgb(255 255 255 / 0.012);
|
||||
}
|
||||
|
||||
.map-canvas__contours span {
|
||||
|
|
@ -383,15 +381,14 @@
|
|||
|
||||
.map-canvas svg path {
|
||||
fill: none;
|
||||
stroke: rgb(var(--nodedc-accent-rgb));
|
||||
stroke: var(--nodedc-text-secondary);
|
||||
stroke-linecap: round;
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 8px rgb(var(--nodedc-accent-rgb) / 0.34));
|
||||
}
|
||||
|
||||
.map-canvas svg circle {
|
||||
fill: #0a0b0d;
|
||||
stroke: rgb(var(--nodedc-accent-rgb));
|
||||
stroke: var(--nodedc-text-secondary);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
|
|
@ -448,7 +445,7 @@
|
|||
}
|
||||
|
||||
.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="orange"] { background: #f2af4c; }
|
||||
|
||||
|
|
@ -529,8 +526,7 @@
|
|||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
box-shadow: 0 0 0 0.26rem var(--station-accent-soft);
|
||||
background: var(--nodedc-text-primary);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@ import {
|
|||
type EventSocketStatus,
|
||||
type LiveRequest,
|
||||
type ReplayRequest,
|
||||
type ViewerSettings,
|
||||
} from "./api";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
|
||||
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
|
||||
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop";
|
||||
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer";
|
||||
|
||||
function messageFor(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
|
|
@ -26,6 +27,7 @@ function messageFor(error: unknown): string {
|
|||
}
|
||||
|
||||
function measuredLatency(state: ConsoleState | null): number | null {
|
||||
if (state?.source_mode !== "live") return null;
|
||||
const metrics = state?.metrics;
|
||||
if (!metrics) return null;
|
||||
|
||||
|
|
@ -34,11 +36,10 @@ function measuredLatency(state: ConsoleState | null): number | null {
|
|||
|
||||
const segments = [
|
||||
metrics.mqtt_to_decode_ms,
|
||||
metrics.decode_ms,
|
||||
metrics.publish_ms,
|
||||
].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() {
|
||||
|
|
@ -130,6 +131,11 @@ export function useK1Console() {
|
|||
[run],
|
||||
);
|
||||
|
||||
const updateViewerSettings = useCallback(
|
||||
(request: ViewerSettings) => run("viewer", () => api.updateViewerSettings(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh(true);
|
||||
|
|
@ -171,7 +177,10 @@ export function useK1Console() {
|
|||
|
||||
useEffect(() => {
|
||||
const latency = measuredLatency(state);
|
||||
if (latency === null) return;
|
||||
if (latency === null) {
|
||||
setLatencyHistory((values) => (values.length ? [] : values));
|
||||
return;
|
||||
}
|
||||
setLatencyHistory((values) => [...values.slice(-23), latency]);
|
||||
}, [state]);
|
||||
|
||||
|
|
@ -189,5 +198,6 @@ export function useK1Console() {
|
|||
startLive,
|
||||
startReplay,
|
||||
stop,
|
||||
updateViewerSettings,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ function DeviceRow({
|
|||
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={selected ? "accent" : "secondary"}
|
||||
variant={selected ? "primary" : "secondary"}
|
||||
disabled={device.connectable === false}
|
||||
onClick={onSelect}
|
||||
>
|
||||
|
|
@ -116,7 +116,7 @@ function LatencyTrace({ values }: { values: number[] }) {
|
|||
const ceiling = Math.max(16, ...values);
|
||||
|
||||
return (
|
||||
<div className="latency-trace" aria-label="Последние измерения задержки потока">
|
||||
<div className="latency-trace" aria-label="Последние измерения времени до публикации">
|
||||
{values.length ? (
|
||||
values.map((value, index) => (
|
||||
<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 {
|
||||
state,
|
||||
backendStatus,
|
||||
|
|
@ -173,7 +179,8 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
|||
}
|
||||
}, [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 frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
|
|
@ -200,25 +207,27 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
|||
if (succeeded) setPassword("");
|
||||
};
|
||||
|
||||
const submitLive = () => {
|
||||
const submitLive = async () => {
|
||||
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);
|
||||
void startReplay({
|
||||
const started = await startReplay({
|
||||
path: replayPath.trim(),
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
|
||||
loop: replayLoop,
|
||||
});
|
||||
if (started) onOpenSpatialScene();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="device-workspace">
|
||||
{error ? (
|
||||
<aside className="error-banner" role="alert">
|
||||
<Icon name="alert" size={18} />
|
||||
<span className="error-banner__dot" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Локальная операция завершилась ошибкой</strong>
|
||||
<p>{localizeRuntimeMessage(error)}</p>
|
||||
|
|
@ -252,10 +261,10 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
|||
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
|
||||
<MetricCard
|
||||
featured
|
||||
eyebrow="ЗАДЕРЖКА КОНТУРА"
|
||||
eyebrow="ДО ПУБЛИКАЦИИ"
|
||||
value={formatNumber(latency)}
|
||||
unit="мс"
|
||||
detail="Приём MQTT → публикация предпросмотра"
|
||||
detail="MQTT callback → Rerun SDK; без экрана"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ЧАСТОТА КАДРОВ"
|
||||
|
|
@ -387,7 +396,7 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
|||
</div>
|
||||
<Button
|
||||
width="full"
|
||||
variant="accent"
|
||||
variant="primary"
|
||||
icon={<Icon name="network" />}
|
||||
disabled={!canConnect}
|
||||
onClick={() => void submitConnect()}
|
||||
|
|
@ -434,10 +443,10 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
|||
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
|
||||
/>
|
||||
<Button
|
||||
variant="accent"
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={isBusy || !liveTargetReady}
|
||||
onClick={submitLive}
|
||||
onClick={() => void submitLive()}
|
||||
>
|
||||
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
|
||||
</Button>
|
||||
|
|
@ -473,10 +482,10 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
|||
onChange={setReplayLoop}
|
||||
/>
|
||||
<Button
|
||||
variant="accent"
|
||||
variant="primary"
|
||||
icon={<Icon name="video" />}
|
||||
disabled={isBusy || replayPath.trim().length === 0}
|
||||
onClick={submitReplay}
|
||||
onClick={() => void submitReplay()}
|
||||
>
|
||||
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
|
||||
</Button>
|
||||
|
|
@ -521,7 +530,7 @@ export function DeviceWorkspace({ console }: { console: ConsoleController }) {
|
|||
<header className="panel-heading panel-heading--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
|
||||
<h2>Задержка потока</h2>
|
||||
<h2>Время до публикации</h2>
|
||||
</div>
|
||||
<strong className="latency-now">
|
||||
{formatNumber(latency)} <span>мс</span>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import {
|
|||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
|
|
@ -21,15 +20,9 @@ import {
|
|||
type WorkspaceDefinition,
|
||||
} from "../productModel";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||
import type { SceneProjection, SceneSettings } from "../sceneSettings";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
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" {
|
||||
if (status === "active") return "success";
|
||||
if (status === "ready") return "accent";
|
||||
|
|
@ -93,7 +86,6 @@ export interface WorkspaceRendererProps {
|
|||
backendStatus: BackendStatus;
|
||||
sourceUrl: string;
|
||||
sceneSettings: SceneSettings;
|
||||
onSceneSettingsChange: (settings: SceneSettings) => void;
|
||||
navigation: WorkspaceNavigation;
|
||||
}
|
||||
|
||||
|
|
@ -103,12 +95,13 @@ function OverviewWorkspace({
|
|||
backendStatus,
|
||||
navigation,
|
||||
}: 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 frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
const adapterOnline = backendStatus === "online";
|
||||
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
|
||||
const rerunReady = Boolean(state?.rerun_grpc_url);
|
||||
|
||||
return (
|
||||
<div className="standard-workspace overview-workspace">
|
||||
|
|
@ -119,10 +112,10 @@ function OverviewWorkspace({
|
|||
<section className="metrics-grid" aria-label="Оперативные показатели">
|
||||
<MetricCard
|
||||
featured
|
||||
eyebrow="ЗАДЕРЖКА"
|
||||
eyebrow="ДО ПУБЛИКАЦИИ"
|
||||
value={formatNumber(latency)}
|
||||
unit="мс"
|
||||
detail="Локальный путь приёма и публикации"
|
||||
detail="MQTT callback → Rerun SDK; без экрана"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ЧАСТОТА"
|
||||
|
|
@ -166,10 +159,10 @@ function OverviewWorkspace({
|
|||
<small>{sourceModeLabel(state?.source_mode)}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state="contract">
|
||||
<div data-state={rerunReady ? "ready" : "idle"}>
|
||||
<span>03</span>
|
||||
<strong>Rerun</strong>
|
||||
<small>ожидает источник</small>
|
||||
<small>{rerunReady ? "gRPC опубликован" : "ожидает поток"}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state="ready">
|
||||
|
|
@ -227,7 +220,6 @@ function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
|||
<div className="empty-spatial-stage__grid" aria-hidden="true" />
|
||||
<div className="spatial-axis" aria-hidden="true">
|
||||
<span data-axis="x">X</span>
|
||||
<span data-axis="y">Y</span>
|
||||
<span data-axis="z">Z</span>
|
||||
</div>
|
||||
<div className="empty-spatial-stage__message">
|
||||
|
|
@ -246,13 +238,13 @@ function SpatialWorkspace({
|
|||
state,
|
||||
sourceUrl,
|
||||
sceneSettings,
|
||||
onSceneSettingsChange,
|
||||
navigation,
|
||||
}: WorkspaceRendererProps) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
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 frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
|
|
@ -276,12 +268,7 @@ function SpatialWorkspace({
|
|||
<div className="spatial-workspace">
|
||||
<div className="spatial-toolbar">
|
||||
<div className="spatial-toolbar__mode">
|
||||
<SegmentedControl
|
||||
label="Проекция сцены"
|
||||
value={sceneSettings.projection}
|
||||
items={projectionItems}
|
||||
onChange={(projection) => onSceneSettingsChange({ ...sceneSettings, projection })}
|
||||
/>
|
||||
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
|
||||
</div>
|
||||
<div className="spatial-toolbar__actions">
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
||||
|
|
@ -323,7 +310,7 @@ function SpatialWorkspace({
|
|||
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Задержка</span>
|
||||
<span>До публикации</span>
|
||||
<strong>{formatNumber(latency)}<small> мс</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -333,7 +320,7 @@ function SpatialWorkspace({
|
|||
<Icon name="alert" />
|
||||
<span>
|
||||
Локальный поток <strong>{sourceModeLabel(state.source_mode).toLocaleLowerCase("ru-RU")}</strong> активен,
|
||||
но Rerun-адаптер пока не назначен.
|
||||
Rerun-мост запускается и опубликует адрес автоматически.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -366,7 +353,7 @@ function SpatialWorkspace({
|
|||
<div className="spatial-contract-strip">
|
||||
<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" />Объекты / маски</span>
|
||||
<span><i data-state="contract" />Компоновка</span>
|
||||
|
|
@ -532,7 +519,7 @@ function MissionWorkspace({ definition }: WorkspaceRendererProps) {
|
|||
<span><i />Связь</span>
|
||||
<span><i />Безопасность</span>
|
||||
</div>
|
||||
<Button variant="accent" width="full" disabled>Сохранить миссию</Button>
|
||||
<Button variant="primary" width="full" disabled>Сохранить миссию</Button>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
<FeatureInventory definition={definition} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
real K1 data and does not generate a placeholder cloud, pose or latency value.
|
||||
Status: the Rerun alpha milestone is implemented for the verified firmware-3
|
||||
MQTT streams. Live capture and replay use real K1 point-cloud and pose messages;
|
||||
the application does not generate a placeholder cloud, trajectory, camera frame
|
||||
or latency value.
|
||||
|
||||
## Architecture
|
||||
## Active device-to-scene path
|
||||
|
||||
```text
|
||||
K1 lio_pcl / lio_pose
|
||||
|
|
@ -11,139 +13,263 @@ K1 lio_pcl / lio_pose
|
|||
v
|
||||
read-only MQTT subscription on TCP 1883
|
||||
|
|
||||
+--> raw .k1mqtt + JSONL + SHA-256 summary (first)
|
||||
+--> raw .k1mqtt + JSONL + SHA-256 summary (written first)
|
||||
|
|
||||
v
|
||||
bounded latest-wins preview queue (32 messages)
|
||||
|
|
||||
v
|
||||
raw-LZ4/protobuf decoder --> foxglove.PointCloud / Pose / SceneUpdate
|
||||
reviewed raw-LZ4/protobuf decoders
|
||||
|
|
||||
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
|
||||
```
|
||||
|
||||
The Paho MQTT callback never decodes the point cloud. It writes and flushes the
|
||||
raw frame and metadata, then enqueues a preview reference. If visualization cannot
|
||||
keep up, the oldest queued preview is discarded while the raw capture continues.
|
||||
The Paho MQTT callback does not decode or render the point cloud. It first
|
||||
writes and flushes the raw frame and metadata, then enqueues a preview message.
|
||||
If visualization cannot keep up, the oldest queued preview is discarded while
|
||||
the raw capture continues. Rerun work stays on the dedicated publisher thread
|
||||
and cannot block raw-first evidence capture.
|
||||
|
||||
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
|
||||
|
||||
Prerequisites are the repository-local Python environment and Node.js 20 or
|
||||
newer. No global Python package or system component is installed.
|
||||
Prerequisites are the repository-local Python environment and Node.js 20.19+ or
|
||||
22.12+. No global Python package or system component is installed.
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
cd apps/k1-viewer
|
||||
npm install
|
||||
npm run typecheck
|
||||
npm run build
|
||||
cd ../..
|
||||
uv run k1link serve
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8000`. `k1link serve` intentionally exposes no LAN bind
|
||||
option because its provisioning endpoint temporarily receives a Wi-Fi password.
|
||||
The password is accepted only in the POST body, is never logged or persisted by
|
||||
the connector, and is cleared from the React form after success.
|
||||
Open `http://127.0.0.1:8000`. The static Control Station, REST API, WebSocket
|
||||
state channel and credential endpoint bind to loopback. The Rerun gRPC server is
|
||||
a separate listener with a different network boundary described under
|
||||
[Network and security boundary](#network-and-security-boundary).
|
||||
|
||||
## Replay the captured proof
|
||||
## Replay a reviewed capture
|
||||
|
||||
1. In **Session**, select **Replay capture**.
|
||||
2. Enter the repository-relative path:
|
||||
1. In **Парк → Локальное устройство**, choose the replay mode.
|
||||
2. Enter a native `mqtt.raw.k1mqtt` capture or a reviewed four-column TSV,
|
||||
for example:
|
||||
|
||||
```text
|
||||
sessions/20260715T122850Z_live_power_cycle/captures/mqtt_scan_full_payloads_03.tsv
|
||||
```
|
||||
|
||||
3. Use speed `1` and enable loop for initial viewer setup.
|
||||
4. Start replay, then select **Open Foxglove 3D**.
|
||||
5. In a Foxglove 3D panel, enable `/k1/points`, `/k1/pose` and
|
||||
`/k1/trajectory`.
|
||||
6. For the cloud, choose `intensity` as the color field and a Turbo/Rainbow
|
||||
colormap or custom two-color gradient. Foxglove can also color by `x`, `y`,
|
||||
`z` and its derived `<distance>`.
|
||||
3. Select replay speed and optional looping, then start the session.
|
||||
4. The runtime starts the process-wide Rerun `RecordingStream` and its
|
||||
gRPC/proxy server on TCP 9876 before the first replay. Later sessions reuse it.
|
||||
5. Open **Наблюдение → Пространственная сцена**. With the manual source field
|
||||
empty, the embedded viewer automatically uses:
|
||||
|
||||
The reviewed TSV reader accepts exactly four columns: receive timestamp, topic,
|
||||
declared payload length and hex payload. It verifies timestamp, UTF-8 topic,
|
||||
declared length, hex encoding and allocation bounds. Native
|
||||
`mqtt.raw.k1mqtt` captures are supported directly and use their sibling metadata
|
||||
timestamps when present.
|
||||
```text
|
||||
rerun+http://127.0.0.1:9876/proxy
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
1. Power K1 to its normal steady-green standby state.
|
||||
2. Confirm the manual power checklist in **Connect**.
|
||||
3. Run the real six-second BLE scan and select the K1 candidate.
|
||||
4. Enter the existing router SSID/password and press **Provision Wi-Fi &
|
||||
connect**. That button is the explicit authorization for one reviewed 99-byte
|
||||
provisioning write; the backend never retries automatically.
|
||||
5. When K1 reports a non-AP private address, press **Start live stream**.
|
||||
6. Open Foxglove before scanning if convenient.
|
||||
7. Double-click the physical K1 button to start scanning. Double-click again to
|
||||
stop, wait for the LED to return to steady green, then stop the local session.
|
||||
2. Confirm the manual power checklist in **Парк → Локальное устройство**.
|
||||
3. Run the real six-second BLE scan and select the intended device from the
|
||||
complete visible-device list.
|
||||
4. Enter the existing router SSID/password and explicitly authorize the reviewed
|
||||
provisioning write. The backend does not retry the write automatically.
|
||||
5. When K1 reports a non-AP private address, start live reception.
|
||||
6. Wait until the UI reports that the local Rerun bridge is ready. No manual
|
||||
viewer URL is needed.
|
||||
7. Open **Наблюдение → Пространственная сцена**.
|
||||
8. Double-click the physical K1 button to start scanning. Real point frames and
|
||||
pose/trajectory updates then appear in the embedded viewport.
|
||||
9. Double-click K1 again to stop physical scanning, wait for steady green, then
|
||||
stop the local session so captures and summaries are finalized.
|
||||
|
||||
Each live run creates an ignored `sessions/<UTC>_viewer_live/` directory with a
|
||||
redacted manifest, operator notes, raw MQTT frames, per-message metadata and a
|
||||
hash summary. 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 |
|
||||
| `/k1/pose` | `foxglove.PoseInFrame` | current decoded K1 pose in the `map` frame |
|
||||
| `/k1/trajectory` | `foxglove.SceneUpdate` | bounded cyan line strip, throttled while growing |
|
||||
| `/k1/metrics` | JSON schema | latency, decode/publish time, FPS, points and drops |
|
||||
| `/world` | `ViewCoordinates` | right-handed Z-up display convention |
|
||||
| `/world/points` | `Points3D` | decoded metric XYZ with computed or available RGB colors |
|
||||
| `/world/sensor_pose` | `Transform3D` + `TransformAxes3D` | current decoded translation, xyzw quaternion and pose axes |
|
||||
| `/world/trajectory` | `LineStrips3D` | bounded path of up to 20,000 decoded poses |
|
||||
|
||||
Point coordinates remain `(x/scaler, y/scaler, z/scaler)` exactly as verified.
|
||||
No axis swap, quaternion normalization or vehicle extrinsic is silently applied.
|
||||
Only the low byte of `rgbi` is labeled intensity; interpreting the upper bytes
|
||||
as RGB remains unverified.
|
||||
Point count, frame rate, queue drops and measured pipeline time remain product
|
||||
metrics in the outer Control Station; they are not logged as Rerun entities and
|
||||
therefore cannot create additional automatic viewer panes.
|
||||
|
||||
## 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
|
||||
from MQTT callback receipt through raw disk write, bounded queue wait, decode,
|
||||
packing and Foxglove channel publish. Rolling p50/p95 values use the last 512
|
||||
published decoded messages.
|
||||
Only the low byte of firmware-3 `rgbi` has been verified as intensity. The
|
||||
upper bytes are not labeled RGB. Legacy frames use RGB only when those decoded
|
||||
fields are actually present.
|
||||
|
||||
This is the exact Mac pipeline latency, not yet sensor-photon-to-screen latency.
|
||||
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
|
||||
clock-correlated device timestamp or high-speed-camera experiment. Foxglove
|
||||
message time therefore uses host receive Unix time while durations use
|
||||
`monotonic_ns`.
|
||||
## Scene controls
|
||||
|
||||
The current product controls call `POST /api/viewer/settings`; backend state is
|
||||
authoritative and the bridge applies the updated settings to subsequent
|
||||
messages.
|
||||
|
||||
| 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
|
||||
|
||||
- Raw panoramic camera frames were not present on the observed MQTT report
|
||||
topics; this milestone intentionally ships point cloud plus trajectory only.
|
||||
- Physical double-click remains the start/stop control. A future MQTT modeling
|
||||
- Raw panoramic camera frames were absent from the observed MQTT report topics.
|
||||
This milestone contains point cloud and pose/trajectory; operational metrics
|
||||
are rendered by the outer Control Station.
|
||||
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
|
||||
publisher needs a separately reviewed state-changing profile.
|
||||
- The React application opens the official Foxglove viewer over a local
|
||||
WebSocket. It does not embed or fork the proprietary modern viewer; Foxglove
|
||||
account/seat terms apply to that viewer independently of this connector.
|
||||
- Exact path axes and a scanner-to-vehicle transform must be calibrated before
|
||||
mounting on the unmanned platform.
|
||||
- No terrain map, elevation model, obstacle segmentation, localization fusion,
|
||||
mission planner or vehicle control is implemented by this viewer milestone.
|
||||
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting
|
||||
calibration task.
|
||||
- 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
|
||||
bridge without a viewer-side substitute:
|
||||
A powered K1 capture was passed through the active MQTT → decoder → Rerun gRPC →
|
||||
embedded Web Viewer path without a viewer-side substitute:
|
||||
|
||||
| Check | Result |
|
||||
| --- | ---: |
|
||||
| MQTT messages read | 2,836 |
|
||||
| `lio_pcl` frames published | 1,140 |
|
||||
| `lio_pose` frames published | 1,215 |
|
||||
| points packed and published | 4,165,862 |
|
||||
| real MQTT messages processed | 80 |
|
||||
| `lio_pcl` frames published | 38 |
|
||||
| `lio_pose` frames published | 42 |
|
||||
| points in the last cloud | 2,775 |
|
||||
| decoder errors | 0 |
|
||||
| final trajectory poses | 1,215 |
|
||||
|
||||
The same replay was then started through the FastAPI/React contract at `10x`.
|
||||
The API exposed the loopback Foxglove URL, live point/pose metrics and non-zero
|
||||
preview-drop accounting under deliberate acceleration, and released TCP 8765
|
||||
after the stop request. This validates replay, overload behavior and lifecycle;
|
||||
the next checkpoint is the powered K1 ideal-LAN live run.
|
||||
The 38 point frames and 42 pose frames account for all 80 observed messages.
|
||||
This proves current real-device decoding, bridge publication and embedded-viewer
|
||||
delivery for point cloud plus trajectory. It does not prove a camera channel or
|
||||
sensor-to-screen latency.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ dependencies = [
|
|||
"foxglove-sdk==0.25.3",
|
||||
"lz4>=4.4,<5",
|
||||
"paho-mqtt>=2.1,<3",
|
||||
"rerun-sdk==0.34.1",
|
||||
"rich>=13.9,<15",
|
||||
"typer>=0.15,<1",
|
||||
"uvicorn[standard]>=0.35,<1",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -11,9 +11,10 @@ from typing import Literal, TypedDict
|
|||
|
||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||
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.replay import iter_replay_messages
|
||||
from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings
|
||||
|
||||
RuntimePhase = Literal[
|
||||
"idle",
|
||||
|
|
@ -25,6 +26,7 @@ RuntimePhase = Literal[
|
|||
]
|
||||
SourceMode = Literal["idle", "live", "replay"]
|
||||
StateCallback = Callable[[], None]
|
||||
BridgeFactory = Callable[..., RerunBridge]
|
||||
|
||||
|
||||
class RuntimeSnapshot(TypedDict):
|
||||
|
|
@ -33,13 +35,21 @@ class RuntimeSnapshot(TypedDict):
|
|||
source_mode: SourceMode
|
||||
foxglove_ws_url: str | None
|
||||
foxglove_viewer_url: str | None
|
||||
rerun_grpc_url: str | None
|
||||
viewer_settings: dict[str, object]
|
||||
metrics: MetricsSnapshot
|
||||
|
||||
|
||||
class VisualizationRuntime:
|
||||
"""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._on_state_change = on_state_change
|
||||
self._thread: threading.Thread | None = None
|
||||
|
|
@ -49,6 +59,12 @@ class VisualizationRuntime:
|
|||
self._source_mode: SourceMode = "idle"
|
||||
self._foxglove_ws_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()
|
||||
|
||||
def snapshot(self) -> RuntimeSnapshot:
|
||||
|
|
@ -59,9 +75,17 @@ class VisualizationRuntime:
|
|||
"source_mode": self._source_mode,
|
||||
"foxglove_ws_url": self._foxglove_ws_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(),
|
||||
}
|
||||
|
||||
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:
|
||||
resolved = path.expanduser().resolve()
|
||||
if not resolved.is_file():
|
||||
|
|
@ -123,6 +147,34 @@ class VisualizationRuntime:
|
|||
assert thread is not None
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -132,6 +184,8 @@ class VisualizationRuntime:
|
|||
target: Callable[[], None],
|
||||
) -> None:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("runtime завершён; перезапустите локальный сервер")
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
raise RuntimeError("поток уже запущен; сначала остановите текущую сессию")
|
||||
self._stop_event = threading.Event()
|
||||
|
|
@ -221,6 +275,7 @@ class VisualizationRuntime:
|
|||
messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=32)
|
||||
source_done = threading.Event()
|
||||
publisher_ready = threading.Event()
|
||||
publisher_aborted = threading.Event()
|
||||
publisher_error: list[BaseException] = []
|
||||
|
||||
def enqueue(message: StreamMessage) -> None:
|
||||
|
|
@ -241,19 +296,41 @@ class VisualizationRuntime:
|
|||
self._metrics.preview_dropped()
|
||||
|
||||
def publish() -> None:
|
||||
bridge: FoxgloveBridge | None = None
|
||||
bridge: RerunBridge | None = None
|
||||
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:
|
||||
self._foxglove_ws_url = bridge.websocket_url
|
||||
self._foxglove_viewer_url = bridge.viewer_url
|
||||
if self._phase != "stopping":
|
||||
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:
|
||||
if self._closed:
|
||||
publisher_aborted.set()
|
||||
else:
|
||||
self._rerun_grpc_url = bridge.grpc_url
|
||||
if not self._closed and self._phase != "stopping":
|
||||
self._phase = running_phase
|
||||
self._message = (
|
||||
"Локальный мост визуализации готов; источник данных запущен."
|
||||
"Локальный Rerun-мост готов; источник данных запущен."
|
||||
)
|
||||
publisher_ready.set()
|
||||
self._notify()
|
||||
if publisher_aborted.is_set():
|
||||
return
|
||||
while not source_done.is_set() or not messages.empty():
|
||||
if self._stop_event.is_set() and source_done.is_set() and messages.empty():
|
||||
break
|
||||
|
|
@ -268,19 +345,43 @@ class VisualizationRuntime:
|
|||
if self._metrics.snapshot()["messages_received"] % 10 == 0:
|
||||
self._notify()
|
||||
except BaseException as exc:
|
||||
with self._lock:
|
||||
closed = self._closed
|
||||
if not closed:
|
||||
publisher_error.append(exc)
|
||||
else:
|
||||
publisher_aborted.set()
|
||||
publisher_ready.set()
|
||||
self._stop_event.set()
|
||||
finally:
|
||||
if bridge is not None:
|
||||
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()
|
||||
if not publisher_ready.wait(timeout=15.0):
|
||||
self._finish_error("Локальный мост визуализации не запустился за 15 секунд.")
|
||||
source_done.set()
|
||||
self._stop_event.set()
|
||||
join_publisher()
|
||||
return
|
||||
if publisher_aborted.is_set():
|
||||
source_done.set()
|
||||
self._stop_event.set()
|
||||
join_publisher()
|
||||
return
|
||||
if publisher_error:
|
||||
self._finish_error(
|
||||
|
|
@ -288,6 +389,7 @@ class VisualizationRuntime:
|
|||
f"{type(publisher_error[0]).__name__}: {publisher_error[0]}"
|
||||
)
|
||||
source_done.set()
|
||||
join_publisher()
|
||||
return
|
||||
|
||||
final_message = "Поток остановлен."
|
||||
|
|
@ -299,11 +401,8 @@ class VisualizationRuntime:
|
|||
self._finish_error(f"Ошибка источника: {type(exc).__name__}: {exc}")
|
||||
finally:
|
||||
source_done.set()
|
||||
publisher.join(timeout=15.0)
|
||||
if publisher.is_alive():
|
||||
self._stop_event.set()
|
||||
self._finish_error("Очередь публикации не завершилась за 15 секунд.")
|
||||
elif publisher_error:
|
||||
join_publisher()
|
||||
if publisher_error:
|
||||
self._finish_error(
|
||||
f"Ошибка публикации: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
|
||||
)
|
||||
|
|
@ -334,6 +433,10 @@ class VisualizationRuntime:
|
|||
self._foxglove_viewer_url = None
|
||||
self._notify()
|
||||
|
||||
def _current_scene_settings(self) -> RerunSceneSettings:
|
||||
with self._lock:
|
||||
return self._scene_settings
|
||||
|
||||
def _notify(self) -> None:
|
||||
callback = self._on_state_change
|
||||
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,
|
||||
"started_at_utc": started_at_utc,
|
||||
"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",
|
||||
"requested_duration_seconds": duration_seconds,
|
||||
"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 = (
|
||||
"# K1 live visualization session\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 "
|
||||
f"was a validated private IPv4 address ({host.rsplit('.', 1)[0]}.x).\n"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from bleak.exc import BleakError
|
||||
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.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
|
||||
from k1link.mqtt import validate_private_ipv4
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
|
@ -43,6 +45,17 @@ class ReplayRequest(BaseModel):
|
|||
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:
|
||||
def __init__(self, repository_root: Path) -> None:
|
||||
self.repository_root = repository_root.resolve()
|
||||
|
|
@ -100,6 +113,8 @@ class ConsoleService:
|
|||
"k1_ip": k1_ip,
|
||||
"foxglove_ws_url": runtime["foxglove_ws_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"],
|
||||
"metrics": {
|
||||
"pipeline_ms": metrics["mqtt_to_publish_ms"],
|
||||
|
|
@ -213,6 +228,21 @@ class ConsoleService:
|
|||
self.runtime.stop()
|
||||
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:
|
||||
with self._lock:
|
||||
self._operation_phase = phase
|
||||
|
|
@ -220,12 +250,23 @@ class ConsoleService:
|
|||
|
||||
|
||||
service = ConsoleService(REPOSITORY_ROOT)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
service.runtime.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="NODE.DC Device Control API",
|
||||
version=__version__,
|
||||
docs_url="/api/docs",
|
||||
redoc_url=None,
|
||||
openapi_url="/api/openapi.json",
|
||||
lifespan=app_lifespan,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -284,6 +325,11 @@ def stop_session() -> dict[str, Any]:
|
|||
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")
|
||||
async def events(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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]
|
||||
|
||||
|
||||
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
97
uv.lock
|
|
@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "bleak"
|
||||
version = "3.0.2"
|
||||
|
|
@ -269,6 +278,7 @@ dependencies = [
|
|||
{ name = "foxglove-sdk" },
|
||||
{ name = "lz4" },
|
||||
{ name = "paho-mqtt" },
|
||||
{ name = "rerun-sdk" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
|
|
@ -288,6 +298,7 @@ requires-dist = [
|
|||
{ name = "foxglove-sdk", specifier = "==0.25.3" },
|
||||
{ name = "lz4", specifier = ">=4.4,<5" },
|
||||
{ name = "paho-mqtt", specifier = ">=2.1,<3" },
|
||||
{ name = "rerun-sdk", specifier = "==0.34.1" },
|
||||
{ name = "rich", specifier = ">=13.9,<15" },
|
||||
{ name = "typer", specifier = ">=0.15,<1" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" },
|
||||
|
|
@ -300,6 +311,25 @@ dev = [
|
|||
{ 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]]
|
||||
name = "packaging"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pluggy"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pydantic"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "rich"
|
||||
version = "14.3.4"
|
||||
|
|
|
|||
Loading…
Reference in New Issue