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
|
!.env.example
|
||||||
config/local.toml
|
config/local.toml
|
||||||
private/
|
private/
|
||||||
|
.runtime/
|
||||||
|
|
||||||
# Real laboratory captures and decoded artifacts are sensitive and large.
|
# Real laboratory captures and decoded artifacts are sensitive and large.
|
||||||
captures/
|
captures/
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,10 @@ export default function App() {
|
||||||
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
|
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
|
||||||
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
|
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
|
||||||
const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
|
const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
|
||||||
const observationLayout = useObservationLayout(runtime.state?.observationSources ?? []);
|
const observationLayout = useObservationLayout(
|
||||||
|
runtime.state?.observationSources ?? [],
|
||||||
|
runtime.setObservationSourceActive,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const remote = runtime.state?.viewerSettings;
|
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,
|
ObservationSourceDescriptor,
|
||||||
ObservationSourceModality,
|
ObservationSourceModality,
|
||||||
} from "../core/runtime/contracts";
|
} from "../core/runtime/contracts";
|
||||||
|
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
||||||
|
|
||||||
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
||||||
"point-cloud": "globe",
|
"point-cloud": "globe",
|
||||||
|
|
@ -29,7 +30,38 @@ export function observationSourceStatusLabel(source: ObservationSourceDescriptor
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ObservationMedia({ source }: { 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 (
|
return (
|
||||||
<video
|
<video
|
||||||
className="observation-media__asset"
|
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} />;
|
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>
|
<strong>{observationSourceStatusLabel(source)}</strong>
|
||||||
<span>
|
<span>
|
||||||
{source.modality === "video"
|
{source.modality === "video"
|
||||||
? "Канал известен, browser-preview ещё не подключён"
|
? source.activation?.controllable
|
||||||
|
? source.activation.selected
|
||||||
|
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
|
||||||
|
: "Откройте канал — локальный адаптер подключит выбранную камеру"
|
||||||
|
: "Канал известен, browser-preview сейчас недоступен"
|
||||||
: source.description}
|
: source.description}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -61,11 +101,13 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
|
||||||
export function ObservationSourcePicker({
|
export function ObservationSourcePicker({
|
||||||
sources,
|
sources,
|
||||||
visibleSourceIds,
|
visibleSourceIds,
|
||||||
|
pendingSourceIds,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
sources: readonly ObservationSourceDescriptor[];
|
sources: readonly ObservationSourceDescriptor[];
|
||||||
visibleSourceIds: ReadonlySet<string>;
|
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;
|
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
|
||||||
|
|
||||||
|
|
@ -108,6 +150,14 @@ export function ObservationSourcePicker({
|
||||||
<div className="observation-source-menu__list">
|
<div className="observation-source-menu__list">
|
||||||
{sources.map((source) => {
|
{sources.map((source) => {
|
||||||
const selected = visibleSourceIds.has(source.id);
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={source.id}
|
key={source.id}
|
||||||
|
|
@ -115,7 +165,8 @@ export function ObservationSourcePicker({
|
||||||
className="nodedc-dropdown-option observation-source-option"
|
className="nodedc-dropdown-option observation-source-option"
|
||||||
data-selected={selected ? "true" : undefined}
|
data-selected={selected ? "true" : undefined}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
onClick={() => onToggle(source.id)}
|
disabled={pending || !canOpen}
|
||||||
|
onClick={() => void onToggle(source.id)}
|
||||||
>
|
>
|
||||||
<span className="nodedc-dropdown-option__icon">
|
<span className="nodedc-dropdown-option__icon">
|
||||||
<Icon name={sourceIcon[source.modality]} size={16} />
|
<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__label">{source.label}</span>
|
||||||
<span className="nodedc-dropdown-option__description">
|
<span className="nodedc-dropdown-option__description">
|
||||||
<i data-availability={source.availability} aria-hidden="true" />
|
<i data-availability={source.availability} aria-hidden="true" />
|
||||||
{observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="nodedc-dropdown-option__check">
|
<span className="nodedc-dropdown-option__check">
|
||||||
|
|
|
||||||
|
|
@ -10,21 +10,25 @@ export interface RerunSelection {
|
||||||
|
|
||||||
export interface RerunViewportProps {
|
export interface RerunViewportProps {
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
|
followLive?: boolean;
|
||||||
onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
|
onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
|
||||||
onSelectionChange?: (selection: RerunSelection | null) => void;
|
onSelectionChange?: (selection: RerunSelection | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RerunViewport({
|
export function RerunViewport({
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
|
followLive = false,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
}: RerunViewportProps) {
|
}: RerunViewportProps) {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||||
|
const [retryNonce, setRetryNonce] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalizedSource = sourceUrl.trim();
|
const normalizedSource = sourceUrl.trim();
|
||||||
if (!normalizedSource || !hostRef.current) {
|
const host = hostRef.current;
|
||||||
|
if (!normalizedSource || !host) {
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
onStatusChange?.("idle");
|
onStatusChange?.("idle");
|
||||||
onSelectionChange?.(null);
|
onSelectionChange?.(null);
|
||||||
|
|
@ -32,51 +36,86 @@ export function RerunViewport({
|
||||||
}
|
}
|
||||||
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let stopViewer: (() => void) | undefined;
|
let disposeViewer: (() => void) | undefined;
|
||||||
|
let recordingOpenTimer: number | undefined;
|
||||||
|
let recordingOpened = false;
|
||||||
const unsubscribers: Array<() => void> = [];
|
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");
|
setStatus("loading");
|
||||||
onStatusChange?.("loading");
|
onStatusChange?.("loading");
|
||||||
|
|
||||||
void import("@rerun-io/web-viewer")
|
void import("@rerun-io/web-viewer")
|
||||||
.then(async ({ WebViewer }) => {
|
.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();
|
const viewer = new WebViewer();
|
||||||
stopViewer = () => {
|
let viewerDisposed = false;
|
||||||
// Remove the gRPC receiver explicitly before tearing down WASM. This
|
disposeViewer = () => {
|
||||||
// closes the browser-side stream promptly so the local SDK server can
|
if (viewerDisposed) return;
|
||||||
// release its port before the next device session starts.
|
viewerDisposed = true;
|
||||||
|
clearRecordingOpenTimer();
|
||||||
|
unsubscribeAll();
|
||||||
try {
|
try {
|
||||||
viewer.close(normalizedSource);
|
if (viewer.ready) viewer.close(normalizedSource);
|
||||||
} catch {
|
} catch {
|
||||||
// The viewer can already be stopped after a startup failure.
|
// The receiver may already be gone after a transport failure.
|
||||||
}
|
}
|
||||||
viewer.stop();
|
try {
|
||||||
|
viewer.stop();
|
||||||
|
} catch {
|
||||||
|
// A partially initialized WASM handle can already be gone after a
|
||||||
|
// startup failure. The host still has to be cleared below.
|
||||||
|
}
|
||||||
|
host.replaceChildren();
|
||||||
};
|
};
|
||||||
|
|
||||||
await viewer.start(
|
unsubscribers.push(
|
||||||
normalizedSource,
|
viewer.on("recording_open", (event) => {
|
||||||
hostRef.current,
|
if (disposed) return;
|
||||||
{
|
recordingOpened = true;
|
||||||
width: "100%",
|
clearRecordingOpenTimer();
|
||||||
height: "100%",
|
if (followLive) {
|
||||||
theme: "dark",
|
try {
|
||||||
hide_welcome_screen: true,
|
// The backend blueprint owns native `Following`; the host only
|
||||||
enable_history: false,
|
// selects its live timeline once. Repeated SetTime calls would
|
||||||
allow_fullscreen: false,
|
// force Rerun out of Following and create needless repaints.
|
||||||
},
|
viewer.set_active_timeline(event.recording_id, "stream_time");
|
||||||
null,
|
} catch {
|
||||||
|
// A receiver can disappear between the event and the WASM call.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStatus("ready");
|
||||||
|
onStatusChange?.("ready");
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
if (disposed) {
|
|
||||||
stopViewer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
viewer.override_panel_state("top", "hidden");
|
|
||||||
viewer.override_panel_state("blueprint", "hidden");
|
|
||||||
viewer.override_panel_state("selection", "hidden");
|
|
||||||
viewer.override_panel_state("time", "hidden");
|
|
||||||
|
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
viewer.on("selection_change", (event) => {
|
viewer.on("selection_change", (event) => {
|
||||||
|
if (disposed) return;
|
||||||
const entity = event.items.find((item) => item.type === "entity");
|
const entity = event.items.find((item) => item.type === "entity");
|
||||||
if (!entity || entity.type !== "entity") {
|
if (!entity || entity.type !== "entity") {
|
||||||
onSelectionChange?.(null);
|
onSelectionChange?.(null);
|
||||||
|
|
@ -90,23 +129,54 @@ export function RerunViewport({
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
setStatus("ready");
|
try {
|
||||||
onStatusChange?.("ready");
|
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(() => {
|
.catch(() => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
const message = "Не удалось запустить встроенный визуализатор.";
|
disposeViewer?.();
|
||||||
setStatus("error");
|
reportError("Не удалось загрузить модуль визуализатора.");
|
||||||
onStatusChange?.("error", message);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
unsubscribers.forEach((unsubscribe) => unsubscribe());
|
clearRecordingOpenTimer();
|
||||||
stopViewer?.();
|
unsubscribeAll();
|
||||||
|
disposeViewer?.();
|
||||||
onSelectionChange?.(null);
|
onSelectionChange?.(null);
|
||||||
};
|
};
|
||||||
}, [onSelectionChange, onStatusChange, sourceUrl]);
|
}, [followLive, onSelectionChange, onStatusChange, retryNonce, sourceUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rerun-viewport" data-status={status}>
|
<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 className="rerun-viewport__notice rerun-viewport__notice--error" role="alert">
|
||||||
<div>
|
<div>
|
||||||
<strong>Источник не открыт</strong>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { ObservationSourceDescriptor } from "../runtime/contracts";
|
import type { ObservationSourceDescriptor } from "../runtime/contracts";
|
||||||
|
import {
|
||||||
|
closeObservationSource,
|
||||||
|
openObservationSource,
|
||||||
|
shouldRestartObservationSource,
|
||||||
|
} from "./layoutPolicy";
|
||||||
|
|
||||||
export interface ObservationWindowRect {
|
export interface ObservationWindowRect {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -15,8 +20,9 @@ export interface ObservationLayoutController {
|
||||||
activeFloatingSourceId: string | null;
|
activeFloatingSourceId: string | null;
|
||||||
maximizedFloatingSourceId: string | null;
|
maximizedFloatingSourceId: string | null;
|
||||||
windowRects: Readonly<Record<string, ObservationWindowRect>>;
|
windowRects: Readonly<Record<string, ObservationWindowRect>>;
|
||||||
toggleSource: (sourceId: string) => void;
|
pendingSourceIds: ReadonlySet<string>;
|
||||||
hideSource: (sourceId: string) => void;
|
toggleSource: (sourceId: string) => Promise<boolean>;
|
||||||
|
hideSource: (sourceId: string) => Promise<boolean>;
|
||||||
setFocusedSourceId: (sourceId: string | null) => void;
|
setFocusedSourceId: (sourceId: string | null) => void;
|
||||||
activateFloatingSource: (sourceId: string) => void;
|
activateFloatingSource: (sourceId: string) => void;
|
||||||
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
|
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
|
||||||
|
|
@ -26,7 +32,11 @@ export interface ObservationLayoutController {
|
||||||
function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
|
function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
|
||||||
if (!source.capabilities.defaultVisible) return false;
|
if (!source.capabilities.defaultVisible) return false;
|
||||||
if (source.availability === "unavailable" || source.availability === "error") 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 {
|
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
|
||||||
|
|
@ -42,8 +52,11 @@ function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): strin
|
||||||
|
|
||||||
export function useObservationLayout(
|
export function useObservationLayout(
|
||||||
sources: readonly ObservationSourceDescriptor[],
|
sources: readonly ObservationSourceDescriptor[],
|
||||||
|
setSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>,
|
||||||
): ObservationLayoutController {
|
): ObservationLayoutController {
|
||||||
const [visibleIds, setVisibleIds] = useState<string[]>([]);
|
const [visibleIds, setVisibleIds] = useState<string[]>([]);
|
||||||
|
const visibleIdsRef = useRef<string[]>([]);
|
||||||
|
const [pendingIds, setPendingIds] = useState<string[]>([]);
|
||||||
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
|
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
|
||||||
const [activeFloatingSourceId, setActiveFloatingSourceId] = useState<string | null>(null);
|
const [activeFloatingSourceId, setActiveFloatingSourceId] = useState<string | null>(null);
|
||||||
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = 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 sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]);
|
||||||
const identity = catalogIdentity(sources);
|
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(() => {
|
useEffect(() => {
|
||||||
setVisibleIds((current) => {
|
const currentVisible = visibleIdsRef.current;
|
||||||
const next = current.filter((sourceId) => sourceIds.has(sourceId));
|
const nextVisible = currentVisible.filter((sourceId) => sourceIds.has(sourceId));
|
||||||
return next.length === current.length ? current : next;
|
if (nextVisible.length !== currentVisible.length) commitVisibleIds(nextVisible);
|
||||||
});
|
|
||||||
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
|
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
|
||||||
setActiveFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
setActiveFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
|
||||||
setMaximizedFloatingSourceId((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));
|
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
|
||||||
return nextEntries.length === entries.length ? current : Object.fromEntries(nextEntries);
|
return nextEntries.length === entries.length ? current : Object.fromEntries(nextEntries);
|
||||||
});
|
});
|
||||||
}, [sourceIds]);
|
}, [commitVisibleIds, sourceIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
|
|
@ -76,30 +101,91 @@ export function useObservationLayout(
|
||||||
}
|
}
|
||||||
if (initializedCatalog.current === identity) return;
|
if (initializedCatalog.current === identity) return;
|
||||||
initializedCatalog.current = identity;
|
initializedCatalog.current = identity;
|
||||||
const defaults = sources
|
let defaults: string[] = [];
|
||||||
.filter(canOpenByDefault)
|
for (const source of sources.filter(canOpenByDefault)) {
|
||||||
.map((source) => source.id);
|
defaults = openObservationSource(defaults, source.id, sources).visibleIds;
|
||||||
setVisibleIds(defaults);
|
}
|
||||||
|
commitVisibleIds(defaults);
|
||||||
const firstFloating = sources.find(
|
const firstFloating = sources.find(
|
||||||
(source) => canOpenByDefault(source) && source.capabilities.overlay,
|
(source) => canOpenByDefault(source) && source.capabilities.overlay,
|
||||||
);
|
);
|
||||||
setActiveFloatingSourceId(firstFloating?.id ?? null);
|
setActiveFloatingSourceId(firstFloating?.id ?? null);
|
||||||
}, [identity, sources]);
|
}, [commitVisibleIds, identity, sources]);
|
||||||
|
|
||||||
const toggleSource = useCallback((sourceId: string) => {
|
const selectedDeliveryIdentity = sources
|
||||||
const selected = visibleIds.includes(sourceId);
|
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
|
||||||
setVisibleIds((current) => selected
|
.map((source) => [
|
||||||
? current.filter((candidate) => candidate !== sourceId)
|
source.id,
|
||||||
: current.includes(sourceId) ? current : [...current, sourceId]);
|
source.delivery?.id,
|
||||||
if (!selected) setActiveFloatingSourceId(sourceId);
|
source.activation?.groupId,
|
||||||
}, [visibleIds]);
|
source.activation?.maxActive,
|
||||||
|
].join(":"))
|
||||||
|
.sort()
|
||||||
|
.join("|");
|
||||||
|
|
||||||
const hideSource = useCallback((sourceId: string) => {
|
useEffect(() => {
|
||||||
setVisibleIds((current) => current.filter((candidate) => candidate !== sourceId));
|
const selected = sources.filter(
|
||||||
setFocusedSourceIdState((current) => current === sourceId ? null : current);
|
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
|
||||||
setActiveFloatingSourceId((current) => current === sourceId ? null : current);
|
);
|
||||||
setMaximizedFloatingSourceId((current) => current === sourceId ? null : current);
|
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) => {
|
const setFocusedSourceId = useCallback((sourceId: string | null) => {
|
||||||
setFocusedSourceIdState(sourceId);
|
setFocusedSourceIdState(sourceId);
|
||||||
|
|
@ -120,6 +206,7 @@ export function useObservationLayout(
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
|
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
|
||||||
|
const pendingSourceIds = useMemo(() => new Set(pendingIds), [pendingIds]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
visibleSourceIds,
|
visibleSourceIds,
|
||||||
|
|
@ -127,6 +214,7 @@ export function useObservationLayout(
|
||||||
activeFloatingSourceId,
|
activeFloatingSourceId,
|
||||||
maximizedFloatingSourceId,
|
maximizedFloatingSourceId,
|
||||||
windowRects,
|
windowRects,
|
||||||
|
pendingSourceIds,
|
||||||
toggleSource,
|
toggleSource,
|
||||||
hideSource,
|
hideSource,
|
||||||
setFocusedSourceId,
|
setFocusedSourceId,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const idleRuntime: MissionRuntimeController = {
|
||||||
pendingAction: null,
|
pendingAction: null,
|
||||||
refresh: () => undefined,
|
refresh: () => undefined,
|
||||||
updateViewerSettings: async () => false,
|
updateViewerSettings: async () => false,
|
||||||
|
setObservationSourceActive: async () => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MissionRuntimeContext = createContext<MissionRuntimeController>(idleRuntime);
|
const MissionRuntimeContext = createContext<MissionRuntimeController>(idleRuntime);
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,27 @@ export interface ObservationSourceCapabilities {
|
||||||
spatialRegistration: "native" | "calibrated" | "unresolved" | "not-applicable";
|
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 {
|
export interface ObservationSourceDescriptor {
|
||||||
id: string;
|
id: string;
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
|
|
@ -123,6 +144,8 @@ export interface ObservationSourceDescriptor {
|
||||||
transport: "rerun-grpc" | "rtsp" | "websocket" | "recording" | "other";
|
transport: "rerun-grpc" | "rtsp" | "websocket" | "recording" | "other";
|
||||||
endpointLabel?: string | null;
|
endpointLabel?: string | null;
|
||||||
previewUrl?: string | null;
|
previewUrl?: string | null;
|
||||||
|
delivery?: ObservationSourceDelivery | null;
|
||||||
|
activation?: ObservationSourceActivation | null;
|
||||||
provider: ObservationSourceProvider;
|
provider: ObservationSourceProvider;
|
||||||
binding: ObservationSourceBinding;
|
binding: ObservationSourceBinding;
|
||||||
capabilities: ObservationSourceCapabilities;
|
capabilities: ObservationSourceCapabilities;
|
||||||
|
|
@ -157,4 +180,5 @@ export interface MissionRuntimeController {
|
||||||
pendingAction: string | null;
|
pendingAction: string | null;
|
||||||
refresh: () => void | Promise<void>;
|
refresh: () => void | Promise<void>;
|
||||||
updateViewerSettings: (settings: ViewerSettings) => Promise<boolean>;
|
updateViewerSettings: (settings: ViewerSettings) => Promise<boolean>;
|
||||||
|
setObservationSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,11 +122,39 @@ export interface XgridsK1Metrics {
|
||||||
[key: string]: number | null | undefined;
|
[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 {
|
export interface XgridsSensorCatalogStream {
|
||||||
stream_id: string;
|
stream_id: string;
|
||||||
|
source_id?: string | null;
|
||||||
|
semantic_channel_id?: string | null;
|
||||||
|
label?: string | null;
|
||||||
sensor_kind?: string | null;
|
sensor_kind?: string | null;
|
||||||
modality?: string | null;
|
modality?: string | null;
|
||||||
availability?: string | null;
|
availability?: string | null;
|
||||||
|
endpoint_label?: string | null;
|
||||||
|
activation?: XgridsCameraPreviewActivation | null;
|
||||||
|
delivery?: XgridsCameraPreviewDelivery | null;
|
||||||
decode_status?: string | null;
|
decode_status?: string | null;
|
||||||
frame_id?: string | null;
|
frame_id?: string | null;
|
||||||
coordinate_convention?: string | null;
|
coordinate_convention?: string | null;
|
||||||
|
|
@ -158,6 +186,7 @@ export interface XgridsK1State {
|
||||||
operations?: XgridsOperation[];
|
operations?: XgridsOperation[];
|
||||||
last_operation?: XgridsOperation | null;
|
last_operation?: XgridsOperation | null;
|
||||||
sensor_catalog?: XgridsSensorCatalog | null;
|
sensor_catalog?: XgridsSensorCatalog | null;
|
||||||
|
camera_preview?: XgridsCameraPreviewState | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealthResponse {
|
export interface HealthResponse {
|
||||||
|
|
@ -239,6 +268,16 @@ export interface ReplayRequest {
|
||||||
loop?: boolean;
|
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 {
|
export class ApiError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
|
|
||||||
|
|
@ -375,6 +414,14 @@ export const xgridsK1Api = {
|
||||||
return invokeState(xgridsK1Actions.compatibilityStreamStop);
|
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> {
|
updateViewerSettings(body: ViewerSettings): Promise<XgridsK1State> {
|
||||||
return invokeState(xgridsK1Actions.viewerSettingsUpdate, body);
|
return invokeState(xgridsK1Actions.viewerSettingsUpdate, body);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -27,5 +27,7 @@ export const xgridsK1Actions = Object.freeze({
|
||||||
compatibilityStreamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"),
|
compatibilityStreamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"),
|
||||||
streamStartReplay: requirePluginAction(xgridsK1Manifest, "stream.start-replay"),
|
streamStartReplay: requirePluginAction(xgridsK1Manifest, "stream.start-replay"),
|
||||||
compatibilityStreamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"),
|
compatibilityStreamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"),
|
||||||
|
cameraPreviewSelect: requirePluginAction(xgridsK1Manifest, "camera.preview.select"),
|
||||||
|
cameraPreviewStop: requirePluginAction(xgridsK1Manifest, "camera.preview.stop"),
|
||||||
viewerSettingsUpdate: requirePluginAction(xgridsK1Manifest, "viewer.settings.update"),
|
viewerSettingsUpdate: requirePluginAction(xgridsK1Manifest, "viewer.settings.update"),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
|
import type { DeviceModelDefinition } from "../../core/device-plugins/contracts";
|
||||||
import type {
|
import type {
|
||||||
ObservationSourceAvailability,
|
ObservationSourceAvailability,
|
||||||
|
ObservationSourceDelivery,
|
||||||
ObservationSourceDescriptor,
|
ObservationSourceDescriptor,
|
||||||
ObservationSourceProvider,
|
ObservationSourceProvider,
|
||||||
} from "../../core/runtime/contracts";
|
} from "../../core/runtime/contracts";
|
||||||
import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
|
import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
|
||||||
import { xgridsK1Manifest } from "./manifest";
|
import { xgridsK1Manifest } from "./manifest";
|
||||||
import type { XgridsK1State } from "./api";
|
import type {
|
||||||
|
XgridsCameraPreviewDelivery,
|
||||||
|
XgridsK1State,
|
||||||
|
XgridsSensorCatalogStream,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
function providerFor(
|
function providerFor(
|
||||||
state: XgridsK1State,
|
state: XgridsK1State,
|
||||||
|
|
@ -43,19 +48,144 @@ function spatialAvailability(state: XgridsK1State): ObservationSourceAvailabilit
|
||||||
return catalogDeclares(state, "spatial.point-cloud.live") ? "declared" : "unavailable";
|
return catalogDeclares(state, "spatial.point-cloud.live") ? "declared" : "unavailable";
|
||||||
}
|
}
|
||||||
|
|
||||||
function cameraAvailability(state: XgridsK1State): ObservationSourceAvailability {
|
function catalogAvailability(value: string | null | undefined): ObservationSourceAvailability {
|
||||||
const stream = state.sensor_catalog?.streams?.find(
|
switch (value?.trim().toLowerCase()) {
|
||||||
(candidate) => candidate.stream_id === "camera.preview.live",
|
case "observed":
|
||||||
|
case "available":
|
||||||
|
return "available";
|
||||||
|
case "connecting":
|
||||||
|
return "connecting";
|
||||||
|
case "streaming":
|
||||||
|
return "streaming";
|
||||||
|
case "degraded":
|
||||||
|
return "degraded";
|
||||||
|
case "unavailable":
|
||||||
|
return "unavailable";
|
||||||
|
case "error":
|
||||||
|
return "error";
|
||||||
|
case "declared":
|
||||||
|
return "declared";
|
||||||
|
default:
|
||||||
|
return "unverified";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cameraCatalogEntry(stream: XgridsSensorCatalogStream): boolean {
|
||||||
|
const sourceId = stream.source_id?.trim();
|
||||||
|
const semanticChannelId = stream.semantic_channel_id?.trim();
|
||||||
|
if (!sourceId || !semanticChannelId) return false;
|
||||||
|
const modality = stream.modality?.trim().toLowerCase();
|
||||||
|
const sensorKind = stream.sensor_kind?.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
modality === "encoded-video" ||
|
||||||
|
modality === "video" ||
|
||||||
|
sensorKind === "camera" ||
|
||||||
|
semanticChannelId.startsWith("camera.")
|
||||||
);
|
);
|
||||||
const compatibilityProfileId =
|
}
|
||||||
state.device_session?.compatibility_profile_id ?? state.compatibility?.profile_id ?? null;
|
|
||||||
if (!stream) return "unavailable";
|
function safeEndpointLabel(value: string | null | undefined): string | null {
|
||||||
if (stream.availability !== "observed" || !compatibilityProfileId) return "unverified";
|
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";
|
if (state.device_session?.connectivity === "degraded") return "degraded";
|
||||||
// The firmware profile proves that both RTSP channels exist, but Mission Core
|
const base = catalogAvailability(stream.availability);
|
||||||
// does not yet expose a browser-decodable preview URL. Do not report a live
|
if (!selected) return base === "streaming" || base === "connecting" ? "available" : base;
|
||||||
// camera merely because the point-cloud acquisition is running.
|
|
||||||
return "declared";
|
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(
|
export function xgridsK1ObservationSources(
|
||||||
|
|
@ -67,9 +197,7 @@ export function xgridsK1ObservationSources(
|
||||||
const clockId = binding.acquisitionId ?? binding.deviceSessionId ?? binding.deviceId ?? null;
|
const clockId = binding.acquisitionId ?? binding.deviceSessionId ?? binding.deviceId ?? null;
|
||||||
const descriptorId = (sourceId: string) =>
|
const descriptorId = (sourceId: string) =>
|
||||||
`${provider.pluginId}:${provider.modelId}:${sourceId}`;
|
`${provider.pluginId}:${provider.modelId}:${sourceId}`;
|
||||||
|
const pointCloud: ObservationSourceDescriptor = {
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: descriptorId("sensor.lidar.primary"),
|
id: descriptorId("sensor.lidar.primary"),
|
||||||
sourceId: "sensor.lidar.primary",
|
sourceId: "sensor.lidar.primary",
|
||||||
semanticChannelId: "spatial.point-cloud.live",
|
semanticChannelId: "spatial.point-cloud.live",
|
||||||
|
|
@ -81,6 +209,8 @@ export function xgridsK1ObservationSources(
|
||||||
transport: "rerun-grpc",
|
transport: "rerun-grpc",
|
||||||
endpointLabel: state.rerun_grpc_url?.trim() ? "Rerun gRPC" : "MQTT → Rerun",
|
endpointLabel: state.rerun_grpc_url?.trim() ? "Rerun gRPC" : "MQTT → Rerun",
|
||||||
previewUrl: state.rerun_grpc_url?.trim() || null,
|
previewUrl: state.rerun_grpc_url?.trim() || null,
|
||||||
|
delivery: null,
|
||||||
|
activation: null,
|
||||||
provider,
|
provider,
|
||||||
binding,
|
binding,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
|
|
@ -96,58 +226,73 @@ export function xgridsK1ObservationSources(
|
||||||
clockId,
|
clockId,
|
||||||
spatialRegistration: "native",
|
spatialRegistration: "native",
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
{
|
|
||||||
id: descriptorId("sensor.camera.left"),
|
const cameraRows = (state.sensor_catalog?.streams ?? []).filter(cameraCatalogEntry);
|
||||||
sourceId: "sensor.camera.left",
|
const sourceIdCounts = new Map<string, number>();
|
||||||
semanticChannelId: "camera.preview.live",
|
for (const stream of cameraRows) {
|
||||||
label: "K1 · камера слева",
|
const sourceId = stream.source_id?.trim();
|
||||||
description: "Левый H.264 preview-канал профиля устройства",
|
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",
|
modality: "video",
|
||||||
role: "auxiliary",
|
role: "auxiliary",
|
||||||
availability: cameraAvailability(state),
|
availability: cameraAvailability(state, stream, selected, delivery, attested),
|
||||||
transport: "rtsp",
|
transport: delivery ? "websocket" : "other",
|
||||||
endpointLabel: "RTSP · left",
|
endpointLabel: safeEndpointLabel(stream.endpoint_label) ?? "Локальный video adapter",
|
||||||
previewUrl: null,
|
previewUrl: null,
|
||||||
|
delivery,
|
||||||
|
activation,
|
||||||
provider,
|
provider,
|
||||||
binding,
|
binding,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
overlay: true,
|
overlay: true,
|
||||||
fullscreen: true,
|
fullscreen: true,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
defaultVisible: false,
|
defaultVisible: selected && Boolean(delivery),
|
||||||
timelineMode: "live-only",
|
timelineMode: "live-only",
|
||||||
seekable: false,
|
seekable: false,
|
||||||
sessionRecording: false,
|
sessionRecording: false,
|
||||||
clockId,
|
clockId,
|
||||||
spatialRegistration: "unresolved",
|
spatialRegistration: "unresolved",
|
||||||
},
|
},
|
||||||
},
|
}];
|
||||||
{
|
});
|
||||||
id: descriptorId("sensor.camera.right"),
|
|
||||||
sourceId: "sensor.camera.right",
|
return [pointCloud, ...cameras];
|
||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@ export function XgridsK1RuntimeProvider({
|
||||||
pendingAction: controller.pendingAction,
|
pendingAction: controller.pendingAction,
|
||||||
refresh: controller.refresh,
|
refresh: controller.refresh,
|
||||||
updateViewerSettings: controller.updateViewerSettings,
|
updateViewerSettings: controller.updateViewerSettings,
|
||||||
|
setObservationSourceActive: controller.setObservationSourceActive,
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
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,
|
operationNeedsReconciliation,
|
||||||
} from "./lifecycle";
|
} from "./lifecycle";
|
||||||
import { localizeRuntimeMessage } from "./messages";
|
import { localizeRuntimeMessage } from "./messages";
|
||||||
|
import { selectMonotonicXgridsState } from "./stateOrdering";
|
||||||
|
|
||||||
export type PendingAction =
|
export type PendingAction =
|
||||||
| "scan"
|
| "scan"
|
||||||
|
|
@ -27,6 +28,7 @@ export type PendingAction =
|
||||||
| "replay"
|
| "replay"
|
||||||
| "stop"
|
| "stop"
|
||||||
| "abort"
|
| "abort"
|
||||||
|
| "camera"
|
||||||
| "viewer";
|
| "viewer";
|
||||||
|
|
||||||
function messageFor(error: unknown): string {
|
function messageFor(error: unknown): string {
|
||||||
|
|
@ -65,7 +67,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
const mounted = useRef(true);
|
const mounted = useRef(true);
|
||||||
|
|
||||||
const acceptState = useCallback((nextState: XgridsK1State) => {
|
const acceptState = useCallback((nextState: XgridsK1State) => {
|
||||||
setState(nextState);
|
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
|
||||||
setBackendStatus("online");
|
setBackendStatus("online");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -219,6 +221,51 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
);
|
);
|
||||||
}, [run, state?.acquisition]);
|
}, [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(
|
const updateViewerSettings = useCallback(
|
||||||
(request: ViewerSettings) => run("viewer", () => xgridsK1Api.updateViewerSettings(request)),
|
(request: ViewerSettings) => run("viewer", () => xgridsK1Api.updateViewerSettings(request)),
|
||||||
[run],
|
[run],
|
||||||
|
|
@ -300,6 +347,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
startReplay,
|
startReplay,
|
||||||
stop,
|
stop,
|
||||||
abort,
|
abort,
|
||||||
|
setObservationSourceActive,
|
||||||
updateViewerSettings,
|
updateViewerSettings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,59 @@ i[data-availability="error"] {
|
||||||
background: #050608;
|
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 {
|
.observation-media__empty {
|
||||||
min-height: 9rem;
|
min-height: 9rem;
|
||||||
background: #07080a;
|
background: #07080a;
|
||||||
|
|
@ -220,6 +273,24 @@ i[data-availability="error"] {
|
||||||
color: var(--nodedc-text-primary);
|
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 {
|
.floating-observation-window__status {
|
||||||
color: var(--nodedc-text-secondary);
|
color: var(--nodedc-text-secondary);
|
||||||
font-size: 0.54rem;
|
font-size: 0.54rem;
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,24 @@
|
||||||
background: rgb(10 11 14 / 0.9);
|
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 {
|
.busy-indicator {
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,7 @@ function SpatialWorkspace({
|
||||||
{sourceUrl.trim() && pointCloudVisible ? (
|
{sourceUrl.trim() && pointCloudVisible ? (
|
||||||
<RerunViewport
|
<RerunViewport
|
||||||
sourceUrl={sourceUrl}
|
sourceUrl={sourceUrl}
|
||||||
|
followLive={state?.sourceMode === "live"}
|
||||||
onStatusChange={onStatusChange}
|
onStatusChange={onStatusChange}
|
||||||
onSelectionChange={onSelectionChange}
|
onSelectionChange={onSelectionChange}
|
||||||
/>
|
/>
|
||||||
|
|
@ -349,6 +350,7 @@ function SpatialWorkspace({
|
||||||
<ObservationSourcePicker
|
<ObservationSourcePicker
|
||||||
sources={observationSources}
|
sources={observationSources}
|
||||||
visibleSourceIds={observationLayout.visibleSourceIds}
|
visibleSourceIds={observationLayout.visibleSourceIds}
|
||||||
|
pendingSourceIds={observationLayout.pendingSourceIds}
|
||||||
onToggle={observationLayout.toggleSource}
|
onToggle={observationLayout.toggleSource}
|
||||||
/>
|
/>
|
||||||
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && sourceUrl.trim() ? (
|
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && sourceUrl.trim() ? (
|
||||||
|
|
@ -434,7 +436,7 @@ function SpatialWorkspace({
|
||||||
onMaximizedChange={(maximized) =>
|
onMaximizedChange={(maximized) =>
|
||||||
observationLayout.setFloatingMaximized(source.id, maximized)}
|
observationLayout.setFloatingMaximized(source.id, maximized)}
|
||||||
onActivate={() => observationLayout.activateFloatingSource(source.id)}
|
onActivate={() => observationLayout.activateFloatingSource(source.id)}
|
||||||
onClose={() => observationLayout.hideSource(source.id)}
|
onClose={() => void observationLayout.hideSource(source.id)}
|
||||||
/>
|
/>
|
||||||
)) : null}
|
)) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -454,12 +456,41 @@ function SpatialWorkspace({
|
||||||
function CameraSourceCard({
|
function CameraSourceCard({
|
||||||
source,
|
source,
|
||||||
focused,
|
focused,
|
||||||
|
visible,
|
||||||
|
pending,
|
||||||
|
selectedPeer,
|
||||||
|
onToggle,
|
||||||
onFocus,
|
onFocus,
|
||||||
}: {
|
}: {
|
||||||
source: ObservationSourceDescriptor;
|
source: ObservationSourceDescriptor;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
selectedPeer: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
onFocus: () => 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 (
|
return (
|
||||||
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
|
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
|
||||||
<header>
|
<header>
|
||||||
|
|
@ -469,7 +500,17 @@ function CameraSourceCard({
|
||||||
<i data-availability={source.availability} aria-hidden="true" />
|
<i data-availability={source.availability} aria-hidden="true" />
|
||||||
{observationSourceStatusLabel(source)}
|
{observationSourceStatusLabel(source)}
|
||||||
</span>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={focused ? `Свернуть ${source.label}` : `Развернуть ${source.label}`}
|
aria-label={focused ? `Свернуть ${source.label}` : `Развернуть ${source.label}`}
|
||||||
|
|
@ -510,6 +551,15 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
|
||||||
key={source.id}
|
key={source.id}
|
||||||
source={source}
|
source={source}
|
||||||
focused={focusedSource?.id === source.id}
|
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(
|
onFocus={() => observationLayout.setFocusedSourceId(
|
||||||
focusedSource?.id === source.id ? null : source.id,
|
focusedSource?.id === source.id ? null : source.id,
|
||||||
)}
|
)}
|
||||||
|
|
@ -523,8 +573,11 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ObservationTimeline
|
<ObservationTimeline
|
||||||
active={sources.some((source) => source.availability === "streaming")}
|
active={sources.some((source) =>
|
||||||
sourceCount={sources.length}
|
source.availability === "streaming" &&
|
||||||
|
(!source.activation || source.activation.selected))}
|
||||||
|
sourceCount={sources.filter((source) =>
|
||||||
|
!source.activation || source.activation.selected).length}
|
||||||
mode={timeline?.mode}
|
mode={timeline?.mode}
|
||||||
seekable={timeline?.seekable}
|
seekable={timeline?.seekable}
|
||||||
synchronization={timeline?.synchronization}
|
synchronization={timeline?.synchronization}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ import { createServer } from "vite";
|
||||||
let server;
|
let server;
|
||||||
let xgridsK1Manifest;
|
let xgridsK1Manifest;
|
||||||
let xgridsK1ObservationSources;
|
let xgridsK1ObservationSources;
|
||||||
|
let openObservationSource;
|
||||||
|
let shouldRestartObservationSource;
|
||||||
|
let consumeCameraLeaseRetry;
|
||||||
|
let resetCameraLeaseRetryBudget;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
|
|
@ -19,6 +23,12 @@ before(async () => {
|
||||||
({ xgridsK1ObservationSources } = await server.ssrLoadModule(
|
({ xgridsK1ObservationSources } = await server.ssrLoadModule(
|
||||||
"/src/device-plugins/xgrids-k1/observationSources.ts",
|
"/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 () => {
|
after(async () => {
|
||||||
|
|
@ -31,7 +41,30 @@ function model() {
|
||||||
return activeModel;
|
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();
|
const activeModel = model();
|
||||||
return {
|
return {
|
||||||
phase: "connected",
|
phase: "connected",
|
||||||
|
|
@ -60,13 +93,20 @@ function declaredState() {
|
||||||
revision: "test-profile",
|
revision: "test-profile",
|
||||||
streams: [
|
streams: [
|
||||||
{ stream_id: "spatial.point-cloud.live", modality: "point-cloud", availability: "observed" },
|
{ 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 {
|
return {
|
||||||
...declaredState(),
|
...declaredState(),
|
||||||
phase: "streaming",
|
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 = []) {
|
function collectUrlLikeStrings(value, found = []) {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
if (value.includes("://")) found.push(value);
|
if (value.includes("://")) found.push(value);
|
||||||
|
|
@ -103,95 +173,199 @@ function collectUrlLikeStrings(value, found = []) {
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("K1 maps to one primary point cloud and two auxiliary camera channels", () => {
|
test("K1 maps zero, one or N catalog cameras without model-specific source ids", () => {
|
||||||
const sources = xgridsK1ObservationSources(declaredState(), model());
|
const zero = xgridsK1ObservationSources(declaredState([]), model());
|
||||||
|
const one = xgridsK1ObservationSources(
|
||||||
assert.equal(sources.length, 3);
|
declaredState([cameraRow("rig.front", "Передняя камера")]),
|
||||||
assert.deepEqual(
|
model(),
|
||||||
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
const many = xgridsK1ObservationSources(declaredState(), model());
|
||||||
|
|
||||||
assert.equal(new Set(sources.map(({ id }) => id)).size, sources.length);
|
assert.deepEqual(zero.map(({ modality }) => modality), ["point-cloud"]);
|
||||||
assert.ok(sources.every(({ provider }) => provider.pluginId && provider.modelId));
|
assert.deepEqual(one.map(({ sourceId }) => sourceId), ["sensor.lidar.primary", "rig.front"]);
|
||||||
assert.ok(sources.every(({ provider }) => provider.pluginVersion));
|
assert.deepEqual(many.map(({ sourceId }) => sourceId), [
|
||||||
assert.ok(sources.every(({ binding }) => binding.deviceId === "device-k1-001"));
|
"sensor.lidar.primary",
|
||||||
assert.ok(sources.every(({ capabilities }) => capabilities.timelineMode === "live-only"));
|
"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 declared = xgridsK1ObservationSources(declaredState(), model());
|
||||||
const streaming = xgridsK1ObservationSources(streamingState(), model());
|
const streaming = xgridsK1ObservationSources(
|
||||||
|
cameraStreamingState("sensor.camera.left"),
|
||||||
assert.deepEqual(
|
model(),
|
||||||
streaming.map(({ id }) => id),
|
|
||||||
declared.map(({ id }) => id),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(streaming.map(({ id }) => id), declared.map(({ id }) => id));
|
||||||
assert.equal(declared[0].availability, "available");
|
assert.equal(declared[0].availability, "available");
|
||||||
assert.equal(streaming[0].availability, "streaming");
|
assert.equal(streaming[0].availability, "streaming");
|
||||||
assert.equal(streaming[0].previewUrl, "rerun+http://127.0.0.1:9877/proxy");
|
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", () => {
|
test("only the authoritative selected camera receives browser delivery", () => {
|
||||||
const sources = xgridsK1ObservationSources(streamingState(), model());
|
const sources = xgridsK1ObservationSources(
|
||||||
|
cameraStreamingState("sensor.camera.left"),
|
||||||
|
model(),
|
||||||
|
);
|
||||||
const cameras = sources.filter(({ modality }) => modality === "video");
|
const cameras = sources.filter(({ modality }) => modality === "video");
|
||||||
|
const [left, right] = cameras;
|
||||||
|
|
||||||
assert.equal(cameras.length, 2);
|
assert.equal(cameras.length, 2);
|
||||||
for (const camera of cameras) {
|
assert.equal(left.activation.selected, true);
|
||||||
assert.equal(camera.availability, "declared");
|
assert.equal(left.activation.maxActive, 1);
|
||||||
assert.equal(camera.transport, "rtsp");
|
assert.equal(left.availability, "streaming");
|
||||||
assert.equal(camera.previewUrl, null);
|
assert.equal(left.delivery.kind, "mse-fmp4-websocket");
|
||||||
assert.equal(camera.capabilities.spatialRegistration, "unresolved");
|
assert.equal(left.transport, "websocket");
|
||||||
assert.equal(camera.capabilities.resizable, true);
|
assert.equal(right.activation.selected, false);
|
||||||
assert.doesNotMatch(camera.endpointLabel ?? "", /:\/\//);
|
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), [
|
assert.deepEqual(collectUrlLikeStrings(sources), [
|
||||||
"rerun+http://127.0.0.1:9877/proxy",
|
"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, /rtsp:\/\//i);
|
||||||
assert.doesNotMatch(serialized, /192\.168\.7\.10/);
|
assert.doesNotMatch(serialized, /192\.168\.7\.10/);
|
||||||
assert.doesNotMatch(serialized, /vendor-(?:preview|viewer)/);
|
assert.doesNotMatch(serialized, /vendor-(?:preview|viewer)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unattested camera catalog entries remain unverified", () => {
|
test("unattested, duplicate and unsafe camera entries fail closed", () => {
|
||||||
const state = declaredState();
|
const duplicate = cameraRow("sensor.camera.left", "Duplicate");
|
||||||
|
const state = cameraStreamingState("sensor.camera.left");
|
||||||
state.compatibility.profile_id = null;
|
state.compatibility.profile_id = null;
|
||||||
state.device_session.compatibility_profile_id = null;
|
state.device_session.compatibility_profile_id = null;
|
||||||
state.sensor_catalog.streams = state.sensor_catalog.streams.map((stream) =>
|
state.sensor_catalog.streams.push(duplicate);
|
||||||
stream.stream_id === "camera.preview.live"
|
state.camera_preview.delivery = {
|
||||||
? { ...stream, availability: "unverified", decode_status: "profile-not-attested" }
|
...state.camera_preview.delivery,
|
||||||
: stream);
|
url: "ws://192.168.7.10:9000/leak",
|
||||||
|
};
|
||||||
|
|
||||||
const cameras = xgridsK1ObservationSources(state, model()).filter(
|
const cameras = xgridsK1ObservationSources(state, model()).filter(
|
||||||
({ modality }) => modality === "video",
|
({ modality }) => modality === "video",
|
||||||
);
|
);
|
||||||
assert.equal(cameras.length, 2);
|
assert.deepEqual(cameras.map(({ sourceId }) => sourceId), ["sensor.camera.right"]);
|
||||||
assert.ok(cameras.every(({ availability }) => availability === "unverified"));
|
assert.equal(cameras[0].availability, "unverified");
|
||||||
assert.ok(cameras.every(({ previewUrl }) => previewUrl === null));
|
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 {
|
return {
|
||||||
plugins: [react(), wasm()],
|
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: {
|
build: {
|
||||||
target: "esnext",
|
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.
|
by physical double-click; no modeling request is published.
|
||||||
|
|
||||||
Left/right camera preview transport, endpoint paths and H.264 framing are now
|
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
|
observed under the exact compatibility profile. The local read-only adapter
|
||||||
not implemented yet. Device status and heartbeat remain raw observed channels
|
copy-remuxes one selected RTSP producer into bounded fMP4/WebSocket delivery for
|
||||||
without semantic decoders. Device calibration command and sensor-to-vehicle
|
the generic MSE UI. Portable FFmpeg packaging, fan-out and remote delivery remain
|
||||||
extrinsics are unavailable.
|
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
|
## Remaining extraction order
|
||||||
|
|
||||||
|
|
@ -118,7 +128,8 @@ extrinsics are unavailable.
|
||||||
5. Replace compatibility routes and singleton state with multi-device session
|
5. Replace compatibility routes and singleton state with multi-device session
|
||||||
routing.
|
routing.
|
||||||
6. Split Edge execution from the Control Station behind authenticated transport.
|
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.
|
modeling-command publisher disabled until its separate safety gate closes.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
@ -149,7 +160,8 @@ extrinsics are unavailable.
|
||||||
- complete SDK-envelope/EvidenceStore hot-path integration;
|
- complete SDK-envelope/EvidenceStore hot-path integration;
|
||||||
- remote Edge split, authenticated WAN relay and fleet orchestration;
|
- remote Edge split, authenticated WAN relay and fleet orchestration;
|
||||||
- automatic K1 start/stop/calibration commands;
|
- 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
|
## 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
|
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
|
future multi-sensor vehicle without adding vendor-specific branches to the host
|
||||||
UI. The currently verified K1 profile exposes one spatial visualization path and
|
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
|
two path-labelled H.264 camera preview producers. The direct physical gate showed
|
||||||
camera delivery adapter, a shared point-cloud/video clock or confirmed camera
|
that only one K1 preview producer can deliver frames at a time. A shared
|
||||||
intrinsics/extrinsics.
|
point-cloud/video clock and camera intrinsics/extrinsics remain unavailable.
|
||||||
|
|
||||||
Hard-coded camera slots, direct RTSP URLs in React components and an apparently
|
Hard-coded camera slots, direct RTSP URLs in React components and an apparently
|
||||||
seekable timeline would therefore create three false contracts: a fixed sensor
|
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.
|
acquisition may change layout context without changing the logical producer.
|
||||||
4. The host owns visibility, z-order, position, resize and fullscreen. A plugin
|
4. The host owns visibility, z-order, position, resize and fullscreen. A plugin
|
||||||
cannot inject global layout behavior.
|
cannot inject global layout behavior.
|
||||||
5. Direct device RTSP URLs do not cross into the generic UI. A future read-only
|
5. Direct device RTSP URLs do not cross into the generic UI. The read-only K1
|
||||||
camera adapter must publish a browser-decodable local delivery URL.
|
adapter publishes an opaque, same-origin fMP4/WebSocket delivery lease.
|
||||||
6. Until a bounded buffer and clock mapping exist, the shared timeline is
|
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
|
`live-only`, `seekable=false`, `sessionRecording=false` and explicitly marked
|
||||||
`host-arrival-best-effort`.
|
`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
|
extrinsics are available and verified. An unresolved camera may be listed as
|
||||||
a source, but its orientation must not be invented.
|
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 |
|
| `sensor.camera.right` | `camera.preview.live` | auxiliary media window / camera card |
|
||||||
|
|
||||||
Both camera producers share the proven canonical channel and remain distinct by
|
Both camera producers share the proven canonical channel and remain distinct by
|
||||||
`sourceId`. Their current state is `declared`: the compatibility profile proves
|
`sourceId`. The plugin publishes both catalog rows dynamically, but delivery is
|
||||||
the channels, but no browser preview URL is advertised yet.
|
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
|
## UI consequences
|
||||||
|
|
||||||
|
|
@ -62,11 +66,12 @@ the channels, but no browser preview URL is advertised yet.
|
||||||
|
|
||||||
## Follow-up gate
|
## Follow-up gate
|
||||||
|
|
||||||
The next implementation step is a plugin-owned, read-only H.264 adapter that
|
The local laboratory adapter now terminates RTSP/RTP with FFmpeg, copy-remuxes
|
||||||
terminates RTSP/RTP locally and exposes a browser-supported stream plus measured
|
H.264 High L4 without transcoding and publishes complete bounded fMP4 segments
|
||||||
per-source metrics. Synchronized rewind becomes eligible only after the adapter
|
to a generic MSE player. Portable FFmpeg packaging, multi-consumer fan-out and
|
||||||
provides bounded buffering and an evidenced mapping between the RTP 90 kHz clock
|
remote/WAN delivery are not claimed by this milestone. Synchronized rewind
|
||||||
and the spatial stream clock.
|
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
|
## Code anchors
|
||||||
|
|
||||||
|
|
@ -76,3 +81,4 @@ and the spatial stream clock.
|
||||||
- `apps/control-station/src/components/ObservationTimeline.tsx`
|
- `apps/control-station/src/components/ObservationTimeline.tsx`
|
||||||
- `apps/control-station/src/workspaces/Workspaces.tsx`
|
- `apps/control-station/src/workspaces/Workspaces.tsx`
|
||||||
- `apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts`
|
- `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",
|
"kind": "DevicePlugin",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"id": "nodedc.device.xgrids-lixelkity-k1",
|
"id": "nodedc.device.xgrids-lixelkity-k1",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"displayName": "XGRIDS K1 Integration"
|
"displayName": "XGRIDS K1 Integration"
|
||||||
},
|
},
|
||||||
"spec": {
|
"spec": {
|
||||||
|
|
@ -23,6 +23,8 @@
|
||||||
"device.discovery.ble",
|
"device.discovery.ble",
|
||||||
"device.provisioning.wifi-over-ble",
|
"device.provisioning.wifi-over-ble",
|
||||||
"network.mqtt.subscribe-private-lan",
|
"network.mqtt.subscribe-private-lan",
|
||||||
|
"network.rtsp.read-private-lan",
|
||||||
|
"media.publish-local-browser",
|
||||||
"evidence.write-session-artifacts"
|
"evidence.write-session-artifacts"
|
||||||
],
|
],
|
||||||
"actions": [
|
"actions": [
|
||||||
|
|
@ -41,6 +43,8 @@
|
||||||
{ "id": "stream.start-live", "mutating": true, "secretFields": [] },
|
{ "id": "stream.start-live", "mutating": true, "secretFields": [] },
|
||||||
{ "id": "stream.start-replay", "mutating": true, "secretFields": [] },
|
{ "id": "stream.start-replay", "mutating": true, "secretFields": [] },
|
||||||
{ "id": "stream.stop", "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": [] }
|
{ "id": "viewer.settings.update", "mutating": true, "secretFields": [] }
|
||||||
],
|
],
|
||||||
"models": [
|
"models": [
|
||||||
|
|
@ -56,6 +60,7 @@
|
||||||
{ "id": "device.provisioning.wifi-over-ble", "label": "Wi-Fi через BLE" },
|
{ "id": "device.provisioning.wifi-over-ble", "label": "Wi-Fi через BLE" },
|
||||||
{ "id": "spatial.point-cloud.live", "label": "Облако точек" },
|
{ "id": "spatial.point-cloud.live", "label": "Облако точек" },
|
||||||
{ "id": "spatial.pose.live", "label": "Траектория" },
|
{ "id": "spatial.pose.live", "label": "Траектория" },
|
||||||
|
{ "id": "camera.preview.live", "label": "Видеокамеры" },
|
||||||
{ "id": "evidence.raw-capture", "label": "Исходная запись" },
|
{ "id": "evidence.raw-capture", "label": "Исходная запись" },
|
||||||
{ "id": "evidence.replay", "label": "Повтор записи" }
|
{ "id": "evidence.replay", "label": "Повтор записи" }
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import rerun as rr
|
import rerun as rr
|
||||||
|
|
@ -21,7 +22,12 @@ from k1link.viewer.metrics import BridgeMetrics
|
||||||
PointColorMode = Literal["intensity", "height", "distance", "rgb", "class"]
|
PointColorMode = Literal["intensity", "height", "distance", "rgb", "class"]
|
||||||
PointPalette = Literal["turbo", "viridis", "plasma", "grayscale", "custom"]
|
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_GRPC_PORT = 9876
|
||||||
DEFAULT_CORS_ORIGINS = (
|
DEFAULT_CORS_ORIGINS = (
|
||||||
"http://127.0.0.1:5173",
|
"http://127.0.0.1:5173",
|
||||||
|
|
@ -66,27 +72,52 @@ class RerunBridge:
|
||||||
self.metrics = metrics or BridgeMetrics()
|
self.metrics = metrics or BridgeMetrics()
|
||||||
self._settings_provider = settings_provider or RerunSceneSettings
|
self._settings_provider = settings_provider or RerunSceneSettings
|
||||||
self._settings = self._settings_provider()
|
self._settings = self._settings_provider()
|
||||||
self._recording = (recording_factory or rr.RecordingStream)("nodedc_mission_core_spatial")
|
if recording_factory is None:
|
||||||
blueprint = _blueprint(self._settings)
|
recording = rr.RecordingStream(
|
||||||
self._url = self._recording.serve_grpc(
|
"nodedc_mission_core_spatial",
|
||||||
grpc_port=grpc_port,
|
recording_id=uuid4(),
|
||||||
default_blueprint=blueprint,
|
)
|
||||||
server_memory_limit="512MiB",
|
else:
|
||||||
newest_first=True,
|
recording = recording_factory("nodedc_mission_core_spatial")
|
||||||
cors_allow_origin=list(cors_allow_origin),
|
try:
|
||||||
)
|
blueprint = _blueprint(self._settings)
|
||||||
self._recording.send_blueprint(
|
url = recording.serve_grpc(
|
||||||
blueprint,
|
grpc_port=grpc_port,
|
||||||
make_active=True,
|
default_blueprint=blueprint,
|
||||||
make_default=True,
|
# This is a reconnect cushion for the live preview, not the source
|
||||||
)
|
# of record. Raw MQTT evidence is persisted independently. A large
|
||||||
self._recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
# late-client backlog can block the native SDK and freeze preview.
|
||||||
self._recording.log(
|
server_memory_limit=LIVE_GRPC_BUFFER_LIMIT,
|
||||||
"/world/sensor_pose",
|
# Rerun 0.34.1 can replay ActivateStore before StoreInfo when an
|
||||||
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
|
# evicted buffer is served newest-first, leaving late viewers on the
|
||||||
static=True,
|
# welcome screen. Preserve protocol order within the bounded cache.
|
||||||
)
|
newest_first=False,
|
||||||
self._path: deque[tuple[float, float, float]] = deque(maxlen=MAX_TRAJECTORY_POSES)
|
cors_allow_origin=list(cors_allow_origin),
|
||||||
|
)
|
||||||
|
recording.send_blueprint(
|
||||||
|
blueprint,
|
||||||
|
make_active=True,
|
||||||
|
make_default=True,
|
||||||
|
)
|
||||||
|
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
||||||
|
recording.log(
|
||||||
|
"/world/sensor_pose",
|
||||||
|
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
|
||||||
|
static=True,
|
||||||
|
)
|
||||||
|
# Do not expose a URL whose StoreInfo, blueprint and static scene are
|
||||||
|
# still waiting in the SDK micro-batcher.
|
||||||
|
recording.flush(timeout_sec=5.0)
|
||||||
|
except BaseException:
|
||||||
|
# Preserve the construction failure while still making a best
|
||||||
|
# effort to release a partially started native listener.
|
||||||
|
with suppress(BaseException):
|
||||||
|
recording.disconnect()
|
||||||
|
raise
|
||||||
|
self._recording = recording
|
||||||
|
self._url = url
|
||||||
|
self._path: list[tuple[float, float, float]] = []
|
||||||
|
self._last_path_append_source_ns = 0
|
||||||
self._last_trajectory_publish_ns = 0
|
self._last_trajectory_publish_ns = 0
|
||||||
self._last_point_count = 0
|
self._last_point_count = 0
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
@ -96,13 +127,14 @@ class RerunBridge:
|
||||||
return self._url
|
return self._url
|
||||||
|
|
||||||
def begin_session(self, metrics: BridgeMetrics | None = None) -> None:
|
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:
|
if self._closed:
|
||||||
raise RuntimeError("Rerun bridge is already closed")
|
raise RuntimeError("Rerun bridge is already closed")
|
||||||
if metrics is not None:
|
if metrics is not None:
|
||||||
self.metrics = metrics
|
self.metrics = metrics
|
||||||
self._settings = self._settings_provider()
|
self._settings = self._settings_provider()
|
||||||
self._path.clear()
|
self._path.clear()
|
||||||
|
self._last_path_append_source_ns = 0
|
||||||
self._last_trajectory_publish_ns = 0
|
self._last_trajectory_publish_ns = 0
|
||||||
self._last_point_count = 0
|
self._last_point_count = 0
|
||||||
self._recording.set_time("stream_time", timestamp=time.time())
|
self._recording.set_time("stream_time", timestamp=time.time())
|
||||||
|
|
@ -118,6 +150,10 @@ class RerunBridge:
|
||||||
make_active=True,
|
make_active=True,
|
||||||
make_default=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:
|
def process(self, envelope: DecodedDataPlaneView) -> None:
|
||||||
self._apply_latest_settings()
|
self._apply_latest_settings()
|
||||||
|
|
@ -158,12 +194,14 @@ class RerunBridge:
|
||||||
self._closed = True
|
self._closed = True
|
||||||
recording = self._recording
|
recording = self._recording
|
||||||
try:
|
try:
|
||||||
recording.flush(timeout_sec=5.0)
|
try:
|
||||||
|
recording.flush(timeout_sec=5.0)
|
||||||
|
finally:
|
||||||
|
recording.disconnect()
|
||||||
finally:
|
finally:
|
||||||
recording.disconnect()
|
# The Python wrapper owns the native gRPC server. Release it even if
|
||||||
# The Python wrapper owns the native gRPC server. Release it immediately
|
# flush or disconnect fails instead of waiting for garbage collection.
|
||||||
# instead of waiting for the publisher thread frame to be collected.
|
del self._recording
|
||||||
del self._recording
|
|
||||||
|
|
||||||
def _set_message_time(self, envelope: DecodedDataPlaneView) -> None:
|
def _set_message_time(self, envelope: DecodedDataPlaneView) -> None:
|
||||||
context = envelope.context
|
context = envelope.context
|
||||||
|
|
@ -207,25 +245,35 @@ class RerunBridge:
|
||||||
)
|
)
|
||||||
|
|
||||||
def _publish_pose(self, frame: DecodedPoseView) -> None:
|
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(
|
self._recording.log(
|
||||||
"/world/sensor_pose",
|
"/world/sensor_pose",
|
||||||
rr.Transform3D(
|
rr.Transform3D(
|
||||||
translation=frame.position_xyz,
|
translation=position,
|
||||||
quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw),
|
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:
|
if not self._settings.show_trajectory:
|
||||||
self._recording.log("/world/trajectory", rr.Clear(recursive=False))
|
self._recording.log("/world/trajectory", rr.Clear(recursive=False))
|
||||||
return
|
return
|
||||||
now_ns = time.monotonic_ns()
|
if not appended:
|
||||||
|
return
|
||||||
if (
|
if (
|
||||||
len(self._path) > 2
|
publish_now_ns - self._last_trajectory_publish_ns
|
||||||
and len(self._path) % 20 != 0
|
< TRAJECTORY_PUBLISH_INTERVAL_NS
|
||||||
and now_ns - self._last_trajectory_publish_ns < 200_000_000
|
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
self._last_trajectory_publish_ns = now_ns
|
self._last_trajectory_publish_ns = publish_now_ns
|
||||||
self._recording.log(
|
self._recording.log(
|
||||||
"/world/trajectory",
|
"/world/trajectory",
|
||||||
rr.LineStrips3D(
|
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:
|
def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint:
|
||||||
accumulation = max(0.0, settings.accumulation_seconds)
|
accumulation = max(0.0, settings.accumulation_seconds)
|
||||||
|
|
@ -255,12 +336,22 @@ def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint:
|
||||||
),
|
),
|
||||||
time_ranges=[time_range],
|
time_ranges=[time_range],
|
||||||
),
|
),
|
||||||
|
_live_time_panel(),
|
||||||
auto_layout=False,
|
auto_layout=False,
|
||||||
auto_views=False,
|
auto_views=False,
|
||||||
collapse_panels=True,
|
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(
|
def _point_colors(
|
||||||
positions: np.ndarray,
|
positions: np.ndarray,
|
||||||
intensities: np.ndarray,
|
intensities: np.ndarray,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ RuntimePhase = Literal[
|
||||||
SourceMode = Literal["idle", "live", "replay"]
|
SourceMode = Literal["idle", "live", "replay"]
|
||||||
StateCallback = Callable[[], None]
|
StateCallback = Callable[[], None]
|
||||||
BridgeFactory = Callable[..., RerunBridge]
|
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):
|
class CanonicalNormalizer(Protocol):
|
||||||
|
|
@ -191,7 +195,13 @@ class VisualizationRuntime:
|
||||||
self._bridge = None
|
self._bridge = None
|
||||||
self._rerun_grpc_url = None
|
self._rerun_grpc_url = None
|
||||||
if bridge is not None:
|
if bridge is not None:
|
||||||
bridge.close()
|
try:
|
||||||
|
bridge.close()
|
||||||
|
except BaseException as exc:
|
||||||
|
self._finish_error(
|
||||||
|
f"Ошибка завершения визуального моста: {type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
self._notify()
|
self._notify()
|
||||||
|
|
||||||
def _start(
|
def _start(
|
||||||
|
|
@ -308,7 +318,7 @@ class VisualizationRuntime:
|
||||||
*,
|
*,
|
||||||
running_phase: RuntimePhase,
|
running_phase: RuntimePhase,
|
||||||
) -> None:
|
) -> None:
|
||||||
messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=32)
|
messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=PREVIEW_QUEUE_SIZE)
|
||||||
source_done = threading.Event()
|
source_done = threading.Event()
|
||||||
publisher_ready = threading.Event()
|
publisher_ready = threading.Event()
|
||||||
publisher_aborted = threading.Event()
|
publisher_aborted = threading.Event()
|
||||||
|
|
@ -334,23 +344,28 @@ class VisualizationRuntime:
|
||||||
def publish() -> None:
|
def publish() -> None:
|
||||||
bridge: RerunBridge | None = None
|
bridge: RerunBridge | None = None
|
||||||
try:
|
try:
|
||||||
bridge = self._bridge
|
with self._lock:
|
||||||
|
bridge = self._bridge
|
||||||
|
if self._closed:
|
||||||
|
publisher_aborted.set()
|
||||||
|
if publisher_aborted.is_set():
|
||||||
|
publisher_ready.set()
|
||||||
|
return
|
||||||
if bridge is None:
|
if bridge is None:
|
||||||
candidate = self._bridge_factory(
|
candidate = self._bridge_factory(
|
||||||
grpc_port=self._grpc_port,
|
grpc_port=self._grpc_port,
|
||||||
metrics=self._metrics,
|
metrics=self._metrics,
|
||||||
settings_provider=self._current_scene_settings,
|
settings_provider=self._current_scene_settings,
|
||||||
)
|
)
|
||||||
|
bridge = candidate
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._closed:
|
if self._closed:
|
||||||
publisher_aborted.set()
|
publisher_aborted.set()
|
||||||
else:
|
else:
|
||||||
self._bridge = candidate
|
self._bridge = candidate
|
||||||
bridge = candidate
|
if publisher_aborted.is_set():
|
||||||
if publisher_aborted.is_set():
|
publisher_ready.set()
|
||||||
candidate.close()
|
return
|
||||||
publisher_ready.set()
|
|
||||||
return
|
|
||||||
assert bridge is not None
|
assert bridge is not None
|
||||||
bridge.begin_session(self._metrics)
|
bridge.begin_session(self._metrics)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|
@ -402,12 +417,19 @@ class VisualizationRuntime:
|
||||||
finally:
|
finally:
|
||||||
if bridge is not None:
|
if bridge is not None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
close_bridge = self._closed and self._bridge is bridge
|
close_bridge = self._closed and (
|
||||||
if close_bridge:
|
self._bridge is bridge or publisher_aborted.is_set()
|
||||||
|
)
|
||||||
|
if close_bridge and self._bridge is bridge:
|
||||||
self._bridge = None
|
self._bridge = None
|
||||||
self._rerun_grpc_url = None
|
self._rerun_grpc_url = None
|
||||||
if close_bridge:
|
if close_bridge:
|
||||||
bridge.close()
|
try:
|
||||||
|
bridge.close()
|
||||||
|
except BaseException as exc:
|
||||||
|
# Process shutdown must not become a false successful
|
||||||
|
# idle state when the native bridge failed to close.
|
||||||
|
publisher_error.append(exc)
|
||||||
|
|
||||||
def join_publisher() -> None:
|
def join_publisher() -> None:
|
||||||
# Never orphan a publisher: the session thread remains its owner.
|
# 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,
|
PluginActionNotFoundError,
|
||||||
PluginExecutionError,
|
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_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
||||||
XGRIDS_K1_MODEL_ID = "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_LIVE = "stream.start-live"
|
||||||
ACTION_STREAM_START_REPLAY = "stream.start-replay"
|
ACTION_STREAM_START_REPLAY = "stream.start-replay"
|
||||||
ACTION_STREAM_STOP = "stream.stop"
|
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"
|
ACTION_VIEWER_SETTINGS_UPDATE = "viewer.settings.update"
|
||||||
|
|
||||||
RequestedStreamId = Literal[
|
RequestedStreamId = Literal[
|
||||||
|
|
@ -145,6 +155,16 @@ class ReplayRequest(StrictRequest):
|
||||||
loop: bool = False
|
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):
|
class ViewerSettingsRequest(StrictRequest):
|
||||||
point_size: float = Field(default=2.5, ge=0.5, le=12.0)
|
point_size: float = Field(default=2.5, ge=0.5, le=12.0)
|
||||||
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
|
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
|
||||||
|
|
@ -189,10 +209,15 @@ class XgridsK1CompatibilityService:
|
||||||
# The host-owned visual runtime receives the vendor normalizer
|
# The host-owned visual runtime receives the vendor normalizer
|
||||||
# explicitly. There is no implicit K1 decoder in the visual layer.
|
# explicitly. There is no implicit K1 decoder in the visual layer.
|
||||||
self.runtime = VisualizationRuntime(normalizer=normalize_k1_message)
|
self.runtime = VisualizationRuntime(normalizer=normalize_k1_message)
|
||||||
|
self.camera_preview = XgridsK1CameraGateway(
|
||||||
|
self.repository_root,
|
||||||
|
XGRIDS_K1_PLUGIN_ID,
|
||||||
|
)
|
||||||
|
|
||||||
def state(self) -> dict[str, Any]:
|
def state(self) -> dict[str, Any]:
|
||||||
runtime = self.runtime.snapshot()
|
runtime = self.runtime.snapshot()
|
||||||
self._reconcile_acquisition(runtime)
|
self._reconcile_acquisition(runtime)
|
||||||
|
camera_preview = self.camera_preview.snapshot()
|
||||||
metrics = runtime["metrics"]
|
metrics = runtime["metrics"]
|
||||||
with self._lock:
|
with self._lock:
|
||||||
operation_phase = self._operation_phase
|
operation_phase = self._operation_phase
|
||||||
|
|
@ -264,7 +289,11 @@ class XgridsK1CompatibilityService:
|
||||||
"attestation": compatibility_attestation,
|
"attestation": compatibility_attestation,
|
||||||
"vendor_writes_enabled": False,
|
"vendor_writes_enabled": False,
|
||||||
"camera_preview": (
|
"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
|
if active_profile_id is not None
|
||||||
else "unverified"
|
else "unverified"
|
||||||
),
|
),
|
||||||
|
|
@ -292,8 +321,13 @@ class XgridsK1CompatibilityService:
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"connection_verification": connection_verification,
|
"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),
|
"device_calibration": _device_calibration_snapshot(active_profile_id),
|
||||||
|
"camera_preview": camera_preview,
|
||||||
"acquisition": acquisition,
|
"acquisition": acquisition,
|
||||||
"operations": operation_documents,
|
"operations": operation_documents,
|
||||||
"last_operation": operation_documents[-1] if operation_documents else None,
|
"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"]}
|
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
|
||||||
if request.device_id not in known_ids:
|
if request.device_id not in known_ids:
|
||||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
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
|
# Unwrap once at the provisioning service boundary. The plain value is
|
||||||
# kept only in this stack frame, included in a keyed request digest, and
|
# kept only in this stack frame, included in a keyed request digest, and
|
||||||
# passed to the reviewed BLE write boundary; it is never journaled.
|
# passed to the reviewed BLE write boundary; it is never journaled.
|
||||||
|
|
@ -808,6 +845,7 @@ class XgridsK1CompatibilityService:
|
||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
acquisition.transition("stopping", message_code="acquisition.stopping")
|
acquisition.transition("stopping", message_code="acquisition.stopping")
|
||||||
|
self.camera_preview.stop_current()
|
||||||
self.runtime.stop()
|
self.runtime.stop()
|
||||||
self._cancel_pending_acquisition_operations(
|
self._cancel_pending_acquisition_operations(
|
||||||
exclude_operation_id=operation.operation_id,
|
exclude_operation_id=operation.operation_id,
|
||||||
|
|
@ -877,6 +915,7 @@ class XgridsK1CompatibilityService:
|
||||||
return self.state()
|
return self.state()
|
||||||
try:
|
try:
|
||||||
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||||
|
self.camera_preview.stop_current()
|
||||||
self.runtime.stop()
|
self.runtime.stop()
|
||||||
self._cancel_pending_acquisition_operations(
|
self._cancel_pending_acquisition_operations(
|
||||||
exclude_operation_id=operation.operation_id,
|
exclude_operation_id=operation.operation_id,
|
||||||
|
|
@ -958,6 +997,7 @@ class XgridsK1CompatibilityService:
|
||||||
def stop(self) -> dict[str, Any]:
|
def stop(self) -> dict[str, Any]:
|
||||||
"""Deprecated capture-only shim; it never claims that K1 stopped scanning."""
|
"""Deprecated capture-only shim; it never claims that K1 stopped scanning."""
|
||||||
|
|
||||||
|
self.camera_preview.stop_current()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
acquisition = self._acquisition
|
acquisition = self._acquisition
|
||||||
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
|
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]:
|
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
|
||||||
self.runtime.update_scene_settings(
|
self.runtime.update_scene_settings(
|
||||||
RerunSceneSettings(
|
RerunSceneSettings(
|
||||||
|
|
@ -1034,6 +1102,22 @@ class XgridsK1CompatibilityService:
|
||||||
self._device_session_opened_at = _utc_now_iso()
|
self._device_session_opened_at = _utc_now_iso()
|
||||||
return self._device_id, self._device_session_id
|
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:
|
def _require_acquisition(self, acquisition_id: str | None) -> AcquisitionRecord:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
acquisition = self._acquisition
|
acquisition = self._acquisition
|
||||||
|
|
@ -1212,6 +1296,10 @@ class XgridsK1ServicePort(Protocol):
|
||||||
|
|
||||||
def stop(self) -> dict[str, Any]: ...
|
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]: ...
|
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1236,6 +1324,8 @@ class XgridsK1PluginFacade:
|
||||||
ACTION_STREAM_START_LIVE,
|
ACTION_STREAM_START_LIVE,
|
||||||
ACTION_STREAM_START_REPLAY,
|
ACTION_STREAM_START_REPLAY,
|
||||||
ACTION_STREAM_STOP,
|
ACTION_STREAM_STOP,
|
||||||
|
ACTION_CAMERA_PREVIEW_SELECT,
|
||||||
|
ACTION_CAMERA_PREVIEW_STOP,
|
||||||
ACTION_VIEWER_SETTINGS_UPDATE,
|
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -1309,6 +1399,18 @@ class XgridsK1PluginFacade:
|
||||||
if action_id == ACTION_STREAM_STOP:
|
if action_id == ACTION_STREAM_STOP:
|
||||||
EmptyRequest.model_validate(payload)
|
EmptyRequest.model_validate(payload)
|
||||||
return await asyncio.to_thread(self.service.stop)
|
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:
|
if action_id == ACTION_VIEWER_SETTINGS_UPDATE:
|
||||||
settings_request = ViewerSettingsRequest.model_validate(payload)
|
settings_request = ViewerSettingsRequest.model_validate(payload)
|
||||||
return await asyncio.to_thread(
|
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_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 {
|
return {
|
||||||
"schema_version": "missioncore.sensor-catalog/v1alpha2",
|
"schema_version": "missioncore.sensor-catalog/v1alpha2",
|
||||||
"revision": active_profile_id or "unprofiled-evidence-only",
|
"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,
|
"frame_id": None,
|
||||||
"coordinate_convention": None,
|
"coordinate_convention": None,
|
||||||
},
|
},
|
||||||
{
|
*camera_streams,
|
||||||
"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,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1497,6 +1646,12 @@ def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribu
|
||||||
adapter = XgridsK1PluginFacade(service)
|
adapter = XgridsK1PluginFacade(service)
|
||||||
return DevicePluginRuntimeContribution(
|
return DevicePluginRuntimeContribution(
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
legacy_routers=(build_xgrids_k1_legacy_router(adapter),),
|
legacy_routers=(
|
||||||
close=service.runtime.close,
|
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"
|
item for item in plugins if item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1"
|
||||||
)
|
)
|
||||||
assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2"
|
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"]["hostApiRange"] == "v1alpha2"
|
||||||
assert plugin["spec"]["compatibilityProfiles"] == [
|
assert plugin["spec"]["compatibilityProfiles"] == [
|
||||||
{
|
{
|
||||||
|
|
@ -113,11 +113,13 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
|
||||||
"stream.start-live",
|
"stream.start-live",
|
||||||
"stream.start-replay",
|
"stream.start-replay",
|
||||||
"stream.stop",
|
"stream.stop",
|
||||||
|
"camera.preview.select",
|
||||||
|
"camera.preview.stop",
|
||||||
"viewer.settings.update",
|
"viewer.settings.update",
|
||||||
} <= action_ids
|
} <= action_ids
|
||||||
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
|
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
|
||||||
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
|
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
|
||||||
"pluginVersion": "0.2.0",
|
"pluginVersion": "0.3.0",
|
||||||
"id": "xgrids.lixelkity-k1",
|
"id": "xgrids.lixelkity-k1",
|
||||||
"vendor": "XGRIDS",
|
"vendor": "XGRIDS",
|
||||||
"displayName": "XGRIDS LixelKity K1",
|
"displayName": "XGRIDS LixelKity K1",
|
||||||
|
|
@ -135,6 +137,7 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
|
||||||
},
|
},
|
||||||
{"id": "spatial.point-cloud.live", "label": "Облако точек"},
|
{"id": "spatial.point-cloud.live", "label": "Облако точек"},
|
||||||
{"id": "spatial.pose.live", "label": "Траектория"},
|
{"id": "spatial.pose.live", "label": "Траектория"},
|
||||||
|
{"id": "camera.preview.live", "label": "Видеокамеры"},
|
||||||
{"id": "evidence.raw-capture", "label": "Исходная запись"},
|
{"id": "evidence.raw-capture", "label": "Исходная запись"},
|
||||||
{"id": "evidence.replay", "label": "Повтор записи"},
|
{"id": "evidence.replay", "label": "Повтор записи"},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import socket
|
|
||||||
import struct
|
import struct
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
@ -14,7 +13,12 @@ from k1link.data_plane import DecodedDataPlaneView, NormalizationError
|
||||||
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
||||||
from k1link.protocol.normalizer import normalize_k1_message
|
from k1link.protocol.normalizer import normalize_k1_message
|
||||||
from k1link.viewer.messages import StreamMessage
|
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
|
from k1link.viewer.runtime import VisualizationRuntime
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -24,6 +28,7 @@ class FakeRecording:
|
||||||
self.times: list[tuple[str, dict[str, object]]] = []
|
self.times: list[tuple[str, dict[str, object]]] = []
|
||||||
self.blueprints: list[object] = []
|
self.blueprints: list[object] = []
|
||||||
self.disconnected = False
|
self.disconnected = False
|
||||||
|
self.flush_count = 0
|
||||||
|
|
||||||
def serve_grpc(self, **_: object) -> str:
|
def serve_grpc(self, **_: object) -> str:
|
||||||
return "rerun+http://127.0.0.1:9876/proxy"
|
return "rerun+http://127.0.0.1:9876/proxy"
|
||||||
|
|
@ -41,23 +46,52 @@ class FakeRecording:
|
||||||
self.disconnected = True
|
self.disconnected = True
|
||||||
|
|
||||||
def flush(self, **_: object) -> None:
|
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(
|
return StreamMessage(
|
||||||
sequence=sequence,
|
sequence=sequence,
|
||||||
topic=topic,
|
topic=topic,
|
||||||
payload=payload,
|
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,
|
received_monotonic_ns=None,
|
||||||
source="test",
|
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(
|
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(),
|
processing_started_monotonic_ns=time.monotonic_ns(),
|
||||||
)
|
)
|
||||||
assert envelope is not None
|
assert envelope is not None
|
||||||
|
|
@ -89,9 +123,58 @@ def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
|
||||||
"message_sequence",
|
"message_sequence",
|
||||||
"stream_time",
|
"stream_time",
|
||||||
}
|
}
|
||||||
|
assert recording.flush_count == 1
|
||||||
|
|
||||||
bridge.close()
|
bridge.close()
|
||||||
assert recording.disconnected is True
|
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:
|
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]]
|
assert custom.tolist() == [[16, 32, 48], [16, 32, 48]]
|
||||||
|
|
||||||
|
|
||||||
def test_real_grpc_server_releases_its_port() -> None:
|
def test_runtime_reuses_one_bridge_across_sequential_sessions(tmp_path: Path) -> None:
|
||||||
probe = socket.socket()
|
|
||||||
probe.bind(("127.0.0.1", 0))
|
|
||||||
port = int(probe.getsockname()[1])
|
|
||||||
probe.close()
|
|
||||||
|
|
||||||
bridge = RerunBridge(
|
|
||||||
grpc_port=port,
|
|
||||||
cors_allow_origin=("http://127.0.0.1:8000",),
|
|
||||||
)
|
|
||||||
assert bridge.grpc_url == f"rerun+http://127.0.0.1:{port}/proxy"
|
|
||||||
bridge.close()
|
|
||||||
|
|
||||||
deadline = time.monotonic() + 2.0
|
|
||||||
while True:
|
|
||||||
available = socket.socket()
|
|
||||||
try:
|
|
||||||
available.bind(("127.0.0.1", port))
|
|
||||||
break
|
|
||||||
except OSError:
|
|
||||||
if time.monotonic() >= deadline:
|
|
||||||
raise
|
|
||||||
time.sleep(0.02)
|
|
||||||
finally:
|
|
||||||
available.close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_exposes_rerun_url_and_stops_cleanly(tmp_path: Path) -> None:
|
|
||||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||||
point_topic = "RealtimePointcloud"
|
point_topic = "RealtimePointcloud"
|
||||||
pose_topic = "RealtimePath"
|
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 len(created) == 1
|
||||||
assert runtime.snapshot()["metrics"]["pcl_frames"] == 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()
|
runtime.close()
|
||||||
assert runtime.snapshot()["rerun_grpc_url"] is None
|
assert runtime.snapshot()["rerun_grpc_url"] is None
|
||||||
assert recording.disconnected is True
|
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:
|
def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) -> None:
|
||||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||||
topic = b"RealtimePointcloud"
|
topic = b"RealtimePointcloud"
|
||||||
|
|
|
||||||
|
|
@ -664,18 +664,22 @@ def test_provisioning_cannot_switch_device_during_active_acquisition(
|
||||||
assert called is False
|
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,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
service, _ = service_with_fake_runtime(tmp_path)
|
service, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
|
||||||
initial = service.state()
|
initial = service.state()
|
||||||
initial_camera = next(
|
initial_cameras = [
|
||||||
stream
|
stream
|
||||||
for stream in initial["sensor_catalog"]["streams"]
|
for stream in initial["sensor_catalog"]["streams"]
|
||||||
if stream["stream_id"] == "camera.preview.live"
|
if stream.get("semantic_channel_id") == "camera.preview.live"
|
||||||
)
|
]
|
||||||
assert initial_camera["availability"] == "unverified"
|
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(
|
state = service.prepare_acquisition(
|
||||||
PrepareAcquisitionRequest(
|
PrepareAcquisitionRequest(
|
||||||
|
|
@ -683,15 +687,21 @@ def test_sensor_catalog_exposes_observed_camera_only_after_profile_attestation(
|
||||||
compatibility_attestation=ATTESTATION,
|
compatibility_attestation=ATTESTATION,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
camera = next(
|
cameras = [
|
||||||
stream
|
stream
|
||||||
for stream in state["sensor_catalog"]["streams"]
|
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 len(cameras) == 2
|
||||||
assert camera["modality"] == "encoded-video"
|
assert all(camera["availability"] == "available" for camera in cameras)
|
||||||
assert camera["decode_status"] == "transport-observed-runtime-adapter-pending"
|
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["connection_verification"]["network_reachability"] == "unknown"
|
||||||
assert state["device_calibration"]["status"] == "unavailable"
|
assert state["device_calibration"]["status"] == "unavailable"
|
||||||
assert state["device_calibration"]["vehicle_extrinsics"] == ("host-domain-not-owned-by-plugin")
|
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