feat: activate self-hosted lidar spatial scene

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

View File

@ -7,8 +7,10 @@ or speculative writes.
Current status: live proof completed on firmware 3.0.2. The Mac provisioned the
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)

View File

@ -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
└── продуктовая пространственная сцена
└── встроенный @rerun-io/web-viewer
└── RRD URL или Rerun gRPC/proxy source
K1 MQTT :1883, read-only
└── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
└── bounded latest-wins preview queue (32 сообщения)
└── проверенный protobuf/LZ4 decoder
└── Rerun Points3D + Transform3D + LineStrips3D
└── gRPC/proxy TCP 9876
└── rerun_grpc_url в REST/WebSocket state
└── встроенный @rerun-io/web-viewer
└── пространственная сцена NODE.DC
React Control Station ←→ REST /api/* + WebSocket /api/events
FastAPI на 127.0.0.1:8000
CoreBluetooth + live/replay runtime
```
Между декодером 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.512.0`, режимы
цвета `intensity`, `height`, `distance`, `rgb`, `class`, палитры Turbo, Viridis,
Plasma, grayscale и custom, накопление `0120` секунд, а также видимость облака,
траектории и сетки. Значение по умолчанию — 12 секунд истории реальных кадров.
Для текущего firmware-3 `lio_pcl` доказана только интенсивность из младшего
байта `rgbi`; RGB используется лишь когда он действительно присутствует в
декодированном формате. Custom/class сейчас означает выбранный сплошной цвет,
а не готовую семантическую классификацию.
Custom timeline, переключатель 2D/3D/карты, семантические слои и сохранение RBL
пока не вызывают Blueprint/playback API. Черновик компоновки фиксируется только
в памяти текущей страницы и не записывается на диск.
## Контракт локального API
@ -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
и безопасная документация.

View File

@ -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>
<TextField
label="Адрес источника"
hint="URL"
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."
/>
<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>
</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="необязательно"
value={sourceDraft}
onChange={(event) => setSourceDraft(event.target.value)}
spellCheck={false}
placeholder="rerun+http://127.0.0.1:9876/proxy"
description="Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."
/>
</div>
),
},
{
id: "formats",
label: "Поддерживаемые адреса",
description: "Rerun gRPC и RRD",
content: (
<div className="inspector-control-stack">
<ControlRow label="Живой поток" layout="stack">
<code className="scene-window-code">rerun+http://…/proxy</code>
</ControlRow>
<ControlRow label="Локальная запись" layout="stack">
<code className="scene-window-code">http://…/recording.rrd</code>
</ControlRow>
<ControlRow label="Удалённая запись" layout="stack">
<code className="scene-window-code">https://…/recording.rrd</code>
</ControlRow>
</div>
),
},
{
id: "adapter",
label: "Локальный адаптер",
description: "Транспорт устройства",
content: (
<div className="inspector-control-stack">
<ControlRow label="Контур">
<span className="scene-window-state">
<i className="api-dot" data-status={console.backendStatus} aria-hidden="true" />
{backendLabel(console.backendStatus)}
</span>
</ControlRow>
<p className="scene-window-note">
Встроенный адаптер публикует локальный Rerun gRPC автоматически после запуска
живого приёма или повтора записи. Ручной адрес для этого не требуется.
</p>
</div>
),
},
]}
/>
</Window>
<Window
open={displayWindowOpen}
title="Параметры отображения"
subtitle="ПРОСТРАНСТВЕННАЯ СЦЕНА"
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,143 +520,138 @@ 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"]}
sections={[
{
id: "points",
label: "Облако точек",
description: "Размер, атрибут и градиент",
group: "ВИЗУАЛЬНЫЙ ПРОФИЛЬ",
content: (
<div className="inspector-control-stack">
<RangeControl
label="Размер точки"
value={displayDraft.pointSize}
min={0.5}
max={12}
step={0.5}
formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => setDisplayDraft((current) => ({ ...current, pointSize }))}
<Inspector
defaultOpen={["points"]}
singleOpen
sections={[
{
id: "points",
label: "Облако точек",
description: "Размер и способ окрашивания",
content: (
<div className="inspector-control-stack">
<RangeControl
label="Размер точки"
value={displayDraft.pointSize}
min={0.5}
max={12}
step={0.5}
formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => setDisplayDraft((current) => ({ ...current, pointSize }))}
/>
<ControlRow label="Атрибут цвета">
<Select
variant="split"
label="Атрибут цвета"
value={displayDraft.colorMode}
options={colorModeOptions}
onChange={(colorMode) => setDisplayDraft((current) => ({ ...current, colorMode }))}
/>
<ControlRow label="Атрибут цвета" layout="stack">
<Select
label="Атрибут цвета"
value={displayDraft.colorMode}
options={colorModeOptions}
onChange={(colorMode) => setDisplayDraft((current) => ({ ...current, colorMode }))}
</ControlRow>
<ControlRow label="Палитра">
<Select
variant="split"
label="Палитра"
value={displayDraft.palette}
options={paletteOptions}
onChange={(palette) => setDisplayDraft((current) => ({ ...current, palette }))}
/>
</ControlRow>
{displayDraft.palette === "custom" ? (
<ControlRow label="Цвет точек">
<ColorField
label="Цвет точек"
value={displayDraft.customColor}
onChange={(customColor) => setDisplayDraft((current) => ({ ...current, customColor }))}
/>
</ControlRow>
<ControlRow label="Палитра" layout="stack">
<Select
label="Палитра"
value={displayDraft.palette}
options={paletteOptions}
onChange={(palette) => setDisplayDraft((current) => ({ ...current, palette }))}
/>
</ControlRow>
{displayDraft.palette === "custom" ? (
<ControlRow label="Цвет точек">
<ColorField
label="Цвет точек"
value={displayDraft.customColor}
onChange={(customColor) => setDisplayDraft((current) => ({ ...current, customColor }))}
/>
</ControlRow>
) : null}
</div>
),
},
{
id: "history",
label: "Накопление и время",
description: "История облака и траектория",
group: "ВИЗУАЛЬНЫЙ ПРОФИЛЬ",
content: (
<div className="inspector-control-stack">
<RangeControl
label="Окно накопления"
value={displayDraft.accumulationSeconds}
min={0}
max={120}
step={1}
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
onChange={(accumulationSeconds) => setDisplayDraft((current) => ({ ...current, accumulationSeconds }))}
/>
<Switch
checked={displayDraft.showTrajectory}
label="Показывать траекторию"
onChange={(showTrajectory) => setDisplayDraft((current) => ({ ...current, showTrajectory }))}
/>
</div>
),
},
{
id: "scene",
label: "Сцена",
description: "Сетка, подписи и области обзора камер",
group: "ВИЗУАЛЬНЫЙ ПРОФИЛЬ",
content: (
<div className="inspector-control-stack">
<Switch checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => setDisplayDraft((current) => ({ ...current, showGrid }))} />
<Switch checked={displayDraft.showLabels} label="Подписи сущностей" onChange={(showLabels) => setDisplayDraft((current) => ({ ...current, showLabels }))} />
<Switch checked={displayDraft.showCameraFrustums} label="Области обзора камер в 3D" onChange={(showCameraFrustums) => setDisplayDraft((current) => ({ ...current, showCameraFrustums }))} />
</div>
),
},
]}
/>
</div>
) : null}
</div>
),
},
{
id: "history",
label: "Накопление и время",
description: "История облака и траектория",
content: (
<div className="inspector-control-stack">
<RangeControl
label="Окно накопления"
value={displayDraft.accumulationSeconds}
min={0}
max={120}
step={1}
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
onChange={(accumulationSeconds) => setDisplayDraft((current) => ({ ...current, accumulationSeconds }))}
/>
<Checker
checked={displayDraft.showTrajectory}
label="Показывать траекторию"
description="Линия пути устройства в координатах сцены"
onChange={(showTrajectory) => setDisplayDraft((current) => ({ ...current, showTrajectory }))}
/>
</div>
),
},
{
id: "scene",
label: "Окружение сцены",
description: "Сетка, подписи и камеры",
content: (
<div className="inspector-control-stack">
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => setDisplayDraft((current) => ({ ...current, showGrid }))} />
<Checker checked={false} disabled label="Подписи сущностей" description="Появятся после подключения семантических сущностей" onChange={() => undefined} />
<Checker checked={false} disabled label="Области обзора камер" description="Камерный канал пока не подключён" onChange={() => undefined} />
</div>
),
},
]}
/>
</Window>
<Window
open={layerInspectorOpen}
title="Слои сцены"
subtitle="ДЕРЕВО СУЩНОСТЕЙ / ПЕРЕОПРЕДЕЛЕНИЯ"
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"

View File

@ -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";

View File

@ -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}>

View File

@ -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]);

View File

@ -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;
}

View File

@ -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 {

View File

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

View File

@ -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 {

View File

@ -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 {

View File

@ -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;
}

View File

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

View File

@ -18,7 +18,6 @@
.api-dot[data-status="online"] {
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;
}
}

View File

@ -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) {

View File

@ -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%);
}

View File

@ -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,
};
}

View File

@ -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 || "Сначала подключите устройство к WiFi"}
/>
<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>

View File

@ -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} />

View File

@ -1,9 +1,11 @@
# K1 live console and Foxglove bridge
# K1 live console and embedded Rerun bridge
Status: alpha implementation for the verified firmware-3 MQTT streams. It uses
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.

View File

@ -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",

View File

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

View File

@ -11,9 +11,10 @@ from typing import Literal, TypedDict
from k1link.artifacts import utc_now_iso, write_json_atomic
from k1link.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:
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:
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._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:
publisher_error.append(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:
bridge.close()
with self._lock:
close_bridge = self._closed and self._bridge is bridge
if close_bridge:
self._bridge = None
self._rerun_grpc_url = None
if close_bridge:
bridge.close()
publisher = threading.Thread(target=publish, name="k1-foxglove-publisher", daemon=True)
def join_publisher() -> None:
# Never orphan a publisher: the session thread remains its owner.
# On process shutdown both are daemon threads, so an irrecoverably
# blocked native call cannot prevent the operating system from exit.
while publisher.is_alive():
publisher.join(timeout=0.25)
publisher = threading.Thread(target=publish, name="k1-rerun-publisher", daemon=True)
publisher.start()
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"
)

View File

@ -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()

281
tests/test_rerun_bridge.py Normal file
View File

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

View File

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

97
uv.lock
View File

@ -33,6 +33,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
[[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"