feat(observation): add dynamic camera sources

This commit is contained in:
DCCONSTRUCTIONS 2026-07-16 20:56:41 +03:00
parent 05bdab24a5
commit a281faf923
15 changed files with 1592 additions and 52 deletions

View File

@ -126,7 +126,9 @@ immutable donor revision remains a packaging and CI prerequisite.
The Observation spatial workspace embeds the open-source Rerun Web Viewer
inside the Mission Core 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.
use an external hosted viewer UI. Dynamic point-cloud and camera source
composition, host-owned window layout and the current live-only timeline contract
are fixed in [`ADR 0006`](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md).
The first K1 live session or replay in a `k1link serve` process creates one local
Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes

View File

@ -29,6 +29,7 @@ import { LandingStage } from "./components/LandingStage";
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
import type { ViewerSettings } from "./core/runtime/contracts";
import { useObservationLayout } from "./core/observation/useObservationLayout";
import {
rootById,
roots,
@ -132,6 +133,7 @@ export default function App() {
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
const observationLayout = useObservationLayout(runtime.state?.observationSources ?? []);
useEffect(() => {
const remote = runtime.state?.viewerSettings;
@ -238,7 +240,7 @@ export default function App() {
if (activeDefinition?.kind === "spatial") {
actions.push(
{ label: "Настроить источник", icon: "network", onClick: openSource },
{ label: "Настроить визуальный движок", icon: "network", onClick: openSource },
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
{ label: "Открыть слои", icon: "list", onClick: openLayers },
);
@ -370,6 +372,7 @@ export default function App() {
backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl}
sceneSettings={sceneSettings}
observationLayout={observationLayout}
navigation={{
openView,
openSource,
@ -384,8 +387,8 @@ export default function App() {
<Window
open={sourceWindowOpen}
title="Источники"
subtitle="Потоки и записи пространственной сцены"
title="Визуальный движок"
subtitle="Rerun gRPC и записи пространственной сцены"
placement="end"
draggable
closeOnBackdrop={false}

View File

@ -0,0 +1,78 @@
import type { RefObject } from "react";
import { WorkspaceWindow } from "@nodedc/ui-react";
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
export function initialObservationWindowRect(index: number): ObservationWindowRect {
return {
x: 18 + (index % 3) * 354,
y: 78 + Math.floor(index / 3) * 226,
width: 336,
height: 210,
};
}
export function FloatingObservationWindow({
source,
index,
boundsRef,
rect,
maximized,
active,
onRectChange,
onMaximizedChange,
onActivate,
onClose,
}: {
source: ObservationSourceDescriptor;
index: number;
boundsRef: RefObject<HTMLElement | null>;
rect?: ObservationWindowRect;
maximized: boolean;
active: boolean;
onRectChange: (rect: ObservationWindowRect) => void;
onMaximizedChange: (maximized: boolean) => void;
onActivate: () => void;
onClose: () => void;
}) {
return (
<WorkspaceWindow
boundsRef={boundsRef}
rect={rect ?? initialObservationWindowRect(index)}
onRectChange={onRectChange}
maximized={maximized}
onMaximizedChange={onMaximizedChange}
onActivate={onActivate}
onClose={onClose}
title={source.label}
subtitle={source.description}
status={(
<span className="floating-observation-window__status">
<i data-availability={source.availability} aria-hidden="true" />
{observationSourceStatusLabel(source)}
</span>
)}
footer={(
<span className="floating-observation-window__footer">
<span>{source.endpointLabel || source.transport}</span>
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
</span>
)}
minWidth={280}
minHeight={190}
resizable={source.capabilities.resizable}
active={active}
zIndex={maximized ? 15 : active ? 9 : 7}
className="floating-observation-window"
closeLabel={`Закрыть ${source.label}`}
maximizeLabel={`Развернуть ${source.label}`}
restoreLabel={`Восстановить ${source.label}`}
moveLabel={`Переместить ${source.label}`}
resizeLabel={`Изменить размер ${source.label}`}
>
<ObservationMedia source={source} />
</WorkspaceWindow>
);
}

View File

@ -0,0 +1,149 @@
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react";
import type {
ObservationSourceAvailability,
ObservationSourceDescriptor,
ObservationSourceModality,
} from "../core/runtime/contracts";
const sourceIcon: Record<ObservationSourceModality, IconName> = {
"point-cloud": "globe",
video: "video",
image: "image",
depth: "image",
};
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
unverified: "Не подтверждён",
declared: "Канал объявлен",
available: "Доступен",
connecting: "Подключение",
streaming: "Эфир",
degraded: "Нестабильно",
unavailable: "Недоступен",
error: "Ошибка",
};
export function observationSourceStatusLabel(source: ObservationSourceDescriptor): string {
return availabilityCopy[source.availability];
}
export function ObservationMedia({ source }: { source: ObservationSourceDescriptor }) {
if (source.previewUrl && source.modality === "video") {
return (
<video
className="observation-media__asset"
src={source.previewUrl}
autoPlay
muted
playsInline
/>
);
}
if (source.previewUrl && (source.modality === "image" || source.modality === "depth")) {
return <img className="observation-media__asset" src={source.previewUrl} alt={source.label} />;
}
return (
<div className="observation-media__empty">
<Icon name={sourceIcon[source.modality]} size={20} />
<strong>{observationSourceStatusLabel(source)}</strong>
<span>
{source.modality === "video"
? "Канал известен, browser-preview ещё не подключён"
: source.description}
</span>
</div>
);
}
export function ObservationSourcePicker({
sources,
visibleSourceIds,
onToggle,
}: {
sources: readonly ObservationSourceDescriptor[];
visibleSourceIds: ReadonlySet<string>;
onToggle: (sourceId: string) => void;
}) {
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
return (
<Dropdown
className="scene-source-picker"
placement="bottom-start"
width={340}
offset={10}
surfaceRole="dialog"
surfaceClassName="observation-source-menu"
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
<button
ref={(node) => {
setAnchorRef(node);
setTriggerRef(node);
}}
type="button"
className="scene-source-picker__trigger"
data-active={open || visibleCount > 0 ? "true" : undefined}
aria-label="Источники данных сцены"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={surfaceId}
onClick={toggle}
>
<Icon name="database" size={17} />
{sources.length > 0 ? <span>{visibleCount}</span> : null}
</button>
)}
>
<header className="observation-source-menu__head">
<div>
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
<strong>Источники данных</strong>
</div>
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
</header>
{sources.length ? (
<div className="observation-source-menu__list">
{sources.map((source) => {
const selected = visibleSourceIds.has(source.id);
return (
<button
key={source.id}
type="button"
className="nodedc-dropdown-option observation-source-option"
data-selected={selected ? "true" : undefined}
aria-pressed={selected}
onClick={() => onToggle(source.id)}
>
<span className="nodedc-dropdown-option__icon">
<Icon name={sourceIcon[source.modality]} size={16} />
</span>
<span className="nodedc-dropdown-option__body">
<span className="nodedc-dropdown-option__label">{source.label}</span>
<span className="nodedc-dropdown-option__description">
<i data-availability={source.availability} aria-hidden="true" />
{observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
</span>
</span>
<span className="nodedc-dropdown-option__check">
{selected ? <Icon name="check" size={15} /> : null}
</span>
</button>
);
})}
</div>
) : (
<div className="observation-source-menu__empty">
<Icon name="database" size={18} />
<strong>Каталог пока пуст</strong>
<span>Выберите профиль устройства его плагин опубликует доступные каналы.</span>
</div>
)}
<footer className="observation-source-menu__foot">
Каналы приходят из активного device-плагина; сцена не знает модель оборудования.
</footer>
</Dropdown>
);
}

View File

@ -0,0 +1,52 @@
import { Button, Icon } from "@nodedc/ui-react";
import type { ObservationTimelineMode } from "../core/runtime/contracts";
export function ObservationTimeline({
active,
sourceCount,
mode = "live-only",
seekable = false,
synchronization = "host-arrival-best-effort",
className = "",
}: {
active: boolean;
sourceCount: number;
mode?: ObservationTimelineMode;
seekable?: boolean;
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
className?: string;
}) {
const buffered = seekable && (mode === "buffered" || mode === "recorded");
const synchronizationLabel = {
"host-arrival-best-effort": "Синхронизация по приходу",
"shared-clock": "Общие часы",
"frame-accurate": "Покадровая синхронизация",
}[synchronization];
return (
<div
className={`observation-timeline ${className}`.trim()}
data-active={active ? "true" : undefined}
data-mode={mode}
>
<Button size="compact" variant="ghost" disabled aria-label="Перейти к началу">
<Icon name="chevron-left" />
</Button>
<Button size="compact" variant="secondary" disabled>
{buffered ? "Воспроизвести" : "Только эфир"}
</Button>
<div className="observation-timeline__track" data-disabled={!buffered ? "true" : undefined}>
<span style={{ width: active ? "100%" : "0%" }} />
</div>
<div className="observation-timeline__meta">
<code>{active ? "LIVE" : "—:—:—.———"}</code>
<small>
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
</small>
</div>
<span className="observation-timeline__follow" data-active={active ? "true" : undefined}>
ЭФИР
</span>
</div>
);
}

View File

@ -0,0 +1,137 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ObservationSourceDescriptor } from "../runtime/contracts";
export interface ObservationWindowRect {
x: number;
y: number;
width: number;
height: number;
}
export interface ObservationLayoutController {
visibleSourceIds: ReadonlySet<string>;
focusedSourceId: string | null;
activeFloatingSourceId: string | null;
maximizedFloatingSourceId: string | null;
windowRects: Readonly<Record<string, ObservationWindowRect>>;
toggleSource: (sourceId: string) => void;
hideSource: (sourceId: string) => void;
setFocusedSourceId: (sourceId: string | null) => void;
activateFloatingSource: (sourceId: string) => void;
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
}
function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
if (!source.capabilities.defaultVisible) return false;
if (source.availability === "unavailable" || source.availability === "error") return false;
return source.modality === "point-cloud" || Boolean(source.previewUrl);
}
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
return sources
.map((source) => [
source.id,
source.binding.deviceSessionId,
source.binding.deviceId,
].filter(Boolean).join(":"))
.sort()
.join("|");
}
export function useObservationLayout(
sources: readonly ObservationSourceDescriptor[],
): ObservationLayoutController {
const [visibleIds, setVisibleIds] = useState<string[]>([]);
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
const [activeFloatingSourceId, setActiveFloatingSourceId] = useState<string | null>(null);
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = useState<string | null>(null);
const [windowRects, setWindowRects] = useState<Record<string, ObservationWindowRect>>({});
const initializedCatalog = useRef<string | null>(null);
const sourceIdList = sources.map((source) => source.id).sort();
const sourceIdsIdentity = sourceIdList.join("\u0000");
const sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]);
const identity = catalogIdentity(sources);
useEffect(() => {
setVisibleIds((current) => {
const next = current.filter((sourceId) => sourceIds.has(sourceId));
return next.length === current.length ? current : next;
});
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
setActiveFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
setMaximizedFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
setWindowRects((current) => {
const entries = Object.entries(current);
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
return nextEntries.length === entries.length ? current : Object.fromEntries(nextEntries);
});
}, [sourceIds]);
useEffect(() => {
if (!identity) {
initializedCatalog.current = null;
return;
}
if (initializedCatalog.current === identity) return;
initializedCatalog.current = identity;
const defaults = sources
.filter(canOpenByDefault)
.map((source) => source.id);
setVisibleIds(defaults);
const firstFloating = sources.find(
(source) => canOpenByDefault(source) && source.capabilities.overlay,
);
setActiveFloatingSourceId(firstFloating?.id ?? null);
}, [identity, sources]);
const toggleSource = useCallback((sourceId: string) => {
const selected = visibleIds.includes(sourceId);
setVisibleIds((current) => selected
? current.filter((candidate) => candidate !== sourceId)
: current.includes(sourceId) ? current : [...current, sourceId]);
if (!selected) setActiveFloatingSourceId(sourceId);
}, [visibleIds]);
const hideSource = useCallback((sourceId: string) => {
setVisibleIds((current) => current.filter((candidate) => candidate !== sourceId));
setFocusedSourceIdState((current) => current === sourceId ? null : current);
setActiveFloatingSourceId((current) => current === sourceId ? null : current);
setMaximizedFloatingSourceId((current) => current === sourceId ? null : current);
}, []);
const setFocusedSourceId = useCallback((sourceId: string | null) => {
setFocusedSourceIdState(sourceId);
if (sourceId) setActiveFloatingSourceId(sourceId);
}, []);
const activateFloatingSource = useCallback((sourceId: string) => {
setActiveFloatingSourceId(sourceId);
}, []);
const setFloatingMaximized = useCallback((sourceId: string, maximized: boolean) => {
setMaximizedFloatingSourceId(maximized ? sourceId : null);
if (maximized) setActiveFloatingSourceId(sourceId);
}, []);
const setWindowRect = useCallback((sourceId: string, rect: ObservationWindowRect) => {
setWindowRects((current) => ({ ...current, [sourceId]: rect }));
}, []);
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
return {
visibleSourceIds,
focusedSourceId,
activeFloatingSourceId,
maximizedFloatingSourceId,
windowRects,
toggleSource,
hideSource,
setFocusedSourceId,
activateFloatingSource,
setFloatingMaximized,
setWindowRect,
};
}

View File

@ -72,6 +72,70 @@ export interface SpatialSourceDescriptor {
kind: "rerun-grpc" | "rrd" | "other";
}
export type ObservationSourceModality = "point-cloud" | "video" | "image" | "depth";
export type ObservationSourceAvailability =
| "unverified"
| "declared"
| "available"
| "connecting"
| "streaming"
| "degraded"
| "unavailable"
| "error";
export type ObservationTimelineMode = "live-only" | "buffered" | "recorded";
export interface ObservationSourceProvider {
pluginId: string;
pluginVersion: string;
modelId: string;
compatibilityProfileId?: string | null;
}
export interface ObservationSourceBinding {
deviceId?: string | null;
deviceSessionId?: string | null;
acquisitionId?: string | null;
}
export interface ObservationSourceCapabilities {
overlay: boolean;
fullscreen: boolean;
resizable: boolean;
defaultVisible: boolean;
timelineMode: ObservationTimelineMode;
seekable: boolean;
sessionRecording: boolean;
clockId?: string | null;
spatialRegistration: "native" | "calibrated" | "unresolved" | "not-applicable";
}
export interface ObservationSourceDescriptor {
id: string;
sourceId: string;
semanticChannelId: string;
label: string;
description: string;
modality: ObservationSourceModality;
role: "primary" | "auxiliary";
availability: ObservationSourceAvailability;
transport: "rerun-grpc" | "rtsp" | "websocket" | "recording" | "other";
endpointLabel?: string | null;
previewUrl?: string | null;
provider: ObservationSourceProvider;
binding: ObservationSourceBinding;
capabilities: ObservationSourceCapabilities;
}
export interface ObservationTimelineSnapshot {
mode: ObservationTimelineMode;
seekable: boolean;
sessionRecording: boolean;
synchronization: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
range: { startSeconds: number; endSeconds: number } | null;
}
export interface MissionRuntimeState {
phase: RuntimePhase;
message?: string | null;
@ -80,6 +144,8 @@ export interface MissionRuntimeState {
acquisition?: RuntimeAcquisitionSnapshot | null;
operations?: readonly RuntimeOperationSnapshot[];
spatialSource?: SpatialSourceDescriptor | null;
observationSources?: readonly ObservationSourceDescriptor[];
observationTimeline?: ObservationTimelineSnapshot;
viewerSettings?: ViewerSettings | null;
sourceMode: SourceMode;
metrics?: StreamMetrics;

View File

@ -122,6 +122,22 @@ export interface XgridsK1Metrics {
[key: string]: number | null | undefined;
}
export interface XgridsSensorCatalogStream {
stream_id: string;
sensor_kind?: string | null;
modality?: string | null;
availability?: string | null;
decode_status?: string | null;
frame_id?: string | null;
coordinate_convention?: string | null;
}
export interface XgridsSensorCatalog {
schema_version?: string | null;
revision?: string | null;
streams?: XgridsSensorCatalogStream[];
}
export interface XgridsK1State {
contract_version?: string | null;
phase?: string | null;
@ -141,6 +157,7 @@ export interface XgridsK1State {
acquisition?: XgridsAcquisition | null;
operations?: XgridsOperation[];
last_operation?: XgridsOperation | null;
sensor_catalog?: XgridsSensorCatalog | null;
}
export interface HealthResponse {

View File

@ -0,0 +1,153 @@
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
import type {
ObservationSourceAvailability,
ObservationSourceDescriptor,
ObservationSourceProvider,
} from "../../core/runtime/contracts";
import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
import { xgridsK1Manifest } from "./manifest";
import type { XgridsK1State } from "./api";
function providerFor(
state: XgridsK1State,
activeModel: DeviceModelDefinition,
): ObservationSourceProvider {
return {
pluginId: xgridsK1Manifest.metadata.id,
pluginVersion: xgridsK1Manifest.metadata.version,
modelId: state.device_ref?.model_id || activeModel.id,
compatibilityProfileId:
state.device_session?.compatibility_profile_id ?? state.compatibility?.profile_id ?? null,
};
}
function bindingFor(state: XgridsK1State) {
const acquisition = effectiveAcquisition(state);
return {
deviceId: state.device_ref?.device_id ?? null,
deviceSessionId: state.device_session?.device_session_id ?? null,
acquisitionId: acquisition?.acquisition_id ?? null,
};
}
function catalogDeclares(state: XgridsK1State, streamId: string): boolean {
return Boolean(state.sensor_catalog?.streams?.some((stream) => stream.stream_id === streamId));
}
function spatialAvailability(state: XgridsK1State): ObservationSourceAvailability {
const mode = confirmedRuntimeSourceMode(state);
if (mode !== "idle" && state.rerun_grpc_url?.trim()) return "streaming";
if (state.rerun_grpc_url?.trim()) return "available";
if (state.device_session?.connectivity === "degraded") return "degraded";
if (state.device_session?.connectivity === "connected") return "available";
return catalogDeclares(state, "spatial.point-cloud.live") ? "declared" : "unavailable";
}
function cameraAvailability(state: XgridsK1State): ObservationSourceAvailability {
const stream = state.sensor_catalog?.streams?.find(
(candidate) => candidate.stream_id === "camera.preview.live",
);
const compatibilityProfileId =
state.device_session?.compatibility_profile_id ?? state.compatibility?.profile_id ?? null;
if (!stream) return "unavailable";
if (stream.availability !== "observed" || !compatibilityProfileId) return "unverified";
if (state.device_session?.connectivity === "degraded") return "degraded";
// The firmware profile proves that both RTSP channels exist, but Mission Core
// does not yet expose a browser-decodable preview URL. Do not report a live
// camera merely because the point-cloud acquisition is running.
return "declared";
}
export function xgridsK1ObservationSources(
state: XgridsK1State,
activeModel: DeviceModelDefinition,
): ObservationSourceDescriptor[] {
const provider = providerFor(state, activeModel);
const binding = bindingFor(state);
const clockId = binding.acquisitionId ?? binding.deviceSessionId ?? binding.deviceId ?? null;
const descriptorId = (sourceId: string) =>
`${provider.pluginId}:${provider.modelId}:${sourceId}`;
return [
{
id: descriptorId("sensor.lidar.primary"),
sourceId: "sensor.lidar.primary",
semanticChannelId: "spatial.point-cloud.live",
label: "K1 · облако точек",
description: "Облако точек, поза и траектория в общей 3D-сцене",
modality: "point-cloud",
role: "primary",
availability: spatialAvailability(state),
transport: "rerun-grpc",
endpointLabel: state.rerun_grpc_url?.trim() ? "Rerun gRPC" : "MQTT → Rerun",
previewUrl: state.rerun_grpc_url?.trim() || null,
provider,
binding,
capabilities: {
overlay: false,
fullscreen: true,
resizable: false,
defaultVisible: true,
timelineMode: "live-only",
// Rerun can hold replay data, but the Mission Core host timeline is not
// wired to its time controller yet.
seekable: false,
sessionRecording: false,
clockId,
spatialRegistration: "native",
},
},
{
id: descriptorId("sensor.camera.left"),
sourceId: "sensor.camera.left",
semanticChannelId: "camera.preview.live",
label: "K1 · камера слева",
description: "Левый H.264 preview-канал профиля устройства",
modality: "video",
role: "auxiliary",
availability: cameraAvailability(state),
transport: "rtsp",
endpointLabel: "RTSP · left",
previewUrl: null,
provider,
binding,
capabilities: {
overlay: true,
fullscreen: true,
resizable: true,
defaultVisible: false,
timelineMode: "live-only",
seekable: false,
sessionRecording: false,
clockId,
spatialRegistration: "unresolved",
},
},
{
id: descriptorId("sensor.camera.right"),
sourceId: "sensor.camera.right",
semanticChannelId: "camera.preview.live",
label: "K1 · камера справа",
description: "Правый H.264 preview-канал профиля устройства",
modality: "video",
role: "auxiliary",
availability: cameraAvailability(state),
transport: "rtsp",
endpointLabel: "RTSP · right",
previewUrl: null,
provider,
binding,
capabilities: {
overlay: true,
fullscreen: true,
resizable: true,
defaultVisible: false,
timelineMode: "live-only",
seekable: false,
sessionRecording: false,
clockId,
spatialRegistration: "unresolved",
},
},
];
}

View File

@ -18,6 +18,7 @@ import {
import { localizeRuntimeMessage } from "./messages";
import { xgridsK1Manifest } from "./manifest";
import { finiteMetric, pipelineLatency } from "./presentation";
import { xgridsK1ObservationSources } from "./observationSources";
import { useXgridsK1Runtime } from "./useXgridsK1Runtime";
export type XgridsK1Controller = ReturnType<typeof useXgridsK1Runtime>;
@ -90,6 +91,16 @@ function normalizeState(
kind: "rerun-grpc",
}
: null,
observationSources: xgridsK1ObservationSources(state, activeModel),
observationTimeline: {
// This is the shared host timeline. A replayable Rerun source alone does
// not make camera and point-cloud time jointly seekable.
mode: "live-only",
seekable: false,
sessionRecording: false,
synchronization: "host-arrival-best-effort",
range: null,
},
viewerSettings: state.viewer_settings,
sourceMode: confirmedRuntimeSourceMode(state),
metrics: {

View File

@ -4,3 +4,4 @@
@import "./styles/spatial.css";
@import "./styles/device.css";
@import "./styles/responsive.css";
@import "./styles/observation.css";

View File

@ -0,0 +1,451 @@
.scene-source-controls {
position: absolute;
z-index: 12;
top: 0.85rem;
left: 0.85rem;
display: flex;
align-items: center;
gap: 0.45rem;
}
.scene-source-picker__trigger,
.scene-source-control,
.scene-focus-exit {
display: inline-grid;
width: 2.75rem;
height: 2.75rem;
place-items: center;
border: 1px solid rgb(255 255 255 / 0.09);
border-radius: 50%;
background: rgb(12 13 16 / 0.82);
color: var(--nodedc-text-secondary);
cursor: pointer;
backdrop-filter: blur(18px);
}
.scene-source-picker__trigger {
position: relative;
}
.scene-source-picker__trigger:hover,
.scene-source-control:hover,
.scene-focus-exit:hover,
.scene-source-picker__trigger[data-active="true"] {
border-color: rgb(255 255 255 / 0.18);
color: var(--nodedc-text-primary);
}
.scene-source-picker__trigger > span {
position: absolute;
top: -0.2rem;
right: -0.2rem;
display: grid;
min-width: 1.05rem;
height: 1.05rem;
place-items: center;
border-radius: 999px;
background: var(--nodedc-text-primary);
color: #090a0c;
padding: 0 0.25rem;
font-size: 0.5rem;
font-weight: 800;
}
.scene-focus-exit {
position: absolute;
z-index: 20;
top: 0.85rem;
right: 0.85rem;
}
.scene-status--top-left {
left: 7.1rem;
}
.scene-status[aria-hidden="true"],
.scene-metrics[aria-hidden="true"] {
display: none;
}
.observation-source-menu {
overflow: hidden;
}
.observation-source-menu__head,
.observation-source-menu__foot {
padding: 0.85rem 0.9rem;
}
.observation-source-menu__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid var(--station-hairline);
}
.observation-source-menu__head strong,
.observation-source-menu__head span {
display: block;
}
.observation-source-menu__head strong {
margin-top: 0.34rem;
color: var(--nodedc-text-primary);
font-size: 0.76rem;
}
.observation-source-menu__head small,
.observation-source-menu__foot {
color: var(--nodedc-text-muted);
font-size: 0.55rem;
line-height: 1.4;
}
.observation-source-menu__list {
display: grid;
max-height: min(28rem, 60vh);
overflow: auto;
padding: 0.35rem;
}
.observation-source-option .nodedc-dropdown-option__description {
display: flex;
align-items: center;
gap: 0.35rem;
}
.observation-source-option i,
.floating-observation-window i,
.camera-slot__status i {
width: 0.42rem;
height: 0.42rem;
flex: 0 0 0.42rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
i[data-availability="available"],
i[data-availability="streaming"] {
background: rgb(var(--nodedc-success-rgb));
}
i[data-availability="connecting"],
i[data-availability="declared"] {
background: rgb(var(--nodedc-warning-rgb));
}
i[data-availability="unverified"] {
background: var(--nodedc-text-muted);
}
i[data-availability="degraded"],
i[data-availability="error"] {
background: rgb(var(--nodedc-danger-rgb));
}
.observation-source-menu__empty,
.camera-grid__empty,
.observation-media__empty {
display: grid;
place-items: center;
align-content: center;
gap: 0.5rem;
color: var(--nodedc-text-muted);
text-align: center;
}
.observation-source-menu__empty {
min-height: 10rem;
padding: 1rem;
}
.observation-source-menu__empty strong,
.observation-media__empty strong,
.camera-grid__empty strong {
color: var(--nodedc-text-secondary);
font-size: 0.7rem;
}
.observation-source-menu__empty span,
.observation-media__empty span,
.camera-grid__empty span {
max-width: 24rem;
font-size: 0.59rem;
line-height: 1.45;
}
.observation-source-menu__foot {
border-top: 1px solid var(--station-hairline);
}
.observation-media__asset,
.observation-media__empty {
width: 100%;
height: 100%;
}
.observation-media__asset {
display: block;
object-fit: contain;
background: #050608;
}
.observation-media__empty {
min-height: 9rem;
background: #07080a;
}
.floating-observation-window__status,
.floating-observation-window__footer {
display: flex;
align-items: center;
gap: 0.45rem;
}
.camera-slot__head-actions button {
display: grid;
width: 1.9rem;
height: 1.9rem;
place-items: center;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 0.05);
color: var(--nodedc-text-secondary);
cursor: pointer;
}
.camera-slot__head-actions button:hover {
background: rgb(255 255 255 / 0.1);
color: var(--nodedc-text-primary);
}
.floating-observation-window__status {
color: var(--nodedc-text-secondary);
font-size: 0.54rem;
}
.floating-observation-window__footer {
width: 100%;
justify-content: space-between;
color: var(--nodedc-text-muted);
font-size: 0.53rem;
}
.floating-observation-window .nodedc-workspace-window__body {
background: #06070a;
}
.observation-timeline {
display: grid;
min-width: 0;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.6rem;
border: 1px solid rgb(255 255 255 / 0.055);
border-radius: 0.9rem;
background: rgb(9 10 13 / 0.78);
padding: 0.4rem;
backdrop-filter: blur(16px);
}
.observation-timeline__track {
height: 0.3rem;
overflow: hidden;
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
}
.observation-timeline__track span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--nodedc-text-primary);
}
.observation-timeline__track[data-disabled="true"] {
opacity: 0.46;
}
.observation-timeline__meta {
display: grid;
justify-items: end;
gap: 0.12rem;
}
.observation-timeline__meta code,
.observation-timeline__meta small {
color: var(--nodedc-text-muted);
font-size: 0.52rem;
white-space: nowrap;
}
.observation-timeline__follow {
display: inline-flex;
align-items: center;
gap: 0.38rem;
color: var(--nodedc-text-muted);
padding: 0.3rem 0.45rem;
font-size: 0.52rem;
font-weight: 820;
}
.observation-timeline__follow::before {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.observation-timeline__follow[data-active="true"] {
color: var(--nodedc-text-primary);
}
.observation-timeline__follow[data-active="true"]::before {
background: rgb(var(--nodedc-success-rgb));
}
.scene-timeline {
position: absolute;
z-index: 10;
right: 0.75rem;
bottom: 0.75rem;
left: 0.75rem;
}
.spatial-workspace[data-focused="true"] {
min-height: 0;
grid-template-rows: minmax(0, 1fr);
gap: 0;
}
.spatial-workspace[data-focused="true"] > .spatial-toolbar,
.spatial-workspace[data-focused="true"] > .spatial-contract-strip {
display: none;
}
.cameras-workspace {
height: 100%;
min-height: 0;
grid-template-rows: auto auto minmax(0, 1fr) auto;
overflow: hidden;
padding-bottom: 0;
}
.camera-workspace__catalog {
display: flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: 0.8rem;
color: var(--nodedc-text-muted);
font-size: 0.6rem;
}
.camera-grid {
display: grid;
min-height: 0;
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
grid-template-rows: none;
grid-auto-rows: minmax(16rem, 1fr);
gap: 0.75rem;
overflow: auto;
}
.camera-grid[data-count="1"] {
grid-template-columns: minmax(0, 1fr);
}
.camera-grid__empty {
min-height: 18rem;
border-radius: 1rem;
background: #07080a;
box-shadow: inset 0 0 0 1px var(--station-hairline);
}
.camera-slot {
grid-template-rows: auto minmax(0, 1fr) auto;
}
.camera-slot header,
.camera-slot footer {
z-index: 2;
}
.camera-slot__head-actions,
.camera-slot__status {
display: flex;
align-items: center;
gap: 0.45rem;
}
.camera-slot__status {
color: var(--nodedc-text-muted);
font-size: 0.56rem;
}
.camera-slot__body {
position: relative;
z-index: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.camera-slot footer {
align-items: flex-end;
}
.camera-slot footer span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.camera-slot footer small {
flex: 0 0 auto;
color: var(--nodedc-text-secondary);
font-size: 0.53rem;
}
.camera-timeline {
position: relative;
}
.cameras-workspace[data-focused="true"] {
height: 100%;
grid-template-rows: minmax(0, 1fr) auto;
padding-bottom: 0;
}
.cameras-workspace[data-focused="true"] > .workspace-lead,
.cameras-workspace[data-focused="true"] > .camera-workspace__catalog {
display: none;
}
.cameras-workspace[data-focused="true"] .camera-grid {
min-height: 0;
overflow: hidden;
}
@media (max-width: 760px) {
.scene-status--top-left {
top: 4.2rem;
left: 0.6rem;
}
.observation-timeline {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.observation-timeline > .nodedc-button:first-child,
.observation-timeline__meta {
display: none;
}
.camera-grid {
grid-template-columns: 1fr;
grid-auto-rows: minmax(14rem, 1fr);
}
}

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Button,
GlassSurface,
@ -7,7 +7,19 @@ import {
} from "@nodedc/ui-react";
import { MetricCard } from "../components/MetricCard";
import type { BackendStatus, MissionRuntimeState } from "../core/runtime/contracts";
import {
ObservationMedia,
ObservationSourcePicker,
observationSourceStatusLabel,
} from "../components/ObservationSources";
import { ObservationTimeline } from "../components/ObservationTimeline";
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
import type {
BackendStatus,
MissionRuntimeState,
ObservationSourceDescriptor,
} from "../core/runtime/contracts";
import {
RerunViewport,
type RerunSelection,
@ -84,6 +96,7 @@ export interface WorkspaceRendererProps {
backendStatus: BackendStatus;
sourceUrl: string;
sceneSettings: SceneSettings;
observationLayout: ObservationLayoutController;
navigation: WorkspaceNavigation;
}
@ -236,6 +249,7 @@ function SpatialWorkspace({
state,
sourceUrl,
sceneSettings,
observationLayout,
navigation,
}: WorkspaceRendererProps) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
@ -246,6 +260,23 @@ function SpatialWorkspace({
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frameRateHz);
const points = finiteMetric(metrics?.pointCount);
const observationSources = state?.observationSources ?? [];
const pointCloudSource = observationSources.find((source) => source.modality === "point-cloud");
const pointCloudVisible = pointCloudSource
? observationLayout.visibleSourceIds.has(pointCloudSource.id)
: Boolean(sourceUrl.trim());
const mediaSources = observationSources.filter(
(source) => source.capabilities.overlay && source.modality !== "point-cloud",
);
const visibleMediaSources = mediaSources.filter((source) =>
observationLayout.visibleSourceIds.has(source.id),
);
const pointCloudFocused = Boolean(
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
);
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
const timeline = state?.observationTimeline;
const viewportRef = useRef<HTMLDivElement>(null);
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
setViewerStatus(status);
@ -253,6 +284,13 @@ function SpatialWorkspace({
}, []);
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
useEffect(() => {
if (pointCloudVisible && sourceUrl.trim()) return;
setViewerStatus("idle");
setViewerMessage("");
setSelection(null);
}, [pointCloudVisible, sourceUrl]);
const viewerStatusLabel = {
idle: "Источник не назначен",
loading: "Подключение",
@ -263,14 +301,14 @@ function SpatialWorkspace({
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
return (
<div className="spatial-workspace">
<div className="spatial-workspace" data-focused={pointCloudFocused ? "true" : undefined}>
<div className="spatial-toolbar">
<div className="spatial-toolbar__mode">
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
</div>
<div className="spatial-toolbar__actions">
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
Источник
Движок
</Button>
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
Слои
@ -281,8 +319,13 @@ function SpatialWorkspace({
</div>
</div>
<div className="spatial-viewport-shell">
{sourceUrl.trim() ? (
<div
ref={viewportRef}
className="spatial-viewport-shell"
data-primary-focused={pointCloudFocused ? "true" : undefined}
data-media-maximized={floatingSourceMaximized ? "true" : undefined}
>
{sourceUrl.trim() && pointCloudVisible ? (
<RerunViewport
sourceUrl={sourceUrl}
onStatusChange={onStatusChange}
@ -292,13 +335,49 @@ function SpatialWorkspace({
<EmptySpatialStage settings={sceneSettings} />
)}
<div className="scene-status scene-status--top-left">
{pointCloudFocused ? (
<button
type="button"
className="scene-focus-exit"
aria-label="Выйти из полноэкранного режима облака точек"
onClick={() => observationLayout.setFocusedSourceId(null)}
>
<Icon name="minimize" size={16} />
</button>
) : !floatingSourceMaximized ? (
<div className="scene-source-controls">
<ObservationSourcePicker
sources={observationSources}
visibleSourceIds={observationLayout.visibleSourceIds}
onToggle={observationLayout.toggleSource}
/>
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && sourceUrl.trim() ? (
<button
type="button"
className="scene-source-control"
aria-label="Развернуть облако точек"
onClick={() => observationLayout.setFocusedSourceId(pointCloudSource.id)}
>
<Icon name="expand" size={16} />
</button>
) : null}
</div>
) : null}
<div
className="scene-status scene-status--top-left"
aria-hidden={pointCloudFocused || floatingSourceMaximized}
>
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
{viewerMessage ? <small>{viewerMessage}</small> : null}
</div>
<div className="scene-metrics" aria-label="Метрики пространственной сцены">
<div
className="scene-metrics"
aria-label="Метрики пространственной сцены"
aria-hidden={pointCloudFocused || floatingSourceMaximized}
>
<div>
<span>КАДР/С</span>
<strong>{formatNumber(frameRate)}</strong>
@ -313,7 +392,7 @@ function SpatialWorkspace({
</div>
</div>
{state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
{!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
<div className="scene-adapter-note">
<Icon name="alert" />
<span>
@ -323,7 +402,7 @@ function SpatialWorkspace({
</div>
) : null}
{selection ? (
{!pointCloudFocused && !floatingSourceMaximized && selection ? (
<div className="scene-selection">
<span>Выбрано</span>
<strong>{selection.entityPath}</strong>
@ -331,21 +410,33 @@ function SpatialWorkspace({
</div>
) : null}
<div className="scene-timeline">
<Button size="compact" variant="ghost" disabled={viewerStatus !== "ready"} aria-label="Перейти к началу">
<Icon name="chevron-left" />
</Button>
<Button size="compact" variant="secondary" disabled={viewerStatus !== "ready"}>
Воспроизвести
</Button>
<div className="scene-timeline__track" data-disabled={viewerStatus !== "ready" ? "true" : undefined}>
<span style={{ width: viewerStatus === "ready" ? "8%" : "0%" }} />
</div>
<code>{viewerStatus === "ready" ? "00:00:00.000" : "—:—:—.———"}</code>
<span className="scene-timeline__follow" data-active={viewerStatus === "ready" ? "true" : undefined}>
ЭФИР
</span>
</div>
{!pointCloudFocused && !floatingSourceMaximized ? (
<ObservationTimeline
active={viewerStatus === "ready"}
sourceCount={Math.max(1, 1 + visibleMediaSources.length)}
mode={timeline?.mode}
seekable={timeline?.seekable}
synchronization={timeline?.synchronization}
className="scene-timeline"
/>
) : null}
{!pointCloudFocused ? visibleMediaSources.map((source, index) => (
<FloatingObservationWindow
key={source.id}
source={source}
index={index}
boundsRef={viewportRef}
rect={observationLayout.windowRects[source.id]}
maximized={observationLayout.maximizedFloatingSourceId === source.id}
active={observationLayout.activeFloatingSourceId === source.id}
onRectChange={(rect) => observationLayout.setWindowRect(source.id, rect)}
onMaximizedChange={(maximized) =>
observationLayout.setFloatingMaximized(source.id, maximized)}
onActivate={() => observationLayout.activateFloatingSource(source.id)}
onClose={() => observationLayout.hideSource(source.id)}
/>
)) : null}
</div>
<div className="spatial-contract-strip">
@ -360,31 +451,85 @@ function SpatialWorkspace({
);
}
function CamerasWorkspace({ definition }: WorkspaceRendererProps) {
const slots = ["Панорама", "Камера 01", "Камера 02", "Глубина"];
function CameraSourceCard({
source,
focused,
onFocus,
}: {
source: ObservationSourceDescriptor;
focused: boolean;
onFocus: () => void;
}) {
return (
<div className="standard-workspace cameras-workspace">
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
<div className="camera-grid">
{slots.map((slot, index) => (
<div className="camera-slot" key={slot} data-primary={index === 0 ? "true" : undefined}>
<div className="camera-slot__grid" aria-hidden="true" />
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
<header>
<span>{slot}</span>
<StatusBadge tone="neutral">Канал не назначен</StatusBadge>
<span>{source.label}</span>
<div className="camera-slot__head-actions">
<span className="camera-slot__status">
<i data-availability={source.availability} aria-hidden="true" />
{observationSourceStatusLabel(source)}
</span>
{source.capabilities.fullscreen ? (
<button
type="button"
aria-label={focused ? `Свернуть ${source.label}` : `Развернуть ${source.label}`}
onClick={onFocus}
>
<Icon name={focused ? "minimize" : "expand"} size={15} />
</button>
) : null}
</div>
</header>
<div className="camera-slot__empty">
<Icon name={index === 3 ? "image" : "video"} />
<span>{index === 3 ? "Ожидается карта глубины" : "Ожидается поток изображения"}</span>
<div className="camera-slot__body">
<ObservationMedia source={source} />
</div>
<footer>
<span>{index === 0 ? "360° / эквидистантная" : index === 3 ? "глубина / метры" : "перспективная / калибровка"}</span>
<button type="button" disabled aria-label={`Настроить ${slot}`}><Icon name="sliders" /></button>
<span>{source.description}</span>
<small>{source.endpointLabel || source.transport}</small>
</footer>
</article>
);
}
function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRendererProps) {
const sources = (state?.observationSources ?? []).filter(
(source) => source.modality === "video" || source.modality === "image" || source.modality === "depth",
);
const focusedSource = sources.find((source) => source.id === observationLayout.focusedSourceId) ?? null;
const timeline = state?.observationTimeline;
const displayedSources = focusedSource ? [focusedSource] : sources;
return (
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
<div className="camera-workspace__catalog">
<span>{sources.length ? `${sources.length} визуальных каналов` : "Каталог источников пуст"}</span>
</div>
))}
<div className="camera-grid" data-count={displayedSources.length}>
{displayedSources.length ? displayedSources.map((source) => (
<CameraSourceCard
key={source.id}
source={source}
focused={focusedSource?.id === source.id}
onFocus={() => observationLayout.setFocusedSourceId(
focusedSource?.id === source.id ? null : source.id,
)}
/>
)) : (
<div className="camera-grid__empty">
<Icon name="video" size={22} />
<strong>Камеры не объявлены</strong>
<span>Выберите модель устройства плагин опубликует доступные видеоканалы.</span>
</div>
<FeatureInventory definition={definition} />
)}
</div>
<ObservationTimeline
active={sources.some((source) => source.availability === "streaming")}
sourceCount={sources.length}
mode={timeline?.mode}
seekable={timeline?.seekable}
synchronization={timeline?.synchronization}
className="camera-timeline"
/>
</div>
);
}

View File

@ -0,0 +1,197 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let xgridsK1Manifest;
let xgridsK1ObservationSources;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ xgridsK1Manifest } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/manifest.ts",
));
({ xgridsK1ObservationSources } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/observationSources.ts",
));
});
after(async () => {
await server?.close();
});
function model() {
const activeModel = xgridsK1Manifest.spec.models[0];
assert.ok(activeModel, "XGRIDS plugin must declare at least one model");
return activeModel;
}
function declaredState() {
const activeModel = model();
return {
phase: "connected",
source_mode: "idle",
k1_ip: "192.168.7.10",
foxglove_ws_url: "ws://192.168.7.10:8765",
foxglove_viewer_url: "http://192.168.7.10:8765/vendor-viewer",
compatibility: {
profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
camera_preview: "rtsp://192.168.7.10:8554/vendor-preview",
},
device_ref: {
device_id: "device-k1-001",
model_id: activeModel.id,
identity_stability: "stable",
identity_basis: "hardware-identifier",
},
device_session: {
device_session_id: "device-session-001",
device_id: "device-k1-001",
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
connectivity: "connected",
},
sensor_catalog: {
schema_version: "missioncore.sensor-catalog/v1alpha2",
revision: "test-profile",
streams: [
{ stream_id: "spatial.point-cloud.live", modality: "point-cloud", availability: "observed" },
{ stream_id: "camera.preview.live", modality: "encoded-video", availability: "observed" },
],
},
};
}
function streamingState() {
return {
...declaredState(),
phase: "streaming",
source_mode: "live",
rerun_grpc_url: "rerun+http://127.0.0.1:9877/proxy",
acquisition: {
acquisition_id: "acquisition-001",
device_id: "device-k1-001",
device_session_id: "device-session-001",
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
control_mode: "operator-manual",
requested_streams: ["spatial.point-cloud.live"],
target_host: "127.0.0.1",
duration_seconds: 0,
evidence_policy: "required",
state: "acquiring",
state_revision: 3,
},
};
}
function collectUrlLikeStrings(value, found = []) {
if (typeof value === "string") {
if (value.includes("://")) found.push(value);
return found;
}
if (Array.isArray(value)) {
for (const item of value) collectUrlLikeStrings(item, found);
return found;
}
if (value && typeof value === "object") {
for (const item of Object.values(value)) collectUrlLikeStrings(item, found);
}
return found;
}
test("K1 maps to one primary point cloud and two auxiliary camera channels", () => {
const sources = xgridsK1ObservationSources(declaredState(), model());
assert.equal(sources.length, 3);
assert.deepEqual(
sources.map(({ sourceId, semanticChannelId, modality, role }) => ({
sourceId,
semanticChannelId,
modality,
role,
})),
[
{
sourceId: "sensor.lidar.primary",
semanticChannelId: "spatial.point-cloud.live",
modality: "point-cloud",
role: "primary",
},
{
sourceId: "sensor.camera.left",
semanticChannelId: "camera.preview.live",
modality: "video",
role: "auxiliary",
},
{
sourceId: "sensor.camera.right",
semanticChannelId: "camera.preview.live",
modality: "video",
role: "auxiliary",
},
],
);
assert.equal(new Set(sources.map(({ id }) => id)).size, sources.length);
assert.ok(sources.every(({ provider }) => provider.pluginId && provider.modelId));
assert.ok(sources.every(({ provider }) => provider.pluginVersion));
assert.ok(sources.every(({ binding }) => binding.deviceId === "device-k1-001"));
assert.ok(sources.every(({ capabilities }) => capabilities.timelineMode === "live-only"));
});
test("observation source ids remain stable while runtime availability changes", () => {
const declared = xgridsK1ObservationSources(declaredState(), model());
const streaming = xgridsK1ObservationSources(streamingState(), model());
assert.deepEqual(
streaming.map(({ id }) => id),
declared.map(({ id }) => id),
);
assert.equal(declared[0].availability, "available");
assert.equal(streaming[0].availability, "streaming");
assert.equal(streaming[0].previewUrl, "rerun+http://127.0.0.1:9877/proxy");
});
test("camera descriptors stay declared and URL-free until a browser delivery adapter exists", () => {
const sources = xgridsK1ObservationSources(streamingState(), model());
const cameras = sources.filter(({ modality }) => modality === "video");
assert.equal(cameras.length, 2);
for (const camera of cameras) {
assert.equal(camera.availability, "declared");
assert.equal(camera.transport, "rtsp");
assert.equal(camera.previewUrl, null);
assert.equal(camera.capabilities.spatialRegistration, "unresolved");
assert.equal(camera.capabilities.resizable, true);
assert.doesNotMatch(camera.endpointLabel ?? "", /:\/\//);
}
assert.deepEqual(collectUrlLikeStrings(sources), [
"rerun+http://127.0.0.1:9877/proxy",
]);
const serialized = JSON.stringify(sources);
assert.doesNotMatch(serialized, /rtsp:\/\//i);
assert.doesNotMatch(serialized, /192\.168\.7\.10/);
assert.doesNotMatch(serialized, /vendor-(?:preview|viewer)/);
});
test("unattested camera catalog entries remain unverified", () => {
const state = declaredState();
state.compatibility.profile_id = null;
state.device_session.compatibility_profile_id = null;
state.sensor_catalog.streams = state.sensor_catalog.streams.map((stream) =>
stream.stream_id === "camera.preview.live"
? { ...stream, availability: "unverified", decode_status: "profile-not-attested" }
: stream);
const cameras = xgridsK1ObservationSources(state, model()).filter(
({ modality }) => modality === "video",
);
assert.equal(cameras.length, 2);
assert.ok(cameras.every(({ availability }) => availability === "unverified"));
assert.ok(cameras.every(({ previewUrl }) => previewUrl === null));
});

View File

@ -0,0 +1,78 @@
# ADR 0006: Vendor-neutral observation sources and live-only timeline
Status: accepted for the experimental Mission Core runtime, 2026-07-16.
## Context
The spatial scene and camera workspace must accept one device, a custom rig or a
future multi-sensor vehicle without adding vendor-specific branches to the host
UI. The currently verified K1 profile exposes one spatial visualization path and
two independent H.264 camera preview producers. The browser does not yet have a
camera delivery adapter, a shared point-cloud/video clock or confirmed camera
intrinsics/extrinsics.
Hard-coded camera slots, direct RTSP URLs in React components and an apparently
seekable timeline would therefore create three false contracts: a fixed sensor
count, browser ownership of device transport and replay that does not exist.
## Decision
1. A device plugin normalizes its capabilities into
`ObservationSourceDescriptor[]`. Mission Core consumes only these generic
descriptors.
2. `id` identifies a runtime source instance; `sourceId` identifies the producer
inside the plugin model; `semanticChannelId` describes the canonical channel.
Transport addresses are not identities.
3. Provider metadata and device/session/acquisition binding are separate. A new
acquisition may change layout context without changing the logical producer.
4. The host owns visibility, z-order, position, resize and fullscreen. A plugin
cannot inject global layout behavior.
5. Direct device RTSP URLs do not cross into the generic UI. A future read-only
camera adapter must publish a browser-decodable local delivery URL.
6. Until a bounded buffer and clock mapping exist, the shared timeline is
`live-only`, `seekable=false`, `sessionRecording=false` and explicitly marked
`host-arrival-best-effort`.
7. Directional 3D camera frustums are not rendered until calibration and
extrinsics are available and verified. An unresolved camera may be listed as
a source, but its orientation must not be invented.
## Current K1 mapping
| Producer | Canonical channel | Host representation |
| --- | --- | --- |
| `sensor.lidar.primary` | `spatial.point-cloud.live` | primary Rerun viewport |
| `sensor.camera.left` | `camera.preview.live` | auxiliary media window / camera card |
| `sensor.camera.right` | `camera.preview.live` | auxiliary media window / camera card |
Both camera producers share the proven canonical channel and remain distinct by
`sourceId`. Their current state is `declared`: the compatibility profile proves
the channels, but no browser preview URL is advertised yet.
## UI consequences
- The spatial scene source picker renders any number of plugin-published sources.
- Auxiliary media uses the design-system `WorkspaceWindow`, constrained to the
scene bounds, with controlled drag, resize, close and maximize state.
- The Cameras workspace filters media modalities and renders 0, 1, 2 or N cards;
only its grid scrolls when the source count exceeds available space.
- Point-cloud focus and camera focus stay inside the application panel rather
than taking over the browser viewport.
- Empty and declared channels remain honest placeholders; no demo frames or
synthetic sensor values are substituted.
## Follow-up gate
The next implementation step is a plugin-owned, read-only H.264 adapter that
terminates RTSP/RTP locally and exposes a browser-supported stream plus measured
per-source metrics. Synchronized rewind becomes eligible only after the adapter
provides bounded buffering and an evidenced mapping between the RTP 90 kHz clock
and the spatial stream clock.
## Code anchors
- `apps/control-station/src/core/runtime/contracts.ts`
- `apps/control-station/src/core/observation/useObservationLayout.ts`
- `apps/control-station/src/components/ObservationSources.tsx`
- `apps/control-station/src/components/ObservationTimeline.tsx`
- `apps/control-station/src/workspaces/Workspaces.tsx`
- `apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts`