feat(k1): add live cameras and reliable spatial following

This commit is contained in:
DCCONSTRUCTIONS 2026-07-17 00:26:03 +03:00
parent a281faf923
commit 2bda1986bd
33 changed files with 2927 additions and 330 deletions

1
.gitignore vendored
View File

@ -21,6 +21,7 @@ build/
!.env.example
config/local.toml
private/
.runtime/
# Real laboratory captures and decoded artifacts are sensitive and large.
captures/

View File

@ -133,7 +133,10 @@ 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 ?? []);
const observationLayout = useObservationLayout(
runtime.state?.observationSources ?? [],
runtime.setObservationSourceActive,
);
useEffect(() => {
const remote = runtime.state?.viewerSettings;

View File

@ -0,0 +1,279 @@
import { useEffect, useRef, useState } from "react";
import { Icon } from "@nodedc/ui-react";
import type { ObservationSourceDelivery } from "../core/runtime/contracts";
type PlayerStatus = "connecting" | "buffering" | "playing" | "error";
export interface CameraLeaseRetryBudget {
deliveryId: string;
count: number;
}
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000] as const;
export function resetCameraLeaseRetryBudget(deliveryId: string): CameraLeaseRetryBudget {
return { deliveryId, count: 0 };
}
export function consumeCameraLeaseRetry(
current: CameraLeaseRetryBudget,
deliveryId: string,
): { budget: CameraLeaseRetryBudget; delay: number | null } {
const count = current.deliveryId === deliveryId ? current.count : 0;
const delay = CAMERA_LEASE_RETRY_DELAYS[count] ?? null;
return {
budget: { deliveryId, count: delay === null ? count : count + 1 },
delay,
};
}
function websocketUrl(path: string): string {
const url = new URL(path, window.location.href);
if (url.protocol === "http:") url.protocol = "ws:";
if (url.protocol === "https:") url.protocol = "wss:";
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
throw new Error("Адаптер вернул неподдерживаемый адрес видеопотока.");
}
return url.toString();
}
export function MseFmp4WebSocketPlayer({
delivery,
label,
}: {
delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" };
label: string;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const leaseRetryRef = useRef(resetCameraLeaseRetryBudget(delivery.id));
const [attempt, setAttempt] = useState(0);
const [status, setStatus] = useState<PlayerStatus>("connecting");
const [message, setMessage] = useState("Подключение к локальному видеопотоку");
useEffect(() => {
const video = videoRef.current;
if (!video) return;
let disposed = false;
let socket: WebSocket | null = null;
let sourceBuffer: SourceBuffer | null = null;
let objectUrl = "";
let retryTimer: number | undefined;
let receivedMedia = false;
let failed = false;
const queue: ArrayBuffer[] = [];
let queuedBytes = 0;
const fail = (copy: string) => {
if (disposed || failed) return;
failed = true;
setStatus("error");
setMessage(copy);
try {
if (socket && socket.readyState < WebSocket.CLOSING) {
socket.close(1011, "Live video buffer reset");
}
} catch {
// The manual reconnect button will create a fresh transport and MSE buffer.
}
};
const retryLease = () => {
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
leaseRetryRef.current = retry.budget;
if (retry.delay === null) {
fail("Camera adapter ещё занят предыдущим окном. Подключитесь повторно.");
return;
}
setStatus("connecting");
setMessage("Освобождение предыдущего окна камеры");
retryTimer = window.setTimeout(() => {
if (!disposed) setAttempt((value) => value + 1);
}, retry.delay);
};
const onPlaying = () => {
if (disposed) return;
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
setStatus("playing");
setMessage("");
};
video.addEventListener("playing", onPlaying);
const appendNext = () => {
if (disposed || !sourceBuffer || sourceBuffer.updating || queue.length === 0) return;
const chunk = queue.shift();
if (!chunk) return;
queuedBytes -= chunk.byteLength;
try {
sourceBuffer.appendBuffer(chunk);
} catch (error) {
fail(error instanceof DOMException && error.name === "QuotaExceededError"
? "Live-буфер переполнен и сброшен, чтобы не накапливать задержку."
: "Не удалось добавить видеосегмент. Повторите подключение.");
}
};
const enqueue = (chunk: ArrayBuffer) => {
if (disposed || chunk.byteLength === 0) return;
// Never drop arbitrary fMP4 fragments: the following samples may depend
// on them. A bounded reset is safer and keeps live latency deterministic.
if (queuedBytes + chunk.byteLength > 2 * 1024 * 1024) {
fail("Видеодекодер не успевает за эфиром. Live-буфер сброшен.");
return;
}
queue.push(chunk);
queuedBytes += chunk.byteLength;
appendNext();
};
const mediaType = delivery.mediaType?.trim();
if (typeof MediaSource === "undefined") {
fail("Этот браузер не поддерживает Media Source Extensions.");
return;
}
if (!mediaType || !MediaSource.isTypeSupported(mediaType)) {
fail(mediaType
? `Браузер не поддерживает ${mediaType}`
: "Адаптер не сообщил MIME/codec fMP4-потока.");
return;
}
setStatus("connecting");
setMessage("Подключение к локальному видеопотоку");
const mediaSource = new MediaSource();
objectUrl = URL.createObjectURL(mediaSource);
video.src = objectUrl;
const onSourceOpen = () => {
if (disposed) return;
try {
sourceBuffer = mediaSource.addSourceBuffer(mediaType);
} catch {
fail("Не удалось создать MSE-буфер для указанного кодека.");
return;
}
sourceBuffer.addEventListener("updateend", () => {
if (disposed || !sourceBuffer) return;
const buffered = sourceBuffer.buffered;
if (buffered.length > 0) {
const end = buffered.end(buffered.length - 1);
const start = buffered.start(0);
if (end - video.currentTime > 1) video.currentTime = Math.max(0, end - 0.1);
if (!receivedMedia) {
receivedMedia = true;
setStatus("buffering");
setMessage("Запуск первого декодированного кадра");
void video.play().catch(() => undefined);
}
const removeBefore = end - 3;
if (removeBefore > start && !sourceBuffer.updating) {
try {
sourceBuffer.remove(0, removeBefore);
return;
} catch {
// Continue appending; quota handling performs a clean reset.
}
}
}
appendNext();
});
sourceBuffer.addEventListener("error", () => {
fail("MSE сообщил об ошибке декодирования видеосегмента.");
});
try {
socket = new WebSocket(websocketUrl(delivery.url));
} catch (error) {
fail(error instanceof Error ? error.message : "Некорректный адрес видеопотока.");
return;
}
socket.binaryType = "arraybuffer";
socket.addEventListener("open", () => {
if (disposed) return;
setStatus("buffering");
setMessage("Ожидание первого видеокадра");
});
socket.addEventListener("message", (event) => {
if (event.data instanceof ArrayBuffer) {
enqueue(event.data);
} else if (event.data instanceof Blob) {
void event.data.arrayBuffer().then(enqueue).catch(() => {
fail("Получен повреждённый видеосегмент.");
});
}
});
socket.addEventListener("error", () => {
fail("Соединение с локальным video adapter потеряно.");
});
socket.addEventListener("close", (event) => {
if (!disposed && !failed && event.code === 1008) {
retryLease();
} else if (!disposed) {
fail(event.code === 1000
? "Видеопоток завершён. Можно подключиться повторно."
: "Видеопоток прерван. Проверьте устройство и повторите подключение.");
}
});
};
mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
return () => {
disposed = true;
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
queue.length = 0;
socket?.close(1000, "Источник скрыт оператором");
video.removeEventListener("playing", onPlaying);
try {
if (sourceBuffer?.updating) sourceBuffer.abort();
} catch {
// The MediaSource can already be closing.
}
try {
if (mediaSource.readyState === "open") mediaSource.endOfStream();
} catch {
// Cleanup must continue even if the browser has already detached MSE.
}
video.pause();
video.removeAttribute("src");
video.load();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attempt, delivery.id, delivery.mediaType, delivery.url]);
return (
<div className="mse-fmp4-player" data-status={status}>
<video
ref={videoRef}
className="observation-media__asset"
aria-label={label}
autoPlay
muted
playsInline
/>
{status !== "playing" ? (
<div className="mse-fmp4-player__status" role="status" aria-live="polite">
<Icon name={status === "error" ? "alert" : "video"} size={20} />
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong>
<span>{message}</span>
{status === "error" ? (
<button
type="button"
onClick={() => {
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
setAttempt((value) => value + 1);
}}
>
<Icon name="refresh" size={14} />
Подключиться повторно
</button>
) : null}
</div>
) : null}
</div>
);
}

View File

@ -5,6 +5,7 @@ import type {
ObservationSourceDescriptor,
ObservationSourceModality,
} from "../core/runtime/contracts";
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
const sourceIcon: Record<ObservationSourceModality, IconName> = {
"point-cloud": "globe",
@ -29,7 +30,38 @@ export function observationSourceStatusLabel(source: ObservationSourceDescriptor
}
export function ObservationMedia({ source }: { source: ObservationSourceDescriptor }) {
if (source.previewUrl && source.modality === "video") {
const sourceSelected = source.activation ? source.activation.selected : true;
const deliveryActive = Boolean(source.delivery && sourceSelected);
if (
deliveryActive &&
source.delivery?.kind === "mse-fmp4-websocket" &&
source.modality === "video"
) {
return <MseFmp4WebSocketPlayer delivery={source.delivery} label={source.label} />;
}
if (deliveryActive && source.delivery?.kind === "video-url" && source.modality === "video") {
return (
<video
className="observation-media__asset"
src={source.delivery.url}
autoPlay
muted
playsInline
/>
);
}
if (
deliveryActive &&
source.delivery?.kind === "image-url" &&
(source.modality === "image" || source.modality === "depth")
) {
return <img className="observation-media__asset" src={source.delivery.url} alt={source.label} />;
}
if (sourceSelected && source.previewUrl && source.modality === "video") {
return (
<video
className="observation-media__asset"
@ -41,7 +73,11 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
);
}
if (source.previewUrl && (source.modality === "image" || source.modality === "depth")) {
if (
sourceSelected &&
source.previewUrl &&
(source.modality === "image" || source.modality === "depth")
) {
return <img className="observation-media__asset" src={source.previewUrl} alt={source.label} />;
}
@ -51,7 +87,11 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
<strong>{observationSourceStatusLabel(source)}</strong>
<span>
{source.modality === "video"
? "Канал известен, browser-preview ещё не подключён"
? source.activation?.controllable
? source.activation.selected
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
: "Откройте канал — локальный адаптер подключит выбранную камеру"
: "Канал известен, browser-preview сейчас недоступен"
: source.description}
</span>
</div>
@ -61,11 +101,13 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
export function ObservationSourcePicker({
sources,
visibleSourceIds,
pendingSourceIds,
onToggle,
}: {
sources: readonly ObservationSourceDescriptor[];
visibleSourceIds: ReadonlySet<string>;
onToggle: (sourceId: string) => void;
pendingSourceIds?: ReadonlySet<string>;
onToggle: (sourceId: string) => void | Promise<boolean>;
}) {
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
@ -108,6 +150,14 @@ export function ObservationSourcePicker({
<div className="observation-source-menu__list">
{sources.map((source) => {
const selected = visibleSourceIds.has(source.id);
const pending = pendingSourceIds?.has(source.id) ?? false;
const canOpen = Boolean(
selected ||
source.modality === "point-cloud" ||
source.previewUrl ||
source.delivery ||
source.activation?.controllable,
);
return (
<button
key={source.id}
@ -115,7 +165,8 @@ export function ObservationSourcePicker({
className="nodedc-dropdown-option observation-source-option"
data-selected={selected ? "true" : undefined}
aria-pressed={selected}
onClick={() => onToggle(source.id)}
disabled={pending || !canOpen}
onClick={() => void onToggle(source.id)}
>
<span className="nodedc-dropdown-option__icon">
<Icon name={sourceIcon[source.modality]} size={16} />
@ -124,7 +175,7 @@ export function ObservationSourcePicker({
<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}
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
</span>
</span>
<span className="nodedc-dropdown-option__check">

View File

@ -10,21 +10,25 @@ export interface RerunSelection {
export interface RerunViewportProps {
sourceUrl: string;
followLive?: boolean;
onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
onSelectionChange?: (selection: RerunSelection | null) => void;
}
export function RerunViewport({
sourceUrl,
followLive = false,
onStatusChange,
onSelectionChange,
}: RerunViewportProps) {
const hostRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [retryNonce, setRetryNonce] = useState(0);
useEffect(() => {
const normalizedSource = sourceUrl.trim();
if (!normalizedSource || !hostRef.current) {
const host = hostRef.current;
if (!normalizedSource || !host) {
setStatus("idle");
onStatusChange?.("idle");
onSelectionChange?.(null);
@ -32,51 +36,86 @@ export function RerunViewport({
}
let disposed = false;
let stopViewer: (() => void) | undefined;
let disposeViewer: (() => void) | undefined;
let recordingOpenTimer: number | undefined;
let recordingOpened = false;
const unsubscribers: Array<() => void> = [];
const unsubscribeAll = () => {
while (unsubscribers.length > 0) {
const unsubscribe = unsubscribers.pop();
try {
unsubscribe?.();
} catch {
// A stopped vendor viewer can already have released its event map.
}
}
};
const clearRecordingOpenTimer = () => {
if (recordingOpenTimer === undefined) return;
window.clearTimeout(recordingOpenTimer);
recordingOpenTimer = undefined;
};
const reportError = (message: string) => {
if (disposed) return;
setStatus("error");
onStatusChange?.("error", message);
};
host.replaceChildren();
setStatus("loading");
onStatusChange?.("loading");
void import("@rerun-io/web-viewer")
.then(async ({ WebViewer }) => {
// React StrictMode intentionally disposes the first effect while the
// dynamic import is still pending. Never start that stale viewer (the
// vendor API treats a missing host as document.body).
if (disposed) return;
const viewer = new WebViewer();
stopViewer = () => {
// Remove the gRPC receiver explicitly before tearing down WASM. This
// closes the browser-side stream promptly so the local SDK server can
// release its port before the next device session starts.
let viewerDisposed = false;
disposeViewer = () => {
if (viewerDisposed) return;
viewerDisposed = true;
clearRecordingOpenTimer();
unsubscribeAll();
try {
viewer.close(normalizedSource);
if (viewer.ready) viewer.close(normalizedSource);
} catch {
// The viewer can already be stopped after a startup failure.
// The receiver may already be gone after a transport failure.
}
viewer.stop();
try {
viewer.stop();
} catch {
// A partially initialized WASM handle can already be gone after a
// startup failure. The host still has to be cleared below.
}
host.replaceChildren();
};
await viewer.start(
normalizedSource,
hostRef.current,
{
width: "100%",
height: "100%",
theme: "dark",
hide_welcome_screen: true,
enable_history: false,
allow_fullscreen: false,
},
null,
unsubscribers.push(
viewer.on("recording_open", (event) => {
if (disposed) return;
recordingOpened = true;
clearRecordingOpenTimer();
if (followLive) {
try {
// The backend blueprint owns native `Following`; the host only
// selects its live timeline once. Repeated SetTime calls would
// force Rerun out of Following and create needless repaints.
viewer.set_active_timeline(event.recording_id, "stream_time");
} catch {
// A receiver can disappear between the event and the WASM call.
}
}
setStatus("ready");
onStatusChange?.("ready");
}),
);
if (disposed) {
stopViewer();
return;
}
viewer.override_panel_state("top", "hidden");
viewer.override_panel_state("blueprint", "hidden");
viewer.override_panel_state("selection", "hidden");
viewer.override_panel_state("time", "hidden");
unsubscribers.push(
viewer.on("selection_change", (event) => {
if (disposed) return;
const entity = event.items.find((item) => item.type === "entity");
if (!entity || entity.type !== "entity") {
onSelectionChange?.(null);
@ -90,23 +129,54 @@ export function RerunViewport({
}),
);
setStatus("ready");
onStatusChange?.("ready");
try {
await viewer.start(
normalizedSource,
host,
{
width: "100%",
height: "100%",
theme: "dark",
hide_welcome_screen: true,
enable_history: false,
allow_fullscreen: false,
},
null,
);
if (disposed) {
disposeViewer();
return;
}
viewer.override_panel_state("top", "hidden");
viewer.override_panel_state("blueprint", "hidden");
viewer.override_panel_state("selection", "hidden");
viewer.override_panel_state("time", "hidden");
if (!recordingOpened) {
recordingOpenTimer = window.setTimeout(() => {
reportError("Визуализатор запущен, но поток записи не открылся.");
}, 12_000);
}
} catch {
disposeViewer();
reportError("Не удалось запустить встроенный визуализатор.");
}
})
.catch(() => {
if (disposed) return;
const message = "Не удалось запустить встроенный визуализатор.";
setStatus("error");
onStatusChange?.("error", message);
disposeViewer?.();
reportError("Не удалось загрузить модуль визуализатора.");
});
return () => {
disposed = true;
unsubscribers.forEach((unsubscribe) => unsubscribe());
stopViewer?.();
clearRecordingOpenTimer();
unsubscribeAll();
disposeViewer?.();
onSelectionChange?.(null);
};
}, [onSelectionChange, onStatusChange, sourceUrl]);
}, [followLive, onSelectionChange, onStatusChange, retryNonce, sourceUrl]);
return (
<div className="rerun-viewport" data-status={status}>
@ -124,7 +194,14 @@ export function RerunViewport({
<div className="rerun-viewport__notice rerun-viewport__notice--error" role="alert">
<div>
<strong>Источник не открыт</strong>
<span>Проверьте адрес RRD или Rerun gRPC в настройках источника.</span>
<span>Проверьте локальный поток Rerun или повторите подключение.</span>
<button
type="button"
className="rerun-viewport__retry"
onClick={() => setRetryNonce((nonce) => nonce + 1)}
>
Повторить подключение
</button>
</div>
</div>
) : null}

View File

@ -0,0 +1,68 @@
import type { ObservationSourceDescriptor } from "../runtime/contracts";
export interface ObservationVisibilityChange {
visibleIds: string[];
removedIds: string[];
}
export function shouldRestartObservationSource(
source: ObservationSourceDescriptor,
): boolean {
return Boolean(
source.activation?.selected &&
source.activation.controllable &&
(source.availability === "error" || !source.delivery),
);
}
export function openObservationSource(
currentIds: readonly string[],
sourceId: string,
sources: readonly ObservationSourceDescriptor[],
): ObservationVisibilityChange {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return { visibleIds: [...currentIds], removedIds: [] };
const uniqueCurrent = [...new Set(currentIds)];
const activation = source.activation;
if (!activation) {
return {
visibleIds: uniqueCurrent.includes(sourceId) ? uniqueCurrent : [...uniqueCurrent, sourceId],
removedIds: [],
};
}
const groupSourceIds = new Set(
sources
.filter((candidate) => candidate.activation?.groupId === activation.groupId)
.map((candidate) => candidate.id),
);
const capacity = Math.max(1, Math.floor(activation.maxActive));
const currentPeers = uniqueCurrent.filter(
(candidate) => candidate !== sourceId && groupSourceIds.has(candidate),
);
const retainedPeerCount = Math.max(0, capacity - 1);
const retainedPeers = new Set(
retainedPeerCount > 0 ? currentPeers.slice(-retainedPeerCount) : [],
);
const visibleIds = uniqueCurrent.filter(
(candidate) => !groupSourceIds.has(candidate) || retainedPeers.has(candidate),
);
if (!visibleIds.includes(sourceId)) visibleIds.push(sourceId);
const visibleSet = new Set(visibleIds);
return {
visibleIds,
removedIds: uniqueCurrent.filter((candidate) => !visibleSet.has(candidate)),
};
}
export function closeObservationSource(
currentIds: readonly string[],
sourceId: string,
): ObservationVisibilityChange {
const visibleIds = currentIds.filter((candidate) => candidate !== sourceId);
return {
visibleIds,
removedIds: visibleIds.length === currentIds.length ? [] : [sourceId],
};
}

View File

@ -1,6 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ObservationSourceDescriptor } from "../runtime/contracts";
import {
closeObservationSource,
openObservationSource,
shouldRestartObservationSource,
} from "./layoutPolicy";
export interface ObservationWindowRect {
x: number;
@ -15,8 +20,9 @@ export interface ObservationLayoutController {
activeFloatingSourceId: string | null;
maximizedFloatingSourceId: string | null;
windowRects: Readonly<Record<string, ObservationWindowRect>>;
toggleSource: (sourceId: string) => void;
hideSource: (sourceId: string) => void;
pendingSourceIds: ReadonlySet<string>;
toggleSource: (sourceId: string) => Promise<boolean>;
hideSource: (sourceId: string) => Promise<boolean>;
setFocusedSourceId: (sourceId: string | null) => void;
activateFloatingSource: (sourceId: string) => void;
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
@ -26,7 +32,11 @@ export interface ObservationLayoutController {
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);
return (
source.modality === "point-cloud" ||
Boolean(source.previewUrl) ||
Boolean(source.delivery && (!source.activation || source.activation.selected))
);
}
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
@ -42,8 +52,11 @@ function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): strin
export function useObservationLayout(
sources: readonly ObservationSourceDescriptor[],
setSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>,
): ObservationLayoutController {
const [visibleIds, setVisibleIds] = useState<string[]>([]);
const visibleIdsRef = useRef<string[]>([]);
const [pendingIds, setPendingIds] = useState<string[]>([]);
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
const [activeFloatingSourceId, setActiveFloatingSourceId] = useState<string | null>(null);
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = useState<string | null>(null);
@ -54,11 +67,23 @@ export function useObservationLayout(
const sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]);
const identity = catalogIdentity(sources);
const commitVisibleIds = useCallback((next: string[]) => {
visibleIdsRef.current = next;
setVisibleIds(next);
}, []);
const clearPresentation = useCallback((removedIds: readonly string[]) => {
if (!removedIds.length) return;
const removed = new Set(removedIds);
setFocusedSourceIdState((current) => current && removed.has(current) ? null : current);
setActiveFloatingSourceId((current) => current && removed.has(current) ? null : current);
setMaximizedFloatingSourceId((current) => current && removed.has(current) ? null : current);
}, []);
useEffect(() => {
setVisibleIds((current) => {
const next = current.filter((sourceId) => sourceIds.has(sourceId));
return next.length === current.length ? current : next;
});
const currentVisible = visibleIdsRef.current;
const nextVisible = currentVisible.filter((sourceId) => sourceIds.has(sourceId));
if (nextVisible.length !== currentVisible.length) commitVisibleIds(nextVisible);
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
setActiveFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
setMaximizedFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
@ -67,7 +92,7 @@ export function useObservationLayout(
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
return nextEntries.length === entries.length ? current : Object.fromEntries(nextEntries);
});
}, [sourceIds]);
}, [commitVisibleIds, sourceIds]);
useEffect(() => {
if (!identity) {
@ -76,30 +101,91 @@ export function useObservationLayout(
}
if (initializedCatalog.current === identity) return;
initializedCatalog.current = identity;
const defaults = sources
.filter(canOpenByDefault)
.map((source) => source.id);
setVisibleIds(defaults);
let defaults: string[] = [];
for (const source of sources.filter(canOpenByDefault)) {
defaults = openObservationSource(defaults, source.id, sources).visibleIds;
}
commitVisibleIds(defaults);
const firstFloating = sources.find(
(source) => canOpenByDefault(source) && source.capabilities.overlay,
);
setActiveFloatingSourceId(firstFloating?.id ?? null);
}, [identity, sources]);
}, [commitVisibleIds, 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 selectedDeliveryIdentity = sources
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
.map((source) => [
source.id,
source.delivery?.id,
source.activation?.groupId,
source.activation?.maxActive,
].join(":"))
.sort()
.join("|");
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);
}, []);
useEffect(() => {
const selected = sources.filter(
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
);
if (!selected.length) return;
let change = { visibleIds: visibleIdsRef.current, removedIds: [] as string[] };
const removed = new Set<string>();
for (const source of selected) {
change = openObservationSource(change.visibleIds, source.id, sources);
change.removedIds.forEach((sourceId) => removed.add(sourceId));
}
commitVisibleIds(change.visibleIds);
clearPresentation([...removed]);
}, [clearPresentation, commitVisibleIds, selectedDeliveryIdentity]);
const markPending = useCallback((source: ObservationSourceDescriptor, pending: boolean) => {
const groupId = source.activation?.groupId;
const affected = groupId
? sources.filter((candidate) => candidate.activation?.groupId === groupId).map(({ id }) => id)
: [source.id];
setPendingIds((current) => pending
? [...new Set([...current, ...affected])]
: current.filter((candidate) => !affected.includes(candidate)));
}, [sources]);
const hideSource = useCallback(async (sourceId: string) => {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return false;
if (source.activation?.selected && source.activation.controllable) {
if (!setSourceActive) return false;
markPending(source, true);
try {
if (!await setSourceActive(source.sourceId, false)) return false;
} finally {
markPending(source, false);
}
}
const change = closeObservationSource(visibleIdsRef.current, sourceId);
commitVisibleIds(change.visibleIds);
clearPresentation(change.removedIds);
return true;
}, [clearPresentation, commitVisibleIds, markPending, setSourceActive, sources]);
const toggleSource = useCallback(async (sourceId: string) => {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return false;
const restart = shouldRestartObservationSource(source);
if (visibleIdsRef.current.includes(sourceId) && !restart) return hideSource(sourceId);
if (source.activation && (!source.activation.selected || restart)) {
if (!source.activation.controllable || !setSourceActive) return false;
markPending(source, true);
try {
if (!await setSourceActive(source.sourceId, true)) return false;
} finally {
markPending(source, false);
}
}
const change = openObservationSource(visibleIdsRef.current, sourceId, sources);
commitVisibleIds(change.visibleIds);
clearPresentation(change.removedIds);
setActiveFloatingSourceId(sourceId);
return true;
}, [clearPresentation, commitVisibleIds, hideSource, markPending, setSourceActive, sources]);
const setFocusedSourceId = useCallback((sourceId: string | null) => {
setFocusedSourceIdState(sourceId);
@ -120,6 +206,7 @@ export function useObservationLayout(
}, []);
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
const pendingSourceIds = useMemo(() => new Set(pendingIds), [pendingIds]);
return {
visibleSourceIds,
@ -127,6 +214,7 @@ export function useObservationLayout(
activeFloatingSourceId,
maximizedFloatingSourceId,
windowRects,
pendingSourceIds,
toggleSource,
hideSource,
setFocusedSourceId,

View File

@ -12,6 +12,7 @@ const idleRuntime: MissionRuntimeController = {
pendingAction: null,
refresh: () => undefined,
updateViewerSettings: async () => false,
setObservationSourceActive: async () => false,
};
const MissionRuntimeContext = createContext<MissionRuntimeController>(idleRuntime);

View File

@ -111,6 +111,27 @@ export interface ObservationSourceCapabilities {
spatialRegistration: "native" | "calibrated" | "unresolved" | "not-applicable";
}
export type ObservationSourceDelivery =
| {
id: string;
kind: "mse-fmp4-websocket";
url: string;
mediaType: string;
}
| {
id: string;
kind: "video-url" | "image-url";
url: string;
mediaType?: string | null;
};
export interface ObservationSourceActivation {
groupId: string;
maxActive: number;
selected: boolean;
controllable: boolean;
}
export interface ObservationSourceDescriptor {
id: string;
sourceId: string;
@ -123,6 +144,8 @@ export interface ObservationSourceDescriptor {
transport: "rerun-grpc" | "rtsp" | "websocket" | "recording" | "other";
endpointLabel?: string | null;
previewUrl?: string | null;
delivery?: ObservationSourceDelivery | null;
activation?: ObservationSourceActivation | null;
provider: ObservationSourceProvider;
binding: ObservationSourceBinding;
capabilities: ObservationSourceCapabilities;
@ -157,4 +180,5 @@ export interface MissionRuntimeController {
pendingAction: string | null;
refresh: () => void | Promise<void>;
updateViewerSettings: (settings: ViewerSettings) => Promise<boolean>;
setObservationSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>;
}

View File

@ -122,11 +122,39 @@ export interface XgridsK1Metrics {
[key: string]: number | null | undefined;
}
export interface XgridsCameraPreviewDelivery {
id: string;
kind: "mse-fmp4-websocket";
url: string;
media_type: string;
}
export interface XgridsCameraPreviewActivation {
group_id: string;
max_active: number;
selected: boolean;
controllable: boolean;
}
export interface XgridsCameraPreviewState {
phase?: string | null;
revision?: number | null;
generation?: number | null;
active_source_id?: string | null;
delivery?: XgridsCameraPreviewDelivery | null;
}
export interface XgridsSensorCatalogStream {
stream_id: string;
source_id?: string | null;
semantic_channel_id?: string | null;
label?: string | null;
sensor_kind?: string | null;
modality?: string | null;
availability?: string | null;
endpoint_label?: string | null;
activation?: XgridsCameraPreviewActivation | null;
delivery?: XgridsCameraPreviewDelivery | null;
decode_status?: string | null;
frame_id?: string | null;
coordinate_convention?: string | null;
@ -158,6 +186,7 @@ export interface XgridsK1State {
operations?: XgridsOperation[];
last_operation?: XgridsOperation | null;
sensor_catalog?: XgridsSensorCatalog | null;
camera_preview?: XgridsCameraPreviewState | null;
}
export interface HealthResponse {
@ -239,6 +268,16 @@ export interface ReplayRequest {
loop?: boolean;
}
export interface SelectCameraPreviewRequest {
source_id: string;
device_session_id: string;
}
export interface StopCameraPreviewRequest {
device_session_id: string;
generation: number;
}
export class ApiError extends Error {
readonly status: number;
@ -375,6 +414,14 @@ export const xgridsK1Api = {
return invokeState(xgridsK1Actions.compatibilityStreamStop);
},
selectCameraPreview(body: SelectCameraPreviewRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.cameraPreviewSelect, body);
},
stopCameraPreview(body: StopCameraPreviewRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.cameraPreviewStop, body);
},
updateViewerSettings(body: ViewerSettings): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.viewerSettingsUpdate, body);
},

View File

@ -27,5 +27,7 @@ export const xgridsK1Actions = Object.freeze({
compatibilityStreamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"),
streamStartReplay: requirePluginAction(xgridsK1Manifest, "stream.start-replay"),
compatibilityStreamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"),
cameraPreviewSelect: requirePluginAction(xgridsK1Manifest, "camera.preview.select"),
cameraPreviewStop: requirePluginAction(xgridsK1Manifest, "camera.preview.stop"),
viewerSettingsUpdate: requirePluginAction(xgridsK1Manifest, "viewer.settings.update"),
});

View File

@ -1,12 +1,17 @@
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
import type {
ObservationSourceAvailability,
ObservationSourceDelivery,
ObservationSourceDescriptor,
ObservationSourceProvider,
} from "../../core/runtime/contracts";
import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
import { xgridsK1Manifest } from "./manifest";
import type { XgridsK1State } from "./api";
import type {
XgridsCameraPreviewDelivery,
XgridsK1State,
XgridsSensorCatalogStream,
} from "./api";
function providerFor(
state: XgridsK1State,
@ -43,19 +48,144 @@ function spatialAvailability(state: XgridsK1State): ObservationSourceAvailabilit
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",
function catalogAvailability(value: string | null | undefined): ObservationSourceAvailability {
switch (value?.trim().toLowerCase()) {
case "observed":
case "available":
return "available";
case "connecting":
return "connecting";
case "streaming":
return "streaming";
case "degraded":
return "degraded";
case "unavailable":
return "unavailable";
case "error":
return "error";
case "declared":
return "declared";
default:
return "unverified";
}
}
function cameraCatalogEntry(stream: XgridsSensorCatalogStream): boolean {
const sourceId = stream.source_id?.trim();
const semanticChannelId = stream.semantic_channel_id?.trim();
if (!sourceId || !semanticChannelId) return false;
const modality = stream.modality?.trim().toLowerCase();
const sensorKind = stream.sensor_kind?.trim().toLowerCase();
return (
modality === "encoded-video" ||
modality === "video" ||
sensorKind === "camera" ||
semanticChannelId.startsWith("camera.")
);
const compatibilityProfileId =
state.device_session?.compatibility_profile_id ?? state.compatibility?.profile_id ?? null;
if (!stream) return "unavailable";
if (stream.availability !== "observed" || !compatibilityProfileId) return "unverified";
}
function safeEndpointLabel(value: string | null | undefined): string | null {
const label = value?.trim();
if (!label || label.length > 128) return null;
if (label.includes("://") || /(?:^|\D)(?:\d{1,3}\.){3}\d{1,3}(?:\D|$)/.test(label)) {
return null;
}
return label;
}
const SENSITIVE_QUERY_KEYS = new Set([
"accesskey", "accesstoken", "apikey", "auth", "authorization", "clientsecret",
"credential", "credentials", "password", "passwd", "passphrase", "privatekey",
"psk", "pwd", "refreshtoken", "secret", "sessionkey", "token", "user",
"username", "wifipassword",
]);
function decodedForInspection(value: string): string | null {
let decoded = value;
try {
for (let depth = 0; depth < 4; depth += 1) {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
}
} catch {
return null;
}
return decoded;
}
function sensitiveQueryKey(value: string): boolean {
const canonical = value.toLowerCase().replace(/[^a-z0-9]/g, "");
return SENSITIVE_QUERY_KEYS.has(canonical) ||
/(?:token|secret|password|passwd|passphrase|credential|credentials)$/.test(canonical);
}
function safeBrowserDeliveryUrl(value: string): boolean {
if (value.length > 4_096 || !value.startsWith("/") || value.startsWith("//")) return false;
const inspected = decodedForInspection(value);
if (!inspected) return false;
if (
/[\\\r\n\u0000-\u001f\u007f]/.test(inspected) ||
/\brtsps?\s*:/i.test(inspected) ||
/(?:^|\D)(?:\d{1,3}\.){3}\d{1,3}(?:\D|$)/.test(inspected) ||
/\[[0-9a-f:]+\]/i.test(inspected) ||
/(?:^|[/?#&=])[^/?#&=\s:@]+:[^/?#&\s@]+@/.test(inspected) ||
/\b(?:basic|bearer)\s+[a-z0-9._~+/=-]+/i.test(inspected)
) return false;
const base = new URL("https://mission-core.invalid/");
let parsed: URL;
let inspectedParsed: URL;
try {
parsed = new URL(value, base);
inspectedParsed = new URL(inspected, base);
} catch {
return false;
}
if (
parsed.origin !== base.origin || inspectedParsed.origin !== base.origin ||
parsed.username || parsed.password || parsed.hash ||
inspectedParsed.username || inspectedParsed.password || inspectedParsed.hash
) return false;
for (const [key] of inspectedParsed.searchParams) {
if (sensitiveQueryKey(key)) return false;
}
return true;
}
function browserDelivery(
value: XgridsCameraPreviewDelivery | null | undefined,
): ObservationSourceDelivery | null {
const id = value?.id?.trim();
const url = value?.url?.trim();
const mediaType = value?.media_type?.trim();
if (!id || value?.kind !== "mse-fmp4-websocket" || !url || !mediaType) return null;
// Browser delivery is host-owned and same-origin. A device RTSP/IP endpoint
// must never cross the plugin boundary into the host descriptor catalog.
if (!safeBrowserDeliveryUrl(url)) return null;
if (!/^video\/mp4(?:\s*;|$)/i.test(mediaType)) return null;
return { id, kind: value.kind, url, mediaType };
}
function cameraAvailability(
state: XgridsK1State,
stream: XgridsSensorCatalogStream,
selected: boolean,
delivery: ObservationSourceDelivery | null,
attested: boolean,
): ObservationSourceAvailability {
if (!attested) 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";
const base = catalogAvailability(stream.availability);
if (!selected) return base === "streaming" || base === "connecting" ? "available" : base;
const phase = state.camera_preview?.phase?.trim().toLowerCase();
if (phase === "error" || base === "error") return "error";
if (delivery && (phase === "streaming" || phase === "active" || phase === "ready")) {
return "streaming";
}
if (phase === "degraded") return "degraded";
return "connecting";
}
export function xgridsK1ObservationSources(
@ -67,9 +197,7 @@ export function xgridsK1ObservationSources(
const clockId = binding.acquisitionId ?? binding.deviceSessionId ?? binding.deviceId ?? null;
const descriptorId = (sourceId: string) =>
`${provider.pluginId}:${provider.modelId}:${sourceId}`;
return [
{
const pointCloud: ObservationSourceDescriptor = {
id: descriptorId("sensor.lidar.primary"),
sourceId: "sensor.lidar.primary",
semanticChannelId: "spatial.point-cloud.live",
@ -81,6 +209,8 @@ export function xgridsK1ObservationSources(
transport: "rerun-grpc",
endpointLabel: state.rerun_grpc_url?.trim() ? "Rerun gRPC" : "MQTT → Rerun",
previewUrl: state.rerun_grpc_url?.trim() || null,
delivery: null,
activation: null,
provider,
binding,
capabilities: {
@ -96,58 +226,73 @@ export function xgridsK1ObservationSources(
clockId,
spatialRegistration: "native",
},
},
{
id: descriptorId("sensor.camera.left"),
sourceId: "sensor.camera.left",
semanticChannelId: "camera.preview.live",
label: "K1 · камера слева",
description: "Левый H.264 preview-канал профиля устройства",
};
const cameraRows = (state.sensor_catalog?.streams ?? []).filter(cameraCatalogEntry);
const sourceIdCounts = new Map<string, number>();
for (const stream of cameraRows) {
const sourceId = stream.source_id?.trim();
if (sourceId) sourceIdCounts.set(sourceId, (sourceIdCounts.get(sourceId) ?? 0) + 1);
}
const attested = Boolean(provider.compatibilityProfileId && binding.deviceSessionId);
const sessionScope = binding.deviceSessionId ?? binding.deviceId ?? "unbound";
const activeSourceId = state.camera_preview?.active_source_id?.trim() ?? null;
const cameras = cameraRows.flatMap<ObservationSourceDescriptor>((stream) => {
const sourceId = stream.source_id?.trim();
const semanticChannelId = stream.semantic_channel_id?.trim();
if (!sourceId || !semanticChannelId || sourceIdCounts.get(sourceId) !== 1) return [];
const rawActivation = stream.activation;
const groupId = rawActivation?.group_id?.trim();
const maxActive = rawActivation?.max_active;
const activationValid = Boolean(
groupId && Number.isInteger(maxActive) && (maxActive ?? 0) > 0,
);
const selected = Boolean(
attested && activationValid && rawActivation?.selected === true && activeSourceId === sourceId,
);
const activation = activationValid
? {
groupId: `${provider.pluginId}:${sessionScope}:${groupId}`,
maxActive: maxActive as number,
selected,
controllable: Boolean(attested && rawActivation?.controllable),
}
: null;
const candidateDelivery = stream.delivery ?? state.camera_preview?.delivery;
const delivery = selected ? browserDelivery(candidateDelivery) : null;
const label = stream.label?.trim() || sourceId;
return [{
id: descriptorId(sourceId),
sourceId,
semanticChannelId,
label,
description: "Видеоканал, опубликованный активным device-плагином",
modality: "video",
role: "auxiliary",
availability: cameraAvailability(state),
transport: "rtsp",
endpointLabel: "RTSP · left",
availability: cameraAvailability(state, stream, selected, delivery, attested),
transport: delivery ? "websocket" : "other",
endpointLabel: safeEndpointLabel(stream.endpoint_label) ?? "Локальный video adapter",
previewUrl: null,
delivery,
activation,
provider,
binding,
capabilities: {
overlay: true,
fullscreen: true,
resizable: true,
defaultVisible: false,
defaultVisible: selected && Boolean(delivery),
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",
},
},
];
}];
});
return [pointCloud, ...cameras];
}

View File

@ -130,6 +130,7 @@ export function XgridsK1RuntimeProvider({
pendingAction: controller.pendingAction,
refresh: controller.refresh,
updateViewerSettings: controller.updateViewerSettings,
setObservationSourceActive: controller.setObservationSourceActive,
};
useEffect(() => {

View File

@ -0,0 +1,55 @@
import type { XgridsCameraPreviewState, XgridsK1State } from "./api";
function monotonicInteger(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
}
function deviceSessionScope(state: XgridsK1State): string | null {
const sessionId = state.device_session?.device_session_id;
return typeof sessionId === "string" && sessionId.trim() ? sessionId : null;
}
function cameraSnapshotIsAtLeastAsNew(
current: XgridsCameraPreviewState,
incoming: XgridsCameraPreviewState,
): boolean {
const currentRevision = monotonicInteger(current.revision);
const incomingRevision = monotonicInteger(incoming.revision);
if (currentRevision !== null || incomingRevision !== null) {
if (incomingRevision === null) return false;
if (currentRevision === null) return true;
if (incomingRevision !== currentRevision) return incomingRevision > currentRevision;
}
const currentGeneration = monotonicInteger(current.generation);
const incomingGeneration = monotonicInteger(incoming.generation);
if (currentGeneration === null) return true;
if (incomingGeneration === null) return false;
return incomingGeneration >= currentGeneration;
}
/**
* Select an authoritative runtime snapshot without allowing asynchronous REST,
* mutation, or event responses to roll camera preview state backwards.
*
* Revision is the primary ordering key. In particular, a stop snapshot with a
* newer revision and a null generation is valid and must replace the active
* generation. Generation is only a tie-breaker when revisions are equal or
* absent.
*/
export function selectMonotonicXgridsState(
current: XgridsK1State | null,
incoming: XgridsK1State,
): XgridsK1State {
if (current && deviceSessionScope(current) !== deviceSessionScope(incoming)) {
return incoming;
}
if (!current?.camera_preview) return incoming;
if (!incoming.camera_preview) return current;
// Backend snapshots are atomic. Reject the whole stale response instead of
// combining camera/catalog/metrics fields captured at different moments.
return cameraSnapshotIsAtLeastAsNew(current.camera_preview, incoming.camera_preview)
? incoming
: current;
}

View File

@ -19,6 +19,7 @@ import {
operationNeedsReconciliation,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import { selectMonotonicXgridsState } from "./stateOrdering";
export type PendingAction =
| "scan"
@ -27,6 +28,7 @@ export type PendingAction =
| "replay"
| "stop"
| "abort"
| "camera"
| "viewer";
function messageFor(error: unknown): string {
@ -65,7 +67,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
const mounted = useRef(true);
const acceptState = useCallback((nextState: XgridsK1State) => {
setState(nextState);
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
setBackendStatus("online");
}, []);
@ -219,6 +221,51 @@ export function useXgridsK1Runtime(enabled: boolean) {
);
}, [run, state?.acquisition]);
const setObservationSourceActive = useCallback(
(sourceId: string, active: boolean) =>
run("camera", async () => {
if (!state) {
throw new ApiError("Состояние устройства ещё не загружено.");
}
const deviceSessionId = state.device_session?.device_session_id?.trim();
if (!deviceSessionId) {
throw new ApiError("Для камеры нет активной сессии устройства.");
}
const catalogSource = state.sensor_catalog?.streams?.find(
(candidate) => candidate.source_id === sourceId,
);
if (!catalogSource || catalogSource.activation?.controllable !== true) {
throw new ApiError("Плагин не разрешает управление выбранным видеоканалом.");
}
if (active) {
if (
catalogSource.activation.selected &&
state.camera_preview?.active_source_id === sourceId &&
state.camera_preview?.phase !== "error" &&
state.camera_preview?.delivery
) {
return state;
}
return xgridsK1Api.selectCameraPreview({
source_id: sourceId,
device_session_id: deviceSessionId,
});
}
if (state.camera_preview?.active_source_id !== sourceId) return state;
const generation = state.camera_preview.generation;
if (!Number.isInteger(generation) || (generation ?? 0) < 1) {
throw new ApiError("Плагин не вернул поколение активной camera-preview сессии.");
}
return xgridsK1Api.stopCameraPreview({
device_session_id: deviceSessionId,
generation: generation as number,
});
}),
[run, state],
);
const updateViewerSettings = useCallback(
(request: ViewerSettings) => run("viewer", () => xgridsK1Api.updateViewerSettings(request)),
[run],
@ -300,6 +347,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
startReplay,
stop,
abort,
setObservationSourceActive,
updateViewerSettings,
};
}

View File

@ -191,6 +191,59 @@ i[data-availability="error"] {
background: #050608;
}
.mse-fmp4-player {
position: relative;
width: 100%;
height: 100%;
min-height: 9rem;
overflow: hidden;
background: #050608;
}
.mse-fmp4-player__status {
position: absolute;
inset: 0;
display: grid;
place-items: center;
align-content: center;
gap: 0.5rem;
padding: 1rem;
background: rgb(5 6 8 / 0.88);
color: var(--nodedc-text-muted);
text-align: center;
}
.mse-fmp4-player__status strong {
color: var(--nodedc-text-secondary);
font-size: 0.7rem;
}
.mse-fmp4-player__status span {
max-width: 24rem;
font-size: 0.59rem;
line-height: 1.45;
}
.mse-fmp4-player__status button {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.25rem;
border: 1px solid rgb(255 255 255 / 0.12);
border-radius: 999px;
background: rgb(255 255 255 / 0.06);
color: var(--nodedc-text-primary);
padding: 0.48rem 0.72rem;
font: inherit;
font-size: 0.58rem;
font-weight: 760;
cursor: pointer;
}
.mse-fmp4-player__status button:hover {
background: rgb(255 255 255 / 0.11);
}
.observation-media__empty {
min-height: 9rem;
background: #07080a;
@ -220,6 +273,24 @@ i[data-availability="error"] {
color: var(--nodedc-text-primary);
}
.camera-slot__head-actions .camera-slot__activation {
width: auto;
min-width: 5.6rem;
height: 1.9rem;
border: 1px solid rgb(255 255 255 / 0.09);
border-radius: 999px;
padding: 0 0.65rem;
font: inherit;
font-size: 0.53rem;
font-weight: 760;
white-space: nowrap;
}
.camera-slot__head-actions .camera-slot__activation:disabled {
cursor: wait;
opacity: 0.48;
}
.floating-observation-window__status {
color: var(--nodedc-text-secondary);
font-size: 0.54rem;

View File

@ -90,6 +90,24 @@
background: rgb(10 11 14 / 0.9);
}
.rerun-viewport__retry {
margin-top: 0.65rem;
border: 1px solid var(--station-hairline-strong);
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
padding: 0.42rem 0.7rem;
color: var(--nodedc-text-primary);
font: inherit;
font-size: 0.61rem;
cursor: pointer;
}
.rerun-viewport__retry:hover,
.rerun-viewport__retry:focus-visible {
background: rgb(255 255 255 / 0.14);
outline: none;
}
.busy-indicator {
width: 1rem;
height: 1rem;

View File

@ -328,6 +328,7 @@ function SpatialWorkspace({
{sourceUrl.trim() && pointCloudVisible ? (
<RerunViewport
sourceUrl={sourceUrl}
followLive={state?.sourceMode === "live"}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
/>
@ -349,6 +350,7 @@ function SpatialWorkspace({
<ObservationSourcePicker
sources={observationSources}
visibleSourceIds={observationLayout.visibleSourceIds}
pendingSourceIds={observationLayout.pendingSourceIds}
onToggle={observationLayout.toggleSource}
/>
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && sourceUrl.trim() ? (
@ -434,7 +436,7 @@ function SpatialWorkspace({
onMaximizedChange={(maximized) =>
observationLayout.setFloatingMaximized(source.id, maximized)}
onActivate={() => observationLayout.activateFloatingSource(source.id)}
onClose={() => observationLayout.hideSource(source.id)}
onClose={() => void observationLayout.hideSource(source.id)}
/>
)) : null}
</div>
@ -454,12 +456,41 @@ function SpatialWorkspace({
function CameraSourceCard({
source,
focused,
visible,
pending,
selectedPeer,
onToggle,
onFocus,
}: {
source: ObservationSourceDescriptor;
focused: boolean;
visible: boolean;
pending: boolean;
selectedPeer: boolean;
onToggle: () => void;
onFocus: () => void;
}) {
const deliveryActive = Boolean(
(source.delivery || source.previewUrl) &&
(!source.activation || source.activation.selected),
);
const restartAvailable = Boolean(
source.activation?.selected &&
source.activation.controllable &&
(source.availability === "error" || !source.delivery),
);
const activationLabel = pending
? "Переключение…"
: restartAvailable
? "Повторить подключение"
: source.activation?.selected && visible
? "Закрыть канал"
: source.activation?.selected
? "Показать канал"
: selectedPeer
? "Переключить"
: "Открыть канал";
return (
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
<header>
@ -469,7 +500,17 @@ function CameraSourceCard({
<i data-availability={source.availability} aria-hidden="true" />
{observationSourceStatusLabel(source)}
</span>
{source.capabilities.fullscreen ? (
{source.activation ? (
<button
type="button"
className="camera-slot__activation"
disabled={pending || (!source.activation.selected && !source.activation.controllable)}
onClick={onToggle}
>
{activationLabel}
</button>
) : null}
{source.capabilities.fullscreen && deliveryActive ? (
<button
type="button"
aria-label={focused ? `Свернуть ${source.label}` : `Развернуть ${source.label}`}
@ -510,6 +551,15 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
key={source.id}
source={source}
focused={focusedSource?.id === source.id}
visible={observationLayout.visibleSourceIds.has(source.id)}
pending={observationLayout.pendingSourceIds.has(source.id)}
selectedPeer={Boolean(
source.activation && sources.some((candidate) =>
candidate.id !== source.id &&
candidate.activation?.groupId === source.activation?.groupId &&
candidate.activation?.selected === true),
)}
onToggle={() => void observationLayout.toggleSource(source.id)}
onFocus={() => observationLayout.setFocusedSourceId(
focusedSource?.id === source.id ? null : source.id,
)}
@ -523,8 +573,11 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
)}
</div>
<ObservationTimeline
active={sources.some((source) => source.availability === "streaming")}
sourceCount={sources.length}
active={sources.some((source) =>
source.availability === "streaming" &&
(!source.activation || source.activation.selected))}
sourceCount={sources.filter((source) =>
!source.activation || source.activation.selected).length}
mode={timeline?.mode}
seekable={timeline?.seekable}
synchronization={timeline?.synchronization}

View File

@ -6,6 +6,10 @@ import { createServer } from "vite";
let server;
let xgridsK1Manifest;
let xgridsK1ObservationSources;
let openObservationSource;
let shouldRestartObservationSource;
let consumeCameraLeaseRetry;
let resetCameraLeaseRetryBudget;
before(async () => {
server = await createServer({
@ -19,6 +23,12 @@ before(async () => {
({ xgridsK1ObservationSources } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/observationSources.ts",
));
({ openObservationSource, shouldRestartObservationSource } = await server.ssrLoadModule(
"/src/core/observation/layoutPolicy.ts",
));
({ consumeCameraLeaseRetry, resetCameraLeaseRetryBudget } = await server.ssrLoadModule(
"/src/components/MseFmp4WebSocketPlayer.tsx",
));
});
after(async () => {
@ -31,7 +41,30 @@ function model() {
return activeModel;
}
function declaredState() {
function cameraRow(sourceId, label) {
return {
stream_id: `camera.preview.${sourceId.split(".").at(-1)}`,
source_id: sourceId,
semantic_channel_id: "camera.preview.live",
label,
sensor_kind: "camera",
modality: "encoded-video",
availability: "available",
endpoint_label: "MSE · fMP4",
activation: {
group_id: "camera.preview.decoder",
max_active: 1,
selected: false,
controllable: true,
},
delivery: null,
};
}
function declaredState(cameraRows = [
cameraRow("sensor.camera.left", "K1 · камера слева"),
cameraRow("sensor.camera.right", "K1 · камера справа"),
]) {
const activeModel = model();
return {
phase: "connected",
@ -60,13 +93,20 @@ function declaredState() {
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" },
...cameraRows,
],
},
camera_preview: {
phase: "idle",
revision: 1,
generation: 0,
active_source_id: null,
delivery: null,
},
};
}
function streamingState() {
function pointCloudStreamingState() {
return {
...declaredState(),
phase: "streaming",
@ -88,6 +128,36 @@ function streamingState() {
};
}
function cameraStreamingState(sourceId) {
const state = pointCloudStreamingState();
const delivery = {
id: `preview-generation-7:${sourceId}`,
kind: "mse-fmp4-websocket",
url: "/api/v1/device-plugins/xgrids-k1/camera-preview/ws?generation=7",
media_type: 'video/mp4; codecs="avc1.640028"',
};
state.sensor_catalog = {
...state.sensor_catalog,
streams: state.sensor_catalog.streams.map((stream) =>
stream.source_id === sourceId
? {
...stream,
availability: "streaming",
activation: { ...stream.activation, selected: true },
delivery,
}
: stream),
};
state.camera_preview = {
phase: "streaming",
revision: 4,
generation: 7,
active_source_id: sourceId,
delivery,
};
return state;
}
function collectUrlLikeStrings(value, found = []) {
if (typeof value === "string") {
if (value.includes("://")) found.push(value);
@ -103,95 +173,199 @@ function collectUrlLikeStrings(value, 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",
},
],
test("K1 maps zero, one or N catalog cameras without model-specific source ids", () => {
const zero = xgridsK1ObservationSources(declaredState([]), model());
const one = xgridsK1ObservationSources(
declaredState([cameraRow("rig.front", "Передняя камера")]),
model(),
);
const many = xgridsK1ObservationSources(declaredState(), model());
assert.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"));
assert.deepEqual(zero.map(({ modality }) => modality), ["point-cloud"]);
assert.deepEqual(one.map(({ sourceId }) => sourceId), ["sensor.lidar.primary", "rig.front"]);
assert.deepEqual(many.map(({ sourceId }) => sourceId), [
"sensor.lidar.primary",
"sensor.camera.left",
"sensor.camera.right",
]);
assert.equal(new Set(many.map(({ id }) => id)).size, many.length);
assert.ok(many.every(({ provider }) => provider.pluginId && provider.modelId));
assert.ok(many.every(({ binding }) => binding.deviceId === "device-k1-001"));
});
test("observation source ids remain stable while runtime availability changes", () => {
test("descriptor ids remain stable while point cloud and selected camera start streaming", () => {
const declared = xgridsK1ObservationSources(declaredState(), model());
const streaming = xgridsK1ObservationSources(streamingState(), model());
assert.deepEqual(
streaming.map(({ id }) => id),
declared.map(({ id }) => id),
const streaming = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
);
assert.deepEqual(streaming.map(({ id }) => id), declared.map(({ id }) => id));
assert.equal(declared[0].availability, "available");
assert.equal(streaming[0].availability, "streaming");
assert.equal(streaming[0].previewUrl, "rerun+http://127.0.0.1:9877/proxy");
});
test("camera descriptors stay declared and URL-free until a browser delivery adapter exists", () => {
const sources = xgridsK1ObservationSources(streamingState(), model());
test("only the authoritative selected camera receives browser delivery", () => {
const sources = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
);
const cameras = sources.filter(({ modality }) => modality === "video");
const [left, right] = cameras;
assert.equal(cameras.length, 2);
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.equal(left.activation.selected, true);
assert.equal(left.activation.maxActive, 1);
assert.equal(left.availability, "streaming");
assert.equal(left.delivery.kind, "mse-fmp4-websocket");
assert.equal(left.transport, "websocket");
assert.equal(right.activation.selected, false);
assert.equal(right.availability, "available");
assert.equal(right.delivery, null);
assert.equal(left.activation.groupId, right.activation.groupId);
assert.match(left.activation.groupId, /device-session-001/);
});
test("switching left to right keeps ids stable and never exposes both deliveries", () => {
const left = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
).filter(({ modality }) => modality === "video");
const right = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.right"),
model(),
).filter(({ modality }) => modality === "video");
assert.deepEqual(right.map(({ id }) => id), left.map(({ id }) => id));
assert.deepEqual(left.filter(({ delivery }) => delivery).map(({ sourceId }) => sourceId), [
"sensor.camera.left",
]);
assert.deepEqual(right.filter(({ delivery }) => delivery).map(({ sourceId }) => sourceId), [
"sensor.camera.right",
]);
});
test("camera descriptors never leak vendor RTSP endpoints or device IP addresses", () => {
const sources = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
);
const cameras = sources.filter(({ modality }) => modality === "video");
assert.ok(cameras.every(({ previewUrl }) => previewUrl === null));
assert.deepEqual(collectUrlLikeStrings(sources), [
"rerun+http://127.0.0.1:9877/proxy",
]);
const serialized = JSON.stringify(sources);
const serialized = JSON.stringify(cameras);
assert.doesNotMatch(serialized, /rtsp:\/\//i);
assert.doesNotMatch(serialized, /192\.168\.7\.10/);
assert.doesNotMatch(serialized, /vendor-(?:preview|viewer)/);
});
test("unattested camera catalog entries remain unverified", () => {
const state = declaredState();
test("unattested, duplicate and unsafe camera entries fail closed", () => {
const duplicate = cameraRow("sensor.camera.left", "Duplicate");
const state = cameraStreamingState("sensor.camera.left");
state.compatibility.profile_id = null;
state.device_session.compatibility_profile_id = null;
state.sensor_catalog.streams = state.sensor_catalog.streams.map((stream) =>
stream.stream_id === "camera.preview.live"
? { ...stream, availability: "unverified", decode_status: "profile-not-attested" }
: stream);
state.sensor_catalog.streams.push(duplicate);
state.camera_preview.delivery = {
...state.camera_preview.delivery,
url: "ws://192.168.7.10:9000/leak",
};
const cameras = xgridsK1ObservationSources(state, model()).filter(
({ modality }) => modality === "video",
);
assert.equal(cameras.length, 2);
assert.ok(cameras.every(({ availability }) => availability === "unverified"));
assert.ok(cameras.every(({ previewUrl }) => previewUrl === null));
assert.deepEqual(cameras.map(({ sourceId }) => sourceId), ["sensor.camera.right"]);
assert.equal(cameras[0].availability, "unverified");
assert.equal(cameras[0].activation.controllable, false);
assert.equal(cameras[0].delivery, null);
});
test("camera delivery rejects literal and encoded endpoint or credential leaks", () => {
const unsafeUrls = [
"/api/preview?upstream=rtsp://camera.local/live",
"/api/preview?upstream=rtsp%3A%2F%2Fcamera.local%2Flive",
"/api/preview?upstream=rtsp%253A%252F%252Fcamera.local%252Flive",
"/api/preview?endpoint=192.168.68.52:8554",
"/api/preview?endpoint=192%2E168%2E68%2E52",
"/api/preview?password=not-for-the-browser",
"/api/preview?%70%61%73%73%77%6f%72%64=not-for-the-browser",
"/api/preview/camera:secret@device",
"/%2f%2fevil.example/preview",
];
for (const url of unsafeUrls) {
const state = cameraStreamingState("sensor.camera.left");
state.camera_preview.delivery = { ...state.camera_preview.delivery, url };
state.sensor_catalog.streams = state.sensor_catalog.streams.map((stream) =>
stream.source_id === "sensor.camera.left"
? { ...stream, delivery: { ...stream.delivery, url } }
: stream,
);
const left = xgridsK1ObservationSources(state, model()).find(
({ sourceId }) => sourceId === "sensor.camera.left",
);
assert.ok(left, `left camera descriptor missing for ${url}`);
assert.equal(left.delivery, null, `unsafe delivery escaped for ${url}`);
}
});
test("manual camera reconnect restores an exhausted lease retry budget", () => {
let budget = resetCameraLeaseRetryBudget("delivery-7");
for (const expectedDelay of [400, 1_000, 2_000]) {
const retry = consumeCameraLeaseRetry(budget, "delivery-7");
assert.equal(retry.delay, expectedDelay);
budget = retry.budget;
}
assert.equal(consumeCameraLeaseRetry(budget, "delivery-7").delay, null);
budget = resetCameraLeaseRetryBudget("delivery-7");
const retryAfterManualReset = consumeCameraLeaseRetry(budget, "delivery-7");
assert.equal(retryAfterManualReset.delay, 400);
assert.equal(retryAfterManualReset.budget.count, 1);
});
test("layout policy evicts only exclusive camera peers", () => {
const sources = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
model(),
);
const left = sources.find(({ sourceId }) => sourceId === "sensor.camera.left");
const right = sources.find(({ sourceId }) => sourceId === "sensor.camera.right");
const pointCloud = sources.find(({ modality }) => modality === "point-cloud");
assert.ok(left && right && pointCloud);
const change = openObservationSource(
[pointCloud.id, left.id],
right.id,
sources,
);
assert.deepEqual(change.visibleIds, [pointCloud.id, right.id]);
assert.deepEqual(change.removedIds, [left.id]);
});
test("selected camera without delivery is explicitly restartable", () => {
const state = cameraStreamingState("sensor.camera.left");
state.camera_preview = {
...state.camera_preview,
phase: "error",
delivery: null,
};
state.sensor_catalog.streams = state.sensor_catalog.streams.map((stream) =>
stream.source_id === "sensor.camera.left"
? { ...stream, availability: "error", delivery: null }
: stream,
);
const sources = xgridsK1ObservationSources(state, model());
const left = sources.find(({ sourceId }) => sourceId === "sensor.camera.left");
const right = sources.find(({ sourceId }) => sourceId === "sensor.camera.right");
assert.ok(left && right);
assert.equal(left.activation.selected, true);
assert.equal(left.delivery, null);
assert.equal(left.availability, "error");
assert.equal(shouldRestartObservationSource(left), true);
assert.equal(shouldRestartObservationSource(right), false);
});

View File

@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let selectMonotonicXgridsState;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ selectMonotonicXgridsState } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/stateOrdering.ts",
));
});
after(async () => {
await server?.close();
});
function snapshot(revision, generation, phase = "streaming", sessionId = "device-session-a") {
return {
phase: "connected",
message: `${phase}:${revision}:${generation}`,
device_session: sessionId === null
? null
: {
device_session_id: sessionId,
device_id: `device-for-${sessionId}`,
connectivity: "connected",
},
camera_preview: {
phase,
revision,
generation,
active_source_id: generation === null ? null : "sensor.camera.left",
delivery: null,
},
};
}
test("accepts the first camera preview snapshot", () => {
const incoming = snapshot(1, 1);
assert.equal(selectMonotonicXgridsState(null, incoming), incoming);
});
test("rejects a lower revision even when its generation is higher", () => {
const current = snapshot(8, 3);
const stale = snapshot(7, 99);
assert.equal(selectMonotonicXgridsState(current, stale), current);
});
test("uses generation only as a tie-breaker for equal revisions", () => {
const current = snapshot(8, 3);
const stale = snapshot(8, 2);
const equal = snapshot(8, 3, "selected");
assert.equal(selectMonotonicXgridsState(current, stale), current);
assert.equal(selectMonotonicXgridsState(current, equal), equal);
});
test("accepts a newer stop revision with a null generation", () => {
const current = snapshot(8, 3);
const stopped = snapshot(9, null, "idle");
assert.equal(selectMonotonicXgridsState(current, stopped), stopped);
});
test("rejects an unversioned camera snapshot after a versioned one", () => {
const current = snapshot(8, 3);
const stale = {
phase: "connected",
device_session: current.device_session,
};
assert.equal(selectMonotonicXgridsState(current, stale), current);
});
test("accepts revision reset when the authoritative device session changes", () => {
const current = snapshot(18, 7, "streaming", "device-session-a");
const nextDevice = snapshot(0, null, "idle", "device-session-b");
assert.equal(selectMonotonicXgridsState(current, nextDevice), nextDevice);
});
test("accepts backend reset that clears and later recreates the device session", () => {
const current = snapshot(18, 7, "streaming", "device-session-a");
const reset = snapshot(0, null, "idle", null);
const reconnected = snapshot(0, null, "idle", "device-session-c");
assert.equal(selectMonotonicXgridsState(current, reset), reset);
assert.equal(selectMonotonicXgridsState(reset, reconnected), reconnected);
});
test("rejects the entire stale atomic snapshot instead of merging unrelated fields", () => {
const current = {
...snapshot(8, 3),
metrics: { point_count: 2_500 },
};
const stale = {
...snapshot(7, 2),
metrics: { point_count: 9_999 },
};
const accepted = selectMonotonicXgridsState(current, stale);
assert.equal(accepted, current);
assert.equal(accepted.metrics.point_count, 2_500);
});

View File

@ -8,6 +8,12 @@ export default defineConfig(({ mode }) => {
return {
plugins: [react(), wasm()],
optimizeDeps: {
// Rerun resolves its WASM asset relative to the package entrypoint. Vite's
// dependency pre-bundler flattens that entrypoint and leaves the WASM URL
// pointing at the SPA fallback, which browsers reject as text/html.
exclude: ["@rerun-io/web-viewer"],
},
build: {
target: "esnext",
},

View File

@ -101,10 +101,20 @@ receiver and operator workflow only. K1 scanning is still started and stopped
by physical double-click; no modeling request is published.
Left/right camera preview transport, endpoint paths and H.264 framing are now
observed under the exact compatibility profile. The read-only runtime adapter is
not implemented yet. Device status and heartbeat remain raw observed channels
without semantic decoders. Device calibration command and sensor-to-vehicle
extrinsics are unavailable.
observed under the exact compatibility profile. The local read-only adapter
copy-remuxes one selected RTSP producer into bounded fMP4/WebSocket delivery for
the generic MSE UI. Portable FFmpeg packaging, fan-out and remote delivery remain
open. Device status and heartbeat remain raw observed channels without semantic
decoders. Device calibration command and sensor-to-vehicle extrinsics are
unavailable.
The 2026-07-17 physical acceptance gate confirmed continuously updating point
clouds, a matching live trajectory, and both camera selections in the same
Mission Core acquisition. The embedded Rerun blueprint owns live-edge following
on `stream_time`; the React shell selects the timeline once and does not drive it
with a timer. Raw captures, camera frames, device identity and network details
remain outside Git. See [`ADR 0007`](adr/0007-k1-camera-preview-copy-remux-gateway.md)
for the measured gate and remaining limits.
## Remaining extraction order
@ -118,7 +128,8 @@ extrinsics are unavailable.
5. Replace compatibility routes and singleton state with multi-device session
routing.
6. Split Edge execution from the Control Station behind authenticated transport.
7. Implement the now-observed read-only RTSP/H.264 camera adapter; keep the
7. Package the read-only RTSP/H.264 camera adapter for each target OS and evolve
same-host MSE delivery toward an authenticated Edge media plane; keep the
modeling-command publisher disabled until its separate safety gate closes.
## Invariants
@ -149,7 +160,8 @@ extrinsics are unavailable.
- complete SDK-envelope/EvidenceStore hot-path integration;
- remote Edge split, authenticated WAN relay and fleet orchestration;
- automatic K1 start/stop/calibration commands;
- camera discovery, decoding, synchronization and visualization.
- automatic camera capability discovery, synchronized recording/rewind,
calibration, panoramic stitching and multi-consumer delivery.
## Workspace-repeatable locked architecture gate

View File

@ -7,9 +7,9 @@ Status: accepted for the experimental Mission Core runtime, 2026-07-16.
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.
two path-labelled H.264 camera preview producers. The direct physical gate showed
that only one K1 preview producer can deliver frames at a time. A shared
point-cloud/video clock and camera intrinsics/extrinsics remain unavailable.
Hard-coded camera slots, direct RTSP URLs in React components and an apparently
seekable timeline would therefore create three false contracts: a fixed sensor
@ -27,12 +27,15 @@ count, browser ownership of device transport and replay that does not exist.
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
5. Direct device RTSP URLs do not cross into the generic UI. The read-only K1
adapter publishes an opaque, same-origin fMP4/WebSocket delivery lease.
6. A descriptor may declare a generic activation group, capacity and selection
state. The K1 camera producers share one group with `maxActive=1`; frontend
layout enforces it for UX and the plugin remains the backend authority.
7. 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
8. 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.
@ -45,8 +48,9 @@ count, browser ownership of device transport and replay that does not exist.
| `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.
`sourceId`. The plugin publishes both catalog rows dynamically, but delivery is
present only on the selected producer. Selecting the peer tears down the old
RTSP/FFmpeg generation before a new browser lease is issued.
## UI consequences
@ -62,11 +66,12 @@ the channels, but no browser preview URL is advertised yet.
## 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.
The local laboratory adapter now terminates RTSP/RTP with FFmpeg, copy-remuxes
H.264 High L4 without transcoding and publishes complete bounded fMP4 segments
to a generic MSE player. Portable FFmpeg packaging, multi-consumer fan-out and
remote/WAN delivery are not claimed by this milestone. Synchronized rewind
becomes eligible only after persisted buffering and an evidenced mapping between
the RTP 90 kHz clock and the spatial stream clock. See ADR 0007.
## Code anchors
@ -76,3 +81,4 @@ and the spatial stream clock.
- `apps/control-station/src/components/ObservationTimeline.tsx`
- `apps/control-station/src/workspaces/Workspaces.tsx`
- `apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts`
- `src/k1link/web/xgrids_k1_camera.py`

View File

@ -0,0 +1,88 @@
# ADR 0007: K1 camera preview copy-remux gateway
Status: accepted for the local laboratory runtime, 2026-07-16.
## Context
The exact K1 firmware 3.0.2 profile exposes two RTSP/TCP H.264 preview paths.
Traffic evidence and the direct physical gate establish High Profile Level 4.0,
800x600, approximately 10 fps, about 6.5 Mbit/s and a one-second GOP. The device
delivers frames to only one preview consumer/path at a time. Browsers cannot read
RTSP directly and device addresses must not enter generic UI contracts.
The product target will eventually require portable Edge packaging and remote
delivery. This milestone only needs a low-latency, same-Mac laboratory path that
does not bake XGRIDS knowledge into the host UI.
## Decision
1. The XGRIDS plugin owns the RTSP session and accepts only the two reviewed
`source_id` values. The browser never supplies a host, port, path or RTSP URL.
2. The target is taken from the active, attested device session and validated as
private IPv4. Every selection has a monotonically increasing generation.
3. Switching sources terminates the previous FFmpeg process before publishing
the new generation. A stale stop cannot close a newer preview.
4. FFmpeg uses `-c:v copy`: it depacketizes the observed vendor RTP framing and
copy-remuxes H.264 into fragmented MP4. No video decode, image processing or
transcode runs in Python.
5. A background ISO-BMFF parser publishes one `ftyp+moov` init segment followed
by complete `moof+mdat` media segments. Boxes and segments are bounded; the
server queue holds at most four segments. A slow consumer causes a clean
stream stop instead of unbounded latency.
6. The generic browser renderer uses MSE with a 2 MiB pending queue and retains
at most about three seconds in `SourceBuffer`. It never drops an arbitrary
dependent fragment; overflow resets the lease.
7. Plugin state exposes only an opaque same-origin WebSocket URL, media type,
generation and generic activation metadata. Device RTSP details remain inside
the plugin.
## Verified gate
The exact captured K1 codec sequence was passed in memory through the selected
copy-remux flags and Chrome MSE. Chrome decoded High L4 at 800x600 with no media
error and without transcoding. No camera frame fixture was persisted or added to
Git.
The final physical gate was completed on 2026-07-17 with the exact firmware
3.0.2 profile. In one Mission Core acquisition the operator confirmed:
- continuously updating point-cloud geometry in the embedded spatial scene;
- a visible trajectory matching an approximately 3.2 by 3.35 metre physical
route (about 1.29 metres of vertical span in the captured poses);
- live left and right camera selection through the generic source controls;
- graceful operator-confirmed scanner stop, receiver finalization and media
process cleanup.
The retained out-of-Git evidence records 11,189 point-cloud frames, 11,564 pose
frames, 33,481,543 published points and zero decode errors. The live preview
queue reported 2,273 bounded drops under load; this is preview backpressure,
not loss of the raw-first evidence, and remains a performance follow-up.
During the gate the scene initially appeared frozen even though counters and
captures advanced. The cause was the embedded Rerun time cursor leaving the live
edge. The accepted implementation now declares the `stream_time` timeline in
native `Following` state in the backend blueprint; the frontend selects that
timeline once when the recording opens and does not poll or force the cursor.
## Deliberate limits
- The current gateway allows one browser consumer because the local UI owns one
operator preview. Multi-tab fan-out is not claimed.
- The Homebrew FFmpeg found on this Mac is marked `development-system`. Portable
releases must supply a pinned repository-local or configured binary.
- Same-origin local WebSocket delivery is not the future WAN transport. A later
Edge/Control Station split should use an authenticated WebRTC/WHEP or equivalent
congestion-aware media plane behind the same descriptor contract.
- Camera recording, synchronized rewind, calibration, frustums, panoramic
stitching and full-resolution raw imagery remain unavailable.
- A longer physical soak test is still required to characterize sustained
preview backpressure and rendering cost on target Edge hardware.
## Code anchors
- `src/k1link/web/xgrids_k1_camera.py`
- `src/k1link/web/xgrids_k1_facade.py`
- `apps/control-station/src/components/MseFmp4WebSocketPlayer.tsx`
- `apps/control-station/src/core/observation/useObservationLayout.ts`
- `apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts`
- `tests/test_xgrids_camera_gateway.py`

View File

@ -3,7 +3,7 @@
"kind": "DevicePlugin",
"metadata": {
"id": "nodedc.device.xgrids-lixelkity-k1",
"version": "0.2.0",
"version": "0.3.0",
"displayName": "XGRIDS K1 Integration"
},
"spec": {
@ -23,6 +23,8 @@
"device.discovery.ble",
"device.provisioning.wifi-over-ble",
"network.mqtt.subscribe-private-lan",
"network.rtsp.read-private-lan",
"media.publish-local-browser",
"evidence.write-session-artifacts"
],
"actions": [
@ -41,6 +43,8 @@
{ "id": "stream.start-live", "mutating": true, "secretFields": [] },
{ "id": "stream.start-replay", "mutating": true, "secretFields": [] },
{ "id": "stream.stop", "mutating": true, "secretFields": [] },
{ "id": "camera.preview.select", "mutating": true, "secretFields": [] },
{ "id": "camera.preview.stop", "mutating": true, "secretFields": [] },
{ "id": "viewer.settings.update", "mutating": true, "secretFields": [] }
],
"models": [
@ -56,6 +60,7 @@
{ "id": "device.provisioning.wifi-over-ble", "label": "Wi-Fi через BLE" },
{ "id": "spatial.point-cloud.live", "label": "Облако точек" },
{ "id": "spatial.pose.live", "label": "Траектория" },
{ "id": "camera.preview.live", "label": "Видеокамеры" },
{ "id": "evidence.raw-capture", "label": "Исходная запись" },
{ "id": "evidence.replay", "label": "Повтор записи" }
],

View File

@ -2,10 +2,11 @@ from __future__ import annotations
import math
import time
from collections import deque
from collections.abc import Callable
from contextlib import suppress
from dataclasses import asdict, dataclass
from typing import Literal
from uuid import uuid4
import numpy as np
import rerun as rr
@ -21,7 +22,12 @@ from k1link.viewer.metrics import BridgeMetrics
PointColorMode = Literal["intensity", "height", "distance", "rgb", "class"]
PointPalette = Literal["turbo", "viridis", "plasma", "grayscale", "custom"]
MAX_TRAJECTORY_POSES = 20_000
MAX_TRAJECTORY_POSES = 2_000
TRAJECTORY_APPEND_INTERVAL_NS = 500_000_000
TRAJECTORY_FORCE_APPEND_NS = 2_000_000_000
TRAJECTORY_MIN_DISTANCE_METERS = 0.02
TRAJECTORY_PUBLISH_INTERVAL_NS = 500_000_000
LIVE_GRPC_BUFFER_LIMIT = "32MiB"
DEFAULT_GRPC_PORT = 9876
DEFAULT_CORS_ORIGINS = (
"http://127.0.0.1:5173",
@ -66,27 +72,52 @@ class RerunBridge:
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_mission_core_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)
if recording_factory is None:
recording = rr.RecordingStream(
"nodedc_mission_core_spatial",
recording_id=uuid4(),
)
else:
recording = recording_factory("nodedc_mission_core_spatial")
try:
blueprint = _blueprint(self._settings)
url = recording.serve_grpc(
grpc_port=grpc_port,
default_blueprint=blueprint,
# This is a reconnect cushion for the live preview, not the source
# of record. Raw MQTT evidence is persisted independently. A large
# late-client backlog can block the native SDK and freeze preview.
server_memory_limit=LIVE_GRPC_BUFFER_LIMIT,
# Rerun 0.34.1 can replay ActivateStore before StoreInfo when an
# evicted buffer is served newest-first, leaving late viewers on the
# welcome screen. Preserve protocol order within the bounded cache.
newest_first=False,
cors_allow_origin=list(cors_allow_origin),
)
recording.send_blueprint(
blueprint,
make_active=True,
make_default=True,
)
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
recording.log(
"/world/sensor_pose",
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
static=True,
)
# Do not expose a URL whose StoreInfo, blueprint and static scene are
# still waiting in the SDK micro-batcher.
recording.flush(timeout_sec=5.0)
except BaseException:
# Preserve the construction failure while still making a best
# effort to release a partially started native listener.
with suppress(BaseException):
recording.disconnect()
raise
self._recording = recording
self._url = url
self._path: list[tuple[float, float, float]] = []
self._last_path_append_source_ns = 0
self._last_trajectory_publish_ns = 0
self._last_point_count = 0
self._closed = False
@ -96,13 +127,14 @@ class RerunBridge:
return self._url
def begin_session(self, metrics: BridgeMetrics | None = None) -> None:
"""Reset session-local state while keeping the process-wide server alive."""
"""Reset session state while keeping the process-lifetime 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_path_append_source_ns = 0
self._last_trajectory_publish_ns = 0
self._last_point_count = 0
self._recording.set_time("stream_time", timestamp=time.time())
@ -118,6 +150,10 @@ class RerunBridge:
make_active=True,
make_default=True,
)
# VisualizationRuntime publishes grpc_url only after this method
# returns, so a late subscriber cannot race the initial StoreInfo and
# blueprint through the SDK micro-batcher.
self._recording.flush(timeout_sec=5.0)
def process(self, envelope: DecodedDataPlaneView) -> None:
self._apply_latest_settings()
@ -158,12 +194,14 @@ class RerunBridge:
self._closed = True
recording = self._recording
try:
recording.flush(timeout_sec=5.0)
try:
recording.flush(timeout_sec=5.0)
finally:
recording.disconnect()
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
# The Python wrapper owns the native gRPC server. Release it even if
# flush or disconnect fails instead of waiting for garbage collection.
del self._recording
def _set_message_time(self, envelope: DecodedDataPlaneView) -> None:
context = envelope.context
@ -207,25 +245,35 @@ class RerunBridge:
)
def _publish_pose(self, frame: DecodedPoseView) -> None:
publish_now_ns = time.monotonic_ns()
position = (
float(frame.position_xyz[0]),
float(frame.position_xyz[1]),
float(frame.position_xyz[2]),
)
self._recording.log(
"/world/sensor_pose",
rr.Transform3D(
translation=frame.position_xyz,
translation=position,
quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw),
),
)
self._path.append(frame.position_xyz)
appended = self._append_trajectory_pose(
position,
frame.context.captured_at_epoch_ns,
)
if not self._settings.show_trajectory:
self._recording.log("/world/trajectory", rr.Clear(recursive=False))
return
now_ns = time.monotonic_ns()
if not appended:
return
if (
len(self._path) > 2
and len(self._path) % 20 != 0
and now_ns - self._last_trajectory_publish_ns < 200_000_000
publish_now_ns - self._last_trajectory_publish_ns
< TRAJECTORY_PUBLISH_INTERVAL_NS
):
return
self._last_trajectory_publish_ns = now_ns
self._last_trajectory_publish_ns = publish_now_ns
self._recording.log(
"/world/trajectory",
rr.LineStrips3D(
@ -235,6 +283,39 @@ class RerunBridge:
),
)
def _append_trajectory_pose(
self,
position: tuple[float, float, float],
source_time_ns: int,
) -> bool:
if not self._path:
self._path.append(position)
self._last_path_append_source_ns = source_time_ns
return True
elapsed_ns = source_time_ns - self._last_path_append_source_ns
if elapsed_ns < 0:
# A source clock discontinuity must not freeze trajectory sampling
# until the timestamp catches up again. Preserve message order and
# start a new sampling interval at the discontinuity.
elapsed_ns = TRAJECTORY_FORCE_APPEND_NS
if elapsed_ns < TRAJECTORY_APPEND_INTERVAL_NS:
return False
if (
math.dist(self._path[-1], position) < TRAJECTORY_MIN_DISTANCE_METERS
and elapsed_ns < TRAJECTORY_FORCE_APPEND_NS
):
return False
self._path.append(position)
self._last_path_append_source_ns = source_time_ns
if len(self._path) > MAX_TRAJECTORY_POSES:
last = self._path[-1]
self._path = self._path[::2]
if self._path[-1] != last:
self._path.append(last)
return True
def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint:
accumulation = max(0.0, settings.accumulation_seconds)
@ -255,12 +336,22 @@ def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint:
),
time_ranges=[time_range],
),
_live_time_panel(),
auto_layout=False,
auto_views=False,
collapse_panels=True,
)
def _live_time_panel() -> rrb.TimePanel:
"""Keep the hidden vendor timeline on its native live edge."""
return rrb.TimePanel(
timeline="stream_time",
play_state="following",
state="hidden",
)
def _point_colors(
positions: np.ndarray,
intensities: np.ndarray,

View File

@ -28,6 +28,10 @@ RuntimePhase = Literal[
SourceMode = Literal["idle", "live", "replay"]
StateCallback = Callable[[], None]
BridgeFactory = Callable[..., RerunBridge]
# TODO: replace the mixed-modality FIFO with a latest point-cloud slot and a
# bounded pose queue. The compact queue protects acquisition from a slow
# visualizer, but under sustained pressure it can still evict pose messages.
PREVIEW_QUEUE_SIZE = 4
class CanonicalNormalizer(Protocol):
@ -191,7 +195,13 @@ class VisualizationRuntime:
self._bridge = None
self._rerun_grpc_url = None
if bridge is not None:
bridge.close()
try:
bridge.close()
except BaseException as exc:
self._finish_error(
f"Ошибка завершения визуального моста: {type(exc).__name__}: {exc}"
)
raise
self._notify()
def _start(
@ -308,7 +318,7 @@ class VisualizationRuntime:
*,
running_phase: RuntimePhase,
) -> None:
messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=32)
messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=PREVIEW_QUEUE_SIZE)
source_done = threading.Event()
publisher_ready = threading.Event()
publisher_aborted = threading.Event()
@ -334,23 +344,28 @@ class VisualizationRuntime:
def publish() -> None:
bridge: RerunBridge | None = None
try:
bridge = self._bridge
with self._lock:
bridge = self._bridge
if self._closed:
publisher_aborted.set()
if publisher_aborted.is_set():
publisher_ready.set()
return
if bridge is None:
candidate = self._bridge_factory(
grpc_port=self._grpc_port,
metrics=self._metrics,
settings_provider=self._current_scene_settings,
)
bridge = candidate
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
if publisher_aborted.is_set():
publisher_ready.set()
return
assert bridge is not None
bridge.begin_session(self._metrics)
with self._lock:
@ -402,12 +417,19 @@ class VisualizationRuntime:
finally:
if bridge is not None:
with self._lock:
close_bridge = self._closed and self._bridge is bridge
if close_bridge:
close_bridge = self._closed and (
self._bridge is bridge or publisher_aborted.is_set()
)
if close_bridge and self._bridge is bridge:
self._bridge = None
self._rerun_grpc_url = None
if close_bridge:
bridge.close()
try:
bridge.close()
except BaseException as exc:
# Process shutdown must not become a false successful
# idle state when the native bridge failed to close.
publisher_error.append(exc)
def join_publisher() -> None:
# Never orphan a publisher: the session thread remains its owner.

View File

@ -0,0 +1,569 @@
from __future__ import annotations
import asyncio
import os
import queue
import signal
import subprocess
import threading
from collections import deque
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import IO, Any, Final, Literal
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from k1link.mqtt import validate_private_ipv4
CameraSourceId = Literal["sensor.camera.left", "sensor.camera.right"]
CAMERA_SOURCE_PATHS: Final[dict[CameraSourceId, str]] = {
"sensor.camera.left": "/live/chn_left_main",
"sensor.camera.right": "/live/chn_right_main",
}
CAMERA_SOURCE_LABELS: Final[dict[CameraSourceId, str]] = {
"sensor.camera.left": "K1 · камера слева",
"sensor.camera.right": "K1 · камера справа",
}
CAMERA_MEDIA_TYPE: Final = 'video/mp4; codecs="avc1.641028"'
CAMERA_EXCLUSIVE_GROUP: Final = "camera.preview.decoder"
MAX_FMP4_BOX_BYTES: Final = 8 * 1024 * 1024
MAX_FMP4_SEGMENT_BYTES: Final = 1024 * 1024
MAX_QUEUED_SEGMENTS: Final = 4
@dataclass
class CameraProcessLease:
generation: int
source_id: CameraSourceId
process: subprocess.Popen[bytes]
stderr_tail: deque[str] = field(default_factory=lambda: deque(maxlen=12))
segments: queue.Queue[tuple[str, bytes] | None] = field(
default_factory=lambda: queue.Queue(maxsize=MAX_QUEUED_SEGMENTS)
)
failure_code: str | None = None
class XgridsK1CameraGateway:
"""One fail-closed K1 RTSP owner with a browser-safe fMP4 delivery plane.
Selection and delivery are deliberately separate. A plugin action selects
one allowlisted producer and publishes an opaque generation. Only a matching
WebSocket may then start FFmpeg. This keeps the device address and RTSP path
out of the generic UI and prevents two browser windows from opening two K1
sessions.
"""
def __init__(self, repository_root: Path, plugin_id: str) -> None:
self._repository_root = repository_root.resolve()
self._plugin_id = plugin_id
self._lock = threading.RLock()
self._revision = 0
self._generation = 0
self._phase = "idle"
self._source_id: CameraSourceId | None = None
self._target_host: str | None = None
self._process: subprocess.Popen[bytes] | None = None
self._process_generation: int | None = None
self._error: dict[str, str] | None = None
self._closed = False
self._ffmpeg_path, self._ffmpeg_source = _resolve_ffmpeg(self._repository_root)
def snapshot(self) -> dict[str, Any]:
with self._lock:
delivery = None
if (
self._source_id is not None
and self._ffmpeg_path is not None
and self._phase != "error"
):
delivery = {
"id": f"camera-preview-{self._generation}",
"kind": "mse-fmp4-websocket",
"url": (
f"/api/v1/device-plugins/{self._plugin_id}"
f"/camera-preview/{self._generation}"
),
"media_type": CAMERA_MEDIA_TYPE,
}
return {
"schema_version": "missioncore.camera-preview/v1alpha1",
"phase": self._phase,
"revision": self._revision,
"generation": self._generation if self._source_id is not None else None,
"active_source_id": self._source_id,
"activation": {
"exclusive_group": CAMERA_EXCLUSIVE_GROUP,
"max_active": 1,
},
"delivery": delivery,
"runtime_dependency": {
"kind": "ffmpeg",
"status": "available" if self._ffmpeg_path is not None else "missing",
"source": self._ffmpeg_source,
},
"error": dict(self._error) if self._error is not None else None,
}
def select(self, source_id: CameraSourceId, target_host: str) -> dict[str, Any]:
if source_id not in CAMERA_SOURCE_PATHS:
raise ValueError("неизвестный camera source")
target = validate_private_ipv4(target_host)
old_process: subprocess.Popen[bytes] | None = None
with self._lock:
if self._closed:
raise RuntimeError("camera gateway уже закрыт")
if self._ffmpeg_path is None:
self._generation += 1
self._revision += 1
self._source_id = source_id
self._target_host = target
self._phase = "error"
self._error = {
"code": "ffmpeg-unavailable",
"message": "Локальный camera adapter FFmpeg не найден.",
}
raise RuntimeError("локальный camera adapter FFmpeg не найден")
if (
self._source_id == source_id
and self._target_host == target
and self._phase in {"selected", "connecting", "streaming"}
):
return self.snapshot()
old_process = self._detach_process_locked()
self._generation += 1
self._revision += 1
self._source_id = source_id
self._target_host = target
self._phase = "selected"
self._error = None
_terminate_process(old_process)
return self.snapshot()
def stop(self, generation: int) -> dict[str, Any]:
old_process: subprocess.Popen[bytes] | None
with self._lock:
if self._source_id is None:
return self.snapshot()
if generation != self._generation:
raise ValueError("camera preview generation устарело")
old_process = self._detach_process_locked()
self._revision += 1
self._phase = "idle"
self._source_id = None
self._target_host = None
self._error = None
_terminate_process(old_process)
return self.snapshot()
def stop_current(self) -> dict[str, Any]:
old_process: subprocess.Popen[bytes] | None
with self._lock:
old_process = self._detach_process_locked()
changed = self._source_id is not None or self._phase != "idle"
if changed:
self._revision += 1
self._phase = "idle"
self._source_id = None
self._target_host = None
self._error = None
_terminate_process(old_process)
return self.snapshot()
def open_delivery(self, generation: int) -> CameraProcessLease:
with self._lock:
if self._closed:
raise RuntimeError("camera gateway закрыт")
if generation != self._generation or self._source_id is None:
raise ValueError("camera preview generation не активно")
if self._process is not None:
raise RuntimeError("для camera preview уже открыт browser consumer")
if self._ffmpeg_path is None or self._target_host is None:
raise RuntimeError("camera preview runtime не готов")
argv = _build_ffmpeg_argv(
self._ffmpeg_path,
self._target_host,
self._source_id,
)
try:
process = subprocess.Popen(
argv,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
shell=False,
start_new_session=(os.name == "posix"),
)
except OSError as exc:
self._revision += 1
self._phase = "error"
self._error = {
"code": "ffmpeg-start-failed",
"message": "Не удалось запустить локальный camera adapter.",
}
raise RuntimeError("не удалось запустить camera adapter") from exc
if process.stdout is None or process.stderr is None:
_terminate_process(process)
raise RuntimeError("camera adapter не открыл media pipes")
lease = CameraProcessLease(
generation=generation,
source_id=self._source_id,
process=process,
)
self._process = process
self._process_generation = generation
self._revision += 1
self._phase = "connecting"
self._error = None
threading.Thread(
target=_drain_stderr,
args=(lease,),
name=f"k1-camera-stderr-{generation}",
daemon=True,
).start()
threading.Thread(
target=_read_fmp4_stdout,
args=(lease,),
name=f"k1-camera-fmp4-{generation}",
daemon=True,
).start()
return lease
def mark_streaming(self, lease: CameraProcessLease) -> None:
with self._lock:
if (
lease.generation != self._generation
or self._process is not lease.process
or self._phase == "streaming"
):
return
self._revision += 1
self._phase = "streaming"
def release_delivery(self, lease: CameraProcessLease, *, client_closed: bool) -> None:
_terminate_process(lease.process)
with self._lock:
if self._process is lease.process:
self._process = None
self._process_generation = None
if lease.generation != self._generation or self._source_id is None:
return
self._revision += 1
if client_closed:
self._phase = "selected"
self._error = None
else:
self._phase = "error"
if lease.failure_code == "consumer-too-slow":
message = (
"Browser не успевает принимать camera stream; "
"канал остановлен без накопления задержки."
)
elif lease.failure_code == "invalid-fmp4":
message = "Camera adapter вернул некорректный fMP4 stream."
elif lease.failure_code == "segment-too-large":
message = "Camera adapter отклонил слишком большой video segment."
else:
message = _safe_ffmpeg_message(lease.stderr_tail)
self._error = {
"code": lease.failure_code or "camera-source-ended",
"message": message,
}
def close(self) -> None:
old_process: subprocess.Popen[bytes] | None
with self._lock:
if self._closed:
return
self._closed = True
old_process = self._detach_process_locked()
self._revision += 1
self._phase = "idle"
self._source_id = None
self._target_host = None
self._error = None
_terminate_process(old_process)
def _detach_process_locked(self) -> subprocess.Popen[bytes] | None:
process = self._process
self._process = None
self._process_generation = None
return process
def _resolve_ffmpeg(repository_root: Path) -> tuple[Path | None, str]:
configured = os.environ.get("MISSIONCORE_FFMPEG_BINARY")
candidates: list[tuple[Path, str]] = []
if configured:
candidates.append((Path(configured).expanduser(), "configured"))
candidates.append((repository_root / ".runtime" / "ffmpeg" / "ffmpeg", "bundled-local"))
# Explicit development-only fallbacks. Product packaging must provide the
# repository-local binary or MISSIONCORE_FFMPEG_BINARY instead.
candidates.extend(
(
(Path("/opt/homebrew/bin/ffmpeg"), "development-system"),
(Path("/usr/local/bin/ffmpeg"), "development-system"),
)
)
for candidate, source in candidates:
try:
resolved = candidate.resolve(strict=True)
except OSError:
continue
if resolved.is_file() and os.access(resolved, os.X_OK):
return resolved, source
return None, "missing"
def _build_ffmpeg_argv(
ffmpeg_path: Path,
target_host: str,
source_id: CameraSourceId,
) -> list[str]:
target = validate_private_ipv4(target_host)
path = CAMERA_SOURCE_PATHS.get(source_id)
if path is None:
raise ValueError("неизвестный camera source")
upstream = f"rtsp://{target}:8554{path}"
return [
str(ffmpeg_path),
"-hide_banner",
"-loglevel",
"warning",
"-nostdin",
"-rtsp_transport",
"tcp",
"-allowed_media_types",
"video",
"-timeout",
"5000000",
"-probesize",
"4000000",
"-analyzeduration",
"2000000",
"-fflags",
"nobuffer",
"-i",
upstream,
"-map",
"0:v:0",
"-an",
"-sn",
"-dn",
"-c:v",
"copy",
"-f",
"mp4",
"-movflags",
"+empty_moov+default_base_moof+omit_tfhd_offset+frag_every_frame+skip_trailer",
"-flush_packets",
"1",
"pipe:1",
]
def _read_exact(stream: IO[bytes], size: int) -> bytes:
chunks: list[bytes] = []
remaining = size
while remaining:
chunk = stream.read(remaining)
if not chunk:
raise EOFError
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def _read_mp4_box(stream: IO[bytes]) -> tuple[bytes, bytes]:
header = _read_exact(stream, 8)
size = int.from_bytes(header[:4], "big")
box_type = header[4:8]
if size == 1:
extended = _read_exact(stream, 8)
size = int.from_bytes(extended, "big")
header += extended
if size == 0 or size < len(header) or size > MAX_FMP4_BOX_BYTES:
raise ValueError("invalid or unbounded ISO-BMFF box")
return box_type, header + _read_exact(stream, size - len(header))
def _enqueue_segment(lease: CameraProcessLease, kind: str, payload: bytes) -> bool:
if len(payload) > MAX_FMP4_SEGMENT_BYTES:
lease.failure_code = "segment-too-large"
_finish_segment_queue(lease)
with suppress(OSError):
lease.process.terminate()
return False
try:
lease.segments.put_nowait((kind, payload))
return True
except queue.Full:
lease.failure_code = "consumer-too-slow"
while True:
try:
lease.segments.get_nowait()
except queue.Empty:
break
lease.segments.put_nowait(None)
with suppress(OSError):
lease.process.terminate()
return False
def _finish_segment_queue(lease: CameraProcessLease) -> None:
try:
lease.segments.put_nowait(None)
except queue.Full:
# A full queue is itself a bounded-latency failure. Never leave FFmpeg
# back-pressured while the browser accumulates tens of seconds.
lease.failure_code = lease.failure_code or "consumer-too-slow"
while True:
try:
lease.segments.get_nowait()
except queue.Empty:
break
lease.segments.put_nowait(None)
def _read_fmp4_stdout(lease: CameraProcessLease) -> None:
stream = lease.process.stdout
if stream is None:
lease.failure_code = "invalid-fmp4"
_finish_segment_queue(lease)
return
init_parts: list[bytes] = []
fragment_parts: list[bytes] = []
init_sent = False
try:
while True:
box_type, box = _read_mp4_box(stream)
if not init_sent:
init_parts.append(box)
if box_type == b"moov":
if not _enqueue_segment(lease, "init", b"".join(init_parts)):
return
init_sent = True
continue
if box_type == b"moof":
fragment_parts = [box]
continue
if fragment_parts:
fragment_parts.append(box)
if box_type == b"mdat":
if not _enqueue_segment(lease, "media", b"".join(fragment_parts)):
return
fragment_parts = []
continue
# `styp`/`sidx` are valid media-segment prefixes. Preserve them and
# wait for the following moof+mdat instead of forwarding raw boxes.
if box_type in {b"styp", b"sidx"}:
fragment_parts.append(box)
except EOFError:
if not init_sent:
lease.failure_code = "invalid-fmp4"
except (OSError, ValueError):
lease.failure_code = "invalid-fmp4"
finally:
_finish_segment_queue(lease)
def _drain_stderr(lease: CameraProcessLease) -> None:
stream = lease.process.stderr
if stream is None:
return
try:
while True:
line = stream.readline()
if not line:
return
text = line.decode("utf-8", errors="replace").strip()
if text:
lease.stderr_tail.append(text[-240:])
except (OSError, ValueError):
return
def _safe_ffmpeg_message(stderr_tail: deque[str]) -> str:
# Never reflect the RTSP URL or device address into browser state.
joined = " ".join(stderr_tail).lower()
if "connection refused" in joined:
return "Camera endpoint отклонил локальное соединение."
if "timed out" in joined or "timeout" in joined:
return "Camera endpoint не ответил за отведённое время."
if "invalid data" in joined or "could not find codec" in joined:
return "Camera endpoint вернул неподдерживаемый media stream."
return "Camera stream завершился до первого пригодного видеофрагмента."
def _terminate_process(process: subprocess.Popen[bytes] | None) -> None:
if process is None:
return
try:
if process.poll() is None:
if os.name == "posix":
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGINT)
else:
process.terminate()
try:
process.wait(timeout=2.0)
except subprocess.TimeoutExpired:
process.terminate()
try:
process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=1.0)
finally:
for stream in (process.stdout, process.stderr):
if stream is not None:
with suppress(OSError):
stream.close()
def build_xgrids_k1_camera_router(
gateway: XgridsK1CameraGateway,
plugin_id: str,
) -> APIRouter:
router = APIRouter(include_in_schema=False)
@router.websocket(
f"/api/v1/device-plugins/{plugin_id}/camera-preview/{{generation}}"
)
async def camera_preview(websocket: WebSocket, generation: int) -> None:
await websocket.accept()
try:
lease = gateway.open_delivery(generation)
except (ValueError, RuntimeError):
await websocket.close(code=1008, reason="Camera preview lease is not active")
return
client_closed = False
try:
while True:
segment = await asyncio.to_thread(lease.segments.get)
if segment is None:
break
kind, payload = segment
if kind == "media":
gateway.mark_streaming(lease)
await websocket.send_bytes(payload)
except WebSocketDisconnect:
client_closed = True
except RuntimeError:
client_closed = True
finally:
gateway.release_delivery(lease, client_closed=client_closed)
with suppress(RuntimeError):
await websocket.close()
return router

View File

@ -35,6 +35,14 @@ from k1link.web.plugin_runtime import (
PluginActionNotFoundError,
PluginExecutionError,
)
from k1link.web.xgrids_k1_camera import (
CAMERA_EXCLUSIVE_GROUP,
CAMERA_SOURCE_LABELS,
CAMERA_SOURCE_PATHS,
CameraSourceId,
XgridsK1CameraGateway,
build_xgrids_k1_camera_router,
)
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
@ -55,6 +63,8 @@ ACTION_ACQUISITION_STATE_READ = "acquisition.state.read"
ACTION_STREAM_START_LIVE = "stream.start-live"
ACTION_STREAM_START_REPLAY = "stream.start-replay"
ACTION_STREAM_STOP = "stream.stop"
ACTION_CAMERA_PREVIEW_SELECT = "camera.preview.select"
ACTION_CAMERA_PREVIEW_STOP = "camera.preview.stop"
ACTION_VIEWER_SETTINGS_UPDATE = "viewer.settings.update"
RequestedStreamId = Literal[
@ -145,6 +155,16 @@ class ReplayRequest(StrictRequest):
loop: bool = False
class CameraPreviewSelectRequest(StrictRequest):
source_id: CameraSourceId
device_session_id: str = Field(min_length=1, max_length=128)
class CameraPreviewStopRequest(StrictRequest):
device_session_id: str = Field(min_length=1, max_length=128)
generation: int = Field(ge=1)
class ViewerSettingsRequest(StrictRequest):
point_size: float = Field(default=2.5, ge=0.5, le=12.0)
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
@ -189,10 +209,15 @@ class XgridsK1CompatibilityService:
# The host-owned visual runtime receives the vendor normalizer
# explicitly. There is no implicit K1 decoder in the visual layer.
self.runtime = VisualizationRuntime(normalizer=normalize_k1_message)
self.camera_preview = XgridsK1CameraGateway(
self.repository_root,
XGRIDS_K1_PLUGIN_ID,
)
def state(self) -> dict[str, Any]:
runtime = self.runtime.snapshot()
self._reconcile_acquisition(runtime)
camera_preview = self.camera_preview.snapshot()
metrics = runtime["metrics"]
with self._lock:
operation_phase = self._operation_phase
@ -264,7 +289,11 @@ class XgridsK1CompatibilityService:
"attestation": compatibility_attestation,
"vendor_writes_enabled": False,
"camera_preview": (
"observed-runtime-adapter-pending"
(
"local-browser-adapter-available"
if camera_preview["runtime_dependency"]["status"] == "available"
else "local-runtime-dependency-missing"
)
if active_profile_id is not None
else "unverified"
),
@ -292,8 +321,13 @@ class XgridsK1CompatibilityService:
else None
),
"connection_verification": connection_verification,
"sensor_catalog": _sensor_catalog(active_profile_id),
"sensor_catalog": _sensor_catalog(
active_profile_id,
device_session_id,
camera_preview,
),
"device_calibration": _device_calibration_snapshot(active_profile_id),
"camera_preview": camera_preview,
"acquisition": acquisition,
"operations": operation_documents,
"last_operation": operation_documents[-1] if operation_documents else None,
@ -367,6 +401,9 @@ class XgridsK1CompatibilityService:
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
if request.device_id not in known_ids:
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
# A reprovision can replace both the device session and its address.
# Revoke the previous browser lease before any new device-side write.
self.camera_preview.stop_current()
# Unwrap once at the provisioning service boundary. The plain value is
# kept only in this stack frame, included in a keyed request digest, and
# passed to the reviewed BLE write boundary; it is never journaled.
@ -808,6 +845,7 @@ class XgridsK1CompatibilityService:
try:
with self._lock:
acquisition.transition("stopping", message_code="acquisition.stopping")
self.camera_preview.stop_current()
self.runtime.stop()
self._cancel_pending_acquisition_operations(
exclude_operation_id=operation.operation_id,
@ -877,6 +915,7 @@ class XgridsK1CompatibilityService:
return self.state()
try:
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
self.camera_preview.stop_current()
self.runtime.stop()
self._cancel_pending_acquisition_operations(
exclude_operation_id=operation.operation_id,
@ -958,6 +997,7 @@ class XgridsK1CompatibilityService:
def stop(self) -> dict[str, Any]:
"""Deprecated capture-only shim; it never claims that K1 stopped scanning."""
self.camera_preview.stop_current()
with self._lock:
acquisition = self._acquisition
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
@ -970,6 +1010,34 @@ class XgridsK1CompatibilityService:
)
)
def select_camera_preview(self, request: CameraPreviewSelectRequest) -> dict[str, Any]:
target = self._camera_target_for_session(request.device_session_id)
self.camera_preview.select(request.source_id, target)
return self.state()
def stop_camera_preview(self, request: CameraPreviewStopRequest) -> dict[str, Any]:
self._camera_target_for_session(request.device_session_id)
self.camera_preview.stop(request.generation)
return self.state()
def close(self) -> None:
camera_error: Exception | None = None
try:
self.camera_preview.close()
except Exception as exc:
camera_error = exc
try:
self.runtime.close()
except Exception as exc:
if camera_error is not None:
exc.add_note(
"camera gateway cleanup also failed: "
f"{type(camera_error).__name__}: {camera_error}"
)
raise
if camera_error is not None:
raise camera_error
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
self.runtime.update_scene_settings(
RerunSceneSettings(
@ -1034,6 +1102,22 @@ class XgridsK1CompatibilityService:
self._device_session_opened_at = _utc_now_iso()
return self._device_id, self._device_session_id
def _camera_target_for_session(self, device_session_id: str) -> str:
with self._lock:
current_session_id = self._device_session_id
target = self._k1_ip
attestation = self._compatibility_attestation
if current_session_id is None or device_session_id != current_session_id:
raise ValueError("указана неактивная device-сессия")
if attestation is None:
raise ValueError("точный compatibility-профиль K1 не подтверждён")
if target is None:
raise ValueError("у плагина нет подтверждённого локального адреса K1")
target = validate_private_ipv4(target)
if target == AP_FALLBACK_IPV4:
raise ValueError("адрес точки доступа K1 нельзя использовать как camera target")
return target
def _require_acquisition(self, acquisition_id: str | None) -> AcquisitionRecord:
with self._lock:
acquisition = self._acquisition
@ -1212,6 +1296,10 @@ class XgridsK1ServicePort(Protocol):
def stop(self) -> dict[str, Any]: ...
def select_camera_preview(self, request: CameraPreviewSelectRequest) -> dict[str, Any]: ...
def stop_camera_preview(self, request: CameraPreviewStopRequest) -> dict[str, Any]: ...
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]: ...
@ -1236,6 +1324,8 @@ class XgridsK1PluginFacade:
ACTION_STREAM_START_LIVE,
ACTION_STREAM_START_REPLAY,
ACTION_STREAM_STOP,
ACTION_CAMERA_PREVIEW_SELECT,
ACTION_CAMERA_PREVIEW_STOP,
ACTION_VIEWER_SETTINGS_UPDATE,
}
)
@ -1309,6 +1399,18 @@ class XgridsK1PluginFacade:
if action_id == ACTION_STREAM_STOP:
EmptyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.stop)
if action_id == ACTION_CAMERA_PREVIEW_SELECT:
select_camera_request = CameraPreviewSelectRequest.model_validate(payload)
return await asyncio.to_thread(
self.service.select_camera_preview,
select_camera_request,
)
if action_id == ACTION_CAMERA_PREVIEW_STOP:
stop_camera_request = CameraPreviewStopRequest.model_validate(payload)
return await asyncio.to_thread(
self.service.stop_camera_preview,
stop_camera_request,
)
if action_id == ACTION_VIEWER_SETTINGS_UPDATE:
settings_request = ViewerSettingsRequest.model_validate(payload)
return await asyncio.to_thread(
@ -1362,8 +1464,67 @@ def _attestation_snapshot(
}
def _sensor_catalog(active_profile_id: str | None) -> dict[str, Any]:
def _sensor_catalog(
active_profile_id: str | None,
device_session_id: str | None,
camera_preview: Mapping[str, Any],
) -> dict[str, Any]:
camera_profile_active = active_profile_id is not None
camera_phase = str(camera_preview.get("phase") or "idle")
active_camera = camera_preview.get("active_source_id")
delivery = camera_preview.get("delivery")
dependency = camera_preview.get("runtime_dependency")
dependency_available = (
isinstance(dependency, Mapping) and dependency.get("status") == "available"
)
camera_streams: list[dict[str, Any]] = []
for source_id in CAMERA_SOURCE_PATHS:
side = "left" if source_id.endswith(".left") else "right"
selected = source_id == active_camera
if not camera_profile_active:
availability = "unverified"
elif not dependency_available:
availability = "unavailable"
elif selected and camera_phase == "streaming":
availability = "streaming"
elif selected and camera_phase == "error":
availability = "error"
elif selected:
availability = "connecting"
else:
availability = "available"
camera_streams.append(
{
"stream_id": f"camera.preview.{side}.live",
"source_id": source_id,
"semantic_channel_id": "camera.preview.live",
"label": CAMERA_SOURCE_LABELS[source_id],
"sensor_kind": "camera",
"modality": "encoded-video",
"availability": availability,
"decode_status": (
"rtsp-h264-observed-browser-remux"
if camera_profile_active and dependency_available
else (
"local-runtime-dependency-missing"
if camera_profile_active
else "profile-not-attested"
)
),
"endpoint_label": f"RTSP · {side}",
"activation": {
"group_id": CAMERA_EXCLUSIVE_GROUP,
"max_active": 1,
"selected": selected,
"controllable": bool(
camera_profile_active and device_session_id and dependency_available
),
},
"delivery": delivery if selected and isinstance(delivery, Mapping) else None,
"frame_id": None,
"coordinate_convention": "unresolved",
}
)
return {
"schema_version": "missioncore.sensor-catalog/v1alpha2",
"revision": active_profile_id or "unprofiled-evidence-only",
@ -1404,19 +1565,7 @@ def _sensor_catalog(active_profile_id: str | None) -> dict[str, Any]:
"frame_id": None,
"coordinate_convention": None,
},
{
"stream_id": "camera.preview.live",
"sensor_kind": "camera-rig",
"modality": "encoded-video",
"availability": "observed" if camera_profile_active else "unverified",
"decode_status": (
"transport-observed-runtime-adapter-pending"
if camera_profile_active
else "profile-not-attested"
),
"frame_id": None,
"coordinate_convention": None,
},
*camera_streams,
],
}
@ -1497,6 +1646,12 @@ def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribu
adapter = XgridsK1PluginFacade(service)
return DevicePluginRuntimeContribution(
adapter=adapter,
legacy_routers=(build_xgrids_k1_legacy_router(adapter),),
close=service.runtime.close,
legacy_routers=(
build_xgrids_k1_legacy_router(adapter),
build_xgrids_k1_camera_router(
service.camera_preview,
XGRIDS_K1_PLUGIN_ID,
),
),
close=service.close,
)

View File

@ -88,7 +88,7 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
item for item in plugins if item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1"
)
assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2"
assert plugin["metadata"]["version"] == "0.2.0"
assert plugin["metadata"]["version"] == "0.3.0"
assert plugin["spec"]["hostApiRange"] == "v1alpha2"
assert plugin["spec"]["compatibilityProfiles"] == [
{
@ -113,11 +113,13 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
"stream.start-live",
"stream.start-replay",
"stream.stop",
"camera.preview.select",
"camera.preview.stop",
"viewer.settings.update",
} <= action_ids
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
"pluginVersion": "0.2.0",
"pluginVersion": "0.3.0",
"id": "xgrids.lixelkity-k1",
"vendor": "XGRIDS",
"displayName": "XGRIDS LixelKity K1",
@ -135,6 +137,7 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
},
{"id": "spatial.point-cloud.live", "label": "Облако точек"},
{"id": "spatial.pose.live", "label": "Траектория"},
{"id": "camera.preview.live", "label": "Видеокамеры"},
{"id": "evidence.raw-capture", "label": "Исходная запись"},
{"id": "evidence.replay", "label": "Повтор записи"},
],

View File

@ -1,7 +1,6 @@
from __future__ import annotations
import json
import socket
import struct
import threading
import time
@ -14,7 +13,12 @@ from k1link.data_plane import DecodedDataPlaneView, NormalizationError
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.protocol.normalizer import normalize_k1_message
from k1link.viewer.messages import StreamMessage
from k1link.viewer.rerun_bridge import RerunBridge, RerunSceneSettings, _point_colors
from k1link.viewer.rerun_bridge import (
RerunBridge,
RerunSceneSettings,
_live_time_panel,
_point_colors,
)
from k1link.viewer.runtime import VisualizationRuntime
@ -24,6 +28,7 @@ class FakeRecording:
self.times: list[tuple[str, dict[str, object]]] = []
self.blueprints: list[object] = []
self.disconnected = False
self.flush_count = 0
def serve_grpc(self, **_: object) -> str:
return "rerun+http://127.0.0.1:9876/proxy"
@ -41,23 +46,52 @@ class FakeRecording:
self.disconnected = True
def flush(self, **_: object) -> None:
return
self.flush_count += 1
def _message(topic: str, payload: bytes, *, sequence: int = 7) -> StreamMessage:
class BlueprintFailureRecording(FakeRecording):
def send_blueprint(self, blueprint: object, **kwargs: object) -> None:
super().send_blueprint(blueprint, **kwargs)
raise RuntimeError("synthetic blueprint failure")
class DisconnectFailureRecording(FakeRecording):
def disconnect(self) -> None:
super().disconnect()
raise RuntimeError("synthetic disconnect failure")
def _message(
topic: str,
payload: bytes,
*,
sequence: int = 7,
received_at_epoch_ns: int = 1_784_124_315_186_225_000,
) -> StreamMessage:
return StreamMessage(
sequence=sequence,
topic=topic,
payload=payload,
received_at_epoch_ns=1_784_124_315_186_225_000,
received_at_epoch_ns=received_at_epoch_ns,
received_monotonic_ns=None,
source="test",
)
def _envelope(topic: str, payload: bytes, *, sequence: int = 7) -> DecodedDataPlaneView:
def _envelope(
topic: str,
payload: bytes,
*,
sequence: int = 7,
received_at_epoch_ns: int = 1_784_124_315_186_225_000,
) -> DecodedDataPlaneView:
envelope = normalize_k1_message(
_message(topic, payload, sequence=sequence),
_message(
topic,
payload,
sequence=sequence,
received_at_epoch_ns=received_at_epoch_ns,
),
processing_started_monotonic_ns=time.monotonic_ns(),
)
assert envelope is not None
@ -89,9 +123,58 @@ def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
"message_sequence",
"stream_time",
}
assert recording.flush_count == 1
bridge.close()
assert recording.disconnected is True
assert recording.flush_count == 2
def test_live_blueprint_follows_stream_time_without_frontend_cursor_writes() -> None:
panel = _live_time_panel()
assert panel.timeline == "stream_time"
assert panel.play_state == "following"
assert panel.state == "hidden"
def test_constructor_disconnects_recording_after_partial_setup_failure() -> None:
recording = BlueprintFailureRecording()
with pytest.raises(RuntimeError, match="synthetic blueprint failure"):
RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
assert recording.disconnected is True
def test_fast_replay_trajectory_sampling_uses_source_time() -> None:
recording = FakeRecording()
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
base_time_ns = 1_784_124_315_000_000_000
for index in range(4):
pose_payload = struct.pack(
"<ffffffff",
index * 0.1,
2.0,
3.0,
99.0,
0.9,
0.1,
0.2,
0.3,
)
bridge.process(
_envelope(
"RealtimePath",
pose_payload,
sequence=index + 1,
received_at_epoch_ns=base_time_ns + index * 600_000_000,
)
)
assert bridge.metrics.snapshot()["trajectory_poses"] == 4
bridge.close()
def test_bad_frame_is_rejected_before_rerun_without_publishing() -> None:
@ -134,34 +217,7 @@ def test_palettes_are_deterministic_and_custom_color_is_exact() -> None:
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:
def test_runtime_reuses_one_bridge_across_sequential_sessions(tmp_path: Path) -> None:
capture = tmp_path / "mqtt.raw.k1mqtt"
point_topic = "RealtimePointcloud"
pose_topic = "RealtimePath"
@ -237,11 +293,63 @@ def test_runtime_exposes_rerun_url_and_stops_cleanly(tmp_path: Path) -> None:
assert len(created) == 1
assert runtime.snapshot()["metrics"]["pcl_frames"] == 1
assert runtime.snapshot()["rerun_grpc_url"] == "rerun+http://127.0.0.1:9876/proxy"
assert recording.disconnected is False
runtime.close()
assert runtime.snapshot()["rerun_grpc_url"] is None
assert recording.disconnected is True
def test_runtime_reports_bridge_close_failure_instead_of_false_idle(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",
)
recording = DisconnectFailureRecording()
runtime = VisualizationRuntime(
bridge_factory=lambda **kwargs: RerunBridge(
recording_factory=lambda _: recording, # type: ignore[arg-type]
**kwargs, # type: ignore[arg-type]
),
normalizer=normalize_k1_message,
)
runtime.start_replay(capture, speed=0.0)
deadline = time.monotonic() + 5.0
snapshot = runtime.snapshot()
while snapshot["phase"] not in {"idle", "error"} and time.monotonic() < deadline:
time.sleep(0.01)
snapshot = runtime.snapshot()
assert snapshot["phase"] == "idle"
assert snapshot["rerun_grpc_url"] == "rerun+http://127.0.0.1:9876/proxy"
assert recording.disconnected is False
with pytest.raises(RuntimeError, match="synthetic disconnect failure"):
runtime.close()
snapshot = runtime.snapshot()
assert snapshot["phase"] == "error"
assert "synthetic disconnect failure" in snapshot["message"]
assert 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"

View File

@ -664,18 +664,22 @@ def test_provisioning_cannot_switch_device_during_active_acquisition(
assert called is False
def test_sensor_catalog_exposes_observed_camera_only_after_profile_attestation(
def test_sensor_catalog_exposes_two_browser_adapter_cameras_after_profile_attestation(
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
initial = service.state()
initial_camera = next(
initial_cameras = [
stream
for stream in initial["sensor_catalog"]["streams"]
if stream["stream_id"] == "camera.preview.live"
)
assert initial_camera["availability"] == "unverified"
if stream.get("semantic_channel_id") == "camera.preview.live"
]
assert {stream["source_id"] for stream in initial_cameras} == {
"sensor.camera.left",
"sensor.camera.right",
}
assert all(stream["availability"] == "unverified" for stream in initial_cameras)
state = service.prepare_acquisition(
PrepareAcquisitionRequest(
@ -683,15 +687,21 @@ def test_sensor_catalog_exposes_observed_camera_only_after_profile_attestation(
compatibility_attestation=ATTESTATION,
)
)
camera = next(
cameras = [
stream
for stream in state["sensor_catalog"]["streams"]
if stream["stream_id"] == "camera.preview.live"
)
if stream.get("semantic_channel_id") == "camera.preview.live"
]
assert camera["availability"] == "observed"
assert camera["modality"] == "encoded-video"
assert camera["decode_status"] == "transport-observed-runtime-adapter-pending"
assert len(cameras) == 2
assert all(camera["availability"] == "available" for camera in cameras)
assert all(camera["modality"] == "encoded-video" for camera in cameras)
assert all(
camera["decode_status"] == "rtsp-h264-observed-browser-remux"
for camera in cameras
)
assert all(camera["activation"]["max_active"] == 1 for camera in cameras)
assert all(camera["delivery"] is None for camera in cameras)
assert state["connection_verification"]["network_reachability"] == "unknown"
assert state["device_calibration"]["status"] == "unavailable"
assert state["device_calibration"]["vehicle_extrinsics"] == ("host-domain-not-owned-by-plugin")

View File

@ -0,0 +1,205 @@
from __future__ import annotations
import json
import sys
from io import BytesIO
from pathlib import Path
import pytest
from k1link.web.xgrids_k1_camera import (
CAMERA_MEDIA_TYPE,
XgridsK1CameraGateway,
_build_ffmpeg_argv,
_read_mp4_box,
)
from k1link.web.xgrids_k1_facade import (
XGRIDS_K1_COMPATIBILITY_PROFILE_ID,
XGRIDS_K1_PLUGIN_ID,
CameraPreviewSelectRequest,
CameraPreviewStopRequest,
XgridsK1CompatibilityService,
)
def _fake_ffmpeg(tmp_path: Path) -> Path:
executable = tmp_path / "fake-ffmpeg"
executable.write_text(
f"#!{sys.executable}\n"
"import sys, time\n"
"def box(kind, payload=b''):\n"
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
"payload = (box(b'ftyp', b'isom') + box(b'moov') + "
"box(b'moof') + box(b'mdat', b'frame'))\n"
"sys.stdout.buffer.write(payload)\n"
"sys.stdout.buffer.flush()\n"
"time.sleep(10)\n",
encoding="utf-8",
)
executable.chmod(0o700)
return executable
def _gateway(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> XgridsK1CameraGateway:
monkeypatch.setenv("MISSIONCORE_FFMPEG_BINARY", str(_fake_ffmpeg(tmp_path)))
return XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
def test_camera_selection_is_exclusive_and_hides_device_transport(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gateway = _gateway(tmp_path, monkeypatch)
try:
left = gateway.select("sensor.camera.left", "192.168.8.52")
assert left["active_source_id"] == "sensor.camera.left"
assert left["generation"] == 1
assert left["activation"]["max_active"] == 1
assert left["delivery"]["kind"] == "mse-fmp4-websocket"
assert left["delivery"]["media_type"] == CAMERA_MEDIA_TYPE
serialized = json.dumps(left)
assert "192.168.8.52" not in serialized
assert "rtsp://" not in serialized
assert "chn_left_main" not in serialized
right = gateway.select("sensor.camera.right", "192.168.8.52")
assert right["active_source_id"] == "sensor.camera.right"
assert right["generation"] == 2
assert right["delivery"]["url"].endswith("/camera-preview/2")
with pytest.raises(ValueError, match="generation"):
gateway.stop(1)
assert gateway.snapshot()["active_source_id"] == "sensor.camera.right"
stopped = gateway.stop(2)
assert stopped["phase"] == "idle"
assert stopped["delivery"] is None
finally:
gateway.close()
def test_camera_ffmpeg_command_is_allowlisted_copy_remux() -> None:
argv = _build_ffmpeg_argv(
Path("/trusted/ffmpeg"),
"10.0.0.24",
"sensor.camera.left",
)
assert argv[0] == "/trusted/ffmpeg"
assert argv[argv.index("-i") + 1] == (
"rtsp://10.0.0.24:8554/live/chn_left_main"
)
assert argv[argv.index("-c:v") + 1] == "copy"
assert argv[argv.index("-allowed_media_types") + 1] == "video"
assert argv[argv.index("-flush_packets") + 1] == "1"
assert "-c:v" in argv
assert ";" not in " ".join(argv)
with pytest.raises(ValueError, match="private IPv4"):
_build_ffmpeg_argv(
Path("/trusted/ffmpeg"),
"example.com",
"sensor.camera.left",
)
def test_iso_bmff_reader_preserves_complete_boxes() -> None:
def box(kind: bytes, payload: bytes = b"") -> bytes:
return (8 + len(payload)).to_bytes(4, "big") + kind + payload
stream = BytesIO(box(b"ftyp", b"isom") + box(b"moov") + box(b"moof"))
assert _read_mp4_box(stream) == (b"ftyp", box(b"ftyp", b"isom"))
assert _read_mp4_box(stream) == (b"moov", box(b"moov"))
assert _read_mp4_box(stream) == (b"moof", box(b"moof"))
oversized = (9 * 1024 * 1024).to_bytes(4, "big") + b"mdat"
with pytest.raises(ValueError, match="unbounded"):
_read_mp4_box(BytesIO(oversized))
def test_gateway_emits_init_and_complete_media_segments_without_transcoding(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gateway = _gateway(tmp_path, monkeypatch)
try:
state = gateway.select("sensor.camera.right", "192.168.1.20")
lease = gateway.open_delivery(state["generation"])
init_segment = lease.segments.get(timeout=3)
media_segment = lease.segments.get(timeout=3)
assert init_segment is not None and init_segment[0] == "init"
assert b"ftyp" in init_segment[1] and b"moov" in init_segment[1]
assert media_segment is not None and media_segment[0] == "media"
assert b"moof" in media_segment[1] and b"mdat" in media_segment[1]
gateway.mark_streaming(lease)
assert gateway.snapshot()["phase"] == "streaming"
gateway.release_delivery(lease, client_closed=True)
assert gateway.snapshot()["phase"] == "selected"
finally:
gateway.close()
def test_service_publishes_two_dynamic_camera_rows_and_stale_stop_is_safe(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("MISSIONCORE_FFMPEG_BINARY", str(_fake_ffmpeg(tmp_path)))
service = XgridsK1CompatibilityService(tmp_path)
try:
with service._lock:
service._k1_ip = "192.168.1.20"
service._device_id = "device-k1-test"
service._device_session_id = "device-session-test"
service._device_session_opened_at = "2026-07-16T20:00:00Z"
service._compatibility_attestation = {
"firmware_version": "3.0.2",
"topology": "direct-lan",
"basis": "operator-attested",
"observed_at": "2026-07-16T20:00:00Z",
}
state = service.select_camera_preview(
CameraPreviewSelectRequest(
source_id="sensor.camera.left",
device_session_id="device-session-test",
)
)
cameras = [
stream
for stream in state["sensor_catalog"]["streams"]
if stream.get("semantic_channel_id") == "camera.preview.live"
]
assert len(cameras) == 2
assert sum(bool(stream["activation"]["selected"]) for stream in cameras) == 1
assert sum(stream["delivery"] is not None for stream in cameras) == 1
assert state["compatibility"]["profile_id"] == XGRIDS_K1_COMPATIBILITY_PROFILE_ID
camera_contract = json.dumps(
{"camera_preview": state["camera_preview"], "streams": cameras}
)
assert "rtsp://" not in camera_contract
assert "192.168.1.20" not in camera_contract
generation = state["camera_preview"]["generation"]
stopped = service.stop_camera_preview(
CameraPreviewStopRequest(
device_session_id="device-session-test",
generation=generation,
)
)
assert stopped["camera_preview"]["phase"] == "idle"
with pytest.raises(ValueError, match="device-сессия"):
service.select_camera_preview(
CameraPreviewSelectRequest(
source_id="sensor.camera.right",
device_session_id="stale-session",
)
)
finally:
service.close()