feat(k1): add live cameras and reliable spatial following
This commit is contained in:
parent
a281faf923
commit
2bda1986bd
|
|
@ -21,6 +21,7 @@ build/
|
|||
!.env.example
|
||||
config/local.toml
|
||||
private/
|
||||
.runtime/
|
||||
|
||||
# Real laboratory captures and decoded artifacts are sensitive and large.
|
||||
captures/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
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,
|
||||
);
|
||||
if (disposed) {
|
||||
stopViewer();
|
||||
return;
|
||||
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.
|
||||
}
|
||||
|
||||
viewer.override_panel_state("top", "hidden");
|
||||
viewer.override_panel_state("blueprint", "hidden");
|
||||
viewer.override_panel_state("selection", "hidden");
|
||||
viewer.override_panel_state("time", "hidden");
|
||||
}
|
||||
setStatus("ready");
|
||||
onStatusChange?.("ready");
|
||||
}),
|
||||
);
|
||||
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const idleRuntime: MissionRuntimeController = {
|
|||
pendingAction: null,
|
||||
refresh: () => undefined,
|
||||
updateViewerSettings: async () => false,
|
||||
setObservationSourceActive: async () => false,
|
||||
};
|
||||
|
||||
const MissionRuntimeContext = createContext<MissionRuntimeController>(idleRuntime);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
const compatibilityProfileId =
|
||||
state.device_session?.compatibility_profile_id ?? state.compatibility?.profile_id ?? null;
|
||||
if (!stream) return "unavailable";
|
||||
if (stream.availability !== "observed" || !compatibilityProfileId) return "unverified";
|
||||
if (state.device_session?.connectivity === "degraded") return "degraded";
|
||||
// The firmware profile proves that both RTSP channels exist, but Mission Core
|
||||
// does not yet expose a browser-decodable preview URL. Do not report a live
|
||||
// camera merely because the point-cloud acquisition is running.
|
||||
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.")
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ export function XgridsK1RuntimeProvider({
|
|||
pendingAction: controller.pendingAction,
|
||||
refresh: controller.refresh,
|
||||
updateViewerSettings: controller.updateViewerSettings,
|
||||
setObservationSourceActive: controller.setObservationSourceActive,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
@ -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": "Повтор записи" }
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
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)
|
||||
self._url = self._recording.serve_grpc(
|
||||
url = recording.serve_grpc(
|
||||
grpc_port=grpc_port,
|
||||
default_blueprint=blueprint,
|
||||
server_memory_limit="512MiB",
|
||||
newest_first=True,
|
||||
# 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),
|
||||
)
|
||||
self._recording.send_blueprint(
|
||||
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(
|
||||
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,
|
||||
)
|
||||
self._path: deque[tuple[float, float, float]] = deque(maxlen=MAX_TRAJECTORY_POSES)
|
||||
# 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()
|
||||
|
|
@ -157,12 +193,14 @@ class RerunBridge:
|
|||
return
|
||||
self._closed = True
|
||||
recording = self._recording
|
||||
try:
|
||||
try:
|
||||
recording.flush(timeout_sec=5.0)
|
||||
finally:
|
||||
recording.disconnect()
|
||||
# The Python wrapper owns the native gRPC server. Release it immediately
|
||||
# instead of waiting for the publisher thread frame to be collected.
|
||||
finally:
|
||||
# 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:
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
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,21 +344,26 @@ class VisualizationRuntime:
|
|||
def publish() -> None:
|
||||
bridge: RerunBridge | None = None
|
||||
try:
|
||||
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
|
||||
assert bridge is not None
|
||||
|
|
@ -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:
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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": "Повтор записи"},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue