Compare commits
32
Commits
6b810952a4
...
c7843a3c7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7843a3c7e | ||
|
|
a3138f3d71 | ||
|
|
d01be34cac | ||
|
|
ac4a00c67d | ||
|
|
aee60f12b9 | ||
|
|
321cde70e8 | ||
|
|
986ea69610 | ||
|
|
72a2f37c03 | ||
|
|
4a24275ddc | ||
|
|
17a07355cf | ||
|
|
cd8a1045c4 | ||
|
|
3235ba7b1c | ||
|
|
9e6b5b403a | ||
|
|
9ef115d3b4 | ||
|
|
606cbbfae2 | ||
|
|
0ca7316a24 | ||
|
|
aff331082f | ||
|
|
52da9b75b7 | ||
|
|
be50144ca8 | ||
|
|
81ae4425cb | ||
|
|
8eaa3ab497 | ||
|
|
b7a51e26e6 | ||
|
|
b8fb4ebcba | ||
|
|
e38f2fb1da | ||
|
|
9df9f58ab8 | ||
|
|
aacc6dc43b | ||
|
|
13ed096f80 | ||
|
|
31313c3c96 | ||
|
|
7aa3ce55c0 | ||
|
|
c70ad345ea | ||
|
|
de19229895 | ||
|
|
b0d0bc8d7f |
@@ -383,8 +383,9 @@ telemetry and the stop action. Operator-manual acquisition still finalizes only
|
||||
local reception; plugin-commanded v0.5.0 acquisition uses the separately gated
|
||||
canonical K1 START/STOP dialogue.
|
||||
|
||||
Plugin v0.6.0 makes the local connection direction explicit. Bridge remains the
|
||||
default and accepted product path; Direct Connect sends the reviewed station
|
||||
Plugin v0.7.0 supervises the v0.6.0 local connection matrix with separate
|
||||
desired, configured and active modes plus exact DeviceInfo-backed Ready.
|
||||
Bridge remains the default and accepted product path; Direct Connect sends the reviewed station
|
||||
provisioning frame for an already-running controller hotspot. Quick Connect
|
||||
sends one separately reviewed AP-enable frame and can associate a prepared Mac
|
||||
through CoreWLAN. Its device activation and prepared-host association were
|
||||
|
||||
@@ -321,10 +321,12 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
|
||||
активным между сессиями; для его закрытия нужно остановить `k1link serve`.
|
||||
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
|
||||
очищается после успешного ответа и не сохраняется в URL/local storage.
|
||||
- Bridge и Direct Connect выполняют только отдельно рассмотренную provisioning-
|
||||
запись; Quick Connect не пишет в GATT и передаёт пароль короткоживущему
|
||||
CoreWLAN helper только через stdin. Случайные GATT writes и автоматические
|
||||
повторы запрещены.
|
||||
- Bridge и Direct Connect выполняют только отдельно рассмотренную 99-байтную
|
||||
provisioning-запись. Quick Connect выполняет отдельную рассмотренную
|
||||
100-байтную AP-enable запись и после AP-ready допускает ровно одну системную
|
||||
ассоциацию с заранее материализованным exact-firmware профилем. Пароль не
|
||||
попадает в браузер/API/argv; системный Wi-Fi Keychain, post-write prompt и
|
||||
автоматические повторы запрещены.
|
||||
- MQTT data live/replay остаётся subscribe-only. Команды изолированы в
|
||||
plugin-owned canonical control session и доступны только через отдельные
|
||||
operator-present UI checkpoints.
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
workspacesForRoot,
|
||||
type RootId,
|
||||
} from "./productModel";
|
||||
import { backendLabel, phaseLabel, phaseTone } from "./presentation";
|
||||
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
|
||||
import {
|
||||
defaultSceneSettings,
|
||||
type PointColorMode,
|
||||
@@ -595,8 +595,8 @@ export default function App() {
|
||||
setRecordedReplayLabel(null);
|
||||
setSourceUrl("");
|
||||
setSourceDraft("");
|
||||
}, []);
|
||||
|
||||
observationLayout.activateAutomaticDefaults();
|
||||
}, [observationLayout.activateAutomaticDefaults]);
|
||||
useEffect(() => {
|
||||
if (!["recordings", "lab-archive"].includes(activeDefinition?.kind ?? "")) {
|
||||
setReplayTransitioning(false);
|
||||
@@ -638,6 +638,8 @@ export default function App() {
|
||||
const contentActions = useApplicationPanelActions({
|
||||
definition: activeDefinition,
|
||||
refreshRuntime: runtime.refresh,
|
||||
resetConnectionScenario: runtime.resetConnectionScenario,
|
||||
connectionScenarioResetting: runtime.pendingAction === "mode",
|
||||
saveWorkspaceLayout,
|
||||
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
|
||||
systemUtilityActions: computeContourSettings.utilityActions,
|
||||
@@ -728,10 +730,7 @@ export default function App() {
|
||||
{
|
||||
id: "local-contour",
|
||||
label: "Локальный контур",
|
||||
description:
|
||||
runtime.state?.activeDevice?.endpointLabel ||
|
||||
selection?.model.displayName ||
|
||||
"Модель не выбрана",
|
||||
description: selection ? "Подключение" : "Модель не выбрана",
|
||||
icon: <Icon name="network" />,
|
||||
active:
|
||||
runtime.backendStatus !== "offline"
|
||||
@@ -777,12 +776,12 @@ export default function App() {
|
||||
headerTools={
|
||||
activeDefinition.kind === "device" ? (
|
||||
<StatusBadge tone={phaseTone(runtime.state?.phase)}>
|
||||
{phaseLabel(runtime.state?.phase)}
|
||||
{localConnectionPhaseLabel(runtime.state?.phase)}
|
||||
</StatusBadge>
|
||||
) : activeDefinition.kind === "spatial" ? (
|
||||
<div className="observation-header-tools">
|
||||
<StatusBadge tone={runtime.state?.sourceMode === "live" ? "success" : "neutral"}>
|
||||
{runtime.state?.sourceMode === "live" ? "Эфир" : "Ожидание эфира"}
|
||||
<StatusBadge tone={["live", "replay"].includes(runtime.state?.sourceMode ?? "") ? "success" : "neutral"}>
|
||||
{runtime.state?.sourceMode === "live" ? "Эфир" : runtime.state?.sourceMode === "replay" ? "Повтор записи" : "Ожидание эфира"}
|
||||
</StatusBadge>
|
||||
{layoutSaveNotice || workspaceLayoutProfile.error ? (
|
||||
<span
|
||||
|
||||
@@ -2,6 +2,14 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationSourceDelivery } from "../core/runtime/contracts";
|
||||
import {
|
||||
cameraBrowserTransportIdentity,
|
||||
cameraTransportCallbackIsCurrent,
|
||||
initialCameraPlaybackRecoveryState,
|
||||
reduceCameraPlaybackRecovery,
|
||||
type CameraPlaybackRecoveryEvent,
|
||||
} from "../core/observation/liveCameraRecovery";
|
||||
import { subscribeToLiveViewerBuildFence } from "../core/observation/liveViewerDiagnostics";
|
||||
|
||||
type PlayerStatus = "connecting" | "buffering" | "playing" | "error";
|
||||
|
||||
@@ -10,7 +18,92 @@ export interface CameraLeaseRetryBudget {
|
||||
count: number;
|
||||
}
|
||||
|
||||
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000] as const;
|
||||
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const;
|
||||
export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000;
|
||||
export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000;
|
||||
// The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a
|
||||
// main-thread stall. Keep one bounded append margin above that complete batch;
|
||||
// crossing either limit replaces the MSE epoch instead of dropping fragments.
|
||||
export const CAMERA_PENDING_QUEUE_MAX_BYTES = 12 * 1024 * 1024;
|
||||
export const CAMERA_PENDING_QUEUE_MAX_SEGMENTS = 96;
|
||||
|
||||
export type CameraStartupWatchdogStage = "first-media" | "first-playable-frame";
|
||||
|
||||
export interface CameraStartupWatchdog {
|
||||
armFirstMedia: () => void;
|
||||
markMediaReceived: () => void;
|
||||
markPlaying: () => void;
|
||||
clear: () => void;
|
||||
pendingStage: () => CameraStartupWatchdogStage | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervise only the disposable browser transport. One fixed first-media
|
||||
* deadline covers both an MSE that never opens and an open-but-silent socket.
|
||||
* The first non-empty fragment then starts a separate first-playable-frame
|
||||
* deadline; later fragments deliberately do not extend it.
|
||||
*/
|
||||
export function createCameraStartupWatchdog({
|
||||
schedule,
|
||||
cancel,
|
||||
onTimeout,
|
||||
firstMediaTimeoutMs = CAMERA_FIRST_MEDIA_TIMEOUT_MS,
|
||||
firstPlayableFrameTimeoutMs = CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS,
|
||||
}: {
|
||||
schedule: (callback: () => void, timeoutMs: number) => number;
|
||||
cancel: (handle: number) => void;
|
||||
onTimeout: (stage: CameraStartupWatchdogStage) => void;
|
||||
firstMediaTimeoutMs?: number;
|
||||
firstPlayableFrameTimeoutMs?: number;
|
||||
}): CameraStartupWatchdog {
|
||||
let timer: number | undefined;
|
||||
let stage: CameraStartupWatchdogStage | null = null;
|
||||
let mediaReceived = false;
|
||||
let playing = false;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer !== undefined) cancel(timer);
|
||||
timer = undefined;
|
||||
stage = null;
|
||||
};
|
||||
const arm = (nextStage: CameraStartupWatchdogStage, timeoutMs: number) => {
|
||||
clearTimer();
|
||||
stage = nextStage;
|
||||
timer = schedule(() => {
|
||||
if (stage !== nextStage || playing) return;
|
||||
timer = undefined;
|
||||
stage = null;
|
||||
onTimeout(nextStage);
|
||||
}, timeoutMs);
|
||||
};
|
||||
|
||||
return {
|
||||
armFirstMedia() {
|
||||
if (mediaReceived || playing || stage !== null) return;
|
||||
arm("first-media", firstMediaTimeoutMs);
|
||||
},
|
||||
markMediaReceived() {
|
||||
if (mediaReceived || playing) return;
|
||||
mediaReceived = true;
|
||||
arm("first-playable-frame", firstPlayableFrameTimeoutMs);
|
||||
},
|
||||
markPlaying() {
|
||||
if (playing) return;
|
||||
playing = true;
|
||||
clearTimer();
|
||||
},
|
||||
clear: clearTimer,
|
||||
pendingStage: () => stage,
|
||||
};
|
||||
}
|
||||
|
||||
export function cameraStartupWatchdogRecoveryMessage(
|
||||
stage: CameraStartupWatchdogStage,
|
||||
): string {
|
||||
return stage === "first-media"
|
||||
? "Камера подключена, но не передаёт медиаданные; восстанавливаем browser-preview."
|
||||
: "Медиаданные поступают, но первый кадр не воспроизводится; пересоздаём decoder.";
|
||||
}
|
||||
|
||||
export function resetCameraLeaseRetryBudget(deliveryId: string): CameraLeaseRetryBudget {
|
||||
return { deliveryId, count: 0 };
|
||||
@@ -19,15 +112,60 @@ export function resetCameraLeaseRetryBudget(deliveryId: string): CameraLeaseRetr
|
||||
export function consumeCameraLeaseRetry(
|
||||
current: CameraLeaseRetryBudget,
|
||||
deliveryId: string,
|
||||
): { budget: CameraLeaseRetryBudget; delay: number | null } {
|
||||
): { budget: CameraLeaseRetryBudget; delay: number } {
|
||||
const count = current.deliveryId === deliveryId ? current.count : 0;
|
||||
const delay = CAMERA_LEASE_RETRY_DELAYS[count] ?? null;
|
||||
const delayIndex = Math.min(count, CAMERA_LEASE_RETRY_DELAYS.length - 1);
|
||||
const delay = CAMERA_LEASE_RETRY_DELAYS[delayIndex];
|
||||
return {
|
||||
budget: { deliveryId, count: delay === null ? count : count + 1 },
|
||||
budget: {
|
||||
deliveryId,
|
||||
count: Math.min(count + 1, CAMERA_LEASE_RETRY_DELAYS.length),
|
||||
},
|
||||
delay,
|
||||
};
|
||||
}
|
||||
|
||||
export function cameraTransportRecoveryIsCurrent(
|
||||
activeAuthorityIdentity: string | null,
|
||||
expectedAuthorityIdentity: string | null,
|
||||
activeEpoch: number,
|
||||
expectedEpoch: number,
|
||||
disposed: boolean,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
expectedAuthorityIdentity
|
||||
&& activeAuthorityIdentity === expectedAuthorityIdentity
|
||||
&& cameraTransportCallbackIsCurrent(activeEpoch, expectedEpoch, disposed),
|
||||
);
|
||||
}
|
||||
|
||||
export function cameraTransportCanOpen(uiBuildStale: boolean): boolean {
|
||||
return !uiBuildStale;
|
||||
}
|
||||
|
||||
export function cameraPendingQueueCanAccept(
|
||||
queuedBytes: number,
|
||||
queuedSegments: number,
|
||||
incomingBytes: number,
|
||||
): boolean {
|
||||
return incomingBytes > 0
|
||||
&& queuedBytes + incomingBytes <= CAMERA_PENDING_QUEUE_MAX_BYTES
|
||||
&& queuedSegments + 1 <= CAMERA_PENDING_QUEUE_MAX_SEGMENTS;
|
||||
}
|
||||
|
||||
export function cameraTransportCloseRecoveryMessage(code: number): string {
|
||||
if (code === 4_008) {
|
||||
return "Browser-reader отстал от эфира; восстанавливаем текущую камеру.";
|
||||
}
|
||||
if (code === 1_008) {
|
||||
return "Camera adapter освобождает прежний browser-reader; переподключаемся.";
|
||||
}
|
||||
if (code === 1_000) {
|
||||
return "Browser-preview завершился; восстанавливаем текущую камеру.";
|
||||
}
|
||||
return "Browser-preview прерван; восстанавливаем текущую камеру.";
|
||||
}
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const url = new URL(path, window.location.href);
|
||||
if (url.protocol === "http:") url.protocol = "ws:";
|
||||
@@ -41,61 +179,149 @@ function websocketUrl(path: string): string {
|
||||
export function MseFmp4WebSocketPlayer({
|
||||
delivery,
|
||||
label,
|
||||
recoveryAuthorityIdentity,
|
||||
}: {
|
||||
delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" };
|
||||
label: string;
|
||||
recoveryAuthorityIdentity: string | null;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const leaseRetryRef = useRef(resetCameraLeaseRetryBudget(delivery.id));
|
||||
const activeAuthorityRef = useRef(recoveryAuthorityIdentity);
|
||||
const recoveryPendingAuthorityRef = useRef<string | null>(null);
|
||||
const transportEpochRef = useRef(0);
|
||||
const transportAuthorityRef = useRef(recoveryAuthorityIdentity);
|
||||
const activeTransportDisposeRef = useRef<(() => void) | null>(null);
|
||||
const uiBuildStaleRef = useRef(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [uiBuildStale, setUiBuildStale] = useState(false);
|
||||
const [status, setStatus] = useState<PlayerStatus>("connecting");
|
||||
const [message, setMessage] = useState("Подключение к локальному видеопотоку");
|
||||
activeAuthorityRef.current = recoveryAuthorityIdentity;
|
||||
const transportIdentity = cameraBrowserTransportIdentity(
|
||||
delivery,
|
||||
recoveryAuthorityIdentity,
|
||||
);
|
||||
|
||||
useEffect(() => subscribeToLiveViewerBuildFence(() => {
|
||||
// Setting this local fence tears down the effect-owned WebSocket and MSE
|
||||
// buffer. It intentionally never invokes a plugin or device command.
|
||||
uiBuildStaleRef.current = true;
|
||||
activeTransportDisposeRef.current?.();
|
||||
setUiBuildStale(true);
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (uiBuildStale || !cameraTransportCanOpen(uiBuildStaleRef.current)) {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
return;
|
||||
}
|
||||
|
||||
if (transportAuthorityRef.current !== recoveryAuthorityIdentity) {
|
||||
transportAuthorityRef.current = recoveryAuthorityIdentity;
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
const transportEpoch = transportEpochRef.current + 1;
|
||||
transportEpochRef.current = transportEpoch;
|
||||
const transportIsCurrent = () => cameraTransportCallbackIsCurrent(
|
||||
transportEpochRef.current,
|
||||
transportEpoch,
|
||||
disposed || uiBuildStaleRef.current,
|
||||
);
|
||||
let socket: WebSocket | null = null;
|
||||
let sourceBuffer: SourceBuffer | null = null;
|
||||
let onBufferUpdateEnd: (() => void) | null = null;
|
||||
let onBufferError: (() => void) | null = null;
|
||||
let objectUrl = "";
|
||||
let retryTimer: number | undefined;
|
||||
let receivedMedia = false;
|
||||
let failed = false;
|
||||
let startupWatchdog: CameraStartupWatchdog | null = null;
|
||||
const recovering = Boolean(
|
||||
activeAuthorityRef.current
|
||||
&& recoveryPendingAuthorityRef.current === activeAuthorityRef.current,
|
||||
);
|
||||
const queue: ArrayBuffer[] = [];
|
||||
let queuedBytes = 0;
|
||||
|
||||
const fail = (copy: string) => {
|
||||
if (disposed || failed) return;
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
startupWatchdog?.clear();
|
||||
failed = true;
|
||||
recoveryPendingAuthorityRef.current = null;
|
||||
setStatus("error");
|
||||
setMessage(copy);
|
||||
try {
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) {
|
||||
socket.close(1011, "Live video buffer reset");
|
||||
socket.close(4_000, "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 ещё занят предыдущим окном. Подключитесь повторно.");
|
||||
const retryTransport = (copy: string) => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
if (!cameraTransportRecoveryIsCurrent(
|
||||
activeAuthorityRef.current,
|
||||
recoveryAuthorityIdentity,
|
||||
transportEpochRef.current,
|
||||
transportEpoch,
|
||||
disposed || uiBuildStaleRef.current,
|
||||
)) {
|
||||
fail(copy);
|
||||
return;
|
||||
}
|
||||
startupWatchdog?.clear();
|
||||
failed = true;
|
||||
queue.length = 0;
|
||||
queuedBytes = 0;
|
||||
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
|
||||
leaseRetryRef.current = retry.budget;
|
||||
recoveryPendingAuthorityRef.current = recoveryAuthorityIdentity;
|
||||
setStatus("connecting");
|
||||
setMessage("Освобождение предыдущего окна камеры");
|
||||
setMessage(copy);
|
||||
try {
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) {
|
||||
socket.close(4_001, "Live video transport recovery");
|
||||
}
|
||||
} catch {
|
||||
// The replacement effect still fences and releases this transport.
|
||||
}
|
||||
retryTimer = window.setTimeout(() => {
|
||||
if (!disposed) setAttempt((value) => value + 1);
|
||||
if (cameraTransportRecoveryIsCurrent(
|
||||
activeAuthorityRef.current,
|
||||
recoveryAuthorityIdentity,
|
||||
transportEpochRef.current,
|
||||
transportEpoch,
|
||||
disposed || uiBuildStaleRef.current,
|
||||
)) {
|
||||
setAttempt((value) => value + 1);
|
||||
}
|
||||
}, retry.delay);
|
||||
};
|
||||
|
||||
if (recoveryAuthorityIdentity) {
|
||||
startupWatchdog = createCameraStartupWatchdog({
|
||||
schedule: (callback, timeoutMs) => window.setTimeout(callback, timeoutMs),
|
||||
cancel: (handle) => window.clearTimeout(handle),
|
||||
onTimeout: (stage) => {
|
||||
retryTransport(cameraStartupWatchdogRecoveryMessage(stage));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const onPlaying = () => {
|
||||
if (disposed) return;
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
startupWatchdog?.markPlaying();
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
recoveryPendingAuthorityRef.current = null;
|
||||
setStatus("playing");
|
||||
setMessage("");
|
||||
};
|
||||
@@ -103,25 +329,32 @@ export function MseFmp4WebSocketPlayer({
|
||||
video.addEventListener("playing", onPlaying);
|
||||
|
||||
const appendNext = () => {
|
||||
if (disposed || !sourceBuffer || sourceBuffer.updating || queue.length === 0) return;
|
||||
if (
|
||||
!transportIsCurrent()
|
||||
|| failed
|
||||
|| !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-буфер переполнен и сброшен, чтобы не накапливать задержку."
|
||||
: "Не удалось добавить видеосегмент. Повторите подключение.");
|
||||
retryTransport(error instanceof DOMException && error.name === "QuotaExceededError"
|
||||
? "Live-буфер переполнен; восстанавливаем канал без накопленной задержки."
|
||||
: "Не удалось добавить видеосегмент; восстанавливаем browser-preview.");
|
||||
}
|
||||
};
|
||||
|
||||
const enqueue = (chunk: ArrayBuffer) => {
|
||||
if (disposed || chunk.byteLength === 0) return;
|
||||
if (!transportIsCurrent() || failed || chunk.byteLength === 0) return;
|
||||
startupWatchdog?.markMediaReceived();
|
||||
// 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-буфер сброшен.");
|
||||
if (!cameraPendingQueueCanAccept(queuedBytes, queue.length, chunk.byteLength)) {
|
||||
retryTransport("Видеодекодер отстал от эфира; восстанавливаем live-буфер.");
|
||||
return;
|
||||
}
|
||||
queue.push(chunk);
|
||||
@@ -142,13 +375,16 @@ export function MseFmp4WebSocketPlayer({
|
||||
}
|
||||
|
||||
setStatus("connecting");
|
||||
setMessage("Подключение к локальному видеопотоку");
|
||||
setMessage(recovering
|
||||
? "Восстановление камеры после разрыва браузерного канала"
|
||||
: "Подключение к локальному видеопотоку");
|
||||
const mediaSource = new MediaSource();
|
||||
objectUrl = URL.createObjectURL(mediaSource);
|
||||
video.src = objectUrl;
|
||||
startupWatchdog?.armFirstMedia();
|
||||
|
||||
const onSourceOpen = () => {
|
||||
if (disposed) return;
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
try {
|
||||
sourceBuffer = mediaSource.addSourceBuffer(mediaType);
|
||||
} catch {
|
||||
@@ -156,8 +392,8 @@ export function MseFmp4WebSocketPlayer({
|
||||
return;
|
||||
}
|
||||
|
||||
sourceBuffer.addEventListener("updateend", () => {
|
||||
if (disposed || !sourceBuffer) return;
|
||||
onBufferUpdateEnd = () => {
|
||||
if (!transportIsCurrent() || failed || !sourceBuffer) return;
|
||||
const buffered = sourceBuffer.buffered;
|
||||
if (buffered.length > 0) {
|
||||
const end = buffered.end(buffered.length - 1);
|
||||
@@ -166,7 +402,9 @@ export function MseFmp4WebSocketPlayer({
|
||||
if (!receivedMedia) {
|
||||
receivedMedia = true;
|
||||
setStatus("buffering");
|
||||
setMessage("Запуск первого декодированного кадра");
|
||||
setMessage(recovering
|
||||
? "Запуск первого кадра восстановленной камеры"
|
||||
: "Запуск первого декодированного кадра");
|
||||
void video.play().catch(() => undefined);
|
||||
}
|
||||
const removeBefore = end - 3;
|
||||
@@ -180,10 +418,12 @@ export function MseFmp4WebSocketPlayer({
|
||||
}
|
||||
}
|
||||
appendNext();
|
||||
});
|
||||
sourceBuffer.addEventListener("error", () => {
|
||||
fail("MSE сообщил об ошибке декодирования видеосегмента.");
|
||||
});
|
||||
};
|
||||
onBufferError = () => {
|
||||
retryTransport("MSE сбросил видеосегмент; восстанавливаем decoder.");
|
||||
};
|
||||
sourceBuffer.addEventListener("updateend", onBufferUpdateEnd);
|
||||
sourceBuffer.addEventListener("error", onBufferError);
|
||||
|
||||
try {
|
||||
socket = new WebSocket(websocketUrl(delivery.url));
|
||||
@@ -193,41 +433,49 @@ export function MseFmp4WebSocketPlayer({
|
||||
}
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.addEventListener("open", () => {
|
||||
if (disposed) return;
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
startupWatchdog?.armFirstMedia();
|
||||
setStatus("buffering");
|
||||
setMessage("Ожидание первого видеокадра");
|
||||
setMessage(recovering
|
||||
? "Ожидание первого кадра после восстановления"
|
||||
: "Ожидание первого видеокадра");
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
enqueue(event.data);
|
||||
} else if (event.data instanceof Blob) {
|
||||
void event.data.arrayBuffer().then(enqueue).catch(() => {
|
||||
fail("Получен повреждённый видеосегмент.");
|
||||
retryTransport("Получен повреждённый видеосегмент; восстанавливаем канал.");
|
||||
});
|
||||
}
|
||||
});
|
||||
socket.addEventListener("error", () => {
|
||||
fail("Соединение с локальным video adapter потеряно.");
|
||||
retryTransport("Связь с локальным video adapter потеряна; переподключаемся.");
|
||||
});
|
||||
socket.addEventListener("close", (event) => {
|
||||
if (!disposed && !failed && event.code === 1008) {
|
||||
retryLease();
|
||||
} else if (!disposed) {
|
||||
fail(event.code === 1000
|
||||
? "Видеопоток завершён. Можно подключиться повторно."
|
||||
: "Видеопоток прерван. Проверьте устройство и повторите подключение.");
|
||||
}
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
retryTransport(cameraTransportCloseRecoveryMessage(event.code));
|
||||
});
|
||||
};
|
||||
|
||||
mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
|
||||
|
||||
return () => {
|
||||
const disposeTransport = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
startupWatchdog?.clear();
|
||||
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
||||
queue.length = 0;
|
||||
socket?.close(1000, "Источник скрыт оператором");
|
||||
socket?.close(1000, "Browser preview transport replaced or hidden");
|
||||
video.removeEventListener("playing", onPlaying);
|
||||
mediaSource.removeEventListener("sourceopen", onSourceOpen);
|
||||
if (sourceBuffer && onBufferUpdateEnd) {
|
||||
sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd);
|
||||
}
|
||||
if (sourceBuffer && onBufferError) {
|
||||
sourceBuffer.removeEventListener("error", onBufferError);
|
||||
}
|
||||
try {
|
||||
if (sourceBuffer?.updating) sourceBuffer.abort();
|
||||
} catch {
|
||||
@@ -243,7 +491,69 @@ export function MseFmp4WebSocketPlayer({
|
||||
video.load();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [attempt, delivery.id, delivery.mediaType, delivery.url]);
|
||||
activeTransportDisposeRef.current = disposeTransport;
|
||||
return () => {
|
||||
if (activeTransportDisposeRef.current === disposeTransport) {
|
||||
activeTransportDisposeRef.current = null;
|
||||
}
|
||||
disposeTransport();
|
||||
};
|
||||
}, [attempt, recoveryAuthorityIdentity, transportIdentity, uiBuildStale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!recoveryAuthorityIdentity
|
||||
|| uiBuildStale
|
||||
|| !cameraTransportCanOpen(uiBuildStaleRef.current)
|
||||
) return;
|
||||
|
||||
let recovery = initialCameraPlaybackRecoveryState(
|
||||
recoveryAuthorityIdentity,
|
||||
Date.now(),
|
||||
);
|
||||
const dispatchRecovery = (event: CameraPlaybackRecoveryEvent) => {
|
||||
if (uiBuildStaleRef.current) return;
|
||||
const decision = reduceCameraPlaybackRecovery(recovery, event, {
|
||||
activeAuthorityIdentity: activeAuthorityRef.current,
|
||||
now: Date.now(),
|
||||
documentVisible: document.visibilityState === "visible",
|
||||
networkOnline: navigator.onLine !== false,
|
||||
});
|
||||
recovery = decision.state;
|
||||
if (!decision.reopen) return;
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
recoveryPendingAuthorityRef.current = recoveryAuthorityIdentity;
|
||||
setStatus("connecting");
|
||||
setMessage("Восстановление камеры после разрыва браузерного канала");
|
||||
// The transport effect cleans up the old WebSocket and MSE object before
|
||||
// opening their replacement. No plugin action or device command occurs.
|
||||
setAttempt((value) => value + 1);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
dispatchRecovery({
|
||||
type: document.visibilityState === "hidden"
|
||||
? "document-hidden"
|
||||
: "document-visible",
|
||||
});
|
||||
};
|
||||
const onOnline = () => dispatchRecovery({ type: "network-online" });
|
||||
const onPageShow = (event: PageTransitionEvent) => {
|
||||
dispatchRecovery({ type: "page-restore", persisted: event.persisted });
|
||||
};
|
||||
const heartbeat = window.setInterval(() => {
|
||||
dispatchRecovery({ type: "heartbeat" });
|
||||
}, 1_000);
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("online", onOnline);
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
return () => {
|
||||
window.clearInterval(heartbeat);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("online", onOnline);
|
||||
window.removeEventListener("pageshow", onPageShow);
|
||||
};
|
||||
}, [delivery.id, recoveryAuthorityIdentity, uiBuildStale]);
|
||||
|
||||
return (
|
||||
<div className="mse-fmp4-player" data-status={status}>
|
||||
@@ -265,6 +575,7 @@ export function MseFmp4WebSocketPlayer({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
recoveryPendingAuthorityRef.current = null;
|
||||
setAttempt((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
|
||||
|
||||
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
||||
"point-cloud": "globe",
|
||||
@@ -63,7 +64,13 @@ export function ObservationMedia({
|
||||
source.delivery?.kind === "mse-fmp4-websocket" &&
|
||||
source.modality === "video"
|
||||
) {
|
||||
return <MseFmp4WebSocketPlayer delivery={source.delivery} label={source.label} />;
|
||||
return (
|
||||
<MseFmp4WebSocketPlayer
|
||||
delivery={source.delivery}
|
||||
label={source.label}
|
||||
recoveryAuthorityIdentity={liveCameraPlaybackAuthorityIdentity(source)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Icon } from "@nodedc/ui-react";
|
||||
import { Button, Icon, Select } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
||||
|
||||
@@ -14,6 +14,9 @@ export function ObservationTimeline({
|
||||
onSeek,
|
||||
onPlayingChange,
|
||||
onJumpToEnd,
|
||||
showJumpToEnd = true,
|
||||
playbackRate,
|
||||
onPlaybackRateChange,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
@@ -30,6 +33,9 @@ export function ObservationTimeline({
|
||||
onSeek?: (timeNs: number) => void;
|
||||
onPlayingChange?: (playing: boolean) => void;
|
||||
onJumpToEnd?: () => void;
|
||||
showJumpToEnd?: boolean;
|
||||
playbackRate?: number;
|
||||
onPlaybackRateChange?: (rate: number) => void;
|
||||
accumulationSeconds?: number;
|
||||
onAccumulationChange?: (value: number) => void;
|
||||
onAccumulationCommit?: () => void;
|
||||
@@ -102,6 +108,20 @@ export function ObservationTimeline({
|
||||
>
|
||||
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||
</Button>
|
||||
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
|
||||
<Select
|
||||
label="Скорость воспроизведения"
|
||||
value={String(playbackRate)}
|
||||
options={[
|
||||
{ value: "0.5", label: "0,5×" },
|
||||
{ value: "1", label: "1×" },
|
||||
{ value: "2", label: "2×" },
|
||||
]}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => onPlaybackRateChange(Number(value))}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
@@ -126,6 +146,7 @@ export function ObservationTimeline({
|
||||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||||
</small>
|
||||
</div>
|
||||
{showJumpToEnd ? (
|
||||
<button
|
||||
type="button"
|
||||
className="observation-timeline__follow"
|
||||
@@ -135,6 +156,7 @@ export function ObservationTimeline({
|
||||
>
|
||||
{buffered ? "К КОНЦУ" : "ЭФИР"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
export interface RecordedObservationPlayback {
|
||||
currentSeconds: number;
|
||||
playing: boolean;
|
||||
rate?: number;
|
||||
}
|
||||
|
||||
export interface RecordedMediaArchive {
|
||||
@@ -290,6 +291,9 @@ export function RecordedFmp4Player({
|
||||
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
||||
const [bufferRevision, setBufferRevision] = useState(0);
|
||||
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
||||
const playbackRate = playback?.rate && Number.isFinite(playback.rate)
|
||||
? Math.min(4, Math.max(0.25, playback.rate))
|
||||
: 1;
|
||||
const epoch = useMemo(
|
||||
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
||||
[archive?.manifest.epochs, currentSeconds],
|
||||
@@ -460,12 +464,21 @@ export function RecordedFmp4Player({
|
||||
return;
|
||||
}
|
||||
}
|
||||
video.playbackRate = playbackRate;
|
||||
if (playback?.playing) {
|
||||
void video.play().catch(() => undefined);
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
|
||||
}, [
|
||||
archive?.byteLength,
|
||||
bufferRevision,
|
||||
currentSeconds,
|
||||
epoch,
|
||||
playback?.playing,
|
||||
playbackRate,
|
||||
visualState,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -475,6 +488,7 @@ export function RecordedFmp4Player({
|
||||
onPlaybackChangeRef.current?.({
|
||||
currentSeconds: epoch.timelineStartSeconds + video.currentTime,
|
||||
playing: !video.paused && !video.ended,
|
||||
rate: video.playbackRate,
|
||||
});
|
||||
};
|
||||
const scheduleVideoFrame = () => {
|
||||
|
||||
@@ -2,14 +2,22 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import {
|
||||
advanceLiveReceiverOpenWatchdog,
|
||||
advanceLiveReceiverWatchdog,
|
||||
initialLiveReceiverOpenWatchdogState,
|
||||
initialLiveReceiverRecoveryState,
|
||||
initialLiveReceiverWatchdogState,
|
||||
LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
||||
LIVE_RECEIVER_OPEN_MAX_AGE_MS,
|
||||
requestLiveReceiverRecovery,
|
||||
} from "../core/observation/liveReceiverWatchdog";
|
||||
import { postLiveViewerDiagnostic } from "../core/observation/liveViewerDiagnostics";
|
||||
import type { LiveViewerFailureStage } from "../core/observation/liveViewerDiagnostics";
|
||||
import {
|
||||
createLiveViewerDiagnosticLifecycle,
|
||||
createLiveViewerInstanceId,
|
||||
createLiveViewerLineage,
|
||||
subscribeToLiveViewerBuildFence,
|
||||
type LiveViewerFailureStage,
|
||||
} from "../core/observation/liveViewerDiagnostics";
|
||||
import {
|
||||
fetchPerceptionPreparationStatus,
|
||||
perceptionPreparationMessage,
|
||||
@@ -81,6 +89,7 @@ export interface RerunViewportProps {
|
||||
followLive?: boolean;
|
||||
liveActivitySequence?: number | null;
|
||||
liveStreamId?: string | null;
|
||||
liveRecoveryAuthorityIdentity?: string | null;
|
||||
autoplayWhenReady?: boolean;
|
||||
presentationGate?: RecordedAdmissionPhase;
|
||||
expectedTimelineStartSeconds?: number;
|
||||
@@ -197,6 +206,51 @@ export function createRecordedOpenWatchdog<T>({
|
||||
};
|
||||
}
|
||||
|
||||
export function createReentrantViewerDisposer(
|
||||
cleanupOnce: () => void,
|
||||
releaseNativeViewer: () => void,
|
||||
): () => void {
|
||||
let cleanupComplete = false;
|
||||
return () => {
|
||||
try {
|
||||
if (!cleanupComplete) {
|
||||
cleanupComplete = true;
|
||||
cleanupOnce();
|
||||
}
|
||||
} finally {
|
||||
// `viewer.start()` can resolve after an earlier pre-ready stop. Reapply
|
||||
// native release on every disposal boundary so that a stale viewer can
|
||||
// never reopen after React and diagnostics have already unmounted it.
|
||||
releaseNativeViewer();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface ActiveLiveViewerOwner {
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
let activeLiveViewerOwner: ActiveLiveViewerOwner | null = null;
|
||||
|
||||
/**
|
||||
* Own exactly one native live receiver per application document.
|
||||
*
|
||||
* React route/StrictMode transitions can overlap two mounted workspaces for a
|
||||
* render turn. Rerun keeps each native gRPC receiver alive independently, so
|
||||
* the overlap used to consume the bounded live replay slots and leave the
|
||||
* operator's visible canvas black. Claiming the next owner synchronously
|
||||
* retires the previous native receiver before the next one starts.
|
||||
*/
|
||||
export function claimExclusiveLiveViewer(release: () => void): () => void {
|
||||
const owner = { release };
|
||||
const previous = activeLiveViewerOwner;
|
||||
activeLiveViewerOwner = owner;
|
||||
previous?.release();
|
||||
return () => {
|
||||
if (activeLiveViewerOwner === owner) activeLiveViewerOwner = null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecordedPlaybackBufferState {
|
||||
bufferedEndNs: number | null;
|
||||
expectedStartNs: number | null;
|
||||
@@ -221,6 +275,55 @@ export function isUsableRecordedPlaybackRange(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A live receiver is presentable only after the exact browser store exposes
|
||||
* real timeline data that the backend has also confirmed publishing.
|
||||
* `WebViewer.start()` and a non-null active recording id are transport setup,
|
||||
* not evidence that the spatial scene can render.
|
||||
*/
|
||||
export function isLiveRerunPresentationReady(
|
||||
viewerStarted: boolean,
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
backendActivitySequence: number | null,
|
||||
): boolean {
|
||||
return viewerStarted &&
|
||||
Number.isSafeInteger(backendActivitySequence) &&
|
||||
(backendActivitySequence ?? 0) > 0 &&
|
||||
isUsableRecordedPlaybackRange(rangeNs);
|
||||
}
|
||||
|
||||
/**
|
||||
* `recording_open` can arrive before Rerun has registered the live timeline.
|
||||
* Selecting it at that point is a silent no-op, so keep retrying only until
|
||||
* the exact live timeline is both available and active.
|
||||
*/
|
||||
export function liveTimelineNeedsSynchronization(
|
||||
followLive: boolean,
|
||||
activeTimeline: string | null | undefined,
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
): boolean {
|
||||
return followLive &&
|
||||
isUsableRecordedPlaybackRange(rangeNs) &&
|
||||
activeTimeline !== "stream_time";
|
||||
}
|
||||
|
||||
/**
|
||||
* Key the native receiver to its data-plane binding. Recovery authority is a
|
||||
* retry fence projected from changing supervisor snapshots; it must not tear
|
||||
* down a healthy WebViewer while this acquisition and URL remain unchanged.
|
||||
*/
|
||||
export function liveRerunReceiverBindingIdentity(
|
||||
sourceUrl: string,
|
||||
liveStreamId: string | null,
|
||||
followLive: boolean,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
followLive ? "live" : "recorded",
|
||||
sourceUrl.trim(),
|
||||
followLive ? liveStreamId?.trim() ?? "" : "",
|
||||
]);
|
||||
}
|
||||
|
||||
/** Describe progressive archive availability without gating first rendering. */
|
||||
export function recordedPlaybackBufferState(
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
@@ -497,6 +600,12 @@ export function rerunViewerInitialSource(
|
||||
return resolvedSourceUrl;
|
||||
}
|
||||
|
||||
export function rerunViewerOpenOptions(
|
||||
followLive: boolean,
|
||||
): { follow_if_http: true } | null {
|
||||
return followLive ? { follow_if_http: true } : null;
|
||||
}
|
||||
|
||||
export function resolveRecordedBlueprintUrl(sourceUrl: string, origin: string): string | null {
|
||||
const normalized = sourceUrl.trim();
|
||||
if (!RECORDED_RRD_PATH.test(normalized)) return null;
|
||||
@@ -813,6 +922,7 @@ export function RerunViewport({
|
||||
followLive = false,
|
||||
liveActivitySequence = null,
|
||||
liveStreamId = null,
|
||||
liveRecoveryAuthorityIdentity = null,
|
||||
autoplayWhenReady = false,
|
||||
presentationGate = "ready",
|
||||
expectedTimelineStartSeconds,
|
||||
@@ -845,7 +955,14 @@ export function RerunViewport({
|
||||
liveActivitySequenceRef.current = liveActivitySequence;
|
||||
const liveStreamIdRef = useRef<string | null>(liveStreamId);
|
||||
liveStreamIdRef.current = liveStreamId;
|
||||
const liveRecoveryAuthorityRef = useRef<string | null>(liveRecoveryAuthorityIdentity);
|
||||
liveRecoveryAuthorityRef.current = liveRecoveryAuthorityIdentity;
|
||||
const liveRecoveryRef = useRef(initialLiveReceiverRecoveryState());
|
||||
const liveViewerInstanceIdRef = useRef<string | null>(null);
|
||||
liveViewerInstanceIdRef.current ??= createLiveViewerInstanceId();
|
||||
const liveViewerLifecycleGenerationRef = useRef(0);
|
||||
const activeViewerLifecycleRef = useRef<(() => void) | null>(null);
|
||||
const uiBuildStaleRef = useRef(false);
|
||||
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
@@ -870,15 +987,28 @@ export function RerunViewport({
|
||||
presentationGate,
|
||||
recordedArtifact !== null,
|
||||
);
|
||||
const liveReceiverBindingIdentity = liveRerunReceiverBindingIdentity(
|
||||
sourceUrl,
|
||||
liveStreamId,
|
||||
followLive,
|
||||
);
|
||||
|
||||
useEffect(() => subscribeToLiveViewerBuildFence(() => {
|
||||
uiBuildStaleRef.current = true;
|
||||
// A stale document may only release its own browser transports. Device
|
||||
// START/STOP remains owned by the acquisition authority while the fresh
|
||||
// application document is loaded.
|
||||
activeViewerLifecycleRef.current?.();
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
||||
}, [followLive, liveStreamId, sourceUrl]);
|
||||
}, [liveReceiverBindingIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedSource = sourceUrl.trim();
|
||||
const host = hostRef.current;
|
||||
if (!normalizedSource || !host) {
|
||||
if (!normalizedSource || !host || uiBuildStaleRef.current) {
|
||||
setStatus("idle");
|
||||
setRecordingBufferProgress(null);
|
||||
onStatusChange?.("idle");
|
||||
@@ -887,6 +1017,7 @@ export function RerunViewport({
|
||||
onPlaybackControllerChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource);
|
||||
let resolvedSource: string;
|
||||
try {
|
||||
@@ -912,18 +1043,29 @@ export function RerunViewport({
|
||||
return;
|
||||
}
|
||||
|
||||
liveViewerLifecycleGenerationRef.current += 1;
|
||||
const diagnosticLifecycle = createLiveViewerDiagnosticLifecycle({
|
||||
lineage: createLiveViewerLineage(
|
||||
liveViewerInstanceIdRef.current!,
|
||||
liveViewerLifecycleGenerationRef.current,
|
||||
),
|
||||
});
|
||||
diagnosticLifecycle.verifyBuild();
|
||||
|
||||
let disposed = false;
|
||||
let disposeViewer: (() => void) | undefined;
|
||||
let recordingOpenTimer: number | undefined;
|
||||
let liveRecordingDiscoveryTimer: number | undefined;
|
||||
let recordedOpenWatchdog: {
|
||||
arm: () => void;
|
||||
clear: () => void;
|
||||
pending: () => boolean;
|
||||
} | null = null;
|
||||
let playbackRangeTimer: number | undefined;
|
||||
let liveRecoveryRetryTimer: number | undefined;
|
||||
let recordingOpened = false;
|
||||
let recordingOpenTimedOut = false;
|
||||
let liveOpenWatchdog = initialLiveReceiverOpenWatchdogState(
|
||||
liveActivitySequenceRef.current,
|
||||
);
|
||||
let viewerStartResolved = false;
|
||||
let latestLiveRangeMaxNs: number | null = null;
|
||||
let liveWatchdog = initialLiveReceiverWatchdogState();
|
||||
@@ -948,22 +1090,68 @@ export function RerunViewport({
|
||||
}
|
||||
};
|
||||
const clearRecordedAdmissionWatchdog = () => recordedOpenWatchdog?.clear();
|
||||
const clearLiveRecordingOpenTimer = () => {
|
||||
if (recordingOpenTimer !== undefined) {
|
||||
window.clearTimeout(recordingOpenTimer);
|
||||
recordingOpenTimer = undefined;
|
||||
const clearLiveRecordingOpenTimer = diagnosticLifecycle.clearAdmissionTimeout;
|
||||
function refreshOpeningLiveReceiver(openForMs: number) {
|
||||
if (disposed || recordingOpened) return;
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_restart_requested",
|
||||
failureStage: "recording-open-timeout",
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs: latestLiveRangeMaxNs,
|
||||
stalledForMs: Math.round(openForMs),
|
||||
recoveryAttempt: liveRecoveryRef.current.attempts || null,
|
||||
});
|
||||
setStatus("loading");
|
||||
onStatusChange?.(
|
||||
"loading",
|
||||
"Живой визуализатор обновляет приёмник продолжающегося потока.",
|
||||
);
|
||||
disposeViewer?.();
|
||||
setRetryNonce((nonce) => nonce + 1);
|
||||
}
|
||||
const armLiveRecordingOpenTimer = () => {
|
||||
clearLiveRecordingOpenTimer();
|
||||
diagnosticLifecycle.armAdmissionTimeout(() => {
|
||||
if (disposed || recordingOpened) return;
|
||||
const observed = advanceLiveReceiverOpenWatchdog(
|
||||
liveOpenWatchdog,
|
||||
liveRecoveryRef.current,
|
||||
liveActivitySequenceRef.current,
|
||||
Date.now(),
|
||||
);
|
||||
liveOpenWatchdog = observed.state;
|
||||
liveRecoveryRef.current = observed.recoveryState;
|
||||
if (observed.signal === "wait-for-store") {
|
||||
// The backend is still publishing this exact acquisition. Preserve
|
||||
// the receiver and its partially replayed store instead of throwing
|
||||
// away startup work on every fixed timeout.
|
||||
armLiveRecordingOpenTimer();
|
||||
return;
|
||||
}
|
||||
if (observed.signal === "refresh-receiver") {
|
||||
// Publication is healthy, so this is presentation-only maintenance:
|
||||
// refresh the aged native receiver without spending (or clearing)
|
||||
// recovery debt.
|
||||
recordingOpenTimedOut = true;
|
||||
refreshOpeningLiveReceiver(observed.openForMs);
|
||||
return;
|
||||
}
|
||||
recordingOpenTimedOut = true;
|
||||
requestLiveRecovery("recording-open-timeout");
|
||||
}, LIVE_RECEIVER_OPEN_MAX_AGE_MS);
|
||||
};
|
||||
const clearLiveRecordingDiscoveryTimer = () => {
|
||||
if (liveRecordingDiscoveryTimer !== undefined) {
|
||||
window.clearInterval(liveRecordingDiscoveryTimer);
|
||||
liveRecordingDiscoveryTimer = undefined;
|
||||
}
|
||||
const clearLiveRecordingDiscoveryTimer = diagnosticLifecycle.clearAdmissionInterval;
|
||||
const clearLiveRecoveryRetryTimer = () => {
|
||||
if (liveRecoveryRetryTimer === undefined) return;
|
||||
window.clearTimeout(liveRecoveryRetryTimer);
|
||||
liveRecoveryRetryTimer = undefined;
|
||||
};
|
||||
const clearRecordingTimers = () => {
|
||||
clearRecordedAdmissionWatchdog();
|
||||
clearLiveRecordingOpenTimer();
|
||||
clearLiveRecordingDiscoveryTimer();
|
||||
clearLiveRecoveryRetryTimer();
|
||||
};
|
||||
const clearPlaybackRangeTimer = () => {
|
||||
if (playbackRangeTimer === undefined) return;
|
||||
@@ -989,7 +1177,7 @@ export function RerunViewport({
|
||||
const reportError = (message: string, failureStage?: LiveViewerFailureStage) => {
|
||||
if (disposed) return;
|
||||
if (followLive) {
|
||||
postLiveViewerDiagnostic({
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_error",
|
||||
failureStage,
|
||||
streamId: liveStreamIdRef.current,
|
||||
@@ -1008,7 +1196,7 @@ export function RerunViewport({
|
||||
) => {
|
||||
if (!followLive || disposed) return false;
|
||||
if (emitErrorEvent) {
|
||||
postLiveViewerDiagnostic({
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_error",
|
||||
failureStage,
|
||||
streamId: liveStreamIdRef.current,
|
||||
@@ -1018,10 +1206,21 @@ export function RerunViewport({
|
||||
recoveryAttempt: liveRecoveryRef.current.attempts || null,
|
||||
});
|
||||
}
|
||||
const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current);
|
||||
const currentRecoveryAuthorityIdentity = liveRecoveryAuthorityRef.current;
|
||||
const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current, {
|
||||
activeAuthorityIdentity: currentRecoveryAuthorityIdentity,
|
||||
expectedAuthorityIdentity: currentRecoveryAuthorityIdentity,
|
||||
disposed,
|
||||
});
|
||||
liveRecoveryRef.current = recovery.state;
|
||||
if (recovery.signal === "stale") {
|
||||
// A newer runtime snapshot owns the next effect. This retired viewer
|
||||
// may release itself but cannot schedule a receiver for that lineage.
|
||||
disposeViewer?.();
|
||||
return true;
|
||||
}
|
||||
if (recovery.signal === "exhausted") {
|
||||
postLiveViewerDiagnostic({
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_recovery_exhausted",
|
||||
failureStage,
|
||||
streamId: liveStreamIdRef.current,
|
||||
@@ -1037,7 +1236,7 @@ export function RerunViewport({
|
||||
);
|
||||
return true;
|
||||
}
|
||||
postLiveViewerDiagnostic({
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_restart_requested",
|
||||
failureStage,
|
||||
streamId: liveStreamIdRef.current,
|
||||
@@ -1052,7 +1251,15 @@ export function RerunViewport({
|
||||
"Живой визуализатор переподключается к продолжающемуся потоку.",
|
||||
);
|
||||
disposeViewer?.();
|
||||
clearLiveRecoveryRetryTimer();
|
||||
liveRecoveryRetryTimer = window.setTimeout(() => {
|
||||
liveRecoveryRetryTimer = undefined;
|
||||
if (
|
||||
disposed
|
||||
|| liveRecoveryAuthorityRef.current !== liveRecoveryAuthorityIdentity
|
||||
) return;
|
||||
setRetryNonce((nonce) => nonce + 1);
|
||||
}, recovery.delayMs ?? 0);
|
||||
return true;
|
||||
};
|
||||
const observeLiveReceiver = (viewerRangeMaxNs: number | null) => {
|
||||
@@ -1072,7 +1279,7 @@ export function RerunViewport({
|
||||
(receiverOpenedAfterRecovery || observed.signal === "receiver-advanced") &&
|
||||
liveRecoveryRef.current.awaitingRecovery
|
||||
) {
|
||||
postLiveViewerDiagnostic({
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_recovered",
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
@@ -1084,7 +1291,7 @@ export function RerunViewport({
|
||||
}
|
||||
if (observed.signal !== "stalled") return;
|
||||
|
||||
postLiveViewerDiagnostic({
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_stalled",
|
||||
failureStage: "receiver-stalled",
|
||||
streamId: liveStreamIdRef.current,
|
||||
@@ -1093,12 +1300,30 @@ export function RerunViewport({
|
||||
stalledForMs: Math.round(observed.stalledForMs),
|
||||
recoveryAttempt: Math.min(
|
||||
liveRecoveryRef.current.attempts + 1,
|
||||
LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
||||
liveRecoveryAuthorityRef.current
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
||||
),
|
||||
});
|
||||
requestLiveRecovery("receiver-stalled", observed.stalledForMs, false);
|
||||
};
|
||||
|
||||
let relinquishLiveViewerOwnership: () => void = () => {};
|
||||
const disposeActiveViewerLifecycle = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
relinquishLiveViewerOwnership();
|
||||
diagnosticLifecycle.dispose();
|
||||
disposeViewer?.();
|
||||
host.replaceChildren();
|
||||
};
|
||||
if (followLive) {
|
||||
relinquishLiveViewerOwnership = claimExclusiveLiveViewer(
|
||||
disposeActiveViewerLifecycle,
|
||||
);
|
||||
}
|
||||
activeViewerLifecycleRef.current = disposeActiveViewerLifecycle;
|
||||
|
||||
host.replaceChildren();
|
||||
appliedPointColorKeyRef.current = null;
|
||||
setStatus("loading");
|
||||
@@ -1113,10 +1338,7 @@ export function RerunViewport({
|
||||
if (disposed) return;
|
||||
|
||||
const viewer = new WebViewer();
|
||||
let viewerDisposed = false;
|
||||
disposeViewer = () => {
|
||||
if (viewerDisposed) return;
|
||||
viewerDisposed = true;
|
||||
disposeViewer = createReentrantViewerDisposer(() => {
|
||||
clearRecordingTimers();
|
||||
clearPlaybackRangeTimer();
|
||||
playbackTimeUpdates.cancel();
|
||||
@@ -1150,6 +1372,7 @@ export function RerunViewport({
|
||||
} catch {
|
||||
// The viewer may already have closed all auxiliary channels.
|
||||
}
|
||||
}, () => {
|
||||
try {
|
||||
if (viewer.ready) viewer.close(resolvedSource);
|
||||
} catch {
|
||||
@@ -1162,7 +1385,7 @@ export function RerunViewport({
|
||||
// startup failure.
|
||||
}
|
||||
host.replaceChildren();
|
||||
};
|
||||
});
|
||||
if (isRecordedSource && recordedArtifact) {
|
||||
recordedOpenWatchdog = createRecordedOpenWatchdog({
|
||||
byteLength: recordedArtifact.byteLength,
|
||||
@@ -1188,8 +1411,9 @@ export function RerunViewport({
|
||||
) return;
|
||||
recordingOpened = true;
|
||||
if (!isRecordedSource) {
|
||||
clearLiveRecordingOpenTimer();
|
||||
clearLiveRecordingDiscoveryTimer();
|
||||
// Store discovery only establishes a candidate. Admission is
|
||||
// committed below after this recording exposes a usable live
|
||||
// range backed by an actual published frame.
|
||||
}
|
||||
if (
|
||||
recordedBlueprintUrl &&
|
||||
@@ -1226,6 +1450,7 @@ export function RerunViewport({
|
||||
let rangeNs = viewer.get_time_range(event.recording_id, timeline);
|
||||
let currentNs = viewer.get_current_time(event.recording_id, timeline);
|
||||
let playing = viewer.get_playing(event.recording_id);
|
||||
let liveTimelineSynchronized = !followLive;
|
||||
if (!followLive && playing) {
|
||||
try {
|
||||
viewer.set_playing(event.recording_id, false);
|
||||
@@ -1287,6 +1512,17 @@ export function RerunViewport({
|
||||
if (disposed || !playbackState) return;
|
||||
try {
|
||||
rangeNs = viewer.get_time_range(event.recording_id, timeline);
|
||||
if (followLive && !liveTimelineSynchronized) {
|
||||
let activeTimeline = viewer.get_active_timeline(event.recording_id);
|
||||
if (liveTimelineNeedsSynchronization(followLive, activeTimeline, rangeNs)) {
|
||||
// The first selection above may have raced timeline
|
||||
// creation. Once a real range exists this retry is safe
|
||||
// and preserves Rerun's native live-following cursor.
|
||||
viewer.set_active_timeline(event.recording_id, timeline);
|
||||
activeTimeline = viewer.get_active_timeline(event.recording_id);
|
||||
}
|
||||
liveTimelineSynchronized = activeTimeline === timeline;
|
||||
}
|
||||
currentNs = viewer.get_current_time(event.recording_id, timeline);
|
||||
playing = viewer.get_playing(event.recording_id);
|
||||
} catch {
|
||||
@@ -1302,7 +1538,11 @@ export function RerunViewport({
|
||||
setRecordingBufferProgress(recordedBuffer.bufferProgress);
|
||||
}
|
||||
const readyToRender = followLive
|
||||
? viewerStartResolved
|
||||
? liveTimelineSynchronized && isLiveRerunPresentationReady(
|
||||
viewerStartResolved,
|
||||
rangeNs,
|
||||
liveActivitySequenceRef.current,
|
||||
)
|
||||
: isRecordedPlaybackReady(viewerStartResolved, artifactVerified, recordedBuffer);
|
||||
const presentationReady = presentationGateRef.current === "ready";
|
||||
if (!followLive && (!readyToRender || !presentationReady) && playing) {
|
||||
@@ -1354,6 +1594,25 @@ export function RerunViewport({
|
||||
});
|
||||
if (readyToRender && !readyPublished) {
|
||||
readyPublished = true;
|
||||
if (followLive) {
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_active_store_admitted",
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs: rangeNs?.max ?? null,
|
||||
});
|
||||
diagnosticLifecycle.markAdmitted();
|
||||
if (liveRecoveryRef.current.awaitingRecovery) {
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_recovered",
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs: rangeNs?.max ?? null,
|
||||
recoveryAttempt: liveRecoveryRef.current.attempts,
|
||||
});
|
||||
}
|
||||
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
||||
}
|
||||
if (!followLive) clearRecordedAdmissionWatchdog();
|
||||
if (!followLive) setRecordingBufferProgress(1);
|
||||
recordedSceneAdmitted = true;
|
||||
@@ -1386,11 +1645,6 @@ export function RerunViewport({
|
||||
// Rerun 0.34.1 may ingest an SDK gRPC store without forwarding its
|
||||
// recording_open event to the JavaScript wrapper. The active store
|
||||
// is the authoritative fallback and avoids hiding a ready canvas.
|
||||
postLiveViewerDiagnostic({
|
||||
eventCode: "live_receiver_active_store_admitted",
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
});
|
||||
admitRecording({
|
||||
application_id: "nodedc_mission_core_spatial",
|
||||
recording_id: recordingId,
|
||||
@@ -1460,7 +1714,7 @@ export function RerunViewport({
|
||||
rerunViewerInitialSource(resolvedSource),
|
||||
host,
|
||||
viewerOptions,
|
||||
null,
|
||||
rerunViewerOpenOptions(followLive),
|
||||
);
|
||||
if (disposed) {
|
||||
disposeViewer();
|
||||
@@ -1489,16 +1743,19 @@ export function RerunViewport({
|
||||
}
|
||||
|
||||
if (!isRecordedSource) {
|
||||
// Measure native store admission from the resolved viewer start,
|
||||
// not from dynamic module import or React effect setup time.
|
||||
liveOpenWatchdog = initialLiveReceiverOpenWatchdogState(
|
||||
liveActivitySequenceRef.current,
|
||||
Date.now(),
|
||||
);
|
||||
discoverActiveLiveRecording();
|
||||
if (!recordingOpened) {
|
||||
liveRecordingDiscoveryTimer = window.setInterval(
|
||||
diagnosticLifecycle.armAdmissionInterval(
|
||||
discoverActiveLiveRecording,
|
||||
100,
|
||||
);
|
||||
recordingOpenTimer = window.setTimeout(() => {
|
||||
recordingOpenTimedOut = true;
|
||||
requestLiveRecovery("recording-open-timeout");
|
||||
}, 12_000);
|
||||
armLiveRecordingOpenTimer();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -1520,12 +1777,14 @@ export function RerunViewport({
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (activeViewerLifecycleRef.current === disposeActiveViewerLifecycle) {
|
||||
activeViewerLifecycleRef.current = null;
|
||||
}
|
||||
disposeActiveViewerLifecycle();
|
||||
clearRecordingTimers();
|
||||
clearPlaybackRangeTimer();
|
||||
playbackTimeUpdates.cancel();
|
||||
unsubscribeAll();
|
||||
disposeViewer?.();
|
||||
onSelectionChange?.(null);
|
||||
onPlaybackControllerChange?.(null);
|
||||
onPlaybackChange?.(null);
|
||||
@@ -1534,9 +1793,8 @@ export function RerunViewport({
|
||||
autoplayWhenReady,
|
||||
expectedTimelineEndSeconds,
|
||||
expectedTimelineStartSeconds,
|
||||
followLive,
|
||||
liveReceiverBindingIdentity,
|
||||
initialPlaybackStartSeconds,
|
||||
liveStreamId,
|
||||
onPlaybackChange,
|
||||
onPlaybackControllerChange,
|
||||
onSelectionChange,
|
||||
@@ -1546,7 +1804,6 @@ export function RerunViewport({
|
||||
recordedArtifact?.sourceUrl,
|
||||
recordedArtifact?.viewerSourceUrl,
|
||||
retryNonce,
|
||||
sourceUrl,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,6 +20,7 @@ export function LaboratoryEvidenceViewer<
|
||||
U extends string = string,
|
||||
>({
|
||||
label,
|
||||
className,
|
||||
mode,
|
||||
modes,
|
||||
expanded,
|
||||
@@ -28,9 +29,12 @@ export function LaboratoryEvidenceViewer<
|
||||
actions,
|
||||
secondaryMode,
|
||||
overlay,
|
||||
transport,
|
||||
trailingActions,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
mode: T;
|
||||
modes: readonly LaboratoryEvidenceViewerMode<T>[];
|
||||
expanded: boolean;
|
||||
@@ -44,6 +48,8 @@ export function LaboratoryEvidenceViewer<
|
||||
onChange: (mode: U) => void;
|
||||
};
|
||||
overlay?: ReactNode;
|
||||
transport?: ReactNode;
|
||||
trailingActions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -62,7 +68,10 @@ export function LaboratoryEvidenceViewer<
|
||||
|
||||
const viewer = (
|
||||
<section
|
||||
className="laboratory-evidence-viewer"
|
||||
className={[
|
||||
"laboratory-evidence-viewer",
|
||||
className,
|
||||
].filter(Boolean).join(" ")}
|
||||
data-expanded={expanded ? "true" : undefined}
|
||||
aria-label={label}
|
||||
>
|
||||
@@ -70,6 +79,11 @@ export function LaboratoryEvidenceViewer<
|
||||
{children}
|
||||
</div>
|
||||
{overlay}
|
||||
{transport ? (
|
||||
<div className="laboratory-evidence-viewer__transport">
|
||||
{transport}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="laboratory-evidence-viewer__controls">
|
||||
{actions}
|
||||
{secondaryMode ? (
|
||||
@@ -86,6 +100,7 @@ export function LaboratoryEvidenceViewer<
|
||||
label={`${label}: режим представления`}
|
||||
onChange={onModeChange}
|
||||
/>
|
||||
{trailingActions}
|
||||
<IconButton
|
||||
ref={expandButtonRef}
|
||||
label={expanded ? `Свернуть ${label}` : `Развернуть ${label}`}
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type CSSProperties,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
|
||||
import {
|
||||
recordedEvidenceSemanticCssColor,
|
||||
resolveRecordedEvidenceSemanticRgb,
|
||||
type RecordedEvidenceSemanticClass,
|
||||
type RecordedEvidenceSemanticPaletteEntry,
|
||||
} from "./RecordedEvidenceSemanticMaskOverlay";
|
||||
|
||||
export type LaboratoryMetricPoint3 = readonly [number, number, number];
|
||||
export type LaboratoryMetricDecision = "threat" | "not-threat" | "unknown";
|
||||
export type LaboratoryMetricSceneMode = "3d" | "plan";
|
||||
|
||||
export interface LaboratoryMetricObstacleVisual {
|
||||
id: string;
|
||||
decision: LaboratoryMetricDecision;
|
||||
state: "current" | "retained" | "held" | "expired";
|
||||
centroidBodyXyzM: LaboratoryMetricPoint3;
|
||||
cellCentersBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricRigVisual {
|
||||
lengthM: number;
|
||||
widthM: number;
|
||||
nominalSensorHeightM: number;
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricCorridorVisual {
|
||||
forwardLengthM: number;
|
||||
rearMarginM: number;
|
||||
halfWidthM: number;
|
||||
}
|
||||
|
||||
function tokenColor(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
): THREE.Color {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
if (value.startsWith("#")) return new THREE.Color(value);
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return new THREE.Color(red / 255, green / 255, blue / 255);
|
||||
}
|
||||
|
||||
function disposeRenderable(object: THREE.Object3D): void {
|
||||
const renderable = object as THREE.Object3D & {
|
||||
geometry?: THREE.BufferGeometry;
|
||||
material?: THREE.Material | THREE.Material[];
|
||||
};
|
||||
renderable.geometry?.dispose();
|
||||
const materials = Array.isArray(renderable.material)
|
||||
? renderable.material
|
||||
: renderable.material
|
||||
? [renderable.material]
|
||||
: [];
|
||||
materials.forEach((material) => material.dispose());
|
||||
}
|
||||
|
||||
function clearGroup(group: THREE.Group): void {
|
||||
while (group.children.length) {
|
||||
const child = group.children[0];
|
||||
if (!child) break;
|
||||
group.remove(child);
|
||||
child.traverse(disposeRenderable);
|
||||
}
|
||||
}
|
||||
|
||||
function scenePoint(point: LaboratoryMetricPoint3): LaboratoryMetricPoint3 {
|
||||
return [point[0], point[2], -point[1]];
|
||||
}
|
||||
|
||||
function positions(points: readonly LaboratoryMetricPoint3[]): Float32Array {
|
||||
const result = new Float32Array(points.length * 3);
|
||||
points.forEach((point, index) => {
|
||||
const [x, y, z] = scenePoint(point);
|
||||
const offset = index * 3;
|
||||
result[offset] = x;
|
||||
result[offset + 1] = y;
|
||||
result[offset + 2] = z;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function decisionColor(
|
||||
host: HTMLElement,
|
||||
decision: LaboratoryMetricDecision,
|
||||
): THREE.Color {
|
||||
if (decision === "threat") {
|
||||
return tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112]);
|
||||
}
|
||||
if (decision === "not-threat") {
|
||||
return tokenColor(host, "--nodedc-success-rgb", [181, 255, 90]);
|
||||
}
|
||||
return tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricEvidenceSceneHandle {
|
||||
resetView: () => void;
|
||||
}
|
||||
|
||||
export const LaboratoryMetricEvidenceScene = forwardRef<
|
||||
LaboratoryMetricEvidenceSceneHandle,
|
||||
{
|
||||
pointCloudBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
localSurfaceBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
obstacles: readonly LaboratoryMetricObstacleVisual[];
|
||||
rig: LaboratoryMetricRigVisual;
|
||||
corridor: LaboratoryMetricCorridorVisual;
|
||||
occupiedVoxelSizeM: number;
|
||||
mode: LaboratoryMetricSceneMode;
|
||||
label: string;
|
||||
showCurrentIncrement: boolean;
|
||||
showLocalSurface: boolean;
|
||||
showRollingMap: boolean;
|
||||
pointSemanticClassIds?: readonly (number | null)[];
|
||||
semanticClasses?: readonly RecordedEvidenceSemanticClass[];
|
||||
semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||
}
|
||||
>(function LaboratoryMetricEvidenceScene({
|
||||
pointCloudBodyXyzM,
|
||||
localSurfaceBodyXyzM,
|
||||
obstacles,
|
||||
rig,
|
||||
corridor,
|
||||
occupiedVoxelSizeM,
|
||||
mode,
|
||||
label,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
pointSemanticClassIds,
|
||||
semanticClasses,
|
||||
semanticPalette,
|
||||
}, ref) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const staticContentRef = useRef<THREE.Group | null>(null);
|
||||
const dynamicContentRef = useRef<THREE.Group | null>(null);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
} catch {
|
||||
setRenderError("Браузер не смог создать метрическую 3D-сцену.");
|
||||
return;
|
||||
}
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(tokenColor(host, "--nodedc-canvas", [5, 5, 6]), 1);
|
||||
renderer.domElement.setAttribute("aria-label", label);
|
||||
renderer.domElement.setAttribute("role", "img");
|
||||
host.prepend(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 300);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.enablePan = true;
|
||||
controls.enableZoom = true;
|
||||
controls.screenSpacePanning = true;
|
||||
controls.minDistance = 0.4;
|
||||
controls.maxDistance = 80;
|
||||
const staticContent = new THREE.Group();
|
||||
const dynamicContent = new THREE.Group();
|
||||
scene.add(staticContent, dynamicContent);
|
||||
sceneRef.current = scene;
|
||||
cameraRef.current = camera;
|
||||
controlsRef.current = controls;
|
||||
staticContentRef.current = staticContent;
|
||||
dynamicContentRef.current = dynamicContent;
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
};
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(host);
|
||||
resize();
|
||||
|
||||
let animationFrame = 0;
|
||||
const render = () => {
|
||||
animationFrame = window.requestAnimationFrame(render);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
render();
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
observer.disconnect();
|
||||
controls.dispose();
|
||||
scene.traverse(disposeRenderable);
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
sceneRef.current = null;
|
||||
cameraRef.current = null;
|
||||
controlsRef.current = null;
|
||||
staticContentRef.current = null;
|
||||
dynamicContentRef.current = null;
|
||||
};
|
||||
}, [label]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = dynamicContentRef.current;
|
||||
if (!host || !content) return;
|
||||
clearGroup(content);
|
||||
|
||||
if (showLocalSurface) {
|
||||
const localSurfaceGeometry = new THREE.BufferGeometry();
|
||||
localSurfaceGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(localSurfaceBodyXyzM), 3),
|
||||
);
|
||||
content.add(new THREE.Points(
|
||||
localSurfaceGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||
size: 1.3,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if (showCurrentIncrement) {
|
||||
const contextGeometry = new THREE.BufferGeometry();
|
||||
contextGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3),
|
||||
);
|
||||
const hasAlignedSemanticClasses =
|
||||
pointSemanticClassIds !== undefined
|
||||
&& pointSemanticClassIds.length === pointCloudBodyXyzM.length
|
||||
&& semanticClasses !== undefined
|
||||
&& semanticPalette !== undefined;
|
||||
if (hasAlignedSemanticClasses) {
|
||||
const declaredIds = new Set(semanticClasses.map((item) => item.id));
|
||||
const colorsByClassId = new Map<number, readonly [number, number, number]>();
|
||||
for (const entry of semanticPalette) {
|
||||
if (!declaredIds.has(entry.classId)) continue;
|
||||
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||
if (rgb) colorsByClassId.set(entry.classId, rgb);
|
||||
}
|
||||
const context = tokenColor(host, "--nodedc-text-muted", [147, 151, 159]);
|
||||
const pointColors = new Float32Array(pointCloudBodyXyzM.length * 3);
|
||||
pointSemanticClassIds.forEach((classId, index) => {
|
||||
const rgb = classId === null ? undefined : colorsByClassId.get(classId);
|
||||
const offset = index * 3;
|
||||
pointColors[offset] = rgb ? rgb[0] / 255 : context.r;
|
||||
pointColors[offset + 1] = rgb ? rgb[1] / 255 : context.g;
|
||||
pointColors[offset + 2] = rgb ? rgb[2] / 255 : context.b;
|
||||
});
|
||||
contextGeometry.setAttribute(
|
||||
"color",
|
||||
new THREE.BufferAttribute(pointColors, 3),
|
||||
);
|
||||
}
|
||||
content.add(new THREE.Points(
|
||||
contextGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: hasAlignedSemanticClasses
|
||||
? new THREE.Color(1, 1, 1)
|
||||
: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||
vertexColors: hasAlignedSemanticClasses,
|
||||
size: 1.55,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.58,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
for (const obstacle of obstacles) {
|
||||
if (
|
||||
(obstacle.state === "current" && !showCurrentIncrement)
|
||||
|| (obstacle.state === "retained" && !showRollingMap)
|
||||
|| obstacle.state === "held"
|
||||
|| obstacle.state === "expired"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const color = decisionColor(host, obstacle.decision);
|
||||
if (obstacle.state === "retained") {
|
||||
const geometry = new THREE.BoxGeometry(
|
||||
occupiedVoxelSizeM * 0.82,
|
||||
occupiedVoxelSizeM * 0.82,
|
||||
occupiedVoxelSizeM * 0.82,
|
||||
);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
wireframe: true,
|
||||
transparent: true,
|
||||
opacity: 0.34,
|
||||
depthWrite: false,
|
||||
});
|
||||
const voxels = new THREE.InstancedMesh(
|
||||
geometry,
|
||||
material,
|
||||
obstacle.cellCentersBodyXyzM.length,
|
||||
);
|
||||
const matrix = new THREE.Matrix4();
|
||||
obstacle.cellCentersBodyXyzM.forEach((point, index) => {
|
||||
matrix.makeTranslation(...scenePoint(point));
|
||||
voxels.setMatrixAt(index, matrix);
|
||||
});
|
||||
voxels.instanceMatrix.needsUpdate = true;
|
||||
voxels.userData.evidenceId = obstacle.id;
|
||||
content.add(voxels);
|
||||
} else {
|
||||
const cellsGeometry = new THREE.BufferGeometry();
|
||||
cellsGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(obstacle.cellCentersBodyXyzM), 3),
|
||||
);
|
||||
content.add(new THREE.Points(
|
||||
cellsGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color,
|
||||
size: 4.4,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.96,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
const centroid = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.065, 12, 8),
|
||||
new THREE.MeshBasicMaterial({ color }),
|
||||
);
|
||||
centroid.position.fromArray(scenePoint(obstacle.centroidBodyXyzM));
|
||||
centroid.userData.evidenceId = obstacle.id;
|
||||
content.add(centroid);
|
||||
}
|
||||
}
|
||||
|
||||
}, [
|
||||
obstacles,
|
||||
occupiedVoxelSizeM,
|
||||
localSurfaceBodyXyzM,
|
||||
pointCloudBodyXyzM,
|
||||
pointSemanticClassIds,
|
||||
semanticClasses,
|
||||
semanticPalette,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = staticContentRef.current;
|
||||
if (!host || !content) return;
|
||||
clearGroup(content);
|
||||
|
||||
const corridorLength = rig.lengthM / 2 + corridor.forwardLengthM + corridor.rearMarginM;
|
||||
const corridorCenterX = (rig.lengthM / 2 + corridor.forwardLengthM - corridor.rearMarginM) / 2;
|
||||
const corridorMesh = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(corridorLength, corridor.halfWidthM * 2),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [232, 56, 126]),
|
||||
transparent: true,
|
||||
opacity: 0.11,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
corridorMesh.rotation.x = -Math.PI / 2;
|
||||
corridorMesh.position.set(corridorCenterX, 0.01, 0);
|
||||
content.add(corridorMesh);
|
||||
const corridorOutline = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(new THREE.BoxGeometry(corridorLength, 0.01, corridor.halfWidthM * 2)),
|
||||
new THREE.LineBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [232, 56, 126]),
|
||||
transparent: true,
|
||||
opacity: 0.7,
|
||||
}),
|
||||
);
|
||||
corridorOutline.position.set(corridorCenterX, 0.015, 0);
|
||||
content.add(corridorOutline);
|
||||
|
||||
const body = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(new THREE.BoxGeometry(rig.lengthM, 0.34, rig.widthM)),
|
||||
new THREE.LineBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-foreground-rgb", [245, 245, 245]),
|
||||
transparent: true,
|
||||
opacity: 0.86,
|
||||
}),
|
||||
);
|
||||
body.position.y = 0.17;
|
||||
content.add(body);
|
||||
const lidar = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.08, 0.08, 0.08, 20),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-foreground-rgb", [245, 245, 245]),
|
||||
}),
|
||||
);
|
||||
lidar.position.y = rig.nominalSensorHeightM;
|
||||
content.add(lidar);
|
||||
|
||||
const grid = new THREE.GridHelper(
|
||||
Math.max(20, corridor.forwardLengthM * 2.5),
|
||||
40,
|
||||
tokenColor(host, "--nodedc-text-muted", [96, 99, 106]),
|
||||
tokenColor(host, "--nodedc-glass-outline", [48, 50, 56]),
|
||||
);
|
||||
const gridMaterials = Array.isArray(grid.material) ? grid.material : [grid.material];
|
||||
gridMaterials.forEach((material) => {
|
||||
material.transparent = true;
|
||||
material.opacity = 0.15;
|
||||
material.depthWrite = false;
|
||||
});
|
||||
content.add(grid);
|
||||
}, [corridor, rig]);
|
||||
|
||||
const resetView = () => {
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
if (!camera || !controls) return;
|
||||
controls.target.set(corridor.forwardLengthM * 0.35, 0.6, 0);
|
||||
if (mode === "plan") {
|
||||
camera.position.set(corridor.forwardLengthM * 0.35, 15, 0.001);
|
||||
camera.up.set(0, 0, -1);
|
||||
} else {
|
||||
camera.position.set(-4.5, 4.8, 8.5);
|
||||
camera.up.set(0, 1, 0);
|
||||
}
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
};
|
||||
|
||||
useEffect(resetView, [corridor.forwardLengthM, mode]);
|
||||
useImperativeHandle(ref, () => ({ resetView }));
|
||||
|
||||
const semanticLegendEntries = (() => {
|
||||
if (
|
||||
!showCurrentIncrement
|
||||
|| !pointSemanticClassIds
|
||||
|| pointSemanticClassIds.length !== pointCloudBodyXyzM.length
|
||||
|| !semanticClasses
|
||||
|| !semanticPalette
|
||||
) return [];
|
||||
const presentIds = new Set(pointSemanticClassIds.filter((item): item is number => item !== null));
|
||||
const classesById = new Map(semanticClasses.map((item) => [item.id, item]));
|
||||
return semanticPalette.flatMap((entry) => {
|
||||
const semanticClass = classesById.get(entry.classId);
|
||||
if (!semanticClass || !presentIds.has(entry.classId) || entry.color.kind === "transparent") return [];
|
||||
return [{
|
||||
id: entry.classId,
|
||||
label: semanticClass.label,
|
||||
cssColor: recordedEvidenceSemanticCssColor(entry.color),
|
||||
}];
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="laboratory-metric-evidence-scene">
|
||||
<div ref={hostRef} className="laboratory-metric-evidence-scene__viewport">
|
||||
{renderError ? <p>{renderError}</p> : null}
|
||||
</div>
|
||||
<div className="laboratory-metric-evidence-scene__legend">
|
||||
<span data-decision="threat">Угроза</span>
|
||||
<span data-decision="not-threat">Вне коридора</span>
|
||||
<span data-decision="unknown">Неизвестно</span>
|
||||
<span data-decision="context">Current increment</span>
|
||||
<span data-decision="local-surface">Local SLAM surface</span>
|
||||
<span data-decision="rolling">Rolling-map occupied</span>
|
||||
{semanticLegendEntries.map((entry) => (
|
||||
<span
|
||||
key={entry.id}
|
||||
data-decision="semantic"
|
||||
style={{ "--laboratory-metric-legend-color": entry.cssColor } as CSSProperties}
|
||||
>
|
||||
{entry.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export type RecordedEvidenceBoxTone =
|
||||
| "accent"
|
||||
| "danger"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "neutral";
|
||||
|
||||
export interface RecordedEvidenceBox {
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
label: string;
|
||||
tone: RecordedEvidenceBoxTone;
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
function rgba(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const channels = getComputedStyle(host)
|
||||
.getPropertyValue(token)
|
||||
.trim()
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function toneColor(host: HTMLElement, tone: RecordedEvidenceBoxTone): string {
|
||||
if (tone === "danger") return rgba(host, "--nodedc-danger-rgb", [255, 104, 112]);
|
||||
if (tone === "success") return rgba(host, "--nodedc-success-rgb", [181, 255, 90]);
|
||||
if (tone === "warning") return rgba(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
if (tone === "neutral") return rgba(host, "--nodedc-foreground-rgb", [245, 245, 245], 0.7);
|
||||
return rgba(host, "--nodedc-accent-rgb", [232, 56, 126]);
|
||||
}
|
||||
|
||||
export function RecordedEvidenceBoxOverlay({
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boxes,
|
||||
ariaLabel,
|
||||
}: {
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boxes: readonly RecordedEvidenceBox[];
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const host = canvas?.parentElement;
|
||||
if (!host || !canvas) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * pixelRatio);
|
||||
canvas.height = Math.round(height * pixelRatio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
|
||||
const scale = Math.min(width / imageWidth, height / imageHeight);
|
||||
const drawWidth = imageWidth * scale;
|
||||
const drawHeight = imageHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
for (const item of boxes) {
|
||||
const [left, top, right, bottom] = item.boxXyxy;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const stroke = toneColor(host, item.tone);
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = Math.max(1.5, 2 * scale);
|
||||
context.setLineDash(item.dashed ? [5, 4] : []);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
context.setLineDash([]);
|
||||
|
||||
const fontSize = Math.max(9, 10 * scale);
|
||||
context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = Math.min(drawWidth, context.measureText(item.label).width + 8);
|
||||
const labelHeight = fontSize + 6;
|
||||
const labelX = Math.min(offsetX + drawWidth - labelWidth, Math.max(offsetX, x));
|
||||
const labelY = Math.max(offsetY, y - labelHeight);
|
||||
context.fillStyle = rgba(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
|
||||
context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke;
|
||||
context.fillText(item.label, labelX + 4, labelY + fontSize + 1, labelWidth - 8);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [boxes, imageHeight, imageWidth]);
|
||||
|
||||
return <canvas ref={canvasRef} role="img" aria-label={ariaLabel} />;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
RecordedEvidenceBoxOverlay,
|
||||
type RecordedEvidenceBox,
|
||||
} from "./RecordedEvidenceBoxOverlay";
|
||||
import {
|
||||
RecordedEvidenceSemanticMaskOverlay,
|
||||
type RecordedEvidenceSemanticOverlay,
|
||||
} from "./RecordedEvidenceSemanticMaskOverlay";
|
||||
|
||||
export function RecordedEvidenceImageScene({
|
||||
src,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boxes,
|
||||
semanticOverlay,
|
||||
ariaLabel,
|
||||
}: {
|
||||
src: string;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boxes: readonly RecordedEvidenceBox[];
|
||||
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
|
||||
useEffect(() => setState("loading"), [src]);
|
||||
|
||||
return (
|
||||
<div className="recorded-evidence-image-scene">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onLoad={() => setState("ready")}
|
||||
onError={() => setState("error")}
|
||||
/>
|
||||
{state === "ready" ? (
|
||||
<>
|
||||
{semanticOverlay ? (
|
||||
<RecordedEvidenceSemanticMaskOverlay
|
||||
{...semanticOverlay}
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
/>
|
||||
) : null}
|
||||
<RecordedEvidenceBoxOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
boxes={boxes}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
{state === "loading" ? (
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
) : (
|
||||
<Icon name="alert" size={18} />
|
||||
)}
|
||||
<span>
|
||||
{state === "loading"
|
||||
? "Декодируем один точный CAMERA-кадр"
|
||||
: "Точный CAMERA-кадр недоступен."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface RecordedEvidenceSemanticClass {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type RecordedEvidenceSemanticToken =
|
||||
| "--nodedc-accent-rgb"
|
||||
| "--nodedc-danger-rgb"
|
||||
| "--nodedc-foreground-rgb"
|
||||
| "--nodedc-success-rgb"
|
||||
| "--nodedc-text-muted"
|
||||
| "--nodedc-warning-rgb";
|
||||
|
||||
export type RecordedEvidenceSemanticColor =
|
||||
| {
|
||||
kind: "token";
|
||||
token: RecordedEvidenceSemanticToken;
|
||||
}
|
||||
| {
|
||||
kind: "diagnostic";
|
||||
rgb: readonly [number, number, number];
|
||||
}
|
||||
| {
|
||||
kind: "transparent";
|
||||
};
|
||||
|
||||
export interface RecordedEvidenceSemanticPaletteEntry {
|
||||
classId: number;
|
||||
color: RecordedEvidenceSemanticColor;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export interface RecordedEvidenceSemanticOverlay {
|
||||
src: string;
|
||||
classes: readonly RecordedEvidenceSemanticClass[];
|
||||
palette: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||
opacity?: number;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
interface DecodedSemanticMask {
|
||||
key: string;
|
||||
width: number;
|
||||
height: number;
|
||||
classIds: Uint8Array;
|
||||
}
|
||||
|
||||
interface PendingSemanticMask {
|
||||
controller: AbortController;
|
||||
subscribers: number;
|
||||
promise: Promise<DecodedSemanticMask>;
|
||||
}
|
||||
|
||||
const MASK_CACHE_LIMIT = 48;
|
||||
const decodedMaskCache = new Map<string, DecodedSemanticMask>();
|
||||
const pendingMaskCache = new Map<string, PendingSemanticMask>();
|
||||
|
||||
const TOKEN_FALLBACKS: Record<RecordedEvidenceSemanticToken, readonly [number, number, number]> = {
|
||||
"--nodedc-accent-rgb": [232, 56, 126],
|
||||
"--nodedc-danger-rgb": [255, 104, 112],
|
||||
"--nodedc-foreground-rgb": [245, 245, 245],
|
||||
"--nodedc-success-rgb": [181, 255, 90],
|
||||
"--nodedc-text-muted": [147, 151, 159],
|
||||
"--nodedc-warning-rgb": [255, 197, 92],
|
||||
};
|
||||
|
||||
function clampChannel(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
}
|
||||
|
||||
function clampOpacity(value: number | undefined, fallback: number): number {
|
||||
return Math.max(0, Math.min(1, Number.isFinite(value) ? Number(value) : fallback));
|
||||
}
|
||||
|
||||
function semanticMaskKey(src: string, width: number, height: number): string {
|
||||
return `${width}x${height}:${src}`;
|
||||
}
|
||||
|
||||
function rememberMask(mask: DecodedSemanticMask): void {
|
||||
decodedMaskCache.delete(mask.key);
|
||||
decodedMaskCache.set(mask.key, mask);
|
||||
while (decodedMaskCache.size > MASK_CACHE_LIMIT) {
|
||||
const oldest = decodedMaskCache.keys().next().value;
|
||||
if (typeof oldest !== "string") break;
|
||||
decodedMaskCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
async function imageSourceFromBlob(
|
||||
blob: Blob,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ source: CanvasImageSource; width: number; height: number; release: () => void }> {
|
||||
if (typeof createImageBitmap === "function") {
|
||||
const bitmap = await createImageBitmap(blob, {
|
||||
colorSpaceConversion: "none",
|
||||
premultiplyAlpha: "none",
|
||||
});
|
||||
if (signal.aborted) {
|
||||
bitmap.close();
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
return {
|
||||
source: bitmap,
|
||||
width: bitmap.width,
|
||||
height: bitmap.height,
|
||||
release: () => bitmap.close(),
|
||||
};
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
image.removeEventListener("load", onLoad);
|
||||
image.removeEventListener("error", onError);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const onLoad = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
reject(new Error("Semantic mask image decode failed"));
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
image.addEventListener("load", onLoad, { once: true });
|
||||
image.addEventListener("error", onError, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
image.src = objectUrl;
|
||||
});
|
||||
return {
|
||||
source: image,
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
release: () => URL.revokeObjectURL(objectUrl),
|
||||
};
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeSemanticMask(
|
||||
key: string,
|
||||
src: string,
|
||||
expectedWidth: number,
|
||||
expectedHeight: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<DecodedSemanticMask> {
|
||||
const response = await fetch(src, { cache: "force-cache", signal });
|
||||
if (!response.ok) throw new Error(`Semantic mask request failed: ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
const decoded = await imageSourceFromBlob(blob, signal);
|
||||
try {
|
||||
if (decoded.width !== expectedWidth || decoded.height !== expectedHeight) {
|
||||
throw new Error(
|
||||
`Semantic mask dimensions ${decoded.width}x${decoded.height} do not match ${expectedWidth}x${expectedHeight}`,
|
||||
);
|
||||
}
|
||||
const decodeCanvas = document.createElement("canvas");
|
||||
decodeCanvas.width = decoded.width;
|
||||
decodeCanvas.height = decoded.height;
|
||||
const context = decodeCanvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("Semantic mask canvas is unavailable");
|
||||
context.drawImage(decoded.source, 0, 0);
|
||||
const rgba = context.getImageData(0, 0, decoded.width, decoded.height).data;
|
||||
const classIds = new Uint8Array(decoded.width * decoded.height);
|
||||
for (let sourceOffset = 0, targetOffset = 0; targetOffset < classIds.length; sourceOffset += 4, targetOffset += 1) {
|
||||
const classId = rgba[sourceOffset] ?? 0;
|
||||
if (rgba[sourceOffset + 1] !== classId || rgba[sourceOffset + 2] !== classId) {
|
||||
throw new Error("Semantic mask must be an 8-bit grayscale class-id PNG");
|
||||
}
|
||||
classIds[targetOffset] = classId;
|
||||
}
|
||||
return { key, width: decoded.width, height: decoded.height, classIds };
|
||||
} finally {
|
||||
decoded.release();
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToSemanticMask(
|
||||
src: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): { promise: Promise<DecodedSemanticMask>; release: () => void } {
|
||||
const key = semanticMaskKey(src, width, height);
|
||||
const cached = decodedMaskCache.get(key);
|
||||
if (cached) {
|
||||
decodedMaskCache.delete(key);
|
||||
decodedMaskCache.set(key, cached);
|
||||
return { promise: Promise.resolve(cached), release: () => undefined };
|
||||
}
|
||||
|
||||
let pending = pendingMaskCache.get(key);
|
||||
if (!pending) {
|
||||
const controller = new AbortController();
|
||||
let next: PendingSemanticMask;
|
||||
const promise = decodeSemanticMask(key, src, width, height, controller.signal)
|
||||
.then((mask) => {
|
||||
rememberMask(mask);
|
||||
return mask;
|
||||
})
|
||||
.finally(() => {
|
||||
if (pendingMaskCache.get(key) === next) pendingMaskCache.delete(key);
|
||||
});
|
||||
next = { controller, subscribers: 0, promise };
|
||||
pendingMaskCache.set(key, next);
|
||||
pending = next;
|
||||
}
|
||||
pending.subscribers += 1;
|
||||
let released = false;
|
||||
return {
|
||||
promise: pending.promise,
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
pending!.subscribers -= 1;
|
||||
if (pending!.subscribers > 0 || decodedMaskCache.has(key)) return;
|
||||
pending!.controller.abort();
|
||||
if (pendingMaskCache.get(key) === pending) pendingMaskCache.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function recordedEvidenceSemanticCssColor(
|
||||
color: RecordedEvidenceSemanticColor,
|
||||
): string {
|
||||
if (color.kind === "transparent") return "transparent";
|
||||
if (color.kind === "token") return `rgb(var(${color.token}))`;
|
||||
const [red, green, blue] = color.rgb.map(clampChannel);
|
||||
return `rgb(${red} ${green} ${blue})`;
|
||||
}
|
||||
|
||||
export function resolveRecordedEvidenceSemanticRgb(
|
||||
host: HTMLElement,
|
||||
color: RecordedEvidenceSemanticColor,
|
||||
): readonly [number, number, number] | null {
|
||||
if (color.kind === "transparent") return null;
|
||||
if (color.kind === "diagnostic") return color.rgb.map(clampChannel) as [number, number, number];
|
||||
const channels = getComputedStyle(host)
|
||||
.getPropertyValue(color.token)
|
||||
.trim()
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
return channels?.length === 3
|
||||
? channels.map(clampChannel) as [number, number, number]
|
||||
: TOKEN_FALLBACKS[color.token];
|
||||
}
|
||||
|
||||
export function RecordedEvidenceSemanticMaskOverlay({
|
||||
src,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
classes,
|
||||
palette,
|
||||
opacity = 0.46,
|
||||
ariaLabel,
|
||||
}: RecordedEvidenceSemanticOverlay & {
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [mask, setMask] = useState<DecodedSemanticMask | null>(null);
|
||||
const [failure, setFailure] = useState<string | null>(null);
|
||||
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
|
||||
const renderMask = failure
|
||||
? null
|
||||
: mask?.key === expectedKey
|
||||
? mask
|
||||
: decodedMaskCache.get(expectedKey) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setFailure(null);
|
||||
const subscription = subscribeToSemanticMask(src, imageWidth, imageHeight);
|
||||
let current = true;
|
||||
void subscription.promise.then((decoded) => {
|
||||
if (!current || decoded.key !== expectedKey) return;
|
||||
const declaredClassIds = new Set(classes.map((item) => item.id));
|
||||
const undeclaredClassId = decoded.classIds.find(
|
||||
(classId) => !declaredClassIds.has(classId),
|
||||
);
|
||||
if (undeclaredClassId !== undefined) {
|
||||
setFailure(`Semantic mask содержит необъявленный class ID ${undeclaredClassId}.`);
|
||||
return;
|
||||
}
|
||||
setMask(decoded);
|
||||
}).catch((error: unknown) => {
|
||||
if (!current || (error instanceof DOMException && error.name === "AbortError")) return;
|
||||
setFailure("Semantic mask не прошла проверку или декодирование.");
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
subscription.release();
|
||||
};
|
||||
}, [classes, expectedKey, imageHeight, imageWidth, src]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const host = canvas?.parentElement;
|
||||
if (!canvas || !host) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * pixelRatio);
|
||||
canvas.height = Math.round(height * pixelRatio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
if (!renderMask) return;
|
||||
|
||||
const declaredClassIds = new Set(
|
||||
classes
|
||||
.map((item) => item.id)
|
||||
.filter((classId) => Number.isInteger(classId) && classId >= 0 && classId <= 255),
|
||||
);
|
||||
const resolvedPalette = new Map<number, { rgb: readonly [number, number, number]; alpha: number }>();
|
||||
for (const entry of palette) {
|
||||
if (!declaredClassIds.has(entry.classId)) continue;
|
||||
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||
if (!rgb) continue;
|
||||
resolvedPalette.set(entry.classId, {
|
||||
rgb,
|
||||
alpha: clampOpacity(entry.opacity, 1) * clampOpacity(opacity, 0.46),
|
||||
});
|
||||
}
|
||||
|
||||
const colorCanvas = document.createElement("canvas");
|
||||
colorCanvas.width = renderMask.width;
|
||||
colorCanvas.height = renderMask.height;
|
||||
const colorContext = colorCanvas.getContext("2d");
|
||||
if (!colorContext) return;
|
||||
const imageData = colorContext.createImageData(renderMask.width, renderMask.height);
|
||||
for (let sourceOffset = 0, targetOffset = 0; sourceOffset < renderMask.classIds.length; sourceOffset += 1, targetOffset += 4) {
|
||||
const color = resolvedPalette.get(renderMask.classIds[sourceOffset] ?? -1);
|
||||
if (!color) continue;
|
||||
imageData.data[targetOffset] = color.rgb[0];
|
||||
imageData.data[targetOffset + 1] = color.rgb[1];
|
||||
imageData.data[targetOffset + 2] = color.rgb[2];
|
||||
imageData.data[targetOffset + 3] = Math.round(color.alpha * 255);
|
||||
}
|
||||
colorContext.putImageData(imageData, 0, 0);
|
||||
|
||||
const scale = Math.min(width / imageWidth, height / imageHeight);
|
||||
const drawWidth = imageWidth * scale;
|
||||
const drawHeight = imageHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.drawImage(colorCanvas, offsetX, offsetY, drawWidth, drawHeight);
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [classes, expectedKey, imageHeight, imageWidth, opacity, palette, renderMask]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="recorded-evidence-semantic-mask-overlay"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={!failure && !renderMask}
|
||||
data-state={failure ? "error" : renderMask ? "ready" : "loading"}
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
{failure ? (
|
||||
<div className="recorded-evidence-semantic-mask-overlay__error" role="alert">
|
||||
{failure}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "../RecordedFmp4Player";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
import {
|
||||
RecordedEvidenceBoxOverlay,
|
||||
type RecordedEvidenceBox,
|
||||
type RecordedEvidenceBoxTone,
|
||||
} from "./RecordedEvidenceBoxOverlay";
|
||||
import {
|
||||
RecordedEvidenceSemanticMaskOverlay,
|
||||
type RecordedEvidenceSemanticOverlay,
|
||||
} from "./RecordedEvidenceSemanticMaskOverlay";
|
||||
|
||||
export type { RecordedEvidenceBox, RecordedEvidenceBoxTone };
|
||||
|
||||
export function RecordedEvidenceVideoScene({
|
||||
source,
|
||||
playback,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boxes,
|
||||
semanticOverlay,
|
||||
ariaLabel,
|
||||
interactive = true,
|
||||
onPlaybackChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback: RecordedObservationPlayback;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boxes: readonly RecordedEvidenceBox[];
|
||||
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
||||
ariaLabel: string;
|
||||
interactive?: boolean;
|
||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="recorded-evidence-video-scene">
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
interactive={interactive}
|
||||
prepare
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
/>
|
||||
{semanticOverlay ? (
|
||||
<RecordedEvidenceSemanticMaskOverlay
|
||||
{...semanticOverlay}
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
/>
|
||||
) : null}
|
||||
<RecordedEvidenceBoxOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
boxes={boxes}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { RecordedObservationPlayback } from "../RecordedFmp4Player";
|
||||
|
||||
export interface RecordedEvidencePlaybackRange {
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
}
|
||||
|
||||
function validRange(
|
||||
range: RecordedEvidencePlaybackRange | null,
|
||||
): range is RecordedEvidencePlaybackRange {
|
||||
return Boolean(
|
||||
range
|
||||
&& Number.isFinite(range.startSeconds)
|
||||
&& Number.isFinite(range.endSeconds)
|
||||
&& range.endSeconds > range.startSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
export function clampRecordedEvidenceSeconds(
|
||||
seconds: number,
|
||||
range: RecordedEvidencePlaybackRange,
|
||||
): number {
|
||||
return Math.min(range.endSeconds, Math.max(range.startSeconds, seconds));
|
||||
}
|
||||
|
||||
export function advanceRecordedEvidencePlayback(
|
||||
playback: RecordedObservationPlayback,
|
||||
elapsedSeconds: number,
|
||||
range: RecordedEvidencePlaybackRange,
|
||||
): RecordedObservationPlayback {
|
||||
if (!playback.playing || !Number.isFinite(elapsedSeconds) || elapsedSeconds <= 0) {
|
||||
return playback;
|
||||
}
|
||||
const next = playback.currentSeconds + elapsedSeconds * (playback.rate ?? 1);
|
||||
if (next >= range.endSeconds) {
|
||||
return { ...playback, currentSeconds: range.endSeconds, playing: false };
|
||||
}
|
||||
return { ...playback, currentSeconds: clampRecordedEvidenceSeconds(next, range) };
|
||||
}
|
||||
|
||||
export function useRecordedEvidencePlayback(
|
||||
range: RecordedEvidencePlaybackRange | null,
|
||||
) {
|
||||
const [playback, setPlayback] = useState<RecordedObservationPlayback>({
|
||||
currentSeconds: 0,
|
||||
playing: false,
|
||||
rate: 1,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!validRange(range)) return;
|
||||
setPlayback((current) => ({
|
||||
...current,
|
||||
currentSeconds: current.currentSeconds === 0
|
||||
? range.startSeconds
|
||||
: clampRecordedEvidenceSeconds(current.currentSeconds, range),
|
||||
}));
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!validRange(range) || !playback.playing) return;
|
||||
let animationFrame = 0;
|
||||
let previous = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const elapsed = Math.max(0, now - previous);
|
||||
if (elapsed >= 32) {
|
||||
previous = now;
|
||||
setPlayback((current) => {
|
||||
if (!current.playing) return current;
|
||||
return advanceRecordedEvidencePlayback(current, elapsed / 1_000, range);
|
||||
});
|
||||
}
|
||||
animationFrame = window.requestAnimationFrame(tick);
|
||||
};
|
||||
animationFrame = window.requestAnimationFrame(tick);
|
||||
return () => window.cancelAnimationFrame(animationFrame);
|
||||
}, [playback.playing, range]);
|
||||
|
||||
const seek = useCallback((seconds: number, pause = true) => {
|
||||
if (!validRange(range)) return;
|
||||
setPlayback((current) => ({
|
||||
...current,
|
||||
currentSeconds: clampRecordedEvidenceSeconds(seconds, range),
|
||||
playing: pause ? false : current.playing,
|
||||
}));
|
||||
}, [range]);
|
||||
|
||||
const setPlaying = useCallback((playing: boolean) => {
|
||||
if (!validRange(range)) return;
|
||||
setPlayback((current) => ({
|
||||
...current,
|
||||
currentSeconds: playing && current.currentSeconds >= range.endSeconds
|
||||
? range.startSeconds
|
||||
: current.currentSeconds,
|
||||
playing,
|
||||
}));
|
||||
}, [range]);
|
||||
|
||||
const setRate = useCallback((rate: number) => {
|
||||
if (![0.5, 1, 2].includes(rate)) return;
|
||||
setPlayback((current) => ({ ...current, rate }));
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({
|
||||
playback,
|
||||
seek,
|
||||
setPlaying,
|
||||
setRate,
|
||||
}), [playback, seek, setPlaying, setRate]);
|
||||
}
|
||||
@@ -6,14 +6,46 @@ import type { WorkspaceDefinition } from "../productModel";
|
||||
interface ApplicationPanelActionsOptions {
|
||||
definition: WorkspaceDefinition | null;
|
||||
refreshRuntime: () => void;
|
||||
resetConnectionScenario?: () => Promise<boolean>;
|
||||
connectionScenarioResetting: boolean;
|
||||
saveWorkspaceLayout: () => Promise<void>;
|
||||
workspaceLayoutSaving: boolean;
|
||||
systemUtilityActions: readonly ApplicationPanelUtilityAction[];
|
||||
}
|
||||
|
||||
export function deviceRuntimeUtilityAction({
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
}: Pick<
|
||||
ApplicationPanelActionsOptions,
|
||||
"refreshRuntime" | "resetConnectionScenario" | "connectionScenarioResetting"
|
||||
>): ApplicationPanelUtilityAction {
|
||||
return {
|
||||
label: resetConnectionScenario
|
||||
? connectionScenarioResetting
|
||||
? "Сбрасываем подключение"
|
||||
: "Сбросить подключение"
|
||||
: "Обновить состояние локального контура",
|
||||
icon: resetConnectionScenario && connectionScenarioResetting
|
||||
? "activity"
|
||||
: "refresh",
|
||||
disabled: resetConnectionScenario && connectionScenarioResetting
|
||||
? true
|
||||
: undefined,
|
||||
onClick: resetConnectionScenario
|
||||
? connectionScenarioResetting
|
||||
? () => undefined
|
||||
: () => void resetConnectionScenario()
|
||||
: refreshRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
export function useApplicationPanelActions({
|
||||
definition,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
saveWorkspaceLayout,
|
||||
workspaceLayoutSaving,
|
||||
systemUtilityActions,
|
||||
@@ -21,11 +53,11 @@ export function useApplicationPanelActions({
|
||||
return useMemo(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
if (definition?.kind === "device") {
|
||||
actions.push({
|
||||
label: "Обновить состояние локального контура",
|
||||
icon: "refresh",
|
||||
onClick: refreshRuntime,
|
||||
});
|
||||
actions.push(deviceRuntimeUtilityAction({
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
}));
|
||||
}
|
||||
if (definition && ["spatial", "recordings"].includes(definition.kind)) {
|
||||
actions.push({
|
||||
@@ -40,6 +72,8 @@ export function useApplicationPanelActions({
|
||||
}, [
|
||||
definition,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
saveWorkspaceLayout,
|
||||
systemUtilityActions,
|
||||
workspaceLayoutSaving,
|
||||
|
||||
@@ -23,6 +23,64 @@ interface DevicePluginHostValue {
|
||||
|
||||
const DevicePluginHostContext = createContext<DevicePluginHostValue | null>(null);
|
||||
|
||||
export const DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY =
|
||||
"nodedc.mission-core.device-model-selection.v1";
|
||||
|
||||
export interface DeviceModelSelectionStorage {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
}
|
||||
|
||||
function browserDeviceModelSelectionStorage(): DeviceModelSelectionStorage | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore only a model that is present in the current reviewed registry.
|
||||
* Removed/renamed models and malformed browser values fail closed to the
|
||||
* picker and are cleared so a later remount cannot keep retrying stale state.
|
||||
*/
|
||||
export function restorePersistedDeviceModelId(
|
||||
registry: DevicePluginRegistry,
|
||||
storage: DeviceModelSelectionStorage | null,
|
||||
): string | null {
|
||||
if (!storage) return null;
|
||||
try {
|
||||
const stored = storage.getItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
|
||||
const modelId = stored?.trim() || null;
|
||||
if (modelId && registry.resolveModel(modelId)) return modelId;
|
||||
if (stored !== null) {
|
||||
storage.removeItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist only an already-admitted host transition; browser storage is never authority. */
|
||||
export function commitPersistedDeviceModelId(
|
||||
modelId: string | null,
|
||||
storage: DeviceModelSelectionStorage | null,
|
||||
): void {
|
||||
if (!storage) return;
|
||||
try {
|
||||
if (modelId === null) {
|
||||
storage.removeItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
storage.setItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY, modelId);
|
||||
} catch {
|
||||
// A denied/full localStorage must not block the in-memory host transition.
|
||||
}
|
||||
}
|
||||
|
||||
export function DevicePluginHostProvider({
|
||||
plugins,
|
||||
children,
|
||||
@@ -31,7 +89,10 @@ export function DevicePluginHostProvider({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(() =>
|
||||
restorePersistedDeviceModelId(registry, selectionStorage)
|
||||
);
|
||||
const [selectionTransitionPending, setSelectionTransitionPending] = useState(false);
|
||||
const [selectionTransitionError, setSelectionTransitionError] = useState<string | null>(null);
|
||||
const transitionInFlight = useRef(false);
|
||||
@@ -56,7 +117,12 @@ export function DevicePluginHostProvider({
|
||||
if (nextModelId !== null && !registry.resolveModel(nextModelId)) {
|
||||
throw new Error(`Модель устройства не зарегистрирована: ${nextModelId}.`);
|
||||
}
|
||||
if (nextModelId === selectedModelId) return true;
|
||||
if (nextModelId === selectedModelId) {
|
||||
// `clearSelection()` must clear a stale persisted value even when the
|
||||
// current in-memory selection is already empty.
|
||||
commitPersistedDeviceModelId(nextModelId, selectionStorage);
|
||||
return true;
|
||||
}
|
||||
|
||||
transitionInFlight.current = true;
|
||||
setSelectionTransitionPending(true);
|
||||
@@ -88,13 +154,14 @@ export function DevicePluginHostProvider({
|
||||
}
|
||||
}
|
||||
setSelectedModelId(nextModelId);
|
||||
commitPersistedDeviceModelId(nextModelId, selectionStorage);
|
||||
return true;
|
||||
} finally {
|
||||
transitionInFlight.current = false;
|
||||
setSelectionTransitionPending(false);
|
||||
}
|
||||
},
|
||||
[registry, selectedModelId],
|
||||
[registry, selectedModelId, selectionStorage],
|
||||
);
|
||||
|
||||
const deactivationRegistrars = useMemo(
|
||||
|
||||
@@ -35,8 +35,11 @@ import { fetchE46GRectifiedDetectorBakeoff } from "./e46gRectifiedDetectorBakeof
|
||||
import { fetchE46HFullRectifiedFrontReplay } from "./e46hFullRectifiedFrontReplay";
|
||||
import { fetchE46IGroundingDinoFullReplay } from "./e46iGroundingDinoFullReplay";
|
||||
import { fetchE46JRawFisheyeRealtime } from "./e46jRawFisheyeRealtime";
|
||||
import { fetchE47SemanticSlamResult } from "./e47SemanticSlam";
|
||||
import { fetchM4ThreatReplayResult } from "./m4ReplayThreat";
|
||||
|
||||
export type AdvancedLaboratoryWorkId =
|
||||
| "m4-replay-threat"
|
||||
| "l3-pointpillars-visual-audit"
|
||||
| "l31-pointpillars-ravnoves"
|
||||
| "l32-pointpillars-camera-review"
|
||||
@@ -61,6 +64,7 @@ export type AdvancedLaboratoryWorkId =
|
||||
| "e46h-full-rectified-front-replay"
|
||||
| "e46i-grounding-dino-full-replay"
|
||||
| "e46j-raw-fisheye-realtime"
|
||||
| "e47-semantic-slam-shadow"
|
||||
| "l34-right-yolox-truth-island-freeze"
|
||||
| "l34a-assisted-yolox-error-audit"
|
||||
| "l34b-nested-box-consolidation-shadow"
|
||||
@@ -76,6 +80,7 @@ export interface AdvancedLaboratoryIndexItem {
|
||||
}
|
||||
|
||||
const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||
"m4-replay-threat",
|
||||
"l3-pointpillars-visual-audit",
|
||||
"l31-pointpillars-ravnoves",
|
||||
"l32-pointpillars-camera-review",
|
||||
@@ -100,6 +105,7 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||
"e46h-full-rectified-front-replay",
|
||||
"e46i-grounding-dino-full-replay",
|
||||
"e46j-raw-fisheye-realtime",
|
||||
"e47-semantic-slam-shadow",
|
||||
"l34-right-yolox-truth-island-freeze",
|
||||
"l34a-assisted-yolox-error-audit",
|
||||
"l34b-nested-box-consolidation-shadow",
|
||||
@@ -110,6 +116,7 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||
];
|
||||
|
||||
const RESULT_PREFIX: Readonly<Record<AdvancedLaboratoryWorkId, string>> = {
|
||||
"m4-replay-threat": "m4-threat-replay",
|
||||
"l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit",
|
||||
"l31-pointpillars-ravnoves": "l31-pointpillars-ravnoves",
|
||||
"l32-pointpillars-camera-review": "l32-pointpillars-camera-review",
|
||||
@@ -134,6 +141,7 @@ const RESULT_PREFIX: Readonly<Record<AdvancedLaboratoryWorkId, string>> = {
|
||||
"e46h-full-rectified-front-replay": "e46h-full-rectified-front-replay",
|
||||
"e46i-grounding-dino-full-replay": "e46i-grounding-dino-full-replay",
|
||||
"e46j-raw-fisheye-realtime": "e46j-raw-fisheye-realtime",
|
||||
"e47-semantic-slam-shadow": "e47-semantic-slam",
|
||||
"l34-right-yolox-truth-island-freeze": "l34-right-yolox-truth-island-freeze",
|
||||
"l34a-assisted-yolox-error-audit": "l34a-assisted-yolox-error-audit",
|
||||
"l34b-nested-box-consolidation-shadow": "l34b-nested-box-consolidation-shadow",
|
||||
@@ -151,6 +159,7 @@ export function isAdvancedLaboratoryWorkId(
|
||||
|
||||
export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
|
||||
return {
|
||||
m4Threat: null,
|
||||
l3: null,
|
||||
l31: null,
|
||||
l32: null,
|
||||
@@ -175,6 +184,7 @@ export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
|
||||
e46h: null,
|
||||
e46i: null,
|
||||
e46j: null,
|
||||
e47: null,
|
||||
l34: null,
|
||||
l34a: null,
|
||||
l34b: null,
|
||||
@@ -273,7 +283,8 @@ export function advancedLaboratoryResultAvailable(
|
||||
workId: AdvancedLaboratoryWorkId,
|
||||
results: AdvancedLaboratoryResults,
|
||||
): boolean {
|
||||
return workId === "l3-pointpillars-visual-audit" ? results.l3 !== null
|
||||
return workId === "m4-replay-threat" ? results.m4Threat !== null
|
||||
: workId === "l3-pointpillars-visual-audit" ? results.l3 !== null
|
||||
: workId === "l31-pointpillars-ravnoves" ? results.l31 !== null
|
||||
: workId === "l32-pointpillars-camera-review" ? results.l32 !== null
|
||||
: workId === "l33-camera-first-detector-review" ? results.l33 !== null
|
||||
@@ -297,6 +308,7 @@ export function advancedLaboratoryResultAvailable(
|
||||
: workId === "e46h-full-rectified-front-replay" ? results.e46h !== null
|
||||
: workId === "e46i-grounding-dino-full-replay" ? results.e46i !== null
|
||||
: workId === "e46j-raw-fisheye-realtime" ? results.e46j !== null
|
||||
: workId === "e47-semantic-slam-shadow" ? results.e47 !== null
|
||||
: workId === "l34-right-yolox-truth-island-freeze" ? results.l34 !== null
|
||||
: workId === "l34a-assisted-yolox-error-audit" ? results.l34a !== null
|
||||
: workId === "l34b-nested-box-consolidation-shadow" ? results.l34b !== null
|
||||
@@ -317,7 +329,9 @@ export async function fetchAdvancedLaboratoryResult(
|
||||
} = {},
|
||||
): Promise<AdvancedLaboratoryResults> {
|
||||
const results = emptyAdvancedLaboratoryResults();
|
||||
if (workId === "l3-pointpillars-visual-audit") {
|
||||
if (workId === "m4-replay-threat") {
|
||||
results.m4Threat = await fetchM4ThreatReplayResult({ fetcher, signal });
|
||||
} else if (workId === "l3-pointpillars-visual-audit") {
|
||||
results.l3 = await fetchL3PointPillarsVisualAudit({ fetcher, signal });
|
||||
} else if (workId === "l31-pointpillars-ravnoves") {
|
||||
results.l31 = await fetchL31PointPillarsRavnoves({ fetcher, signal });
|
||||
@@ -395,6 +409,8 @@ export async function fetchAdvancedLaboratoryResult(
|
||||
results.e46i = await fetchE46IGroundingDinoFullReplay({ fetcher, signal });
|
||||
} else if (workId === "e46j-raw-fisheye-realtime") {
|
||||
results.e46j = await fetchE46JRawFisheyeRealtime({ fetcher, signal });
|
||||
} else if (workId === "e47-semantic-slam-shadow") {
|
||||
results.e47 = await fetchE47SemanticSlamResult({ fetcher, signal });
|
||||
} else if (workId === "l34-right-yolox-truth-island-freeze") {
|
||||
results.l34 = await fetchL34RightYoloxTruthIsland({ fetcher, signal });
|
||||
} else if (workId === "l34a-assisted-yolox-error-audit") {
|
||||
|
||||
@@ -31,8 +31,11 @@ import type { E46GRectifiedDetectorBakeoffResult } from "./e46gRectifiedDetector
|
||||
import type { E46HFullRectifiedFrontReplayResult } from "./e46hFullRectifiedFrontReplay";
|
||||
import type { E46IGroundingDinoFullReplayResult } from "./e46iGroundingDinoFullReplay";
|
||||
import type { E46JRawFisheyeRealtimeResult } from "./e46jRawFisheyeRealtime";
|
||||
import type { E47SemanticSlamResult } from "./e47SemanticSlam";
|
||||
import type { M4ThreatReplayResult } from "./m4ReplayThreat";
|
||||
|
||||
export interface AdvancedLaboratoryResults {
|
||||
m4Threat: M4ThreatReplayResult | null;
|
||||
l3: L3PointPillarsVisualAuditResult | null;
|
||||
l31: L31PointPillarsRavnovesResult | null;
|
||||
l32: L32PointPillarsCameraReviewResult | null;
|
||||
@@ -57,6 +60,7 @@ export interface AdvancedLaboratoryResults {
|
||||
e46h: E46HFullRectifiedFrontReplayResult | null;
|
||||
e46i: E46IGroundingDinoFullReplayResult | null;
|
||||
e46j: E46JRawFisheyeRealtimeResult | null;
|
||||
e47: E47SemanticSlamResult | null;
|
||||
l34: L34RightYoloxTruthIslandResult | null;
|
||||
l34a: L34AAssistedYoloxErrorAuditResult | null;
|
||||
l34b: L34BResult | null;
|
||||
|
||||
@@ -235,7 +235,6 @@ export class AdvancedLaboratoryContractError extends Error {
|
||||
this.name = "AdvancedLaboratoryContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
@@ -968,6 +967,7 @@ export async function fetchAdvancedLaboratoryResults({
|
||||
const e39 = settledCatalogValue(settled[7]);
|
||||
const e40 = settledCatalogValue(settled[8]);
|
||||
return {
|
||||
m4Threat: null,
|
||||
l3: null, l31: null,
|
||||
l32: null,
|
||||
l33: null,
|
||||
@@ -985,9 +985,9 @@ export async function fetchAdvancedLaboratoryResults({
|
||||
e46b: null,
|
||||
e46c: null,
|
||||
e46d: null,
|
||||
e46e: null,
|
||||
e46f: null,
|
||||
e46e: null, e46f: null,
|
||||
e46g: null, e46h: null, e46i: null, e46j: null,
|
||||
e47: null,
|
||||
l34: null,
|
||||
l34a: null,
|
||||
l34b: null,
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
export type E47SemanticDisposition = "labeled" | "ambiguous";
|
||||
|
||||
export interface E47SemanticClass {
|
||||
classId: number;
|
||||
label: string;
|
||||
disposition: E47SemanticDisposition;
|
||||
colorRgb: readonly [number, number, number];
|
||||
}
|
||||
|
||||
export interface E47SemanticSlamResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "diagnostic-semantic-slam-shadow";
|
||||
profileId: string;
|
||||
baseM4ResultId: string;
|
||||
semanticResultId: string;
|
||||
geometryResultId: string;
|
||||
sourcePackId: string;
|
||||
calibrationContentSha256: string;
|
||||
provider: {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
modelRevision: string;
|
||||
modelWeightsSha256: string;
|
||||
preprocessId: string;
|
||||
};
|
||||
temporalBinding: {
|
||||
semanticToCamera: "exact-sequence-and-session-time";
|
||||
cameraToLidar: "accepted-e6-nearest-host-arrival-best-effort";
|
||||
clockBasis: "recorded-host-monotonic-arrival";
|
||||
maximumLidarCameraDeltaMs: number;
|
||||
maximumPosePointDeltaMs: number;
|
||||
physicalSynchronizationProven: false;
|
||||
};
|
||||
taxonomy: readonly E47SemanticClass[];
|
||||
metrics: {
|
||||
frames: {
|
||||
total: number;
|
||||
maskAvailable: number;
|
||||
sourceAvailable: number;
|
||||
};
|
||||
points: {
|
||||
total: number;
|
||||
projected: number;
|
||||
labeled: number;
|
||||
ambiguous: number;
|
||||
unprojected: number;
|
||||
absent: number;
|
||||
};
|
||||
observations: {
|
||||
total: number;
|
||||
labeled: number;
|
||||
ambiguous: number;
|
||||
unprojected: number;
|
||||
absent: number;
|
||||
};
|
||||
runtime: {
|
||||
elapsedMs: number;
|
||||
framesPerSecond: number;
|
||||
};
|
||||
};
|
||||
acceptance: {
|
||||
artifactContractPassed: boolean;
|
||||
frameAccountingPassed: boolean;
|
||||
pointAccountingPassed: boolean;
|
||||
observationBindingPassed: boolean;
|
||||
temporalBindingPassed: boolean;
|
||||
independentSemanticTruthPassed: false;
|
||||
providerPromoted: false;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface E47SemanticTimelineFrame {
|
||||
sequence: number;
|
||||
sourcePointCount: number;
|
||||
classIds: readonly number[];
|
||||
statusCodes: readonly number[];
|
||||
counts: {
|
||||
labeled: number;
|
||||
ambiguous: number;
|
||||
unprojected: number;
|
||||
absent: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface E47SemanticTimelineChunk {
|
||||
resultId: string;
|
||||
startSequence: number;
|
||||
frameCount: number;
|
||||
nextSequence: number | null;
|
||||
frames: readonly E47SemanticTimelineFrame[];
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export class E47SemanticSlamContractError extends Error {}
|
||||
|
||||
const object = (value: unknown, label: string): Record<string, unknown> => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const array = (value: unknown, label: string): readonly unknown[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const text = (value: unknown, label: string): string => {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const finite = (value: unknown, label: string): number => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const integer = (value: unknown, label: string): number => {
|
||||
const parsed = finite(value, label);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const exact = <T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T => {
|
||||
if (value !== expected) {
|
||||
throw new E47SemanticSlamContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
};
|
||||
|
||||
function resultId(value: unknown): string {
|
||||
const parsed = text(value, "E47 result id");
|
||||
if (!/^e47-semantic-slam-[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new E47SemanticSlamContractError("E47 result id: нарушена идентичность.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function m4ResultId(value: unknown): string {
|
||||
const parsed = text(value, "E47 base M4 result id");
|
||||
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new E47SemanticSlamContractError("E47 base M4 result id: нарушена идентичность.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sha256(value: unknown, label: string): string {
|
||||
const parsed = text(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидался SHA-256.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function taxonomy(value: unknown): readonly E47SemanticClass[] {
|
||||
const classes = array(value, "E47 taxonomy").map((raw) => {
|
||||
const item = object(raw, "E47 semantic class");
|
||||
const classId = integer(item.class_id, "E47 class id");
|
||||
if (classId > 255) {
|
||||
throw new E47SemanticSlamContractError("E47 class id: вышел за uint8.");
|
||||
}
|
||||
const disposition = text(item.disposition, "E47 class disposition");
|
||||
if (disposition !== "labeled" && disposition !== "ambiguous") {
|
||||
throw new E47SemanticSlamContractError("E47 class disposition: неизвестное значение.");
|
||||
}
|
||||
const rgb = array(item.color_rgb, "E47 class color").map(
|
||||
(channel) => integer(channel, "E47 color channel"),
|
||||
);
|
||||
if (rgb.length !== 3 || rgb.some((channel) => channel > 255)) {
|
||||
throw new E47SemanticSlamContractError("E47 class color: нарушен RGB-контракт.");
|
||||
}
|
||||
return {
|
||||
classId,
|
||||
label: text(item.label, "E47 class label"),
|
||||
disposition: disposition as E47SemanticDisposition,
|
||||
colorRgb: [rgb[0]!, rgb[1]!, rgb[2]!] as const,
|
||||
};
|
||||
});
|
||||
if (!classes.length || new Set(classes.map((item) => item.classId)).size !== classes.length) {
|
||||
throw new E47SemanticSlamContractError("E47 taxonomy: классы отсутствуют или дублируются.");
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E47SemanticSlamResult {
|
||||
const item = object(value, "E47 result");
|
||||
exact(item.schema_version, "missioncore.e47-semantic-slam-view/v1", "E47 view schema");
|
||||
exact(item.status, "diagnostic-semantic-slam-shadow", "E47 status");
|
||||
exact(item.ground_truth, false, "E47 ground truth");
|
||||
exact(item.semantic_authority, "diagnostic-only", "E47 semantic authority");
|
||||
exact(item.navigation_or_safety_accepted, false, "E47 safety authority");
|
||||
exact(item.actuation_allowed, false, "E47 actuation authority");
|
||||
const provider = object(item.provider, "E47 provider");
|
||||
const temporalBinding = object(item.temporal_binding, "E47 temporal binding");
|
||||
const metrics = object(item.metrics, "E47 metrics");
|
||||
const frames = object(metrics.frames, "E47 frame metrics");
|
||||
const points = object(metrics.points, "E47 point metrics");
|
||||
const observations = object(metrics.observations, "E47 observation metrics");
|
||||
const runtime = object(metrics.runtime, "E47 runtime metrics");
|
||||
const acceptance = object(item.acceptance, "E47 acceptance");
|
||||
const frameMetrics = {
|
||||
total: exact(frames.total, 4489, "E47 frame total"),
|
||||
maskAvailable: integer(frames.mask_available, "E47 mask frames"),
|
||||
sourceAvailable: integer(frames.source_available, "E47 source frames"),
|
||||
};
|
||||
if (
|
||||
frameMetrics.maskAvailable > frameMetrics.total
|
||||
|| frameMetrics.sourceAvailable > frameMetrics.total
|
||||
) {
|
||||
throw new E47SemanticSlamContractError("E47 frame accounting: нарушен контракт.");
|
||||
}
|
||||
const pointMetrics = {
|
||||
total: integer(points.total, "E47 total points"),
|
||||
projected: integer(points.projected, "E47 projected points"),
|
||||
labeled: integer(points.labeled, "E47 labeled points"),
|
||||
ambiguous: integer(points.ambiguous, "E47 ambiguous points"),
|
||||
unprojected: integer(points.unprojected, "E47 unprojected points"),
|
||||
absent: integer(points.absent, "E47 absent points"),
|
||||
};
|
||||
if (
|
||||
pointMetrics.projected !== pointMetrics.labeled + pointMetrics.ambiguous
|
||||
|| pointMetrics.total !== pointMetrics.projected
|
||||
+ pointMetrics.unprojected
|
||||
+ pointMetrics.absent
|
||||
) {
|
||||
throw new E47SemanticSlamContractError("E47 point accounting: нарушен контракт.");
|
||||
}
|
||||
const observationMetrics = {
|
||||
total: integer(observations.total, "E47 total observations"),
|
||||
labeled: integer(observations.labeled, "E47 labeled observations"),
|
||||
ambiguous: integer(observations.ambiguous, "E47 ambiguous observations"),
|
||||
unprojected: integer(observations.unprojected, "E47 unprojected observations"),
|
||||
absent: integer(observations.absent, "E47 absent observations"),
|
||||
};
|
||||
if (
|
||||
observationMetrics.total !== observationMetrics.labeled
|
||||
+ observationMetrics.ambiguous
|
||||
+ observationMetrics.unprojected
|
||||
+ observationMetrics.absent
|
||||
) {
|
||||
throw new E47SemanticSlamContractError("E47 observation accounting: нарушен контракт.");
|
||||
}
|
||||
const runtimeMetrics = {
|
||||
elapsedMs: finite(runtime.elapsed_ms, "E47 elapsed"),
|
||||
framesPerSecond: finite(runtime.frames_per_second, "E47 FPS"),
|
||||
};
|
||||
if (runtimeMetrics.elapsedMs <= 0 || runtimeMetrics.framesPerSecond <= 0) {
|
||||
throw new E47SemanticSlamContractError("E47 runtime accounting: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId: resultId(item.result_id),
|
||||
createdAtUtc: text(item.created_at_utc, "E47 created at"),
|
||||
status: "diagnostic-semantic-slam-shadow",
|
||||
profileId: text(item.profile_id, "E47 profile"),
|
||||
baseM4ResultId: m4ResultId(item.base_m4_result_id),
|
||||
semanticResultId: text(item.semantic_result_id, "E47 semantic source"),
|
||||
geometryResultId: text(item.geometry_result_id, "E47 geometry source"),
|
||||
sourcePackId: text(item.source_pack_id, "E47 source pack"),
|
||||
calibrationContentSha256: sha256(item.calibration_content_sha256, "E47 calibration"),
|
||||
provider: {
|
||||
providerId: text(provider.provider_id, "E47 provider id"),
|
||||
modelId: text(provider.model_id, "E47 model id"),
|
||||
modelRevision: text(provider.model_revision, "E47 model revision"),
|
||||
modelWeightsSha256: sha256(provider.model_weights_sha256, "E47 model weights"),
|
||||
preprocessId: text(provider.preprocess_id, "E47 preprocess id"),
|
||||
},
|
||||
temporalBinding: {
|
||||
semanticToCamera: exact(
|
||||
temporalBinding.semantic_to_camera,
|
||||
"exact-sequence-and-session-time",
|
||||
"E47 semantic/camera binding",
|
||||
),
|
||||
cameraToLidar: exact(
|
||||
temporalBinding.camera_to_lidar,
|
||||
"accepted-e6-nearest-host-arrival-best-effort",
|
||||
"E47 camera/LiDAR binding",
|
||||
),
|
||||
clockBasis: exact(
|
||||
temporalBinding.clock_basis,
|
||||
"recorded-host-monotonic-arrival",
|
||||
"E47 clock basis",
|
||||
),
|
||||
maximumLidarCameraDeltaMs: finite(
|
||||
temporalBinding.maximum_lidar_camera_delta_ms,
|
||||
"E47 maximum camera/LiDAR delta",
|
||||
),
|
||||
maximumPosePointDeltaMs: finite(
|
||||
temporalBinding.maximum_pose_point_delta_ms,
|
||||
"E47 maximum pose/point delta",
|
||||
),
|
||||
physicalSynchronizationProven: exact(
|
||||
temporalBinding.physical_synchronization_proven,
|
||||
false,
|
||||
"E47 physical synchronization",
|
||||
),
|
||||
},
|
||||
taxonomy: taxonomy(item.taxonomy),
|
||||
metrics: {
|
||||
frames: frameMetrics,
|
||||
points: pointMetrics,
|
||||
observations: observationMetrics,
|
||||
runtime: runtimeMetrics,
|
||||
},
|
||||
acceptance: {
|
||||
artifactContractPassed: exact(
|
||||
acceptance.artifact_contract_passed,
|
||||
true,
|
||||
"E47 artifact contract",
|
||||
),
|
||||
frameAccountingPassed: exact(
|
||||
acceptance.frame_accounting_passed,
|
||||
true,
|
||||
"E47 frame accounting",
|
||||
),
|
||||
pointAccountingPassed: exact(
|
||||
acceptance.point_accounting_passed,
|
||||
true,
|
||||
"E47 point accounting",
|
||||
),
|
||||
observationBindingPassed: exact(
|
||||
acceptance.observation_binding_passed,
|
||||
true,
|
||||
"E47 observation binding",
|
||||
),
|
||||
temporalBindingPassed: exact(
|
||||
acceptance.temporal_binding_passed,
|
||||
true,
|
||||
"E47 temporal binding",
|
||||
),
|
||||
independentSemanticTruthPassed: exact(
|
||||
acceptance.independent_semantic_truth_passed,
|
||||
false,
|
||||
"E47 independent truth",
|
||||
),
|
||||
providerPromoted: exact(
|
||||
acceptance.provider_promoted,
|
||||
false,
|
||||
"E47 provider promotion",
|
||||
),
|
||||
},
|
||||
limitations: array(item.limitations, "E47 limitations").map(
|
||||
(entry) => text(entry, "E47 limitation"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE47SemanticSlamResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E47SemanticSlamResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e47-semantic-slam/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new E47SemanticSlamContractError(`E47 LAB недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const payload = object(await response.json(), "E47 catalog");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.e47-semantic-slam-catalog/v1",
|
||||
"E47 catalog schema",
|
||||
);
|
||||
const items = array(payload.items, "E47 catalog items");
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
|
||||
function parseFrame(
|
||||
value: unknown,
|
||||
expectedSequence: number,
|
||||
declaredTaxonomy: readonly E47SemanticClass[] | undefined,
|
||||
): E47SemanticTimelineFrame {
|
||||
const item = object(value, "E47 semantic frame");
|
||||
exact(item.schema_version, "missioncore.e47-semantic-slam-frame/v1", "E47 frame schema");
|
||||
const sequence = integer(item.sequence, "E47 frame sequence");
|
||||
if (sequence !== expectedSequence) {
|
||||
throw new E47SemanticSlamContractError("E47 frame sequence: нарушен порядок.");
|
||||
}
|
||||
const sourcePointCount = integer(item.source_point_count, "E47 frame source points");
|
||||
const classIds = array(item.class_ids, "E47 frame classes").map((entry) => {
|
||||
const parsed = finite(entry, "E47 frame class");
|
||||
if (!Number.isInteger(parsed) || parsed < -1 || parsed > 255) {
|
||||
throw new E47SemanticSlamContractError("E47 frame class: вышел за контракт.");
|
||||
}
|
||||
return parsed;
|
||||
});
|
||||
const statusCodes = array(item.status_codes, "E47 frame statuses").map((entry) => {
|
||||
const parsed = integer(entry, "E47 frame status");
|
||||
if (parsed > 3) {
|
||||
throw new E47SemanticSlamContractError("E47 frame status: неизвестное значение.");
|
||||
}
|
||||
return parsed;
|
||||
});
|
||||
if (classIds.length !== sourcePointCount || statusCodes.length !== sourcePointCount) {
|
||||
throw new E47SemanticSlamContractError("E47 frame point accounting: нарушен контракт.");
|
||||
}
|
||||
if (classIds.some((classId, index) => {
|
||||
const status = statusCodes[index];
|
||||
return status === 0 || status === 1 ? classId !== -1 : classId < 0;
|
||||
})) {
|
||||
throw new E47SemanticSlamContractError("E47 frame class/status binding: нарушен контракт.");
|
||||
}
|
||||
if (declaredTaxonomy) {
|
||||
const classesById = new Map(declaredTaxonomy.map((item) => [item.classId, item]));
|
||||
if (classIds.some((classId, index) => {
|
||||
const status = statusCodes[index];
|
||||
if (status !== 2 && status !== 3) return false;
|
||||
const semanticClass = classesById.get(classId);
|
||||
return !semanticClass
|
||||
|| (status === 2 && semanticClass.disposition !== "ambiguous")
|
||||
|| (status === 3 && semanticClass.disposition !== "labeled");
|
||||
})) {
|
||||
throw new E47SemanticSlamContractError("E47 frame taxonomy binding: нарушен контракт.");
|
||||
}
|
||||
}
|
||||
const counts = object(item.counts, "E47 frame counts");
|
||||
const parsedCounts = {
|
||||
labeled: integer(counts.labeled, "E47 frame labeled"),
|
||||
ambiguous: integer(counts.ambiguous, "E47 frame ambiguous"),
|
||||
unprojected: integer(counts.unprojected, "E47 frame unprojected"),
|
||||
absent: integer(counts.absent, "E47 frame absent"),
|
||||
};
|
||||
if (Object.values(parsedCounts).reduce((sum, count) => sum + count, 0) !== sourcePointCount) {
|
||||
throw new E47SemanticSlamContractError("E47 frame status accounting: нарушен контракт.");
|
||||
}
|
||||
const actualCounts = {
|
||||
labeled: statusCodes.filter((status) => status === 3).length,
|
||||
ambiguous: statusCodes.filter((status) => status === 2).length,
|
||||
unprojected: statusCodes.filter((status) => status === 1).length,
|
||||
absent: statusCodes.filter((status) => status === 0).length,
|
||||
};
|
||||
if (Object.keys(actualCounts).some(
|
||||
(key) => actualCounts[key as keyof typeof actualCounts]
|
||||
!== parsedCounts[key as keyof typeof parsedCounts],
|
||||
)) {
|
||||
throw new E47SemanticSlamContractError("E47 frame status histogram: нарушен контракт.");
|
||||
}
|
||||
return { sequence, sourcePointCount, classIds, statusCodes, counts: parsedCounts };
|
||||
}
|
||||
|
||||
export async function fetchE47SemanticTimelineChunk(
|
||||
result: string,
|
||||
startSequence: number,
|
||||
frameCount: number,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
taxonomy,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
taxonomy?: readonly E47SemanticClass[];
|
||||
} = {},
|
||||
): Promise<E47SemanticTimelineChunk> {
|
||||
resultId(result);
|
||||
const parameters = new URLSearchParams({
|
||||
start: String(startSequence),
|
||||
count: String(frameCount),
|
||||
});
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e47-semantic-slam/results/${result}/timeline/chunk?${parameters}`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E47SemanticSlamContractError(`E47 timeline chunk: HTTP ${response.status}.`);
|
||||
}
|
||||
const payload = object(await response.json(), "E47 semantic chunk");
|
||||
exact(payload.schema_version, "missioncore.e47-semantic-slam-chunk/v1", "E47 chunk schema");
|
||||
exact(payload.result_id, result, "E47 chunk result");
|
||||
const parsedStart = integer(payload.start_sequence, "E47 chunk start");
|
||||
if (parsedStart !== startSequence) {
|
||||
throw new E47SemanticSlamContractError("E47 chunk start: нарушен контракт.");
|
||||
}
|
||||
const frames = array(payload.frames, "E47 chunk frames").map(
|
||||
(frame, offset) => parseFrame(frame, parsedStart + offset, taxonomy),
|
||||
);
|
||||
const parsedCount = integer(payload.frame_count, "E47 chunk count");
|
||||
if (parsedCount !== frames.length || parsedCount > frameCount) {
|
||||
throw new E47SemanticSlamContractError("E47 chunk frame count: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId: result,
|
||||
startSequence: parsedStart,
|
||||
frameCount: parsedCount,
|
||||
nextSequence: payload.next_sequence === null
|
||||
? null
|
||||
: integer(payload.next_sequence, "E47 next sequence"),
|
||||
frames,
|
||||
};
|
||||
}
|
||||
|
||||
export function e47SemanticMaskUrl(result: string, sequence: number): string {
|
||||
resultId(result);
|
||||
if (!Number.isInteger(sequence) || sequence < 0 || sequence >= 4489) {
|
||||
throw new E47SemanticSlamContractError("E47 mask sequence: вне recorded replay.");
|
||||
}
|
||||
return `/api/v1/laboratory/e47-semantic-slam/results/${result}/masks/${sequence}`;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
M4Matrix3,
|
||||
M4Point3,
|
||||
M4ThreatTimelineFrame,
|
||||
} from "./m4ReplayThreat";
|
||||
|
||||
export interface M4LocalSurfaceProfile {
|
||||
windowSeconds: number;
|
||||
voxelSizeM: number;
|
||||
radiusM: number;
|
||||
pointLimit: number;
|
||||
}
|
||||
|
||||
export interface M4LocalSurface {
|
||||
pointsBodyXyzM: readonly M4Point3[];
|
||||
sourceFrameCount: number;
|
||||
sourcePointCount: number;
|
||||
voxelCount: number;
|
||||
}
|
||||
|
||||
function bodyPointToMap(
|
||||
point: M4Point3,
|
||||
origin: M4Point3,
|
||||
basis: M4Matrix3,
|
||||
): M4Point3 {
|
||||
return [
|
||||
origin[0] + point[0] * basis[0][0] + point[1] * basis[0][1] + point[2] * basis[0][2],
|
||||
origin[1] + point[0] * basis[1][0] + point[1] * basis[1][1] + point[2] * basis[1][2],
|
||||
origin[2] + point[0] * basis[2][0] + point[1] * basis[2][1] + point[2] * basis[2][2],
|
||||
];
|
||||
}
|
||||
|
||||
function mapPointToBody(
|
||||
point: M4Point3,
|
||||
origin: M4Point3,
|
||||
basis: M4Matrix3,
|
||||
): M4Point3 {
|
||||
const delta: M4Point3 = [
|
||||
point[0] - origin[0],
|
||||
point[1] - origin[1],
|
||||
point[2] - origin[2],
|
||||
];
|
||||
return [
|
||||
delta[0] * basis[0][0] + delta[1] * basis[1][0] + delta[2] * basis[2][0],
|
||||
delta[0] * basis[0][1] + delta[1] * basis[1][1] + delta[2] * basis[2][1],
|
||||
delta[0] * basis[0][2] + delta[1] * basis[1][2] + delta[2] * basis[2][2],
|
||||
];
|
||||
}
|
||||
|
||||
function emptySurface(): M4LocalSurface {
|
||||
return {
|
||||
pointsBodyXyzM: [],
|
||||
sourceFrameCount: 0,
|
||||
sourcePointCount: 0,
|
||||
voxelCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildM4LocalSurface(
|
||||
availableFrames: readonly M4ThreatTimelineFrame[],
|
||||
activeFrame: M4ThreatTimelineFrame | null,
|
||||
profile: M4LocalSurfaceProfile,
|
||||
): M4LocalSurface {
|
||||
const activeBody = activeFrame?.bodyFrame;
|
||||
if (
|
||||
!activeFrame
|
||||
|| !activeBody
|
||||
|| profile.windowSeconds <= 0
|
||||
|| profile.voxelSizeM <= 0
|
||||
|| profile.radiusM <= 0
|
||||
|| profile.pointLimit < 1
|
||||
) {
|
||||
return emptySurface();
|
||||
}
|
||||
|
||||
const startTimeNs = activeFrame.sourceTimeNs - profile.windowSeconds * 1_000_000_000;
|
||||
const frames = availableFrames
|
||||
.filter((frame) => (
|
||||
frame.bodyFrame
|
||||
&& frame.sourceTimeNs >= startTimeNs
|
||||
&& frame.sourceTimeNs <= activeFrame.sourceTimeNs
|
||||
))
|
||||
.sort((left, right) => left.sequence - right.sequence);
|
||||
if (!frames.length) return emptySurface();
|
||||
|
||||
const radiusSquared = profile.radiusM * profile.radiusM;
|
||||
const voxels = new Map<string, M4Point3>();
|
||||
let sourcePointCount = 0;
|
||||
for (const frame of frames) {
|
||||
const sourceBody = frame.bodyFrame;
|
||||
if (!sourceBody) continue;
|
||||
sourcePointCount += frame.pointCloudBodyXyzM.length;
|
||||
for (const sourcePoint of frame.pointCloudBodyXyzM) {
|
||||
const mapPoint = bodyPointToMap(
|
||||
sourcePoint,
|
||||
sourceBody.originMapXyzM,
|
||||
sourceBody.basisMapFromBody,
|
||||
);
|
||||
const activePoint = mapPointToBody(
|
||||
mapPoint,
|
||||
activeBody.originMapXyzM,
|
||||
activeBody.basisMapFromBody,
|
||||
);
|
||||
if (
|
||||
activePoint[0] * activePoint[0]
|
||||
+ activePoint[1] * activePoint[1]
|
||||
+ activePoint[2] * activePoint[2]
|
||||
> radiusSquared
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const key = [
|
||||
Math.floor(mapPoint[0] / profile.voxelSizeM),
|
||||
Math.floor(mapPoint[1] / profile.voxelSizeM),
|
||||
Math.floor(mapPoint[2] / profile.voxelSizeM),
|
||||
].join(":");
|
||||
if (!voxels.has(key)) voxels.set(key, activePoint);
|
||||
}
|
||||
}
|
||||
|
||||
const retained = [...voxels.values()];
|
||||
const stride = Math.max(1, Math.ceil(retained.length / profile.pointLimit));
|
||||
return {
|
||||
pointsBodyXyzM: retained
|
||||
.filter((_, index) => index % stride === 0)
|
||||
.slice(0, profile.pointLimit),
|
||||
sourceFrameCount: frames.length,
|
||||
sourcePointCount,
|
||||
voxelCount: voxels.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
export type M4ThreatDecision = "threat" | "not-threat" | "unknown";
|
||||
export type M4ThreatMotion = "moving" | "stationary" | "unknown";
|
||||
export type M4Point3 = readonly [number, number, number];
|
||||
export type M4Matrix3 = readonly [M4Point3, M4Point3, M4Point3];
|
||||
|
||||
export interface M4ThreatReplayResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
profileId: string;
|
||||
rigProfileId: string;
|
||||
corridorProfileId: string;
|
||||
sourceResultIds: {
|
||||
detector: string;
|
||||
geometry: string;
|
||||
temporal: string;
|
||||
};
|
||||
metrics: {
|
||||
decisions: Record<M4ThreatDecision, number>;
|
||||
evidence: {
|
||||
cameraOnly: number;
|
||||
currentMetric: number;
|
||||
rollingMapRetained: number;
|
||||
staleOrHeld: number;
|
||||
};
|
||||
fixtures: {
|
||||
critical: number;
|
||||
criticalFalseNotThreat: number;
|
||||
passed: number;
|
||||
total: number;
|
||||
};
|
||||
runtime: {
|
||||
framesPerSecond: number;
|
||||
providerLatencyP50Ms: number;
|
||||
providerLatencyP95Ms: number;
|
||||
providerLatencyMaxMs: number;
|
||||
};
|
||||
bodyFrame: {
|
||||
available: number;
|
||||
qualified: number;
|
||||
rejected: number;
|
||||
cameraForwardAlignmentDeg: {
|
||||
p95: number;
|
||||
maximum: number;
|
||||
};
|
||||
};
|
||||
reasonCounts: Readonly<Record<string, number>>;
|
||||
};
|
||||
configuration: {
|
||||
virtualBodyM: readonly [number, number];
|
||||
nominalSensorHeightM: number;
|
||||
forwardCorridorM: number;
|
||||
predictionHorizonSeconds: number;
|
||||
bodyFrame: {
|
||||
origin: "local-surface-vertical-projection";
|
||||
up: "vendor-slam-map-gravity-axis";
|
||||
forward: "smoothed-slam-trajectory-validated-by-camera-axis";
|
||||
};
|
||||
};
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface M4ThreatAssessment {
|
||||
componentId: string;
|
||||
decision: M4ThreatDecision;
|
||||
corridorIntersection: "intersects" | "clear" | "unknown";
|
||||
relativeSpeedMps: number | null;
|
||||
closestApproachM: number | null;
|
||||
ttcSeconds: number | null;
|
||||
reasonCodes: readonly string[];
|
||||
}
|
||||
|
||||
export interface M4ThreatMetricVisual {
|
||||
componentId: string;
|
||||
state: "current" | "retained" | "held" | "expired";
|
||||
motion: M4ThreatMotion;
|
||||
centroidBodyXyzM: M4Point3;
|
||||
cellCentersBodyXyzM: readonly M4Point3[];
|
||||
assessment: M4ThreatAssessment;
|
||||
}
|
||||
|
||||
export interface M4ThreatCameraProposal {
|
||||
proposalId: string;
|
||||
bboxXyxy: readonly [number, number, number, number];
|
||||
objectness: number;
|
||||
semanticHint: string | null;
|
||||
occupiedSupport: boolean;
|
||||
rangeM: number | null;
|
||||
threatDecision: M4ThreatDecision | null;
|
||||
threatReasonCodes: readonly string[];
|
||||
}
|
||||
|
||||
export interface M4ThreatVisualFrame {
|
||||
resultId: string;
|
||||
cameraUrl: string;
|
||||
ordinal: number;
|
||||
sequence: number;
|
||||
frameId: string;
|
||||
sourceTimeNs: number;
|
||||
pointCloudBodyXyzM: readonly M4Point3[];
|
||||
pointCloudSourceCount: number;
|
||||
pointCloudSampleCount: number;
|
||||
pointCloudLayer: "current-increment";
|
||||
rollingMapComponentCount: number;
|
||||
metricObstacles: readonly M4ThreatMetricVisual[];
|
||||
cameraProposals: readonly M4ThreatCameraProposal[];
|
||||
rig: {
|
||||
lengthM: number;
|
||||
widthM: number;
|
||||
nominalSensorHeightM: number;
|
||||
};
|
||||
corridor: {
|
||||
forwardLengthM: number;
|
||||
rearMarginM: number;
|
||||
halfWidthM: number;
|
||||
predictionHorizonSeconds: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface M4ThreatVisualIndexItem {
|
||||
ordinal: number;
|
||||
sequence: number;
|
||||
frameId: string;
|
||||
sourceTimeNs: number;
|
||||
metricObstacleCount: number;
|
||||
cameraProposalCount: number;
|
||||
pointCloudSampleCount: number;
|
||||
}
|
||||
|
||||
export interface M4ThreatTimelineFrame {
|
||||
sequence: number;
|
||||
frameId: string;
|
||||
sourceTimeNs: number;
|
||||
sessionSeconds: number;
|
||||
sourceAvailable: boolean;
|
||||
spatialAvailable: boolean;
|
||||
bodyFrame: {
|
||||
originMapXyzM: M4Point3;
|
||||
basisMapFromBody: M4Matrix3;
|
||||
} | null;
|
||||
pointCloudBodyXyzM: readonly M4Point3[];
|
||||
pointCloudSourceCount: number;
|
||||
pointCloudSampleCount: number;
|
||||
pointCloudLayer: "current-increment";
|
||||
rollingMapComponentCount: number;
|
||||
metricObstacles: readonly M4ThreatMetricVisual[];
|
||||
cameraProposals: readonly M4ThreatCameraProposal[];
|
||||
decisionCounts: Record<M4ThreatDecision, number>;
|
||||
cameraUrl: string;
|
||||
}
|
||||
|
||||
export interface M4ThreatTimeline {
|
||||
resultId: string;
|
||||
recordedSourceSessionId: "20260720T065719Z_viewer_live";
|
||||
imageWidth: 800;
|
||||
imageHeight: 600;
|
||||
frameCount: 4489;
|
||||
frameTimesNs: readonly number[];
|
||||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
nominalFrameIntervalSeconds: number;
|
||||
nominalRateHz: number;
|
||||
maxChunkFrames: number;
|
||||
pointSampleLimit: number;
|
||||
maximumSourcePointsPerFrame: number;
|
||||
pointDelivery: "exact-current-increment";
|
||||
sourceRepresentationId: "registered-map-increment-v1";
|
||||
localSurfaceVisualization: {
|
||||
derivation: "bounded-registered-increment-accumulation";
|
||||
windowSeconds: number;
|
||||
voxelSizeM: number;
|
||||
radiusM: number;
|
||||
pointLimit: number;
|
||||
authority: "visual-derived";
|
||||
};
|
||||
occupiedVoxelSizeM: number;
|
||||
rig: M4ThreatVisualFrame["rig"];
|
||||
corridor: M4ThreatVisualFrame["corridor"];
|
||||
}
|
||||
|
||||
export interface M4ThreatTimelineChunk {
|
||||
resultId: string;
|
||||
startSequence: number;
|
||||
frameCount: number;
|
||||
nextSequence: number | null;
|
||||
frames: readonly M4ThreatTimelineFrame[];
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
class M4ThreatContractError extends Error {}
|
||||
const object = (value: unknown, label: string): Record<string, unknown> => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new M4ThreatContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
const array = (value: unknown, label: string): readonly unknown[] => {
|
||||
if (!Array.isArray(value)) throw new M4ThreatContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
};
|
||||
const text = (value: unknown, label: string): string => {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new M4ThreatContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const number = (value: unknown, label: string): number => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new M4ThreatContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const integer = (value: unknown, label: string): number => {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new M4ThreatContractError(`${label}: ожидалось целое.`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const exact = <T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T => {
|
||||
if (value !== expected) throw new M4ThreatContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
};
|
||||
const optionalNumber = (value: unknown, label: string): number | null => (
|
||||
value === null ? null : number(value, label)
|
||||
);
|
||||
const vector = (value: unknown, size: number, label: string): number[] => {
|
||||
const parsed = array(value, label).map((item) => number(item, label));
|
||||
if (parsed.length !== size) throw new M4ThreatContractError(`${label}: неверная размерность.`);
|
||||
return parsed;
|
||||
};
|
||||
const point3 = (value: unknown, label: string): M4Point3 => {
|
||||
const parsed = vector(value, 3, label);
|
||||
return [parsed[0]!, parsed[1]!, parsed[2]!];
|
||||
};
|
||||
const decision = (value: unknown, label: string): M4ThreatDecision => {
|
||||
if (value !== "threat" && value !== "not-threat" && value !== "unknown") {
|
||||
throw new M4ThreatContractError(`${label}: неизвестное решение.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const motion = (value: unknown): M4ThreatMotion => {
|
||||
if (value !== "moving" && value !== "stationary" && value !== "unknown") {
|
||||
throw new M4ThreatContractError("M4.6 motion: неизвестное состояние.");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const resultId = (value: unknown): string => {
|
||||
const parsed = text(value, "M4.6 result id");
|
||||
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new M4ThreatContractError("M4.6 result id: нарушена идентичность.");
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
function parseAssessment(value: unknown): M4ThreatAssessment {
|
||||
const item = object(value, "M4.6 assessment");
|
||||
const intersection = text(item.corridor_intersection, "M4.6 intersection");
|
||||
if (intersection !== "intersects" && intersection !== "clear" && intersection !== "unknown") {
|
||||
throw new M4ThreatContractError("M4.6 intersection: неизвестное состояние.");
|
||||
}
|
||||
return {
|
||||
componentId: text(item.component_id, "M4.6 component"),
|
||||
decision: decision(item.decision, "M4.6 decision"),
|
||||
corridorIntersection: intersection,
|
||||
relativeSpeedMps: optionalNumber(item.relative_speed_mps, "M4.6 relative speed"),
|
||||
closestApproachM: optionalNumber(item.closest_approach_m, "M4.6 closest approach"),
|
||||
ttcSeconds: optionalNumber(item.ttc_seconds, "M4.6 TTC"),
|
||||
reasonCodes: array(item.reason_codes, "M4.6 reasons").map((reason) => text(reason, "M4.6 reason")),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCameraProposal(value: unknown): M4ThreatCameraProposal {
|
||||
const item = object(value, "M4.6 camera proposal");
|
||||
return {
|
||||
proposalId: text(item.proposal_id, "M4.6 proposal id"),
|
||||
bboxXyxy: vector(item.bbox_xyxy, 4, "M4.6 bbox") as [number, number, number, number],
|
||||
objectness: number(item.objectness, "M4.6 objectness"),
|
||||
semanticHint: item.semantic_hint === null ? null : text(item.semantic_hint, "M4.6 hint"),
|
||||
occupiedSupport: typeof item.occupied_support === "boolean" ? item.occupied_support : false,
|
||||
rangeM: optionalNumber(item.range_m, "M4.6 range"),
|
||||
threatDecision: item.threat_decision === null
|
||||
? null
|
||||
: decision(item.threat_decision, "M4.6 camera threat"),
|
||||
threatReasonCodes: array(item.threat_reason_codes, "M4.6 threat reasons").map(
|
||||
(reason) => text(reason, "M4.6 threat reason"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMetricVisual(value: unknown): M4ThreatMetricVisual {
|
||||
const item = object(value, "M4.6 metric visual");
|
||||
const state = text(item.state, "M4.6 temporal state");
|
||||
if (
|
||||
state !== "current"
|
||||
&& state !== "retained"
|
||||
&& state !== "held"
|
||||
&& state !== "expired"
|
||||
) {
|
||||
throw new M4ThreatContractError("M4.6 temporal state: неизвестное состояние.");
|
||||
}
|
||||
return {
|
||||
componentId: text(item.component_id, "M4.6 visual component"),
|
||||
state,
|
||||
motion: motion(item.motion),
|
||||
centroidBodyXyzM: vector(item.centroid_body_xyz_m, 3, "M4.6 centroid") as [number, number, number],
|
||||
cellCentersBodyXyzM: array(item.cell_centers_body_xyz_m, "M4.6 cells").map(
|
||||
(point) => vector(point, 3, "M4.6 cell") as [number, number, number],
|
||||
),
|
||||
assessment: parseAssessment(item.assessment),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatReplayResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<M4ThreatReplayResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/m4-threat/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "M4.6 catalog");
|
||||
exact(catalog.schema_version, "missioncore.m4-threat-replay-catalog/v1", "M4.6 catalog schema");
|
||||
const items = array(catalog.items, "M4.6 results");
|
||||
if (!items.length) return null;
|
||||
const item = object(items[0], "M4.6 result");
|
||||
exact(item.schema_version, "missioncore.m4-threat-replay-view/v1", "M4.6 view schema");
|
||||
exact(item.accepted, true, "M4.6 acceptance");
|
||||
exact(item.authority, "replay-simulated", "M4.6 authority");
|
||||
exact(item.physical_collision_accepted, false, "M4.6 physical authority");
|
||||
exact(item.actuation_allowed, false, "M4.6 actuation");
|
||||
const metrics = object(item.metrics, "M4.6 metrics");
|
||||
const decisions = object(metrics.decisions, "M4.6 decisions");
|
||||
const evidence = object(metrics.evidence, "M4.6 evidence");
|
||||
const fixtures = object(metrics.fixtures, "M4.6 fixtures");
|
||||
const runtime = object(metrics.runtime, "M4.6 runtime");
|
||||
const bodyFrame = object(metrics.body_frame, "M4.6 body frame");
|
||||
const cameraAlignment = object(
|
||||
bodyFrame.camera_forward_alignment_deg,
|
||||
"M4.6 camera alignment",
|
||||
);
|
||||
const configuration = object(item.configuration, "M4.6 configuration");
|
||||
const configuredBodyFrame = object(configuration.body_frame, "M4.6 configured body frame");
|
||||
const sourceResultIds = object(item.source_result_ids, "M4.6 sources");
|
||||
return {
|
||||
resultId: resultId(item.result_id),
|
||||
createdAtUtc: text(item.created_at_utc, "M4.6 created"),
|
||||
profileId: text(item.profile_id, "M4.6 profile"),
|
||||
rigProfileId: text(item.rig_profile_id, "M4.6 rig"),
|
||||
corridorProfileId: text(item.corridor_profile_id, "M4.6 corridor"),
|
||||
sourceResultIds: {
|
||||
detector: text(sourceResultIds.detector, "M4.6 detector"),
|
||||
geometry: text(sourceResultIds.geometry, "M4.6 geometry"),
|
||||
temporal: text(sourceResultIds.temporal, "M4.6 temporal"),
|
||||
},
|
||||
metrics: {
|
||||
decisions: {
|
||||
threat: integer(decisions.threat, "M4.6 threat count"),
|
||||
"not-threat": integer(decisions["not-threat"], "M4.6 clear count"),
|
||||
unknown: integer(decisions.unknown, "M4.6 unknown count"),
|
||||
},
|
||||
evidence: {
|
||||
cameraOnly: integer(evidence["camera-only"], "M4.6 camera-only"),
|
||||
currentMetric: integer(evidence["current-metric"], "M4.6 metric"),
|
||||
rollingMapRetained: evidence["rolling-map-retained"] === undefined
|
||||
? 0
|
||||
: integer(evidence["rolling-map-retained"], "M4.6 rolling map"),
|
||||
staleOrHeld: integer(evidence["stale-or-held"], "M4.6 stale"),
|
||||
},
|
||||
fixtures: {
|
||||
critical: integer(fixtures.critical, "M4.6 critical fixtures"),
|
||||
criticalFalseNotThreat: integer(fixtures.critical_false_not_threat, "M4.6 false-safe"),
|
||||
passed: integer(fixtures.passed, "M4.6 fixtures passed"),
|
||||
total: integer(fixtures.total, "M4.6 fixtures total"),
|
||||
},
|
||||
runtime: {
|
||||
framesPerSecond: number(runtime.frames_per_second, "M4.6 FPS"),
|
||||
providerLatencyP50Ms: number(runtime.provider_latency_p50_ms, "M4.6 p50"),
|
||||
providerLatencyP95Ms: number(runtime.provider_latency_p95_ms, "M4.6 p95"),
|
||||
providerLatencyMaxMs: number(runtime.provider_latency_max_ms, "M4.6 max"),
|
||||
},
|
||||
bodyFrame: {
|
||||
available: integer(bodyFrame.available, "M4.6 available body frames"),
|
||||
qualified: integer(bodyFrame.qualified, "M4.6 qualified body frames"),
|
||||
rejected: integer(bodyFrame.rejected, "M4.6 rejected body frames"),
|
||||
cameraForwardAlignmentDeg: {
|
||||
p95: number(cameraAlignment.p95, "M4.6 body frame camera alignment p95"),
|
||||
maximum: number(cameraAlignment.maximum, "M4.6 body frame camera alignment maximum"),
|
||||
},
|
||||
},
|
||||
reasonCounts: Object.fromEntries(
|
||||
Object.entries(object(metrics.reason_counts, "M4.6 reasons")).map(
|
||||
([key, value]) => [key, integer(value, `M4.6 ${key}`)],
|
||||
),
|
||||
),
|
||||
},
|
||||
configuration: {
|
||||
virtualBodyM: vector(configuration.virtual_body_m, 2, "M4.6 body") as [number, number],
|
||||
nominalSensorHeightM: number(configuration.nominal_sensor_height_m, "M4.6 height"),
|
||||
forwardCorridorM: number(configuration.forward_corridor_m, "M4.6 corridor"),
|
||||
predictionHorizonSeconds: number(configuration.prediction_horizon_seconds, "M4.6 horizon"),
|
||||
bodyFrame: {
|
||||
origin: exact(
|
||||
configuredBodyFrame.origin,
|
||||
"local-surface-vertical-projection",
|
||||
"M4.6 body frame origin",
|
||||
),
|
||||
up: exact(
|
||||
configuredBodyFrame.up,
|
||||
"vendor-slam-map-gravity-axis",
|
||||
"M4.6 body frame up",
|
||||
),
|
||||
forward: exact(
|
||||
configuredBodyFrame.forward,
|
||||
"smoothed-slam-trajectory-validated-by-camera-axis",
|
||||
"M4.6 body frame forward",
|
||||
),
|
||||
},
|
||||
},
|
||||
limitations: array(item.limitations, "M4.6 limitations").map((value) => text(value, "M4.6 limitation")),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatVisualIndex(
|
||||
result: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<readonly M4ThreatVisualIndexItem[]> {
|
||||
const response = await fetcher(`/api/v1/laboratory/m4-threat/results/${result}/visuals`, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 visual index: HTTP ${response.status}.`);
|
||||
const payload = object(await response.json(), "M4.6 visual index");
|
||||
exact(payload.schema_version, "missioncore.m4-threat-visual-catalog/v1", "M4.6 visual schema");
|
||||
exact(payload.result_id, result, "M4.6 visual result");
|
||||
return array(payload.items, "M4.6 visual items").map((raw) => {
|
||||
const item = object(raw, "M4.6 visual item");
|
||||
return {
|
||||
ordinal: integer(item.ordinal, "M4.6 visual ordinal"),
|
||||
sequence: integer(item.sequence, "M4.6 visual sequence"),
|
||||
frameId: text(item.frame_id, "M4.6 visual frame"),
|
||||
sourceTimeNs: integer(item.source_time_ns, "M4.6 visual time"),
|
||||
metricObstacleCount: integer(item.metric_obstacle_count, "M4.6 visual metric"),
|
||||
cameraProposalCount: integer(item.camera_proposal_count, "M4.6 visual camera"),
|
||||
pointCloudSampleCount: integer(item.point_cloud_sample_count, "M4.6 visual points"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatVisual(
|
||||
result: string,
|
||||
ordinal: number,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<M4ThreatVisualFrame> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/m4-threat/results/${result}/visuals/${ordinal}`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 visual frame: HTTP ${response.status}.`);
|
||||
const item = object(await response.json(), "M4.6 visual frame");
|
||||
const frameSchema = text(item.schema_version, "M4.6 frame schema");
|
||||
if (
|
||||
frameSchema !== "missioncore.perception-threat-visual-frame/v1"
|
||||
&& frameSchema !== "missioncore.perception-threat-visual-frame/v2"
|
||||
) {
|
||||
throw new M4ThreatContractError("M4.6 frame schema: нарушен контракт.");
|
||||
}
|
||||
const rollingMapV2 = frameSchema === "missioncore.perception-threat-visual-frame/v2";
|
||||
exact(item.result_id, result, "M4.6 frame result");
|
||||
const rig = object(item.rig, "M4.6 visual rig");
|
||||
const corridor = object(item.corridor, "M4.6 visual corridor");
|
||||
return {
|
||||
resultId: result,
|
||||
cameraUrl: text(item.camera_url, "M4.6 camera URL"),
|
||||
ordinal: integer(item.ordinal, "M4.6 ordinal"),
|
||||
sequence: integer(item.sequence, "M4.6 sequence"),
|
||||
frameId: text(item.frame_id, "M4.6 frame id"),
|
||||
sourceTimeNs: integer(item.source_time_ns, "M4.6 frame time"),
|
||||
pointCloudBodyXyzM: array(item.point_cloud_body_xyz_m, "M4.6 points").map(
|
||||
(point) => vector(point, 3, "M4.6 point") as [number, number, number],
|
||||
),
|
||||
pointCloudSourceCount: integer(item.point_cloud_source_count, "M4.6 source points"),
|
||||
pointCloudSampleCount: integer(item.point_cloud_sample_count, "M4.6 sample points"),
|
||||
pointCloudLayer: rollingMapV2
|
||||
? exact(item.point_cloud_layer, "current-increment", "M4.6 point layer")
|
||||
: "current-increment",
|
||||
rollingMapComponentCount: rollingMapV2
|
||||
? integer(item.rolling_map_component_count, "M4.6 rolling components")
|
||||
: 0,
|
||||
metricObstacles: array(item.metric_obstacles, "M4.6 metric visuals").map(parseMetricVisual),
|
||||
cameraProposals: array(item.camera_proposals, "M4.6 camera proposals").map(parseCameraProposal),
|
||||
rig: {
|
||||
lengthM: number(rig.length_m, "M4.6 rig length"),
|
||||
widthM: number(rig.width_m, "M4.6 rig width"),
|
||||
nominalSensorHeightM: number(rig.nominal_sensor_height_m, "M4.6 sensor height"),
|
||||
},
|
||||
corridor: {
|
||||
forwardLengthM: number(corridor.forward_length_m, "M4.6 forward corridor"),
|
||||
rearMarginM: number(corridor.rear_margin_m, "M4.6 rear corridor"),
|
||||
halfWidthM: number(corridor.half_width_m, "M4.6 half width"),
|
||||
predictionHorizonSeconds: number(corridor.prediction_horizon_seconds, "M4.6 visual horizon"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatTimeline(
|
||||
result: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<M4ThreatTimeline> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/m4-threat/results/${result}/timeline`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 timeline: HTTP ${response.status}.`);
|
||||
const payload = object(await response.json(), "M4.6 timeline");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.recorded-spatial-evidence-timeline/v1",
|
||||
"M4.6 timeline schema",
|
||||
);
|
||||
exact(payload.result_id, result, "M4.6 timeline result");
|
||||
exact(payload.authority, "replay-simulated", "M4.6 timeline authority");
|
||||
const recorded = object(payload.recorded_source, "M4.6 recorded source");
|
||||
exact(
|
||||
recorded.session_id,
|
||||
"20260720T065719Z_viewer_live",
|
||||
"M4.6 recorded session",
|
||||
);
|
||||
exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id");
|
||||
exact(
|
||||
recorded.representation_id,
|
||||
"registered-map-increment-v1",
|
||||
"M4.6 recorded representation",
|
||||
);
|
||||
exact(
|
||||
recorded.synchronization,
|
||||
"host-arrival-best-effort",
|
||||
"M4.6 recorded synchronization",
|
||||
);
|
||||
const frameCount = exact(payload.frame_count, 4489, "M4.6 timeline frame count");
|
||||
const frameTimesNs = array(payload.frame_times_ns, "M4.6 timeline index").map(
|
||||
(value) => integer(value, "M4.6 timeline time"),
|
||||
);
|
||||
if (
|
||||
frameTimesNs.length !== frameCount
|
||||
|| frameTimesNs.some((value, index) => index > 0 && value <= (frameTimesNs[index - 1] ?? value))
|
||||
) {
|
||||
throw new M4ThreatContractError("M4.6 timeline index: нарушен порядок.");
|
||||
}
|
||||
const rig = object(payload.rig, "M4.6 timeline rig");
|
||||
const corridor = object(payload.corridor, "M4.6 timeline corridor");
|
||||
const localSurface = object(
|
||||
payload.local_surface_visualization,
|
||||
"M4.6 local surface profile",
|
||||
);
|
||||
return {
|
||||
resultId: result,
|
||||
recordedSourceSessionId: "20260720T065719Z_viewer_live",
|
||||
imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
|
||||
imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
|
||||
frameCount,
|
||||
frameTimesNs,
|
||||
timelineStartSeconds: number(payload.timeline_start_seconds, "M4.6 timeline start"),
|
||||
timelineEndSeconds: number(payload.timeline_end_seconds, "M4.6 timeline end"),
|
||||
nominalFrameIntervalSeconds: number(
|
||||
payload.nominal_frame_interval_seconds,
|
||||
"M4.6 timeline interval",
|
||||
),
|
||||
nominalRateHz: number(payload.nominal_rate_hz, "M4.6 timeline rate"),
|
||||
maxChunkFrames: integer(payload.max_chunk_frames, "M4.6 max chunk"),
|
||||
pointSampleLimit: integer(payload.point_sample_limit, "M4.6 point limit"),
|
||||
maximumSourcePointsPerFrame: integer(
|
||||
payload.maximum_source_points_per_frame,
|
||||
"M4.6 maximum source points",
|
||||
),
|
||||
pointDelivery: exact(
|
||||
payload.point_delivery,
|
||||
"exact-current-increment",
|
||||
"M4.6 point delivery",
|
||||
),
|
||||
sourceRepresentationId: "registered-map-increment-v1",
|
||||
localSurfaceVisualization: {
|
||||
derivation: exact(
|
||||
localSurface.derivation,
|
||||
"bounded-registered-increment-accumulation",
|
||||
"M4.6 local surface derivation",
|
||||
),
|
||||
windowSeconds: number(localSurface.window_seconds, "M4.6 local surface window"),
|
||||
voxelSizeM: number(localSurface.voxel_size_m, "M4.6 local surface voxel"),
|
||||
radiusM: number(localSurface.radius_m, "M4.6 local surface radius"),
|
||||
pointLimit: integer(localSurface.point_limit, "M4.6 local surface point limit"),
|
||||
authority: exact(
|
||||
localSurface.authority,
|
||||
"visual-derived",
|
||||
"M4.6 local surface authority",
|
||||
),
|
||||
},
|
||||
occupiedVoxelSizeM: number(
|
||||
corridor.occupied_voxel_size_m,
|
||||
"M4.6 occupied voxel size",
|
||||
),
|
||||
rig: {
|
||||
lengthM: number(rig.length_m, "M4.6 rig length"),
|
||||
widthM: number(rig.width_m, "M4.6 rig width"),
|
||||
nominalSensorHeightM: number(rig.nominal_sensor_height_m, "M4.6 sensor height"),
|
||||
},
|
||||
corridor: {
|
||||
forwardLengthM: number(corridor.forward_length_m, "M4.6 forward corridor"),
|
||||
rearMarginM: number(corridor.rear_margin_m, "M4.6 rear corridor"),
|
||||
halfWidthM: number(corridor.half_width_m, "M4.6 half width"),
|
||||
predictionHorizonSeconds: number(
|
||||
corridor.prediction_horizon_seconds,
|
||||
"M4.6 prediction horizon",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatTimelineChunk(
|
||||
result: string,
|
||||
startSequence: number,
|
||||
frameCount: number,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<M4ThreatTimelineChunk> {
|
||||
const params = new URLSearchParams({
|
||||
start: String(startSequence),
|
||||
count: String(frameCount),
|
||||
});
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/m4-threat/results/${result}/timeline/chunk?${params}`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 timeline chunk: HTTP ${response.status}.`);
|
||||
const payload = object(await response.json(), "M4.6 timeline chunk");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.recorded-spatial-evidence-chunk/v1",
|
||||
"M4.6 timeline chunk schema",
|
||||
);
|
||||
exact(payload.result_id, result, "M4.6 timeline chunk result");
|
||||
exact(payload.authority, "replay-simulated", "M4.6 timeline chunk authority");
|
||||
const parsedStart = integer(payload.start_sequence, "M4.6 timeline chunk start");
|
||||
if (parsedStart !== startSequence) {
|
||||
throw new M4ThreatContractError("M4.6 timeline chunk start: нарушен контракт.");
|
||||
}
|
||||
const frames = array(payload.frames, "M4.6 timeline frames").map((raw, offset) =>
|
||||
parseTimelineFrame(raw, result, parsedStart + offset));
|
||||
const parsedCount = integer(payload.frame_count, "M4.6 timeline chunk count");
|
||||
if (parsedCount !== frames.length || parsedCount > frameCount) {
|
||||
throw new M4ThreatContractError("M4.6 timeline chunk count: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId: result,
|
||||
startSequence: parsedStart,
|
||||
frameCount: parsedCount,
|
||||
nextSequence: payload.next_sequence === null
|
||||
? null
|
||||
: integer(payload.next_sequence, "M4.6 timeline next sequence"),
|
||||
frames,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTimelineFrame(
|
||||
value: unknown,
|
||||
result: string,
|
||||
expectedSequence: number,
|
||||
): M4ThreatTimelineFrame {
|
||||
const item = object(value, "M4.6 timeline frame");
|
||||
exact(
|
||||
item.schema_version,
|
||||
"missioncore.recorded-spatial-evidence-frame/v1",
|
||||
"M4.6 timeline frame schema",
|
||||
);
|
||||
exact(item.authority, "replay-simulated", "M4.6 timeline frame authority");
|
||||
const sequence = integer(item.sequence, "M4.6 timeline sequence");
|
||||
if (sequence !== expectedSequence) {
|
||||
throw new M4ThreatContractError("M4.6 timeline frame order: нарушен контракт.");
|
||||
}
|
||||
const counts = object(item.decision_counts, "M4.6 timeline decisions");
|
||||
const spatialAvailable = typeof item.spatial_available === "boolean"
|
||||
&& item.spatial_available;
|
||||
const bodyFrame = item.body_frame === null
|
||||
? null
|
||||
: object(item.body_frame, "M4.6 timeline body frame");
|
||||
if (spatialAvailable !== (bodyFrame !== null)) {
|
||||
throw new M4ThreatContractError("M4.6 timeline body frame: нарушена доступность.");
|
||||
}
|
||||
const basis = bodyFrame === null
|
||||
? null
|
||||
: array(bodyFrame.basis_map_from_body, "M4.6 timeline body basis").map(
|
||||
(row) => point3(row, "M4.6 timeline body basis row"),
|
||||
);
|
||||
if (basis !== null && basis.length !== 3) {
|
||||
throw new M4ThreatContractError("M4.6 timeline body basis: нарушен размер.");
|
||||
}
|
||||
const cameraUrl = text(item.camera_url, "M4.6 timeline camera URL");
|
||||
if (!cameraUrl.includes(`/results/${result}/timeline/frames/${sequence}/camera`)) {
|
||||
throw new M4ThreatContractError("M4.6 timeline camera URL: нарушена идентичность.");
|
||||
}
|
||||
return {
|
||||
sequence,
|
||||
frameId: text(item.frame_id, "M4.6 timeline frame id"),
|
||||
sourceTimeNs: integer(item.source_time_ns, "M4.6 timeline source time"),
|
||||
sessionSeconds: number(item.session_seconds, "M4.6 timeline time"),
|
||||
sourceAvailable: typeof item.source_available === "boolean" && item.source_available,
|
||||
spatialAvailable,
|
||||
bodyFrame: bodyFrame === null || basis === null
|
||||
? null
|
||||
: {
|
||||
originMapXyzM: point3(
|
||||
bodyFrame.origin_map_xyz_m,
|
||||
"M4.6 timeline body origin",
|
||||
),
|
||||
basisMapFromBody: [basis[0]!, basis[1]!, basis[2]!],
|
||||
},
|
||||
pointCloudBodyXyzM: array(item.point_cloud_body_xyz_m, "M4.6 timeline points").map(
|
||||
(point) => vector(point, 3, "M4.6 timeline point") as [number, number, number],
|
||||
),
|
||||
pointCloudSourceCount: integer(item.point_cloud_source_count, "M4.6 source points"),
|
||||
pointCloudSampleCount: integer(item.point_cloud_sample_count, "M4.6 sampled points"),
|
||||
pointCloudLayer: exact(
|
||||
item.point_cloud_layer,
|
||||
"current-increment",
|
||||
"M4.6 timeline point layer",
|
||||
),
|
||||
rollingMapComponentCount: integer(
|
||||
item.rolling_map_component_count,
|
||||
"M4.6 rolling components",
|
||||
),
|
||||
metricObstacles: array(item.metric_obstacles, "M4.6 timeline obstacles").map(
|
||||
parseMetricVisual,
|
||||
),
|
||||
cameraProposals: array(item.camera_proposals, "M4.6 timeline proposals").map(
|
||||
parseCameraProposal,
|
||||
),
|
||||
decisionCounts: {
|
||||
threat: integer(counts.threat, "M4.6 timeline threat"),
|
||||
"not-threat": integer(counts["not-threat"], "M4.6 timeline clear"),
|
||||
unknown: integer(counts.unknown, "M4.6 timeline unknown"),
|
||||
},
|
||||
cameraUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectM4ThreatTimelineSequence(
|
||||
frameTimesNs: readonly number[],
|
||||
seconds: number,
|
||||
): number | null {
|
||||
if (!frameTimesNs.length || !Number.isFinite(seconds)) return null;
|
||||
const targetNs = seconds * 1_000_000_000;
|
||||
let low = 0;
|
||||
let high = frameTimesNs.length - 1;
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const current = frameTimesNs[middle];
|
||||
if (current === undefined || current < targetNs) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const current = frameTimesNs[low];
|
||||
const previousIndex = Math.max(0, low - 1);
|
||||
const previous = frameTimesNs[previousIndex];
|
||||
if (current === undefined) return frameTimesNs.length - 1;
|
||||
if (previous === undefined) return low;
|
||||
return Math.abs(previous - targetNs) <= Math.abs(current - targetNs) ? previousIndex : low;
|
||||
}
|
||||
|
||||
export function selectM4ThreatTimelineFrame(
|
||||
frames: readonly M4ThreatTimelineFrame[],
|
||||
seconds: number,
|
||||
): M4ThreatTimelineFrame | null {
|
||||
if (!frames.length) return null;
|
||||
const local = selectM4ThreatTimelineSequence(
|
||||
frames.map((frame) => frame.sourceTimeNs),
|
||||
seconds,
|
||||
);
|
||||
return local === null ? null : frames[local] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import type {
|
||||
ObservationSourceDelivery,
|
||||
ObservationSourceDescriptor,
|
||||
} from "../runtime/contracts";
|
||||
|
||||
export const CAMERA_WAKE_GAP_MS = 5_000;
|
||||
export const CAMERA_HIDDEN_REOPEN_MS = 1_000;
|
||||
export const CAMERA_REOPEN_COOLDOWN_MS = 1_000;
|
||||
|
||||
export type CameraPlaybackRecoveryEvent =
|
||||
| { type: "document-hidden" }
|
||||
| { type: "document-visible" }
|
||||
| { type: "network-online" }
|
||||
| { type: "page-restore"; persisted: boolean }
|
||||
| { type: "heartbeat" };
|
||||
|
||||
export interface CameraPlaybackRecoveryState {
|
||||
authorityIdentity: string;
|
||||
hiddenAt: number | null;
|
||||
lastObservedAt: number;
|
||||
lastReopenAt: number | null;
|
||||
}
|
||||
|
||||
export interface CameraPlaybackRecoveryContext {
|
||||
activeAuthorityIdentity: string | null;
|
||||
now: number;
|
||||
documentVisible: boolean;
|
||||
networkOnline: boolean;
|
||||
}
|
||||
|
||||
export interface CameraPlaybackRecoveryDecision {
|
||||
state: CameraPlaybackRecoveryState;
|
||||
reopen: boolean;
|
||||
}
|
||||
|
||||
export function cameraTransportCallbackIsCurrent(
|
||||
activeEpoch: number,
|
||||
callbackEpoch: number,
|
||||
disposed: boolean,
|
||||
): boolean {
|
||||
return !disposed && activeEpoch === callbackEpoch;
|
||||
}
|
||||
|
||||
export function cameraBrowserTransportIdentity(
|
||||
delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" },
|
||||
authorityIdentity: string | null,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
delivery.id,
|
||||
delivery.url,
|
||||
delivery.mediaType,
|
||||
authorityIdentity,
|
||||
]);
|
||||
}
|
||||
|
||||
function trimmedString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind browser-only recovery to one exact, server-authoritative live camera.
|
||||
* A point-cloud recovery, retained delivery or selected camera without the
|
||||
* active acquisition/session tuple must not reopen a decoder.
|
||||
*/
|
||||
export function liveCameraPlaybackAuthorityIdentity(
|
||||
source: ObservationSourceDescriptor,
|
||||
): string | null {
|
||||
const delivery = source.delivery;
|
||||
const activation = source.activation;
|
||||
const deviceId = trimmedString(source.binding?.deviceId);
|
||||
const deviceSessionId = trimmedString(source.binding?.deviceSessionId);
|
||||
const acquisitionId = trimmedString(source.binding?.acquisitionId);
|
||||
const sourceId = trimmedString(source.sourceId);
|
||||
const descriptorId = trimmedString(source.id);
|
||||
const activationGroupId = trimmedString(activation?.groupId);
|
||||
const deliveryId = trimmedString(delivery?.id);
|
||||
const deliveryUrl = trimmedString(delivery?.url);
|
||||
const presentationLease = source.presentationLease;
|
||||
const recoveryPresentation = presentationLease?.kind === "active-stream-recovery";
|
||||
const recoveryLeaseValid = Boolean(
|
||||
recoveryPresentation
|
||||
&& trimmedString(presentationLease.runtimeId)
|
||||
&& trimmedString(presentationLease.acquisitionId) === acquisitionId
|
||||
&& positiveInteger(presentationLease.acquisitionStateRevision)
|
||||
&& positiveInteger(presentationLease.producerGeneration)
|
||||
&& positiveInteger(presentationLease.recoveryGeneration),
|
||||
);
|
||||
const availabilityAuthoritative = source.availability === "streaming"
|
||||
|| (
|
||||
recoveryLeaseValid
|
||||
&& (source.availability === "connecting" || source.availability === "degraded")
|
||||
);
|
||||
const mediaType = delivery?.kind === "mse-fmp4-websocket"
|
||||
? trimmedString(delivery.mediaType)
|
||||
: "";
|
||||
if (
|
||||
source.modality !== "video"
|
||||
|| !availabilityAuthoritative
|
||||
|| (presentationLease != null && !recoveryLeaseValid)
|
||||
|| delivery?.kind !== "mse-fmp4-websocket"
|
||||
|| activation?.selected !== true
|
||||
|| activation.maxActive !== 1
|
||||
|| !activationGroupId
|
||||
|| !descriptorId
|
||||
|| !sourceId
|
||||
|| !deliveryId
|
||||
|| !deliveryUrl
|
||||
|| !/^video\/mp4(?:\s*;|$)/i.test(mediaType)
|
||||
|| !deviceId
|
||||
|| !deviceSessionId
|
||||
|| !acquisitionId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify([
|
||||
descriptorId,
|
||||
sourceId,
|
||||
deviceId,
|
||||
deviceSessionId,
|
||||
acquisitionId,
|
||||
activationGroupId,
|
||||
activation.maxActive,
|
||||
deliveryId,
|
||||
deliveryUrl,
|
||||
mediaType,
|
||||
recoveryLeaseValid ? [
|
||||
presentationLease?.runtimeId,
|
||||
presentationLease?.acquisitionStateRevision,
|
||||
presentationLease?.producerGeneration,
|
||||
presentationLease?.recoveryGeneration,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
export function initialCameraPlaybackRecoveryState(
|
||||
authorityIdentity: string,
|
||||
now: number,
|
||||
): CameraPlaybackRecoveryState {
|
||||
return {
|
||||
authorityIdentity,
|
||||
hiddenAt: null,
|
||||
lastObservedAt: now,
|
||||
lastReopenAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce browser lifecycle signals without performing I/O. `reopen=true`
|
||||
* means replace the current browser WebSocket + MSE pair; it never means a
|
||||
* device START/STOP, camera selection or network mutation.
|
||||
*/
|
||||
export function reduceCameraPlaybackRecovery(
|
||||
current: CameraPlaybackRecoveryState,
|
||||
event: CameraPlaybackRecoveryEvent,
|
||||
context: CameraPlaybackRecoveryContext,
|
||||
): CameraPlaybackRecoveryDecision {
|
||||
const { now } = context;
|
||||
if (event.type === "document-hidden") {
|
||||
return {
|
||||
state: {
|
||||
...current,
|
||||
hiddenAt: now,
|
||||
lastObservedAt: now,
|
||||
},
|
||||
reopen: false,
|
||||
};
|
||||
}
|
||||
|
||||
const authorityCurrent = Boolean(
|
||||
context.activeAuthorityIdentity
|
||||
&& context.activeAuthorityIdentity === current.authorityIdentity,
|
||||
);
|
||||
const documentReady = context.documentVisible && context.networkOnline;
|
||||
let candidate = false;
|
||||
let hiddenAt = current.hiddenAt;
|
||||
|
||||
if (event.type === "document-visible") {
|
||||
candidate = hiddenAt !== null && now - hiddenAt >= CAMERA_HIDDEN_REOPEN_MS;
|
||||
hiddenAt = null;
|
||||
} else if (event.type === "network-online") {
|
||||
candidate = true;
|
||||
} else if (event.type === "page-restore") {
|
||||
candidate = event.persisted;
|
||||
} else if (event.type === "heartbeat") {
|
||||
candidate = now - current.lastObservedAt >= CAMERA_WAKE_GAP_MS;
|
||||
}
|
||||
|
||||
const outsideCooldown = current.lastReopenAt === null
|
||||
|| now - current.lastReopenAt >= CAMERA_REOPEN_COOLDOWN_MS;
|
||||
const reopen = candidate && authorityCurrent && documentReady && outsideCooldown;
|
||||
return {
|
||||
state: {
|
||||
...current,
|
||||
hiddenAt,
|
||||
lastObservedAt: now,
|
||||
lastReopenAt: reopen ? now : current.lastReopenAt,
|
||||
},
|
||||
reopen,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
ObservationSourceDescriptor,
|
||||
SpatialSourceDescriptor,
|
||||
} from "../runtime/contracts";
|
||||
|
||||
export interface LiveReceiverWatchdogState {
|
||||
lastBackendActivitySequence: number | null;
|
||||
lastViewerRangeMaxNs: number | null;
|
||||
@@ -19,16 +24,163 @@ export interface LiveReceiverRecoveryState {
|
||||
awaitingRecovery: boolean;
|
||||
}
|
||||
|
||||
export type LiveReceiverRecoverySignal = "retry" | "exhausted";
|
||||
export interface LiveReceiverOpenWatchdogState {
|
||||
lastBackendActivitySequence: number | null;
|
||||
openedAtMs: number;
|
||||
}
|
||||
|
||||
export type LiveReceiverOpenWatchdogSignal =
|
||||
| "wait-for-store"
|
||||
| "refresh-receiver"
|
||||
| "restart-receiver";
|
||||
|
||||
export interface LiveReceiverOpenWatchdogResult {
|
||||
state: LiveReceiverOpenWatchdogState;
|
||||
recoveryState: LiveReceiverRecoveryState;
|
||||
signal: LiveReceiverOpenWatchdogSignal;
|
||||
openForMs: number;
|
||||
}
|
||||
|
||||
export type LiveReceiverRecoverySignal = "retry" | "exhausted" | "stale";
|
||||
|
||||
export interface LiveReceiverRecoveryResult {
|
||||
state: LiveReceiverRecoveryState;
|
||||
signal: LiveReceiverRecoverySignal;
|
||||
attempt: number;
|
||||
delayMs: number | null;
|
||||
}
|
||||
|
||||
export interface LiveReceiverRecoveryRequest {
|
||||
activeAuthorityIdentity: string | null;
|
||||
expectedAuthorityIdentity: string | null;
|
||||
disposed?: boolean;
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
export const LIVE_RECEIVER_STALL_THRESHOLD_MS = 5_000;
|
||||
export const LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS = 3;
|
||||
// The bridge publishes its URL only after StoreInfo, blueprint and static
|
||||
// scene data have been flushed. A receiver that still has not admitted that
|
||||
// store after one operator-visible four-second window is wedged, not merely
|
||||
// slow; keeping it for 48 seconds made a healthy live scan look blank.
|
||||
export const LIVE_RECEIVER_OPEN_MAX_AGE_MS = 4_000;
|
||||
const LIVE_RECEIVER_RECOVERY_DELAYS_MS = [400, 1_000, 2_000, 5_000] as const;
|
||||
|
||||
export function liveReceiverRecoveryRetryDelay(attempt: number): number {
|
||||
const normalizedAttempt = Number.isSafeInteger(attempt) && attempt > 0 ? attempt : 1;
|
||||
return LIVE_RECEIVER_RECOVERY_DELAYS_MS[
|
||||
Math.min(normalizedAttempt - 1, LIVE_RECEIVER_RECOVERY_DELAYS_MS.length - 1)
|
||||
];
|
||||
}
|
||||
|
||||
function trimmedString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind durable browser-only Rerun recovery to one exact authoritative spatial
|
||||
* presentation. A healthy live descriptor is fenced by its acquisition and
|
||||
* session tuple; a reconnecting/degraded descriptor additionally requires the
|
||||
* server-issued active-stream-recovery generation lease.
|
||||
*/
|
||||
export function liveRerunRecoveryAuthorityIdentity(
|
||||
source: ObservationSourceDescriptor | null | undefined,
|
||||
spatialSource: SpatialSourceDescriptor | null | undefined,
|
||||
): string | null {
|
||||
if (!source || !spatialSource) return null;
|
||||
const descriptorId = trimmedString(source.id);
|
||||
const sourceId = trimmedString(source.sourceId);
|
||||
const semanticChannelId = trimmedString(source.semanticChannelId);
|
||||
const previewUrl = trimmedString(source.previewUrl);
|
||||
const spatialId = trimmedString(spatialSource.id);
|
||||
const spatialUrl = trimmedString(spatialSource.url);
|
||||
const deviceId = trimmedString(source.binding?.deviceId);
|
||||
const deviceSessionId = trimmedString(source.binding?.deviceSessionId);
|
||||
const acquisitionId = trimmedString(source.binding?.acquisitionId);
|
||||
const pluginId = trimmedString(source.provider?.pluginId);
|
||||
const pluginVersion = trimmedString(source.provider?.pluginVersion);
|
||||
const modelId = trimmedString(source.provider?.modelId);
|
||||
const compatibilityProfileId = trimmedString(source.provider?.compatibilityProfileId);
|
||||
const clockId = trimmedString(source.capabilities?.clockId);
|
||||
const presentationLease = source.presentationLease;
|
||||
const recoveryPresentation = presentationLease?.kind === "active-stream-recovery";
|
||||
const recoveryLeaseValid = Boolean(
|
||||
recoveryPresentation
|
||||
&& trimmedString(presentationLease.runtimeId)
|
||||
&& trimmedString(presentationLease.acquisitionId) === acquisitionId
|
||||
&& positiveInteger(presentationLease.acquisitionStateRevision)
|
||||
&& positiveInteger(presentationLease.producerGeneration)
|
||||
&& positiveInteger(presentationLease.recoveryGeneration),
|
||||
);
|
||||
const availabilityAuthoritative = source.availability === "streaming"
|
||||
|| (
|
||||
recoveryLeaseValid
|
||||
&& (source.availability === "connecting" || source.availability === "degraded")
|
||||
);
|
||||
if (
|
||||
source.modality !== "point-cloud"
|
||||
|| source.transport !== "rerun-grpc"
|
||||
|| spatialSource.kind !== "rerun-grpc"
|
||||
|| source.capabilities.timelineMode !== "live-only"
|
||||
|| source.capabilities.spatialRegistration !== "native"
|
||||
|| source.delivery != null
|
||||
|| !availabilityAuthoritative
|
||||
|| (presentationLease != null && !recoveryLeaseValid)
|
||||
|| !descriptorId
|
||||
|| !sourceId
|
||||
|| !semanticChannelId
|
||||
|| !previewUrl
|
||||
|| previewUrl !== spatialUrl
|
||||
|| !spatialId
|
||||
|| spatialId !== acquisitionId
|
||||
|| !deviceId
|
||||
|| !deviceSessionId
|
||||
|| !acquisitionId
|
||||
|| clockId !== acquisitionId
|
||||
|| !pluginId
|
||||
|| !pluginVersion
|
||||
|| !modelId
|
||||
|| !compatibilityProfileId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify([
|
||||
descriptorId,
|
||||
sourceId,
|
||||
semanticChannelId,
|
||||
deviceId,
|
||||
deviceSessionId,
|
||||
acquisitionId,
|
||||
spatialId,
|
||||
spatialUrl,
|
||||
pluginId,
|
||||
pluginVersion,
|
||||
modelId,
|
||||
compatibilityProfileId,
|
||||
recoveryLeaseValid ? [
|
||||
presentationLease?.runtimeId,
|
||||
presentationLease?.acquisitionStateRevision,
|
||||
presentationLease?.producerGeneration,
|
||||
presentationLease?.recoveryGeneration,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
export function liveReceiverRecoveryAuthorityIsCurrent(
|
||||
activeAuthorityIdentity: string | null,
|
||||
expectedAuthorityIdentity: string | null,
|
||||
disposed = false,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
!disposed
|
||||
&& expectedAuthorityIdentity
|
||||
&& activeAuthorityIdentity === expectedAuthorityIdentity,
|
||||
);
|
||||
}
|
||||
|
||||
export function initialLiveReceiverWatchdogState(): LiveReceiverWatchdogState {
|
||||
return {
|
||||
@@ -47,16 +199,104 @@ export function initialLiveReceiverRecoveryState(): LiveReceiverRecoveryState {
|
||||
};
|
||||
}
|
||||
|
||||
function validBackendActivitySequence(value: number | null | undefined): number | null {
|
||||
return Number.isSafeInteger(value) && (value ?? -1) >= 0 ? (value as number) : null;
|
||||
}
|
||||
|
||||
export function initialLiveReceiverOpenWatchdogState(
|
||||
backendActivitySequence: number | null = null,
|
||||
openedAtMs = Date.now(),
|
||||
): LiveReceiverOpenWatchdogState {
|
||||
return {
|
||||
lastBackendActivitySequence: validBackendActivitySequence(backendActivitySequence),
|
||||
openedAtMs: Number.isFinite(openedAtMs) ? openedAtMs : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep one still-opening Rerun receiver alive while the backend is proving
|
||||
* fresh publication progress. Recreating the WASM receiver on a fixed timer
|
||||
* can repeatedly discard an otherwise healthy late StoreInfo replay. Rolling
|
||||
* patience is nevertheless bounded: a receiver that has not admitted a store
|
||||
* by the absolute open-age limit is refreshed without consuming the recovery
|
||||
* budget. A true lack of backend progress delegates to the bounded restart
|
||||
* policy. Recovery debt is cleared only after viewer admission, never merely
|
||||
* because the backend counter advanced.
|
||||
*/
|
||||
export function advanceLiveReceiverOpenWatchdog(
|
||||
current: LiveReceiverOpenWatchdogState,
|
||||
recoveryState: LiveReceiverRecoveryState,
|
||||
backendActivitySequence: number | null,
|
||||
nowMs = Date.now(),
|
||||
maxOpenAgeMs = LIVE_RECEIVER_OPEN_MAX_AGE_MS,
|
||||
): LiveReceiverOpenWatchdogResult {
|
||||
const sequence = validBackendActivitySequence(backendActivitySequence);
|
||||
const previous = current.lastBackendActivitySequence;
|
||||
const backendAdvanced = sequence !== null && (
|
||||
(previous === null && sequence > 0) ||
|
||||
(previous !== null && sequence > previous)
|
||||
);
|
||||
const state = {
|
||||
lastBackendActivitySequence: sequence === null
|
||||
? previous
|
||||
: Math.max(sequence, previous ?? 0),
|
||||
openedAtMs: current.openedAtMs,
|
||||
};
|
||||
const openForMs = Math.max(0, nowMs - current.openedAtMs);
|
||||
if (backendAdvanced) {
|
||||
return {
|
||||
state,
|
||||
recoveryState,
|
||||
signal: openForMs >= maxOpenAgeMs
|
||||
? "refresh-receiver"
|
||||
: "wait-for-store",
|
||||
openForMs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
state,
|
||||
recoveryState,
|
||||
signal: "restart-receiver",
|
||||
openForMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound viewer-only restarts independently from scanner and acquisition
|
||||
* lifecycle. The caller may dispose and recreate the browser receiver, but
|
||||
* must never issue START/STOP or reconnect the physical device.
|
||||
* lifecycle unless the caller proves that one exact live/recovery authority
|
||||
* is still current. Under that fence retries remain durable and use a capped
|
||||
* delay while the attempt counter stays truthful. The caller may dispose and
|
||||
* recreate only the browser receiver; it must never issue START/STOP or
|
||||
* reconnect the physical device.
|
||||
*/
|
||||
export function requestLiveReceiverRecovery(
|
||||
current: LiveReceiverRecoveryState,
|
||||
maxAttempts = LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
||||
request?: LiveReceiverRecoveryRequest,
|
||||
): LiveReceiverRecoveryResult {
|
||||
if (current.attempts >= maxAttempts) {
|
||||
const maxAttempts = positiveInteger(request?.maxAttempts)
|
||||
? request.maxAttempts
|
||||
: LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS;
|
||||
const expectedAuthorityIdentity = request?.expectedAuthorityIdentity ?? null;
|
||||
const exactAuthorityCurrent = liveReceiverRecoveryAuthorityIsCurrent(
|
||||
request?.activeAuthorityIdentity ?? null,
|
||||
expectedAuthorityIdentity,
|
||||
request?.disposed === true,
|
||||
);
|
||||
if (
|
||||
request
|
||||
&& (
|
||||
request.disposed === true
|
||||
|| request.activeAuthorityIdentity !== expectedAuthorityIdentity
|
||||
)
|
||||
) {
|
||||
return {
|
||||
state: current,
|
||||
signal: "stale",
|
||||
attempt: current.attempts,
|
||||
delayMs: null,
|
||||
};
|
||||
}
|
||||
if (current.attempts >= maxAttempts && !exactAuthorityCurrent) {
|
||||
return {
|
||||
state: {
|
||||
attempts: current.attempts,
|
||||
@@ -64,6 +304,7 @@ export function requestLiveReceiverRecovery(
|
||||
},
|
||||
signal: "exhausted",
|
||||
attempt: current.attempts,
|
||||
delayMs: null,
|
||||
};
|
||||
}
|
||||
const attempt = current.attempts + 1;
|
||||
@@ -74,6 +315,7 @@ export function requestLiveReceiverRecovery(
|
||||
},
|
||||
signal: "retry",
|
||||
attempt,
|
||||
delayMs: exactAuthorityCurrent ? liveReceiverRecoveryRetryDelay(attempt) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,10 +333,7 @@ export function advanceLiveReceiverWatchdog(
|
||||
},
|
||||
thresholdMs = LIVE_RECEIVER_STALL_THRESHOLD_MS,
|
||||
): LiveReceiverWatchdogResult {
|
||||
const backendSequence = Number.isSafeInteger(sample.backendActivitySequence) &&
|
||||
(sample.backendActivitySequence ?? -1) >= 0
|
||||
? sample.backendActivitySequence
|
||||
: null;
|
||||
const backendSequence = validBackendActivitySequence(sample.backendActivitySequence);
|
||||
const viewerRange = Number.isFinite(sample.viewerRangeMaxNs) &&
|
||||
(sample.viewerRangeMaxNs ?? -1) >= 0
|
||||
? sample.viewerRangeMaxNs
|
||||
|
||||
@@ -22,7 +22,66 @@ export interface LiveViewerDiagnostic {
|
||||
recoveryAttempt?: number | null;
|
||||
}
|
||||
|
||||
export interface LiveViewerLineage {
|
||||
uiBuildId: string;
|
||||
documentInstanceId: string;
|
||||
viewerInstanceId: string;
|
||||
lifecycleGeneration: number;
|
||||
}
|
||||
|
||||
export interface LiveViewerDiagnosticScheduler {
|
||||
setTimeout(callback: () => void, delayMilliseconds: number): number;
|
||||
clearTimeout(handle: number): void;
|
||||
setInterval(callback: () => void, delayMilliseconds: number): number;
|
||||
clearInterval(handle: number): void;
|
||||
}
|
||||
|
||||
export interface LiveViewerDiagnosticLifecycle {
|
||||
readonly lineage: LiveViewerLineage;
|
||||
readonly signal: AbortSignal;
|
||||
active(): boolean;
|
||||
admitted(): boolean;
|
||||
post(event: LiveViewerDiagnostic): void;
|
||||
verifyBuild(): void;
|
||||
armAdmissionTimeout(callback: () => void, delayMilliseconds: number): void;
|
||||
armAdmissionInterval(callback: () => void, delayMilliseconds: number): void;
|
||||
clearAdmissionTimeout(): void;
|
||||
clearAdmissionInterval(): void;
|
||||
markAdmitted(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface StaleUiBuild {
|
||||
loadedUiBuildId: string;
|
||||
expectedUiBuildId: string;
|
||||
}
|
||||
|
||||
export interface UiBuildStaleCoordinator {
|
||||
subscribe(listener: (event: StaleUiBuild) => void): () => void;
|
||||
report(event: StaleUiBuild): void;
|
||||
stale(): boolean;
|
||||
}
|
||||
|
||||
const LIVE_VIEWER_DIAGNOSTIC_SCHEMA = "missioncore.live-viewer-diagnostic/v2";
|
||||
const LIVE_VIEWER_CLIENT_CONTRACT_SCHEMA = "missioncore.live-viewer-client-contract/v1";
|
||||
const UI_BUILD_HEADER = "x-missioncore-ui-build";
|
||||
const DEVELOPMENT_UI_BUILD_ID = "development";
|
||||
const SAFE_STREAM_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const HASHED_UI_BUILD_ID = /^\/assets\/[A-Za-z0-9._/-]+-[A-Za-z0-9_-]{8,}\.js$/;
|
||||
const UI_BUILD_CHECK_INTERVAL_MILLISECONDS = 15_000;
|
||||
const UI_BUILD_RELOAD_DELAY_MILLISECONDS = 50;
|
||||
|
||||
let documentInstanceId: string | null = null;
|
||||
let sharedUiBuildCoordinator: UiBuildStaleCoordinator | null = null;
|
||||
let buildMonitorSubscribers = 0;
|
||||
let buildMonitorInterval: number | null = null;
|
||||
let buildMonitorAbort: AbortController | null = null;
|
||||
let buildMonitorOnlineListener: (() => void) | null = null;
|
||||
let buildMonitorVisibilityListener: (() => void) | null = null;
|
||||
|
||||
function randomInstanceId(): string {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
function safeInteger(value: number | null | undefined): number | undefined {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
||||
@@ -30,11 +89,75 @@ function safeInteger(value: number | null | undefined): number | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function postLiveViewerDiagnostic(event: LiveViewerDiagnostic): void {
|
||||
function browserScheduler(): LiveViewerDiagnosticScheduler {
|
||||
return {
|
||||
setTimeout: (callback, delayMilliseconds) => window.setTimeout(callback, delayMilliseconds),
|
||||
clearTimeout: (handle) => window.clearTimeout(handle),
|
||||
setInterval: (callback, delayMilliseconds) => window.setInterval(callback, delayMilliseconds),
|
||||
clearInterval: (handle) => window.clearInterval(handle),
|
||||
};
|
||||
}
|
||||
|
||||
export function uiBuildIdFromModuleScripts(
|
||||
scriptSources: readonly string[],
|
||||
baseUrl: string,
|
||||
): string {
|
||||
for (const source of scriptSources) {
|
||||
try {
|
||||
const pathname = new URL(source, baseUrl).pathname;
|
||||
if (HASHED_UI_BUILD_ID.test(pathname)) return pathname;
|
||||
} catch {
|
||||
// A malformed non-entry script is not the running application build.
|
||||
}
|
||||
}
|
||||
return DEVELOPMENT_UI_BUILD_ID;
|
||||
}
|
||||
|
||||
export function currentUiBuildId(): string {
|
||||
const scripts = Array.from(
|
||||
document.querySelectorAll<HTMLScriptElement>('script[type="module"][src]'),
|
||||
(script) => script.src,
|
||||
);
|
||||
return uiBuildIdFromModuleScripts(scripts, window.location.href);
|
||||
}
|
||||
|
||||
export function liveViewerDocumentInstanceId(): string {
|
||||
documentInstanceId ??= randomInstanceId();
|
||||
return documentInstanceId;
|
||||
}
|
||||
|
||||
export function createLiveViewerInstanceId(): string {
|
||||
return randomInstanceId();
|
||||
}
|
||||
|
||||
export function createLiveViewerLineage(
|
||||
viewerInstanceId: string,
|
||||
lifecycleGeneration: number,
|
||||
overrides: Partial<Pick<LiveViewerLineage, "uiBuildId" | "documentInstanceId">> = {},
|
||||
): LiveViewerLineage {
|
||||
if (!Number.isSafeInteger(lifecycleGeneration) || lifecycleGeneration < 1) {
|
||||
throw new Error("Live viewer lifecycle generation must be a positive safe integer");
|
||||
}
|
||||
return {
|
||||
uiBuildId: overrides.uiBuildId ?? currentUiBuildId(),
|
||||
documentInstanceId: overrides.documentInstanceId ?? liveViewerDocumentInstanceId(),
|
||||
viewerInstanceId,
|
||||
lifecycleGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
export function liveViewerDiagnosticBody(
|
||||
event: LiveViewerDiagnostic,
|
||||
lineage: LiveViewerLineage,
|
||||
): Record<string, string | number> {
|
||||
const streamId = event.streamId?.trim();
|
||||
const body = {
|
||||
schema_version: "missioncore.live-viewer-diagnostic/v1",
|
||||
return {
|
||||
schema_version: LIVE_VIEWER_DIAGNOSTIC_SCHEMA,
|
||||
event_code: event.eventCode,
|
||||
ui_build_id: lineage.uiBuildId,
|
||||
document_instance_id: lineage.documentInstanceId,
|
||||
viewer_instance_id: lineage.viewerInstanceId,
|
||||
lifecycle_generation: lineage.lifecycleGeneration,
|
||||
...(event.failureStage ? { failure_stage: event.failureStage } : {}),
|
||||
...(streamId && SAFE_STREAM_ID.test(streamId) ? { stream_id: streamId } : {}),
|
||||
...(safeInteger(event.backendActivitySequence) === undefined
|
||||
@@ -50,12 +173,252 @@ export function postLiveViewerDiagnostic(event: LiveViewerDiagnostic): void {
|
||||
? {}
|
||||
: { recovery_attempt: safeInteger(event.recoveryAttempt) }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createUiBuildStaleCoordinator({
|
||||
scheduleReload,
|
||||
reload,
|
||||
}: {
|
||||
scheduleReload: (callback: () => void, delayMilliseconds: number) => void;
|
||||
reload: () => void;
|
||||
}): UiBuildStaleCoordinator {
|
||||
const listeners = new Set<(event: StaleUiBuild) => void>();
|
||||
let staleEvent: StaleUiBuild | null = null;
|
||||
let reloadScheduled = false;
|
||||
return {
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
if (staleEvent) listener(staleEvent);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
report(event) {
|
||||
if (event.loadedUiBuildId === event.expectedUiBuildId || staleEvent) return;
|
||||
staleEvent = event;
|
||||
for (const listener of [...listeners]) listener(event);
|
||||
if (reloadScheduled) return;
|
||||
reloadScheduled = true;
|
||||
scheduleReload(reload, UI_BUILD_RELOAD_DELAY_MILLISECONDS);
|
||||
},
|
||||
stale() {
|
||||
return staleEvent !== null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function browserUiBuildCoordinator(): UiBuildStaleCoordinator {
|
||||
sharedUiBuildCoordinator ??= createUiBuildStaleCoordinator({
|
||||
scheduleReload: (callback, delayMilliseconds) => {
|
||||
window.setTimeout(callback, delayMilliseconds);
|
||||
},
|
||||
reload: () => window.location.reload(),
|
||||
});
|
||||
return sharedUiBuildCoordinator;
|
||||
}
|
||||
|
||||
function inspectUiBuildResponse(
|
||||
response: Response,
|
||||
loadedUiBuildId: string,
|
||||
signal?: AbortSignal,
|
||||
): void {
|
||||
if (signal?.aborted || loadedUiBuildId === DEVELOPMENT_UI_BUILD_ID) return;
|
||||
const expectedUiBuildId = response.headers.get(UI_BUILD_HEADER);
|
||||
if (
|
||||
expectedUiBuildId &&
|
||||
expectedUiBuildId !== loadedUiBuildId &&
|
||||
(response.status === 409 || response.ok)
|
||||
) {
|
||||
browserUiBuildCoordinator().report({ loadedUiBuildId, expectedUiBuildId });
|
||||
}
|
||||
}
|
||||
|
||||
export function postLiveViewerDiagnostic(
|
||||
event: LiveViewerDiagnostic,
|
||||
lineage: LiveViewerLineage,
|
||||
signal?: AbortSignal,
|
||||
): void {
|
||||
if (signal?.aborted) return;
|
||||
void fetch("/api/v1/viewer/live-diagnostics", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(liveViewerDiagnosticBody(event, lineage)),
|
||||
keepalive: true,
|
||||
signal,
|
||||
}).then((response) => {
|
||||
inspectUiBuildResponse(response, lineage.uiBuildId, signal);
|
||||
}).catch(() => {
|
||||
// Diagnostics must never interfere with the live receiver recovery path.
|
||||
// Diagnostics and build fencing must never interfere with receiver recovery.
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyLiveViewerClientBuild(
|
||||
lineage: Pick<LiveViewerLineage, "uiBuildId">,
|
||||
signal?: AbortSignal,
|
||||
): void {
|
||||
if (signal?.aborted || lineage.uiBuildId === DEVELOPMENT_UI_BUILD_ID) return;
|
||||
void fetch("/api/v1/viewer/client-contract", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
signal,
|
||||
}).then(async (response) => {
|
||||
if (signal?.aborted) return;
|
||||
inspectUiBuildResponse(response, lineage.uiBuildId, signal);
|
||||
if (!response.ok) return;
|
||||
const contract = await response.json() as unknown;
|
||||
if (
|
||||
signal?.aborted ||
|
||||
typeof contract !== "object" ||
|
||||
contract === null ||
|
||||
!("schema_version" in contract) ||
|
||||
contract.schema_version !== LIVE_VIEWER_CLIENT_CONTRACT_SCHEMA ||
|
||||
!("ui_build_id" in contract) ||
|
||||
typeof contract.ui_build_id !== "string" ||
|
||||
contract.ui_build_id === lineage.uiBuildId
|
||||
) return;
|
||||
browserUiBuildCoordinator().report({
|
||||
loadedUiBuildId: lineage.uiBuildId,
|
||||
expectedUiBuildId: contract.ui_build_id,
|
||||
});
|
||||
}).catch(() => {
|
||||
// Offline periods are handled by the existing live-stream recovery path.
|
||||
});
|
||||
}
|
||||
|
||||
function startBuildMonitor(): void {
|
||||
if (buildMonitorAbort || typeof window === "undefined") return;
|
||||
const lineage = createLiveViewerLineage(createLiveViewerInstanceId(), 1);
|
||||
const controller = new AbortController();
|
||||
buildMonitorAbort = controller;
|
||||
const verify = createAbortFencedBuildVerifier(
|
||||
controller.signal,
|
||||
(signal) => verifyLiveViewerClientBuild(lineage, signal),
|
||||
);
|
||||
buildMonitorInterval = window.setInterval(
|
||||
verify,
|
||||
UI_BUILD_CHECK_INTERVAL_MILLISECONDS,
|
||||
);
|
||||
buildMonitorOnlineListener = verify;
|
||||
buildMonitorVisibilityListener = () => {
|
||||
if (document.visibilityState === "visible") verify();
|
||||
};
|
||||
window.addEventListener("online", buildMonitorOnlineListener);
|
||||
document.addEventListener("visibilitychange", buildMonitorVisibilityListener);
|
||||
verify();
|
||||
}
|
||||
|
||||
export function createAbortFencedBuildVerifier(
|
||||
signal: AbortSignal,
|
||||
verifier: (signal: AbortSignal) => void,
|
||||
): () => void {
|
||||
return () => {
|
||||
if (signal.aborted) return;
|
||||
verifier(signal);
|
||||
};
|
||||
}
|
||||
|
||||
function stopBuildMonitor(): void {
|
||||
buildMonitorAbort?.abort();
|
||||
buildMonitorAbort = null;
|
||||
if (buildMonitorInterval !== null) window.clearInterval(buildMonitorInterval);
|
||||
buildMonitorInterval = null;
|
||||
if (buildMonitorOnlineListener) {
|
||||
window.removeEventListener("online", buildMonitorOnlineListener);
|
||||
}
|
||||
if (buildMonitorVisibilityListener) {
|
||||
document.removeEventListener("visibilitychange", buildMonitorVisibilityListener);
|
||||
}
|
||||
buildMonitorOnlineListener = null;
|
||||
buildMonitorVisibilityListener = null;
|
||||
}
|
||||
|
||||
export function subscribeToLiveViewerBuildFence(
|
||||
listener: (event: StaleUiBuild) => void,
|
||||
): () => void {
|
||||
const unsubscribe = browserUiBuildCoordinator().subscribe(listener);
|
||||
buildMonitorSubscribers += 1;
|
||||
if (buildMonitorSubscribers === 1) startBuildMonitor();
|
||||
return () => {
|
||||
unsubscribe();
|
||||
buildMonitorSubscribers = Math.max(0, buildMonitorSubscribers - 1);
|
||||
if (buildMonitorSubscribers === 0) stopBuildMonitor();
|
||||
};
|
||||
}
|
||||
|
||||
export function createLiveViewerDiagnosticLifecycle({
|
||||
lineage,
|
||||
scheduler = browserScheduler(),
|
||||
diagnosticPoster = postLiveViewerDiagnostic,
|
||||
buildVerifier = verifyLiveViewerClientBuild,
|
||||
}: {
|
||||
lineage: LiveViewerLineage;
|
||||
scheduler?: LiveViewerDiagnosticScheduler;
|
||||
diagnosticPoster?: (
|
||||
event: LiveViewerDiagnostic,
|
||||
lineage: LiveViewerLineage,
|
||||
signal?: AbortSignal,
|
||||
) => void;
|
||||
buildVerifier?: (
|
||||
lineage: Pick<LiveViewerLineage, "uiBuildId">,
|
||||
signal?: AbortSignal,
|
||||
) => void;
|
||||
}): LiveViewerDiagnosticLifecycle {
|
||||
const abort = new AbortController();
|
||||
let isActive = true;
|
||||
let isAdmitted = false;
|
||||
let admissionTimeout: number | null = null;
|
||||
let admissionInterval: number | null = null;
|
||||
const clearAdmissionTimeout = () => {
|
||||
if (admissionTimeout === null) return;
|
||||
scheduler.clearTimeout(admissionTimeout);
|
||||
admissionTimeout = null;
|
||||
};
|
||||
const clearAdmissionInterval = () => {
|
||||
if (admissionInterval === null) return;
|
||||
scheduler.clearInterval(admissionInterval);
|
||||
admissionInterval = null;
|
||||
};
|
||||
return {
|
||||
lineage,
|
||||
signal: abort.signal,
|
||||
active: () => isActive,
|
||||
admitted: () => isAdmitted,
|
||||
post(event) {
|
||||
if (!isActive) return;
|
||||
diagnosticPoster(event, lineage, abort.signal);
|
||||
},
|
||||
verifyBuild() {
|
||||
if (!isActive) return;
|
||||
buildVerifier(lineage, abort.signal);
|
||||
},
|
||||
armAdmissionTimeout(callback, delayMilliseconds) {
|
||||
clearAdmissionTimeout();
|
||||
if (!isActive || isAdmitted) return;
|
||||
admissionTimeout = scheduler.setTimeout(() => {
|
||||
admissionTimeout = null;
|
||||
if (isActive && !isAdmitted) callback();
|
||||
}, delayMilliseconds);
|
||||
},
|
||||
armAdmissionInterval(callback, delayMilliseconds) {
|
||||
clearAdmissionInterval();
|
||||
if (!isActive || isAdmitted) return;
|
||||
admissionInterval = scheduler.setInterval(() => {
|
||||
if (isActive && !isAdmitted) callback();
|
||||
}, delayMilliseconds);
|
||||
},
|
||||
clearAdmissionTimeout,
|
||||
clearAdmissionInterval,
|
||||
markAdmitted() {
|
||||
if (!isActive) return;
|
||||
isAdmitted = true;
|
||||
clearAdmissionTimeout();
|
||||
clearAdmissionInterval();
|
||||
},
|
||||
dispose() {
|
||||
if (!isActive) return;
|
||||
isActive = false;
|
||||
abort.abort();
|
||||
clearAdmissionTimeout();
|
||||
clearAdmissionInterval();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface ObservationLayoutController {
|
||||
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
|
||||
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
|
||||
setViewportSize: (size: ObservationViewportSize) => void;
|
||||
activateAutomaticDefaults: () => void;
|
||||
snapshot: () => ObservationLayoutSnapshot | null;
|
||||
restore: (snapshot: ObservationLayoutSnapshot) => void;
|
||||
}
|
||||
@@ -68,6 +69,66 @@ function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One exact live presentation lease. The stable source id is deliberately not
|
||||
* sufficient: the same live camera and even the same browser delivery generation
|
||||
* can be reused by a later acquisition.
|
||||
*/
|
||||
export function automaticLivePresentationIdentity(
|
||||
source: ObservationSourceDescriptor,
|
||||
): string | null {
|
||||
const acquisitionId = source.binding.acquisitionId?.trim();
|
||||
const deliveryId = source.delivery?.id?.trim();
|
||||
if (
|
||||
!acquisitionId
|
||||
|| !deliveryId
|
||||
|| !canOpenByDefault(source)
|
||||
|| source.activation?.selected !== true
|
||||
) return null;
|
||||
return JSON.stringify([source.id, acquisitionId, deliveryId]);
|
||||
}
|
||||
|
||||
/** A deliberate close fences every delivery generation in that acquisition. */
|
||||
export function livePresentationCloseFence(
|
||||
source: ObservationSourceDescriptor,
|
||||
): string | null {
|
||||
const acquisitionId = source.binding.acquisitionId?.trim();
|
||||
return acquisitionId ? JSON.stringify([source.id, acquisitionId]) : null;
|
||||
}
|
||||
|
||||
export interface LiveDefaultPresentationAdmission {
|
||||
visibleIds: string[];
|
||||
removedIds: string[];
|
||||
admittedIdentities: string[];
|
||||
}
|
||||
|
||||
export function admitLiveDefaultPresentations(
|
||||
currentIds: readonly string[],
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
admittedIdentities: ReadonlySet<string>,
|
||||
closedAcquisitionSources: ReadonlySet<string>,
|
||||
): LiveDefaultPresentationAdmission {
|
||||
let change = { visibleIds: [...currentIds], removedIds: [] as string[] };
|
||||
const removed = new Set<string>();
|
||||
const admitted: string[] = [];
|
||||
for (const source of sources) {
|
||||
const presentationIdentity = automaticLivePresentationIdentity(source);
|
||||
if (
|
||||
!presentationIdentity
|
||||
|| admittedIdentities.has(presentationIdentity)
|
||||
|| closedAcquisitionSources.has(livePresentationCloseFence(source) ?? "")
|
||||
) continue;
|
||||
change = openObservationSource(change.visibleIds, source.id, sources);
|
||||
change.removedIds.forEach((sourceId) => removed.add(sourceId));
|
||||
admitted.push(presentationIdentity);
|
||||
}
|
||||
return {
|
||||
visibleIds: change.visibleIds,
|
||||
removedIds: [...removed],
|
||||
admittedIdentities: admitted,
|
||||
};
|
||||
}
|
||||
|
||||
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
|
||||
return sources
|
||||
.map((source) => [
|
||||
@@ -107,6 +168,8 @@ export function useObservationLayout(
|
||||
const viewportSizeRef = useRef<ObservationViewportSize | null>(null);
|
||||
const desiredSnapshotRef = useRef<ObservationLayoutSnapshot | null>(null);
|
||||
const restoredLayoutAuthorityRef = useRef(false);
|
||||
const admittedLivePresentationIdentitiesRef = useRef(new Set<string>());
|
||||
const closedLiveAcquisitionSourcesRef = useRef(new Set<string>());
|
||||
const initializedCatalog = useRef<string | null>(null);
|
||||
const sourceIdList = sources.map((source) => source.id).sort();
|
||||
const sourceIdsIdentity = sourceIdList.join("\u0000");
|
||||
@@ -284,31 +347,25 @@ export function useObservationLayout(
|
||||
sources,
|
||||
]);
|
||||
|
||||
const selectedDeliveryIdentity = sources
|
||||
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
|
||||
.map((source) => [
|
||||
source.id,
|
||||
source.delivery?.id,
|
||||
source.activation?.groupId,
|
||||
source.activation?.maxActive,
|
||||
].join(":"))
|
||||
.sort()
|
||||
.join("|");
|
||||
const selectedDeliveryIdentity = JSON.stringify(sources
|
||||
.map(automaticLivePresentationIdentity)
|
||||
.filter((candidate): candidate is string => candidate !== null)
|
||||
.sort());
|
||||
|
||||
useEffect(() => {
|
||||
if (restoredLayoutAuthorityRef.current) return;
|
||||
const selected = sources.filter(
|
||||
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
|
||||
const admission = admitLiveDefaultPresentations(
|
||||
visibleIdsRef.current,
|
||||
sources,
|
||||
admittedLivePresentationIdentitiesRef.current,
|
||||
closedLiveAcquisitionSourcesRef.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], false);
|
||||
if (!admission.admittedIdentities.length) return;
|
||||
admission.admittedIdentities.forEach((presentationIdentity) => {
|
||||
admittedLivePresentationIdentitiesRef.current.add(presentationIdentity);
|
||||
});
|
||||
commitVisibleIds(admission.visibleIds);
|
||||
clearPresentation(admission.removedIds, false);
|
||||
persistLiveLayout();
|
||||
}, [clearPresentation, commitVisibleIds, persistLiveLayout, selectedDeliveryIdentity]);
|
||||
|
||||
@@ -334,6 +391,8 @@ export function useObservationLayout(
|
||||
markPending(source, false);
|
||||
}
|
||||
}
|
||||
const closeFence = livePresentationCloseFence(source);
|
||||
if (closeFence) closedLiveAcquisitionSourcesRef.current.add(closeFence);
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const change = closeObservationSource(visibleIdsRef.current, sourceId);
|
||||
commitVisibleIds(change.visibleIds);
|
||||
@@ -356,6 +415,8 @@ export function useObservationLayout(
|
||||
markPending(source, false);
|
||||
}
|
||||
}
|
||||
const closeFence = livePresentationCloseFence(source);
|
||||
if (closeFence) closedLiveAcquisitionSourcesRef.current.delete(closeFence);
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const change = openObservationSource(visibleIdsRef.current, sourceId, sources);
|
||||
commitVisibleIds(change.visibleIds);
|
||||
@@ -427,6 +488,46 @@ export function useObservationLayout(
|
||||
}
|
||||
}, [applyDesiredSnapshot, persistLiveLayout]);
|
||||
|
||||
const activateAutomaticDefaults = useCallback(() => {
|
||||
// A new operator-started live acquisition owns its initial presentation.
|
||||
// Keep saved geometry, but do not let an older layout suppress a camera
|
||||
// that the device plugin has just selected and delivered automatically.
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const currentSources = sourcesRef.current;
|
||||
let nextVisibleIds = visibleIdsRef.current;
|
||||
for (const source of currentSources.filter(canOpenByDefault)) {
|
||||
if (automaticLivePresentationIdentity(source)) continue;
|
||||
nextVisibleIds = openObservationSource(
|
||||
nextVisibleIds,
|
||||
source.id,
|
||||
currentSources,
|
||||
).visibleIds;
|
||||
}
|
||||
const admission = admitLiveDefaultPresentations(
|
||||
nextVisibleIds,
|
||||
currentSources,
|
||||
admittedLivePresentationIdentitiesRef.current,
|
||||
closedLiveAcquisitionSourcesRef.current,
|
||||
);
|
||||
admission.admittedIdentities.forEach((presentationIdentity) => {
|
||||
admittedLivePresentationIdentitiesRef.current.add(presentationIdentity);
|
||||
});
|
||||
nextVisibleIds = admission.visibleIds;
|
||||
commitVisibleIds(nextVisibleIds);
|
||||
clearPresentation(admission.removedIds, false);
|
||||
const firstFloatingDefault = currentSources.find(
|
||||
(source) => (
|
||||
canOpenByDefault(source)
|
||||
&& source.capabilities.overlay
|
||||
&& nextVisibleIds.includes(source.id)
|
||||
),
|
||||
);
|
||||
if (firstFloatingDefault) {
|
||||
commitActiveFloatingSourceId(firstFloatingDefault.id);
|
||||
}
|
||||
persistLiveLayout();
|
||||
}, [clearPresentation, commitActiveFloatingSourceId, commitVisibleIds, persistLiveLayout]);
|
||||
|
||||
const snapshot = useCallback((): ObservationLayoutSnapshot | null => {
|
||||
if (!viewportSizeRef.current) return null;
|
||||
persistLiveLayout();
|
||||
@@ -464,6 +565,7 @@ export function useObservationLayout(
|
||||
setFloatingMaximized,
|
||||
setWindowRect,
|
||||
setViewportSize,
|
||||
activateAutomaticDefaults,
|
||||
snapshot,
|
||||
restore,
|
||||
};
|
||||
|
||||
@@ -154,6 +154,15 @@ export interface ObservationSourceActivation {
|
||||
controllable: boolean;
|
||||
}
|
||||
|
||||
export interface ObservationSourcePresentationLease {
|
||||
kind: "active-stream-recovery";
|
||||
runtimeId: string;
|
||||
acquisitionId: string;
|
||||
acquisitionStateRevision: number;
|
||||
producerGeneration: number;
|
||||
recoveryGeneration: number;
|
||||
}
|
||||
|
||||
export interface ObservationSourceDescriptor {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
@@ -168,6 +177,7 @@ export interface ObservationSourceDescriptor {
|
||||
previewUrl?: string | null;
|
||||
delivery?: ObservationSourceDelivery | null;
|
||||
activation?: ObservationSourceActivation | null;
|
||||
presentationLease?: ObservationSourcePresentationLease | null;
|
||||
provider: ObservationSourceProvider;
|
||||
binding: ObservationSourceBinding;
|
||||
capabilities: ObservationSourceCapabilities;
|
||||
@@ -201,6 +211,7 @@ export interface MissionRuntimeController {
|
||||
backendStatus: BackendStatus;
|
||||
pendingAction: string | null;
|
||||
refresh: () => void | Promise<void>;
|
||||
resetConnectionScenario?: () => Promise<boolean>;
|
||||
updateViewerSettings: (settings: ViewerSettings) => Promise<boolean>;
|
||||
setObservationSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ export function phaseLabel(phase: RuntimePhase | null | undefined): string {
|
||||
return phase ? phaseLabels[phase] : "Нет состояния";
|
||||
}
|
||||
|
||||
export function localConnectionPhaseLabel(
|
||||
phase: RuntimePhase | null | undefined,
|
||||
): string {
|
||||
if (phase === "configuring") return "Подключение";
|
||||
if (phase === "connected") return "Подключение установлено";
|
||||
return phaseLabel(phase);
|
||||
}
|
||||
|
||||
export function phaseTone(phase: RuntimePhase | null | undefined): StatusTone {
|
||||
if (!phase || phase === "unconfigured" || phase === "idle") return "neutral";
|
||||
if (phase === "error") return "danger";
|
||||
|
||||
@@ -190,8 +190,8 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
{
|
||||
id: "local-device",
|
||||
root: "fleet",
|
||||
label: "Локальное устройство",
|
||||
title: "Локальное устройство",
|
||||
label: "Подключение",
|
||||
title: "Подключение",
|
||||
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
|
||||
description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
|
||||
icon: "network",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
@import "./styles/laboratory-reporting.css";
|
||||
@import "./styles/laboratory-evidence-report.css";
|
||||
@import "./styles/e34-temporal-layer.css";
|
||||
@import "./styles/m4-replay-threat.css";
|
||||
@import "./styles/e35-degradation-recovery.css";
|
||||
@import "./styles/e30-human-review.css";
|
||||
@import "./styles/spatial.css";
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.e46c-video-scene {
|
||||
.recorded-evidence-video-scene,
|
||||
.recorded-evidence-image-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -55,12 +56,23 @@
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.e46c-video-scene > .recorded-media-player {
|
||||
.recorded-evidence-image-scene > img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.recorded-evidence-video-scene > .recorded-media-player {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.e46c-video-scene > canvas {
|
||||
.recorded-evidence-video-scene > canvas,
|
||||
.recorded-evidence-image-scene > canvas {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
@@ -70,6 +82,39 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.recorded-evidence-image-scene > .l3-visual-audit__state {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.recorded-evidence-semantic-mask-overlay__error {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 4.9rem;
|
||||
left: 50%;
|
||||
max-width: min(32rem, calc(100% - 2rem));
|
||||
border: 1px solid rgb(var(--nodedc-warning-rgb) / 0.44);
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.42rem 0.58rem;
|
||||
color: rgb(var(--nodedc-warning-rgb));
|
||||
font-size: 0.54rem;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer .laboratory-metric-evidence-scene__legend,
|
||||
.m4-replay-threat-evidence-viewer .m4-replay-threat-visual__overlay {
|
||||
bottom: 6.2rem;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__timeline {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.e46e-ready-stack-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -460,6 +460,14 @@
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer__transport {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
right: 0.6rem;
|
||||
bottom: 0.6rem;
|
||||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-expanded="true"] {
|
||||
position: fixed;
|
||||
z-index: var(--nodedc-layer-overlay);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
.laboratory-metric-evidence-scene,
|
||||
.laboratory-metric-evidence-scene__viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck,
|
||||
.m4-replay-threat-visual__layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer[data-active="true"] {
|
||||
z-index: 1;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__buffering {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 4.9rem;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--nodedc-glass-outline);
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.42rem 0.58rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.54rem;
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer .laboratory-evidence-viewer__controls {
|
||||
width: calc(100% - 1.2rem);
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer .l3-visual-audit__actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer-controls {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer-controls .nodedc-segmented__item {
|
||||
padding-inline: 0.3rem;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer .laboratory-evidence-viewer__transport {
|
||||
bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__timeline {
|
||||
padding: 0.5rem 0.55rem;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__timeline .observation-timeline__playback {
|
||||
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__viewport canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__viewport canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__viewport p {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend {
|
||||
background: var(--nodedc-floating-surface);
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
right: 0.6rem;
|
||||
bottom: 0.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
padding: 0.42rem 0.55rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span::before {
|
||||
width: 0.38rem;
|
||||
height: 0.38rem;
|
||||
border-radius: 50%;
|
||||
background: var(--laboratory-metric-legend-color, var(--nodedc-text-muted));
|
||||
content: "";
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span[data-decision="threat"]::before {
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span[data-decision="not-threat"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span[data-decision="unknown"]::before {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span[data-decision="rolling"]::before {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgb(var(--nodedc-accent-rgb));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span[data-decision="local-surface"]::before {
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
opacity: 0.72;
|
||||
}
|
||||
@@ -399,6 +399,11 @@ i[data-availability="error"] {
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.observation-timeline__playback > .nodedc-select-anchor {
|
||||
width: 7.5rem;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
|
||||
@@ -204,7 +204,105 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The shared shell switches to its compact overlay at 760 px, but Mission
|
||||
Core's 20.75 rem navigation leaves less than the plugin's usable 32 rem
|
||||
workspace before that point. Bridge the structural breakpoint locally so
|
||||
761--929 px never renders a full sidebar beside a sub-32-rem device UI. */
|
||||
@media (min-width: 761px) and (max-width: 929px) {
|
||||
.nodedc-header-shell {
|
||||
width: 100%;
|
||||
padding: 0.85rem 1rem 0.65rem;
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.nodedc-header {
|
||||
min-height: 8.6rem;
|
||||
}
|
||||
|
||||
.nodedc-header__row {
|
||||
min-height: 8.6rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-content: start;
|
||||
row-gap: 0.65rem;
|
||||
}
|
||||
|
||||
.nodedc-header__center {
|
||||
order: 3;
|
||||
grid-column: 1 / -1;
|
||||
justify-content: stretch;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.nodedc-header__center .nodedc-segmented,
|
||||
.nodedc-header__center .nodedc-header-navigation {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.nodedc-app-shell {
|
||||
--nodedc-app-header-height: 10.25rem;
|
||||
--nodedc-app-page-pad: 0.7rem;
|
||||
--nodedc-app-panel-gap: 0.7rem;
|
||||
}
|
||||
|
||||
.nodedc-app-shell__stage {
|
||||
padding: 0 var(--nodedc-app-page-pad) var(--nodedc-app-page-pad);
|
||||
}
|
||||
|
||||
.nodedc-app-shell__navigation,
|
||||
.nodedc-app-shell__content {
|
||||
right: var(--nodedc-app-page-pad);
|
||||
bottom: var(--nodedc-app-page-pad);
|
||||
left: var(--nodedc-app-page-pad);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nodedc-app-shell[data-navigation-open="true"] .nodedc-app-shell__stage,
|
||||
.nodedc-app-shell[data-content-open="true"] .nodedc-app-shell__stage {
|
||||
padding-left: var(--nodedc-app-page-pad);
|
||||
pointer-events: none;
|
||||
transform: translateX(calc(100vw + var(--nodedc-app-page-pad)));
|
||||
}
|
||||
|
||||
.nodedc-app-shell[data-content-open="true"] .nodedc-app-shell__navigation {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodedc-application-panel,
|
||||
.nodedc-admin-panel {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.nodedc-application-panel {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.nodedc-application-panel__titles p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nodedc-application-panel__action:first-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
/* The shared application panel is a grid. Its header has three fixed-size
|
||||
controls, so the grid item's automatic min-content width can widen the
|
||||
body past the panel padding on narrow phones. Keep both grid rows owned
|
||||
by the panel's content box; long plugin content must wrap inside it. */
|
||||
.nodedc-application-panel__head,
|
||||
.nodedc-application-panel__body {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.nodedc-application-panel__head {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.polygon-review-lead,
|
||||
.polygon-review-player__header,
|
||||
.polygon-review-order,
|
||||
|
||||
@@ -37,6 +37,25 @@ interface ContourSnapshot {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function contourRuntimeAuthorityPresentation(
|
||||
state: MissionRuntimeState | null,
|
||||
) {
|
||||
const deviceControlConnectivity = state?.deviceSession?.connectivity ?? null;
|
||||
return {
|
||||
aiActive: Boolean(
|
||||
state?.sourceMode === "live"
|
||||
&& state.phase === "streaming"
|
||||
&& state.metrics?.aiFrameRateHz
|
||||
&& state.metrics.aiFrameRateHz > 0
|
||||
),
|
||||
deviceControlConnectivity,
|
||||
controlledDevice: state?.activeDevice
|
||||
&& (deviceControlConnectivity === "connected" || deviceControlConnectivity === "degraded")
|
||||
? state.activeDevice
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function validControlPlaneHealth(value: unknown): value is ControlPlaneHealth {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const item = value as Partial<ControlPlaneHealth>;
|
||||
@@ -155,16 +174,16 @@ export function ContourHealthWorkspace({
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
const aiActive = Boolean(
|
||||
state?.metrics?.aiFrameRateHz
|
||||
&& state.metrics.aiFrameRateHz > 0,
|
||||
);
|
||||
const {
|
||||
aiActive,
|
||||
deviceControlConnectivity,
|
||||
controlledDevice,
|
||||
} = contourRuntimeAuthorityPresentation(state);
|
||||
const simulationWorker = snapshot?.simulationWorker ?? null;
|
||||
const controlPlaneReady = Boolean(snapshot?.controlPlane?.ok);
|
||||
const runtimeReady = snapshot?.pluginRuntimes.filter(
|
||||
(runtime) => runtime.status === "ready",
|
||||
).length ?? 0;
|
||||
const connectedDevice = state?.activeDevice ?? null;
|
||||
const processCount = (snapshot?.pluginRuntimes.length ?? 0) + 2;
|
||||
const readyProcessCount = runtimeReady
|
||||
+ (controlPlaneReady ? 1 : 0)
|
||||
@@ -325,11 +344,16 @@ export function ContourHealthWorkspace({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{connectedDevice ? (
|
||||
{controlledDevice ? (
|
||||
<section className="contour-connected-device">
|
||||
<span className="section-eyebrow">ПОДКЛЮЧЁННОЕ УСТРОЙСТВО</span>
|
||||
<strong>{connectedDevice.displayName}</strong>
|
||||
<small>{connectedDevice.endpointLabel ?? connectedDevice.modelId}</small>
|
||||
<span className="section-eyebrow">ПОДТВЕРЖДЁННАЯ УПРАВЛЯЮЩАЯ СЕССИЯ</span>
|
||||
<strong>{controlledDevice.displayName}</strong>
|
||||
<small>
|
||||
{controlledDevice.endpointLabel ?? controlledDevice.modelId}
|
||||
{deviceControlConnectivity === "degraded"
|
||||
? " · управление подтверждено, поток данных нарушен"
|
||||
: " · управление подтверждено"}
|
||||
</small>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -101,9 +101,8 @@ export function DeviceWorkspace({
|
||||
<div className="device-plugin-slot">
|
||||
<div className="device-plugin-slot__bar">
|
||||
<div>
|
||||
<span className="section-eyebrow">АКТИВНАЯ МОДЕЛЬ</span>
|
||||
<strong>{selection.model.displayName}</strong>
|
||||
<small>{selection.plugin.manifest.metadata.displayName} · v{selection.plugin.manifest.metadata.version}</small>
|
||||
<span className="section-eyebrow">СЦЕНАРИЙ ПОДКЛЮЧЕНИЯ</span>
|
||||
<strong>Модель выбрана</strong>
|
||||
{selectionTransitionError ? (
|
||||
<small className="device-plugin-slot__error" role="alert">
|
||||
{selectionTransitionError}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
import {
|
||||
RerunViewport,
|
||||
@@ -155,7 +156,8 @@ function SpatialWorkspace({
|
||||
const [showDetections2d, setShowDetections2d] = useState(false);
|
||||
const [showSegmentation, setShowSegmentation] = useState(false);
|
||||
const [showCuboids3d, setShowCuboids3d] = useState(false);
|
||||
const recordedSource = state?.sourceMode === "replay" || /\.rrd(?:$|[?#])/i.test(sourceUrl);
|
||||
const recordedSource = Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl);
|
||||
const liveRerunSource = !recordedSource && /^rerun\+https?:\/\//i.test(sourceUrl.trim());
|
||||
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
|
||||
? recordedSessionAdmission?.phase ?? "loading"
|
||||
: "ready";
|
||||
@@ -164,6 +166,10 @@ function SpatialWorkspace({
|
||||
isRecordedPlaybackPresentationReady(viewerStatus, playbackState));
|
||||
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
// An explicit manual gRPC source has no Mission Core metrics producer. Its
|
||||
// native Rerun range is therefore the only available activity proof.
|
||||
const livePresentationActivitySequence = metrics?.publishedFrameCount ??
|
||||
(liveRerunSource && !streamActive ? 1 : null);
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frameRateHz);
|
||||
const points = finiteMetric(metrics?.pointCount);
|
||||
@@ -490,12 +496,14 @@ function SpatialWorkspace({
|
||||
data-primary-focused={pointCloudFocused ? "true" : undefined}
|
||||
data-media-maximized={floatingSourceMaximized ? "true" : undefined}
|
||||
>
|
||||
{sourceUrl.trim() && pointCloudVisible ? (
|
||||
{sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
||||
<RerunViewport
|
||||
sourceUrl={sourceUrl}
|
||||
recordedArtifact={recordedSource ? recordedReplay : null}
|
||||
followLive={!recordedReplay && streamActive}
|
||||
liveActivitySequence={metrics?.publishedFrameCount} liveStreamId={state?.spatialSource?.id}
|
||||
followLive={liveRerunSource}
|
||||
liveActivitySequence={livePresentationActivitySequence}
|
||||
liveStreamId={state?.spatialSource?.id}
|
||||
liveRecoveryAuthorityIdentity={!recordedSource && streamActive ? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource) : null}
|
||||
autoplayWhenReady={recordedSource}
|
||||
presentationGate={recordedSessionGate}
|
||||
expectedTimelineStartSeconds={recordedSource
|
||||
|
||||
@@ -39,6 +39,8 @@ import { E46GRectifiedDetectorBakeoffResultView } from "./E46GRectifiedDetectorB
|
||||
import { E46HFullRectifiedFrontReplayResultView } from "./E46HFullRectifiedFrontReplayResult";
|
||||
import { E46IGroundingDinoFullReplayResultView } from "./E46IGroundingDinoFullReplayResult";
|
||||
import { E46JRawFisheyeRealtimeResultView } from "./E46JRawFisheyeRealtimeResult";
|
||||
import { E47SemanticSlamResultView } from "./E47SemanticSlamResult";
|
||||
import { M4ReplayThreatResultView } from "./M4ReplayThreatResult";
|
||||
|
||||
export { isAdvancedLaboratoryWorkId };
|
||||
export type { AdvancedLaboratoryWorkId };
|
||||
@@ -81,6 +83,12 @@ export function AdvancedLaboratoryResult({
|
||||
failedSessionId: string | null;
|
||||
replayError: string | null;
|
||||
}) {
|
||||
if (workId === "m4-replay-threat" && results.m4Threat) {
|
||||
return <M4ReplayThreatResultView rigLabel={rigLabel} result={results.m4Threat} />;
|
||||
}
|
||||
if (workId === "e47-semantic-slam-shadow" && results.e47) {
|
||||
return <E47SemanticSlamResultView rigLabel={rigLabel} result={results.e47} />;
|
||||
}
|
||||
if (workId === "l3-pointpillars-visual-audit" && results.l3) {
|
||||
return <L3PointPillarsResult result={results.l3} />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "../../components/RecordedFmp4Player";
|
||||
import {
|
||||
RecordedEvidenceVideoScene,
|
||||
type RecordedEvidenceBox,
|
||||
} from "../../components/laboratory/RecordedEvidenceVideoScene";
|
||||
import {
|
||||
selectE46CVideoFrame,
|
||||
type E46CMotionState,
|
||||
@@ -11,26 +14,10 @@ import {
|
||||
} from "../../core/laboratory/e46cFullReplayWorldTracks";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
|
||||
function color(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const channels = getComputedStyle(host)
|
||||
.getPropertyValue(token)
|
||||
.trim()
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function stateColor(host: HTMLElement, state: E46CMotionState): string {
|
||||
if (state === "dynamic") return color(host, "--nodedc-accent-rgb", [232, 56, 126]);
|
||||
if (state === "static") return color(host, "--nodedc-success-rgb", [181, 255, 90]);
|
||||
return color(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
function stateTone(state: E46CMotionState): RecordedEvidenceBox["tone"] {
|
||||
if (state === "dynamic") return "accent";
|
||||
if (state === "static") return "success";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function stateLabel(state: E46CMotionState): string {
|
||||
@@ -50,97 +37,40 @@ export function E46CRecordedVideoScene({
|
||||
playback: RecordedObservationPlayback;
|
||||
onPlaybackChange: (playback: RecordedObservationPlayback) => void;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const frame = useMemo(
|
||||
() => selectE46CVideoFrame(overlay.frames, playback.currentSeconds),
|
||||
[overlay.frames, playback.currentSeconds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!host || !canvas) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * pixelRatio);
|
||||
canvas.height = Math.round(height * pixelRatio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
if (!frame) return;
|
||||
|
||||
const scale = Math.min(
|
||||
width / overlay.imageWidth,
|
||||
height / overlay.imageHeight,
|
||||
);
|
||||
const drawWidth = overlay.imageWidth * scale;
|
||||
const drawHeight = overlay.imageHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
for (const item of frame.objects) {
|
||||
const [left, top, right, bottom] = item.boxXyxy;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const stroke = stateColor(host, item.motionState);
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = Math.max(1.5, 2 * scale);
|
||||
context.setLineDash(item.cameraEvidenceCurrent ? [] : [5, 4]);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
context.setLineDash([]);
|
||||
|
||||
const boxes = useMemo<readonly RecordedEvidenceBox[]>(() => (
|
||||
frame?.objects.map((item) => {
|
||||
const identity = `S${item.routeTrackId}${
|
||||
item.worldTrackId === null ? "" : `→W${item.worldTrackId}`
|
||||
}`;
|
||||
const label = `${identity} · ${item.displayCategory} · ${stateLabel(
|
||||
return {
|
||||
boxXyxy: item.boxXyxy,
|
||||
label: `${identity} · ${item.displayCategory} · ${stateLabel(
|
||||
item.motionState,
|
||||
)} · ${Math.round(item.score * 100)}%`;
|
||||
const fontSize = Math.max(9, 10 * scale);
|
||||
context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = context.measureText(label).width + 8;
|
||||
const labelHeight = fontSize + 6;
|
||||
const labelX = Math.min(
|
||||
offsetX + drawWidth - labelWidth,
|
||||
Math.max(offsetX, x),
|
||||
);
|
||||
const labelY = Math.max(offsetY, y - labelHeight);
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
|
||||
context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke;
|
||||
context.fillText(label, labelX + 4, labelY + fontSize + 1);
|
||||
}
|
||||
)} · ${Math.round(item.score * 100)}%`,
|
||||
tone: stateTone(item.motionState),
|
||||
dashed: !item.cameraEvidenceCurrent,
|
||||
};
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [frame, overlay.imageHeight, overlay.imageWidth]);
|
||||
}) ?? []
|
||||
), [frame]);
|
||||
|
||||
return (
|
||||
<div className="e46c-video-scene" ref={hostRef}>
|
||||
<RecordedFmp4Player
|
||||
<RecordedEvidenceVideoScene
|
||||
source={source}
|
||||
playback={playback}
|
||||
interactive
|
||||
prepare
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
/>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label={
|
||||
imageWidth={overlay.imageWidth}
|
||||
imageHeight={overlay.imageHeight}
|
||||
boxes={boxes}
|
||||
ariaLabel={
|
||||
frame
|
||||
? `E46C video frame ${frame.frameIndex}: ${frame.objects.length} route objects`
|
||||
: "E46C recorded video overlay"
|
||||
}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { E47SemanticSlamResult } from "../../core/laboratory/e47SemanticSlam";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { M4ReplayThreatVisual } from "./M4ReplayThreatVisual";
|
||||
|
||||
export function E47SemanticSlamResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E47SemanticSlamResult;
|
||||
}) {
|
||||
const pointProjectedCoverage = result.metrics.points.total
|
||||
? result.metrics.points.projected / result.metrics.points.total
|
||||
: 0;
|
||||
const pointLabeledCoverage = result.metrics.points.total
|
||||
? result.metrics.points.labeled / result.metrics.points.total
|
||||
: 0;
|
||||
const observationLabeledCoverage = result.metrics.observations.total
|
||||
? result.metrics.observations.labeled / result.metrics.observations.total
|
||||
: 0;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="E47 · semantic mask → KB4 → SLAM shadow"
|
||||
description="Зафиксированные EoMT-маски проецируются заводской KB4-калибровкой на исходные точки SLAM/LiDAR и отдельно агрегируются по уже существующим геометрическим наблюдениям. Это диагностический слой: он не меняет occupancy, motion, threat или safe/unknown."
|
||||
status="Diagnostic contract passed · provider quality gate open"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{
|
||||
label: "Конфигурация",
|
||||
value: `${rigLabel} · RIGHT camera + registered SLAM cloud · recorded replay`,
|
||||
},
|
||||
{
|
||||
label: "Semantic control",
|
||||
value: `${result.provider.modelId} · exact revision ${result.provider.modelRevision.slice(0, 12)}`,
|
||||
},
|
||||
{
|
||||
label: "Проекция",
|
||||
value: `factory KB4 · ${result.calibrationContentSha256.slice(0, 12)} · frame-local point IDs`,
|
||||
},
|
||||
{
|
||||
label: "Синхрон",
|
||||
value: `semantic↔camera exact ledger · camera↔LiDAR E6 best-effort ≤${formatNumber(result.temporalBinding.maximumLidarCameraDeltaMs, 0)} ms · HW sync: нет`,
|
||||
},
|
||||
{
|
||||
label: "Покрытие",
|
||||
value: `${result.metrics.frames.maskAvailable}/${result.metrics.frames.total} masks · ${formatNumber(pointProjectedCoverage * 100, 1)}% projected · ${formatNumber(pointLabeledCoverage * 100, 1)}% unambiguous`,
|
||||
},
|
||||
{
|
||||
label: "Визуал",
|
||||
value: "4489-frame VIDEO/CAMERA/3D/PLAN · один recorded clock · semantic layer",
|
||||
},
|
||||
]}
|
||||
brief={{
|
||||
question: "Можно ли добавить плотную семантику камеры к сильной SLAM/LiDAR-геометрии, не превратив классификацию в источник ложного свободного пространства?",
|
||||
approach: "Для всех 4489 кадров переиспользованы неизменяемые EoMT masks, factory KB4 extrinsic/intrinsic и тот же source point index space, на котором построен M4.6. Semantic↔camera сверяется fail-closed по sequence и session-time; camera↔LiDAR сохраняет исходный bounded nearest-arrival E6 contract, а не выдаётся за hardware-sync. Каждая точка получает labeled, ambiguous, unprojected или absent.",
|
||||
principalResult: `${result.metrics.points.labeled.toLocaleString("ru-RU")} точек получили однозначный класс, ${result.metrics.points.ambiguous.toLocaleString("ru-RU")} остались semantic-ambiguous, ${result.metrics.points.unprojected.toLocaleString("ru-RU")} не спроецировались. Из ${result.metrics.observations.total.toLocaleString("ru-RU")} неизменённых geometry observations однозначный класс получили ${formatNumber(observationLabeledCoverage * 100, 1)}%.`,
|
||||
limitation: "EoMT здесь — фиксированный control provider, а не выбранная production-модель. Physical camera↔LiDAR hardware-sync не доказан; принят только E6 nearest-host-arrival best-effort в пределах 100 мс. Semantic/instance truth, obstacle recall и fisheye-specific качество независимо не размечены; отсутствие класса никогда не означает free.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: "semantic-slam-diagnostic-shadow/v1",
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: result.semanticResultId,
|
||||
version: result.provider.modelRevision,
|
||||
role: "sealed full-route uint8 semantic masks",
|
||||
identitySha256: result.provider.modelWeightsSha256,
|
||||
},
|
||||
{
|
||||
kind: "source",
|
||||
name: result.sourcePackId,
|
||||
version: "registered map increments + vendor SLAM pose",
|
||||
role: "точный frame-local point index space",
|
||||
identitySha256: result.sourcePackId.split("-").at(-1) ?? null,
|
||||
},
|
||||
{
|
||||
kind: "source",
|
||||
name: result.geometryResultId,
|
||||
version: "immutable M4 geometry observations",
|
||||
role: "неизменяемые obstacle IDs, occupancy и metric geometry",
|
||||
identitySha256: result.geometryResultId.split("-").at(-1) ?? null,
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: "semantic diagnostic fusion",
|
||||
version: result.profileId,
|
||||
role: "KB4 mask projection + point/observation accounting без safety authority",
|
||||
identitySha256: result.resultId.split("-").at(-1) ?? null,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="E47 VISUAL EVIDENCE · VIDEO / CAMERA / 3D / PLAN"
|
||||
title="Синхронный контроль маски, semantic-точек, геометрии и коридора"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<M4ReplayThreatVisual
|
||||
resultId={result.baseM4ResultId}
|
||||
semantic={{
|
||||
resultId: result.resultId,
|
||||
taxonomy: result.taxonomy,
|
||||
}}
|
||||
/>
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Semantic/SLAM seam принят; качество provider ещё не принято"
|
||||
status="Жёлтый: артефакты и проекция доказаны, independent semantic truth отсутствует"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{
|
||||
label: "Semantic masks",
|
||||
value: `${result.metrics.frames.maskAvailable}/${result.metrics.frames.total}`,
|
||||
hint: "exact immutable full-route archive",
|
||||
},
|
||||
{
|
||||
label: "Point labels",
|
||||
value: result.metrics.points.labeled.toLocaleString("ru-RU"),
|
||||
hint: `${result.metrics.points.unprojected.toLocaleString("ru-RU")} unprojected · ${result.metrics.points.ambiguous.toLocaleString("ru-RU")} ambiguous`,
|
||||
},
|
||||
{
|
||||
label: "Observation labels",
|
||||
value: result.metrics.observations.labeled.toLocaleString("ru-RU"),
|
||||
hint: `${result.metrics.observations.ambiguous.toLocaleString("ru-RU")} ambiguous`,
|
||||
},
|
||||
{
|
||||
label: "Derivative build",
|
||||
value: `${formatNumber(result.metrics.runtime.framesPerSecond, 1)} FPS`,
|
||||
hint: `${formatNumber(result.metrics.runtime.elapsedMs / 1000, 1)} s offline`,
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Одна каноническая модель-независимая форма принимает sealed semantic mask, привязывает её к исходному кадру и к factory KB4, маркирует полный frame-local point space и публикует проверяемое semantic evidence для существующих geometry observations. Текущий M4.6 при этом не изменён.",
|
||||
notProved: "Не доказаны physical hardware-sync, class accuracy, instance separation, удержание отдельных объектов, obstacle recall, перенос на другой маршрут/provider и production latency на Worker 006. Semantic evidence не имеет navigation/safety authority.",
|
||||
decision: "Оставить EoMT как контрольную ветку. Следующий честный A/B — NVIDIA CitySemSegFormer на замороженном truth-island через тот же provider contract; после ручного GT сравнивать качество, а не интерфейс или цвет overlay.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { M4ThreatReplayResult } from "../../core/laboratory/m4ReplayThreat";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { M4ReplayThreatVisual } from "./M4ReplayThreatVisual";
|
||||
|
||||
export function M4ReplayThreatResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: M4ThreatReplayResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
const totalAssessments = Object.values(metrics.decisions).reduce(
|
||||
(sum, value) => sum + value,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="M4.6 · dual-evidence threat replay"
|
||||
description="Camera и LiDAR дают независимые доказательства, после чего один source-neutral слой оценивает пересечение виртуального коридора, ближайшее сближение и TTC. Ни один сенсор не назначен first."
|
||||
status="Replay contract passed · CV quality gate open"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{
|
||||
label: "Конфигурация",
|
||||
value: `${rigLabel} · RIGHT camera + LiDAR geometry · recorded replay`,
|
||||
},
|
||||
{
|
||||
label: "Виртуальный корпус",
|
||||
value: `${result.configuration.virtualBodyM[0]}×${result.configuration.virtualBodyM[1]} м · LiDAR ${result.configuration.nominalSensorHeightM} м`,
|
||||
},
|
||||
{
|
||||
label: "Коридор",
|
||||
value: `${result.configuration.forwardCorridorM} м · horizon ${result.configuration.predictionHorizonSeconds} с`,
|
||||
},
|
||||
{
|
||||
label: "Опорная СК",
|
||||
value: `SLAM gravity · route-forward · ${metrics.bodyFrame.qualified}/${metrics.bodyFrame.available} qualified`,
|
||||
},
|
||||
{
|
||||
label: "Визуал",
|
||||
value: "4489-frame VIDEO/CAMERA/3D/PLAN · единый recorded clock",
|
||||
},
|
||||
]}
|
||||
brief={{
|
||||
question: "Может ли единый слой обнаруживать потенциальное препятствие по двум независимым источникам, не теряя LiDAR-only объекты и не объявляя camera-only наблюдение безопасным?",
|
||||
approach: "Все 4489 кадров RAVNOVES00 повторно пропущены через неизменяемые detector, metric geometry и temporal ledgers. Текущий lio_pcl increment хранится отдельно от bounded rolling map: отсутствие повторной публикации точки не считается свободным пространством. Виртуальный base_footprint привязан к gravity-оси SLAM map и направлению сглаженной траектории, проверенному camera extrinsic.",
|
||||
principalResult: `${metrics.evidence.currentMetric.toLocaleString("ru-RU")} current metric, ${metrics.evidence.rollingMapRetained.toLocaleString("ru-RU")} rolling-map и ${metrics.evidence.cameraOnly.toLocaleString("ru-RU")} camera-only публикаций учтены. Кадры 1880 и 2584 закрепляют компактные бетонные полусферы как автоматические метрические регрессии; критические fixtures: ${metrics.fixtures.passed}/${metrics.fixtures.total}, ложных safe: ${metrics.fixtures.criticalFalseNotThreat}.`,
|
||||
limitation: "Корпус и коридор пока виртуальные, replay не является live-проходом или физическим collision test. На машине виртуальная привязка должна замениться измеренным rigid T_body_from_sensor; independent object truth остаётся следующим gate.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: "dual-evidence-replay-threat/v3",
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: result.sourceResultIds.detector,
|
||||
version: "frozen camera proposals",
|
||||
role: "независимое image-space evidence без safety authority",
|
||||
identitySha256: result.sourceResultIds.detector.split("-").at(-1) ?? null,
|
||||
},
|
||||
{
|
||||
kind: "source",
|
||||
name: result.sourceResultIds.geometry,
|
||||
version: "frozen metric geometry",
|
||||
role: "LiDAR occupied components и camera association",
|
||||
identitySha256: result.sourceResultIds.geometry.split("-").at(-1) ?? null,
|
||||
},
|
||||
{
|
||||
kind: "source",
|
||||
name: result.sourceResultIds.temporal,
|
||||
version: "frozen temporal + rolling obstacle map",
|
||||
role: "current increment / rolling retained / bounded motion history",
|
||||
identitySha256: result.sourceResultIds.temporal.split("-").at(-1) ?? null,
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: "dual-evidence virtual corridor",
|
||||
version: result.profileId,
|
||||
role: "classless corridor intersection, closest approach and TTC",
|
||||
identitySha256: result.resultId.split("-").at(-1) ?? null,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="M4.6 VISUAL EVIDENCE · VIDEO / CAMERA / 3D / PLAN"
|
||||
title="Синхронный контроль рамок, расстояний, облака точек и виртуального коридора"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<M4ReplayThreatVisual resultId={result.resultId} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Replay-инфраструктура принята; object recall ещё проверяется"
|
||||
status="Жёлтый: pipeline целостен, независимый object-truth gate не пройден"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{
|
||||
label: "Replay frames",
|
||||
value: "4489/4489",
|
||||
hint: `${formatNumber(metrics.runtime.framesPerSecond, 1)} FPS offline`,
|
||||
},
|
||||
{
|
||||
label: "Metric evidence",
|
||||
value: metrics.evidence.currentMetric.toLocaleString("ru-RU"),
|
||||
hint: `${metrics.evidence.rollingMapRetained.toLocaleString("ru-RU")} rolling-map publications`,
|
||||
},
|
||||
{
|
||||
label: "Threat / clear",
|
||||
value: `${metrics.decisions.threat.toLocaleString("ru-RU")} / ${metrics.decisions["not-threat"].toLocaleString("ru-RU")}`,
|
||||
hint: `${totalAssessments.toLocaleString("ru-RU")} assessments accounted`,
|
||||
},
|
||||
{
|
||||
label: "Critical false-safe",
|
||||
value: String(metrics.fixtures.criticalFalseNotThreat),
|
||||
hint: `${metrics.fixtures.passed}/${metrics.fixtures.total} deterministic fixtures passed`,
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `На неизменяемом RAVNOVES00 каждый current, rolling-map, stale/held и camera-only объект получил ровно одну консервативную оценку. CURRENT INCREMENT и ROLLING MAP независимо включаются в viewer; кадры 1880 и 2584 закреплены как регрессии компактных бетонных полусфер. ${metrics.bodyFrame.qualified}/${metrics.bodyFrame.available} body frames квалифицированы без переноса handheld roll/pitch на SLAM-мир.`,
|
||||
notProved: "Не доказаны live realtime, измеренный T_body_from_sensor и геометрия физического корпуса, независимая object-level правильность, навигационная или safety-пригодность и выдача команд.",
|
||||
decision: "Сохранить dual-evidence provider как канонический replay seam и переходить к независимому object-centric gate; физическую геометрию и live/actuation authority не смешивать с дальнейшей CV-разработкой.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Icon, IconButton } from "@nodedc/ui-react";
|
||||
|
||||
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
||||
import {
|
||||
LaboratoryMetricEvidenceScene,
|
||||
type LaboratoryMetricEvidenceSceneHandle,
|
||||
type LaboratoryMetricSceneMode,
|
||||
} from "../../components/laboratory/LaboratoryMetricEvidenceScene";
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import { RecordedEvidenceImageScene } from "../../components/laboratory/RecordedEvidenceImageScene";
|
||||
import type {
|
||||
RecordedEvidenceSemanticClass,
|
||||
RecordedEvidenceSemanticOverlay,
|
||||
RecordedEvidenceSemanticPaletteEntry,
|
||||
} from "../../components/laboratory/RecordedEvidenceSemanticMaskOverlay";
|
||||
import {
|
||||
RecordedEvidenceVideoScene,
|
||||
type RecordedEvidenceBox,
|
||||
} from "../../components/laboratory/RecordedEvidenceVideoScene";
|
||||
import { useRecordedEvidencePlayback } from "../../components/laboratory/useRecordedEvidencePlayback";
|
||||
import {
|
||||
e47SemanticMaskUrl,
|
||||
type E47SemanticClass,
|
||||
} from "../../core/laboratory/e47SemanticSlam";
|
||||
import type {
|
||||
M4ThreatCameraProposal,
|
||||
M4ThreatTimelineFrame,
|
||||
} from "../../core/laboratory/m4ReplayThreat";
|
||||
import { buildM4LocalSurface } from "../../core/laboratory/m4LocalSurface";
|
||||
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
|
||||
import { replayObservationSession } from "../../core/observation/sessionArchive";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
import {
|
||||
useM4ThreatTimelineFrame,
|
||||
useM4ThreatTimelineMetadata,
|
||||
} from "./useM4ThreatTimeline";
|
||||
import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline";
|
||||
|
||||
type M4ThreatViewMode = "video" | "camera" | LaboratoryMetricSceneMode;
|
||||
|
||||
function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] {
|
||||
if (proposal.threatDecision === "threat") return "danger";
|
||||
if (proposal.threatDecision === "not-threat") return "success";
|
||||
if (proposal.threatDecision === "unknown") return "warning";
|
||||
return proposal.occupiedSupport ? "accent" : "warning";
|
||||
}
|
||||
|
||||
function proposalLabel(proposal: M4ThreatCameraProposal): string {
|
||||
const decision = proposal.threatDecision ?? "unknown";
|
||||
if (proposal.rangeM === null) return decision;
|
||||
const range = `${proposal.rangeM.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} м`;
|
||||
return `${range} · ${decision}`;
|
||||
}
|
||||
|
||||
function boxes(proposals: readonly M4ThreatCameraProposal[]): readonly RecordedEvidenceBox[] {
|
||||
return proposals.map((proposal) => ({
|
||||
boxXyxy: proposal.bboxXyxy,
|
||||
label: proposalLabel(proposal),
|
||||
tone: toneForProposal(proposal),
|
||||
dashed: !proposal.occupiedSupport,
|
||||
}));
|
||||
}
|
||||
|
||||
function message(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
||||
}
|
||||
|
||||
function SpatialState({ message: text }: { message: string }) {
|
||||
return (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface M4ReplayThreatSemanticLayer {
|
||||
resultId: string;
|
||||
taxonomy: readonly E47SemanticClass[];
|
||||
}
|
||||
|
||||
export function M4ReplayThreatVisual({
|
||||
resultId,
|
||||
semantic,
|
||||
}: {
|
||||
resultId: string;
|
||||
semantic?: M4ReplayThreatSemanticLayer;
|
||||
}) {
|
||||
const [mode, setMode] = useState<M4ThreatViewMode>("video");
|
||||
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode>("3d");
|
||||
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
||||
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
||||
const [showRollingMap, setShowRollingMap] = useState(true);
|
||||
const [showSemantic, setShowSemantic] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
|
||||
const metadata = useM4ThreatTimelineMetadata(resultId);
|
||||
const playbackRange = useMemo(() => metadata.timeline ? ({
|
||||
startSeconds: metadata.timeline.timelineStartSeconds,
|
||||
endSeconds: metadata.timeline.timelineEndSeconds,
|
||||
}) : null, [metadata.timeline]);
|
||||
const playbackController = useRecordedEvidencePlayback(playbackRange);
|
||||
const timelineFrame = useM4ThreatTimelineFrame({
|
||||
resultId,
|
||||
timeline: metadata.timeline,
|
||||
currentSeconds: playbackController.playback.currentSeconds,
|
||||
});
|
||||
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
||||
const [videoLoading, setVideoLoading] = useState(false);
|
||||
const [videoError, setVideoError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setVideoSource(null);
|
||||
setVideoError(null);
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeline = metadata.timeline;
|
||||
if (!timeline || videoSource) return;
|
||||
const controller = new AbortController();
|
||||
setVideoLoading(true);
|
||||
setVideoError(null);
|
||||
void replayObservationSession(timeline.recordedSourceSessionId, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((replay) => {
|
||||
if (replay.kind !== "ready") {
|
||||
throw new Error("RIGHT-видео RAVNOVES00 ещё готовится к воспроизведению.");
|
||||
}
|
||||
const source = recordedObservationSources(replay.launch).find(
|
||||
(candidate) => candidate.modality === "video"
|
||||
&& candidate.semanticChannelId === "camera.video.recorded",
|
||||
);
|
||||
const delivery = source?.delivery?.kind === "recorded-fmp4-manifest"
|
||||
? source.delivery
|
||||
: null;
|
||||
if (
|
||||
!source
|
||||
|| !delivery
|
||||
|| delivery.timelineStartSeconds !== timeline.timelineStartSeconds
|
||||
|| delivery.timelineEndSeconds < timeline.timelineEndSeconds
|
||||
) {
|
||||
throw new Error("RIGHT-видео не совпало с recorded-realtime timeline M4.6.");
|
||||
}
|
||||
if (!controller.signal.aborted) setVideoSource(source);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setVideoError(message(caught, "Видео-доказательство M4.6 недоступно."));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setVideoLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [metadata.timeline, videoSource]);
|
||||
|
||||
const lastFrameRef = useRef<M4ThreatTimelineFrame | null>(null);
|
||||
useEffect(() => {
|
||||
lastFrameRef.current = null;
|
||||
}, [resultId]);
|
||||
if (timelineFrame.activeFrame) lastFrameRef.current = timelineFrame.activeFrame;
|
||||
const frame = timelineFrame.activeFrame ?? lastFrameRef.current;
|
||||
const semanticTimeline = useE47SemanticTimelineFrame({
|
||||
resultId: semantic?.resultId ?? null,
|
||||
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
||||
frameCount: metadata.timeline?.frameCount ?? 0,
|
||||
taxonomy: semantic?.taxonomy ?? [],
|
||||
});
|
||||
const displayingBufferedFrame = Boolean(
|
||||
frame
|
||||
&& timelineFrame.activeSequence !== null
|
||||
&& frame.sequence !== timelineFrame.activeSequence,
|
||||
);
|
||||
const activeBoxes = useMemo(() => boxes(frame?.cameraProposals ?? []), [frame]);
|
||||
const sceneObstacles = useMemo(() => frame?.metricObstacles.map((obstacle) => ({
|
||||
id: obstacle.componentId,
|
||||
decision: obstacle.assessment.decision,
|
||||
state: obstacle.state,
|
||||
centroidBodyXyzM: obstacle.centroidBodyXyzM,
|
||||
cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM,
|
||||
})) ?? [], [frame]);
|
||||
const currentIncrementObstacles = frame?.metricObstacles.filter(
|
||||
(item) => item.state === "current",
|
||||
) ?? [];
|
||||
const rollingMapObstacles = frame?.metricObstacles.filter(
|
||||
(item) => item.state === "retained",
|
||||
) ?? [];
|
||||
const nearest = frame?.metricObstacles
|
||||
.map((item) => item.assessment.closestApproachM)
|
||||
.filter((value): value is number => value !== null)
|
||||
.sort((left, right) => left - right)[0] ?? null;
|
||||
const localSurface = useMemo(() => buildM4LocalSurface(
|
||||
timelineFrame.availableFrames,
|
||||
frame,
|
||||
metadata.timeline?.localSurfaceVisualization ?? {
|
||||
windowSeconds: 2,
|
||||
voxelSizeM: 0.1,
|
||||
radiusM: 12,
|
||||
pointLimit: 20_000,
|
||||
},
|
||||
), [frame, metadata.timeline, timelineFrame.availableFrames]);
|
||||
const semanticClasses = useMemo<readonly RecordedEvidenceSemanticClass[]>(
|
||||
() => semantic?.taxonomy.map((item) => ({
|
||||
id: item.classId,
|
||||
label: `semantic: ${item.label}`,
|
||||
})) ?? [],
|
||||
[semantic?.taxonomy],
|
||||
);
|
||||
const semanticPalette = useMemo<readonly RecordedEvidenceSemanticPaletteEntry[]>(
|
||||
() => semantic?.taxonomy.map((item) => ({
|
||||
classId: item.classId,
|
||||
color: item.disposition === "ambiguous"
|
||||
? { kind: "token" as const, token: "--nodedc-warning-rgb" as const }
|
||||
: { kind: "diagnostic" as const, rgb: item.colorRgb },
|
||||
opacity: item.disposition === "ambiguous" ? 0.22 : 0.56,
|
||||
})) ?? [],
|
||||
[semantic?.taxonomy],
|
||||
);
|
||||
const semanticFrame = semanticTimeline.activeFrame?.sequence === frame?.sequence
|
||||
? semanticTimeline.activeFrame
|
||||
: null;
|
||||
const semanticIntegrityError = semantic && frame?.spatialAvailable && semanticFrame && (
|
||||
semanticFrame.sourcePointCount !== frame.pointCloudSourceCount
|
||||
|| frame.pointCloudSampleCount !== frame.pointCloudSourceCount
|
||||
|| frame.pointCloudBodyXyzM.length !== frame.pointCloudSourceCount
|
||||
)
|
||||
? "E47 semantic point index space не совпал с exact current increment M4.6."
|
||||
: null;
|
||||
const alignedSemanticPointIds = useMemo<readonly (number | null)[] | undefined>(() => {
|
||||
if (
|
||||
!semantic
|
||||
|| !showSemantic
|
||||
|| !frame
|
||||
|| !frame.spatialAvailable
|
||||
|| !semanticFrame
|
||||
|| semanticIntegrityError
|
||||
) return undefined;
|
||||
return semanticFrame.classIds.map((classId, index) => {
|
||||
const status = semanticFrame.statusCodes[index];
|
||||
return status === 2 || status === 3 ? classId : null;
|
||||
});
|
||||
}, [frame, semantic, semanticFrame, semanticIntegrityError, showSemantic]);
|
||||
const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined =
|
||||
semantic && showSemantic && frame
|
||||
? {
|
||||
src: e47SemanticMaskUrl(semantic.resultId, frame.sequence),
|
||||
classes: semanticClasses,
|
||||
palette: semanticPalette,
|
||||
opacity: 0.48,
|
||||
ariaLabel: `E47 semantic mask frame ${frame.sequence + 1}`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const seek = (seconds: number) => playbackController.seek(seconds);
|
||||
const handleModeChange = (next: M4ThreatViewMode) => {
|
||||
if (next === "camera") playbackController.setPlaying(false);
|
||||
if (next === "3d" || next === "plan") setSpatialMode(next);
|
||||
setMode(next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (playbackController.playback.playing || !frame) return;
|
||||
const image = new Image();
|
||||
image.src = frame.cameraUrl;
|
||||
}, [frame?.cameraUrl, playbackController.playback.playing]);
|
||||
|
||||
const actions = (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton
|
||||
label="Назад на 5 секунд"
|
||||
disabled={!metadata.timeline}
|
||||
onClick={() => seek(playbackController.playback.currentSeconds - 5)}
|
||||
>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Вперёд на 5 секунд"
|
||||
disabled={!metadata.timeline}
|
||||
onClick={() => seek(playbackController.playback.currentSeconds + 5)}
|
||||
>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{mode === "3d" || mode === "plan" || semantic ? (
|
||||
<div
|
||||
className="nodedc-segmented m4-replay-threat-visual__layer-controls"
|
||||
role="group"
|
||||
aria-label="Слои пространственного evidence"
|
||||
>
|
||||
{mode === "3d" || mode === "plan" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-segmented__item"
|
||||
data-active={showCurrentIncrement ? "true" : undefined}
|
||||
aria-pressed={showCurrentIncrement}
|
||||
onClick={() => setShowCurrentIncrement((visible) => !visible)}
|
||||
>
|
||||
CURRENT
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-segmented__item"
|
||||
data-active={showLocalSurface ? "true" : undefined}
|
||||
aria-pressed={showLocalSurface}
|
||||
title="Bounded local SLAM surface · visual-derived"
|
||||
onClick={() => setShowLocalSurface((visible) => !visible)}
|
||||
>
|
||||
LOCAL SLAM
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-segmented__item"
|
||||
data-active={showRollingMap ? "true" : undefined}
|
||||
aria-pressed={showRollingMap}
|
||||
onClick={() => setShowRollingMap((visible) => !visible)}
|
||||
>
|
||||
ROLLING
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{semantic ? (
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-segmented__item"
|
||||
data-active={showSemantic ? "true" : undefined}
|
||||
aria-pressed={showSemantic}
|
||||
onClick={() => setShowSemantic((visible) => !visible)}
|
||||
>
|
||||
SEMANTICS
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const trailingActions = mode === "3d" || mode === "plan" ? (
|
||||
<IconButton
|
||||
label="Сбросить ракурс"
|
||||
onClick={() => metricSceneRef.current?.resetView()}
|
||||
>
|
||||
<Icon name="refresh" size={16} />
|
||||
</IconButton>
|
||||
) : null;
|
||||
|
||||
const overlay = metadata.timeline && frame ? (
|
||||
<div className="l3-visual-audit__overlay m4-replay-threat-visual__overlay">
|
||||
<div>
|
||||
<span>RAVNOVES00 · recorded realtime</span>
|
||||
<strong>frame {frame.sequence + 1}/{metadata.timeline.frameCount}</strong>
|
||||
<small>
|
||||
+{(frame.sessionSeconds - metadata.timeline.timelineStartSeconds).toFixed(3)} с
|
||||
· {displayingBufferedFrame
|
||||
? "держим последний кадр, следующий в буфере"
|
||||
: playbackController.playback.playing ? "воспроизведение" : "пауза / seek"}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Spatial evidence</span>
|
||||
<strong>
|
||||
{currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling
|
||||
</strong>
|
||||
<small>
|
||||
{frame.spatialAvailable
|
||||
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} exact · ${localSurface.pointsBodyXyzM.length} local SLAM / ${localSurface.sourceFrameCount} frames`
|
||||
: "body frame / current increment unavailable"}
|
||||
{semantic && semanticFrame
|
||||
? ` · semantic L ${semanticFrame.counts.labeled} · A ${semanticFrame.counts.ambiguous} · U ${semanticFrame.counts.unprojected} · Ø ${semanticFrame.counts.absent}`
|
||||
: semantic ? " · semantic buffer" : ""}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Virtual corridor</span>
|
||||
<strong>
|
||||
{frame.decisionCounts.threat} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`}
|
||||
</strong>
|
||||
<small>
|
||||
{metadata.timeline.corridor.forwardLengthM} м · body {metadata.timeline.rig.lengthM}×{metadata.timeline.rig.widthM} м · REPLAY-SIMULATED
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const timeline = metadata.timeline;
|
||||
let content;
|
||||
if (metadata.error) {
|
||||
content = <SpatialState message={metadata.error} />;
|
||||
} else if (!timeline) {
|
||||
content = (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Открываем recorded-realtime timeline M4.6</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<div className="m4-replay-threat-visual__deck">
|
||||
<div
|
||||
className="m4-replay-threat-visual__layer"
|
||||
data-active={mode === "video" ? "true" : undefined}
|
||||
aria-hidden={mode !== "video"}
|
||||
>
|
||||
{videoSource ? (
|
||||
<RecordedEvidenceVideoScene
|
||||
source={videoSource}
|
||||
playback={playbackController.playback}
|
||||
imageWidth={timeline.imageWidth}
|
||||
imageHeight={timeline.imageHeight}
|
||||
boxes={activeBoxes}
|
||||
semanticOverlay={mode === "video" ? semanticOverlay : undefined}
|
||||
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
||||
interactive={false}
|
||||
/>
|
||||
) : videoError ? (
|
||||
<SpatialState message={videoError} />
|
||||
) : (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>{videoLoading ? "Подготавливаем локальный видеобуфер" : "Открываем RIGHT-видео RAVNOVES00"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="m4-replay-threat-visual__layer"
|
||||
data-active={mode === "camera" ? "true" : undefined}
|
||||
aria-hidden={mode !== "camera"}
|
||||
>
|
||||
{mode === "camera" && frame ? (
|
||||
<RecordedEvidenceImageScene
|
||||
src={frame.cameraUrl}
|
||||
imageWidth={timeline.imageWidth}
|
||||
imageHeight={timeline.imageHeight}
|
||||
boxes={activeBoxes}
|
||||
semanticOverlay={mode === "camera" ? semanticOverlay : undefined}
|
||||
ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="m4-replay-threat-visual__layer"
|
||||
data-active={mode === "3d" || mode === "plan" ? "true" : undefined}
|
||||
aria-hidden={mode !== "3d" && mode !== "plan"}
|
||||
>
|
||||
{frame ? (
|
||||
<LaboratoryMetricEvidenceScene
|
||||
ref={metricSceneRef}
|
||||
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
|
||||
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
||||
obstacles={sceneObstacles}
|
||||
rig={timeline.rig}
|
||||
corridor={timeline.corridor}
|
||||
occupiedVoxelSizeM={timeline.occupiedVoxelSizeM}
|
||||
mode={spatialMode}
|
||||
label="M4.6 exact current increment, bounded local SLAM surface and rolling occupancy"
|
||||
showCurrentIncrement={showCurrentIncrement}
|
||||
showLocalSurface={showLocalSurface}
|
||||
showRollingMap={showRollingMap}
|
||||
pointSemanticClassIds={alignedSemanticPointIds}
|
||||
semanticClasses={semanticClasses}
|
||||
semanticPalette={semanticPalette}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{timelineFrame.loading || displayingBufferedFrame ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Догружаем следующий spatial-буфер без сброса сцены</span>
|
||||
</div>
|
||||
) : null}
|
||||
{timelineFrame.error ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="alert">
|
||||
<Icon name="alert" size={16} />
|
||||
<span>{timelineFrame.error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{semantic && semanticTimeline.loading ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Догружаем semantic-point evidence E47</span>
|
||||
</div>
|
||||
) : null}
|
||||
{semanticTimeline.error ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="alert">
|
||||
<Icon name="alert" size={16} />
|
||||
<span>{semanticTimeline.error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{semanticIntegrityError ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="alert">
|
||||
<Icon name="alert" size={16} />
|
||||
<span>{semanticIntegrityError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{frame && !frame.spatialAvailable && (mode === "3d" || mode === "plan") ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||
На этом кадре нет квалифицированного body frame; сцена сохранена.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const transport = timeline ? (
|
||||
<ObservationTimeline
|
||||
className="m4-replay-threat-visual__timeline"
|
||||
active
|
||||
sourceCount={3}
|
||||
mode="recorded"
|
||||
seekable
|
||||
synchronization="host-arrival-best-effort"
|
||||
rangeNs={{
|
||||
min: Math.round(timeline.timelineStartSeconds * 1_000_000_000),
|
||||
max: Math.round(timeline.timelineEndSeconds * 1_000_000_000),
|
||||
}}
|
||||
currentNs={Math.round(playbackController.playback.currentSeconds * 1_000_000_000)}
|
||||
playing={playbackController.playback.playing}
|
||||
playbackRate={playbackController.playback.rate ?? 1}
|
||||
onSeek={(timeNs) => playbackController.seek(timeNs / 1_000_000_000)}
|
||||
onPlayingChange={playbackController.setPlaying}
|
||||
onPlaybackRateChange={playbackController.setRate}
|
||||
showJumpToEnd={false}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit m4-replay-threat-visual">
|
||||
<LaboratoryEvidenceViewer
|
||||
label={semantic
|
||||
? "E47 semantic + SLAM diagnostic replay"
|
||||
: "M4.6 dual-evidence recorded-realtime replay"}
|
||||
className="m4-replay-threat-evidence-viewer"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "camera", label: "CAMERA" },
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "PLAN" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
onModeChange={handleModeChange}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
transport={transport}
|
||||
trailingActions={trailingActions}
|
||||
>
|
||||
{content}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||
|
||||
export type LaboratoryProfileId =
|
||||
| "rig-dual-evidence-virtual-corridor-v1"
|
||||
| "rig-camera-local-surface-v1"
|
||||
| "rig-track-geometry-temporal-v1"
|
||||
| "rig-ravnoves-perception-gate-v1"
|
||||
@@ -56,6 +57,20 @@ interface KnownWorkDefinition {
|
||||
const rig = (rigLabel: string): string => rigLabel.trim() || "Сенсорный риг";
|
||||
|
||||
const KNOWN_WORKS: Readonly<Record<Exclude<LaboratoryWorkId, `session:${string}`>, KnownWorkDefinition>> = {
|
||||
"m4-replay-threat": {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + LiDAR dual evidence`,
|
||||
experimentId: "m4-ravnoves00-dual-evidence-threat",
|
||||
experimentName: "RAVNOVES00 dual-evidence threat qualification",
|
||||
variantName: "M4.6 · virtual corridor replay · VIDEO/CAMERA/3D",
|
||||
},
|
||||
"e47-semantic-slam-shadow": {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + LiDAR dual evidence`,
|
||||
experimentId: "ravnoves00-semantic-slam-shadow-r1",
|
||||
experimentName: "RAVNOVES00 semantic mask → KB4 → SLAM diagnostic shadow",
|
||||
variantName: "E47 · EoMT control · full semantic point projection",
|
||||
},
|
||||
"e28-local-surface": {
|
||||
profileId: "rig-camera-local-surface-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera-first + local-surface LiDAR`,
|
||||
|
||||
@@ -18,6 +18,7 @@ function mergeResults(
|
||||
next: AdvancedLaboratoryResults,
|
||||
): AdvancedLaboratoryResults {
|
||||
return {
|
||||
m4Threat: next.m4Threat ?? current.m4Threat,
|
||||
l3: next.l3 ?? current.l3,
|
||||
l31: next.l31 ?? current.l31,
|
||||
l32: next.l32 ?? current.l32,
|
||||
@@ -42,6 +43,7 @@ function mergeResults(
|
||||
e46h: next.e46h ?? current.e46h,
|
||||
e46i: next.e46i ?? current.e46i,
|
||||
e46j: next.e46j ?? current.e46j,
|
||||
e47: next.e47 ?? current.e47,
|
||||
l34: next.l34 ?? current.l34,
|
||||
l34a: next.l34a ?? current.l34a,
|
||||
l34b: next.l34b ?? current.l34b,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchE47SemanticTimelineChunk,
|
||||
type E47SemanticClass,
|
||||
type E47SemanticTimelineChunk,
|
||||
type E47SemanticTimelineFrame,
|
||||
} from "../../core/laboratory/e47SemanticSlam";
|
||||
|
||||
const CHUNK_SIZE = 24;
|
||||
const RETAINED_CHUNK_COUNT = 8;
|
||||
const PREFETCH_CHUNKS_AHEAD = 2;
|
||||
|
||||
function chunkWindowStarts(activeStart: number, frameCount: number): readonly number[] {
|
||||
return Array.from(
|
||||
{ length: PREFETCH_CHUNKS_AHEAD + 2 },
|
||||
(_, index) => activeStart + (index - 1) * CHUNK_SIZE,
|
||||
).filter((start) => start >= 0 && start < frameCount);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Semantic point evidence E47 недоступен.";
|
||||
}
|
||||
|
||||
export function useE47SemanticTimelineFrame({
|
||||
resultId,
|
||||
activeSequence,
|
||||
frameCount,
|
||||
taxonomy,
|
||||
}: {
|
||||
resultId: string | null;
|
||||
activeSequence: number | null;
|
||||
frameCount: number;
|
||||
taxonomy: readonly E47SemanticClass[];
|
||||
}) {
|
||||
const [chunks, setChunks] = useState<ReadonlyMap<number, E47SemanticTimelineChunk>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const chunksRef = useRef(chunks);
|
||||
const inFlight = useRef(new Map<number, AbortController>());
|
||||
const activeStartRef = useRef<number | null>(null);
|
||||
chunksRef.current = chunks;
|
||||
|
||||
useEffect(() => {
|
||||
for (const controller of inFlight.current.values()) controller.abort();
|
||||
inFlight.current.clear();
|
||||
const empty = new Map<number, E47SemanticTimelineChunk>();
|
||||
chunksRef.current = empty;
|
||||
setChunks(empty);
|
||||
setError(null);
|
||||
return () => {
|
||||
for (const controller of inFlight.current.values()) controller.abort();
|
||||
inFlight.current.clear();
|
||||
};
|
||||
}, [resultId]);
|
||||
|
||||
const activeStart = activeSequence === null
|
||||
? null
|
||||
: Math.floor(activeSequence / CHUNK_SIZE) * CHUNK_SIZE;
|
||||
activeStartRef.current = activeStart;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resultId || activeStart === null || frameCount < 1) return;
|
||||
for (const start of chunkWindowStarts(activeStart, frameCount)) {
|
||||
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
|
||||
const controller = new AbortController();
|
||||
inFlight.current.set(start, controller);
|
||||
void fetchE47SemanticTimelineChunk(resultId, start, CHUNK_SIZE, {
|
||||
signal: controller.signal,
|
||||
taxonomy,
|
||||
})
|
||||
.then((chunk) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setChunks((current) => {
|
||||
const next = new Map(current);
|
||||
next.set(start, chunk);
|
||||
const center = activeStartRef.current ?? start;
|
||||
const retained = [...next.keys()]
|
||||
.sort((left, right) => Math.abs(left - center) - Math.abs(right - center))
|
||||
.slice(0, RETAINED_CHUNK_COUNT);
|
||||
const bounded = new Map(retained.map((key) => [key, next.get(key)!]));
|
||||
chunksRef.current = bounded;
|
||||
return bounded;
|
||||
});
|
||||
if (start === activeStartRef.current) setError(null);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted && start === activeStartRef.current) {
|
||||
setError(errorMessage(caught));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlight.current.get(start) === controller) inFlight.current.delete(start);
|
||||
});
|
||||
}
|
||||
}, [activeStart, frameCount, resultId, taxonomy]);
|
||||
|
||||
const activeFrame: E47SemanticTimelineFrame | null = useMemo(() => {
|
||||
if (activeSequence === null || activeStart === null) return null;
|
||||
return chunks.get(activeStart)?.frames.find(
|
||||
(frame) => frame.sequence === activeSequence,
|
||||
) ?? null;
|
||||
}, [activeSequence, activeStart, chunks]);
|
||||
|
||||
return {
|
||||
activeFrame,
|
||||
loading: Boolean(resultId) && activeSequence !== null && !activeFrame && !error,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchM4ThreatTimeline,
|
||||
fetchM4ThreatTimelineChunk,
|
||||
selectM4ThreatTimelineSequence,
|
||||
type M4ThreatTimeline,
|
||||
type M4ThreatTimelineChunk,
|
||||
type M4ThreatTimelineFrame,
|
||||
} from "../../core/laboratory/m4ReplayThreat";
|
||||
|
||||
const REQUESTED_CHUNK_FRAMES = 24;
|
||||
const RETAINED_CHUNK_COUNT = 8;
|
||||
const PREFETCH_CHUNKS_AHEAD = 2;
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function m4ThreatChunkWindowStarts(
|
||||
activeChunkStart: number,
|
||||
chunkSize: number,
|
||||
frameCount: number,
|
||||
): readonly number[] {
|
||||
if (chunkSize < 1 || frameCount < 1) return [];
|
||||
return Array.from(
|
||||
{ length: PREFETCH_CHUNKS_AHEAD + 2 },
|
||||
(_, index) => activeChunkStart + (index - 1) * chunkSize,
|
||||
).filter((start) => start >= 0 && start < frameCount);
|
||||
}
|
||||
|
||||
export function useM4ThreatTimelineMetadata(resultId: string) {
|
||||
const [timeline, setTimeline] = useState<M4ThreatTimeline | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setTimeline(null);
|
||||
setError(null);
|
||||
void fetchM4ThreatTimeline(resultId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (!controller.signal.aborted) setTimeline(next);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(errorMessage(caught, "Recorded-realtime timeline M4.6 недоступен."));
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [resultId]);
|
||||
|
||||
return { timeline, loading: !timeline && !error, error };
|
||||
}
|
||||
|
||||
export function useM4ThreatTimelineFrame({
|
||||
resultId,
|
||||
timeline,
|
||||
currentSeconds,
|
||||
}: {
|
||||
resultId: string;
|
||||
timeline: M4ThreatTimeline | null;
|
||||
currentSeconds: number;
|
||||
}) {
|
||||
const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inFlight = useRef(new Map<number, AbortController>());
|
||||
const chunksRef = useRef(chunks);
|
||||
const activeChunkStartRef = useRef<number | null>(null);
|
||||
chunksRef.current = chunks;
|
||||
|
||||
useEffect(() => {
|
||||
for (const controller of inFlight.current.values()) controller.abort();
|
||||
inFlight.current.clear();
|
||||
const empty = new Map<number, M4ThreatTimelineChunk>();
|
||||
chunksRef.current = empty;
|
||||
setChunks(empty);
|
||||
setError(null);
|
||||
return () => {
|
||||
for (const controller of inFlight.current.values()) controller.abort();
|
||||
inFlight.current.clear();
|
||||
};
|
||||
}, [resultId, timeline]);
|
||||
|
||||
const activeSequence = useMemo(
|
||||
() => timeline
|
||||
? selectM4ThreatTimelineSequence(timeline.frameTimesNs, currentSeconds)
|
||||
: null,
|
||||
[currentSeconds, timeline],
|
||||
);
|
||||
const chunkSize = Math.min(
|
||||
REQUESTED_CHUNK_FRAMES,
|
||||
timeline?.maxChunkFrames ?? REQUESTED_CHUNK_FRAMES,
|
||||
);
|
||||
const activeChunkStart = activeSequence === null
|
||||
? null
|
||||
: Math.floor(activeSequence / chunkSize) * chunkSize;
|
||||
activeChunkStartRef.current = activeChunkStart;
|
||||
|
||||
useEffect(() => {
|
||||
if (!timeline || activeChunkStart === null) return;
|
||||
const starts = m4ThreatChunkWindowStarts(
|
||||
activeChunkStart,
|
||||
chunkSize,
|
||||
timeline.frameCount,
|
||||
);
|
||||
for (const start of starts) {
|
||||
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
|
||||
const controller = new AbortController();
|
||||
inFlight.current.set(start, controller);
|
||||
void fetchM4ThreatTimelineChunk(resultId, start, chunkSize, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((chunk) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setChunks((current) => {
|
||||
const next = new Map(current);
|
||||
next.set(start, chunk);
|
||||
const center = activeChunkStartRef.current ?? start;
|
||||
const retained = [...next.keys()]
|
||||
.sort((left, right) => (
|
||||
Math.abs(left - center) - Math.abs(right - center)
|
||||
))
|
||||
.slice(0, RETAINED_CHUNK_COUNT);
|
||||
const bounded = new Map(retained.map((key) => [key, next.get(key)!]));
|
||||
chunksRef.current = bounded;
|
||||
return bounded;
|
||||
});
|
||||
if (start === activeChunkStartRef.current) setError(null);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted && start === activeChunkStartRef.current) {
|
||||
setError(errorMessage(caught, "3D chunk M4.6 недоступен."));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlight.current.get(start) === controller) inFlight.current.delete(start);
|
||||
});
|
||||
}
|
||||
}, [activeChunkStart, chunkSize, resultId, timeline]);
|
||||
|
||||
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
|
||||
if (activeSequence === null || activeChunkStart === null) return null;
|
||||
return chunks.get(activeChunkStart)?.frames.find(
|
||||
(frame) => frame.sequence === activeSequence,
|
||||
) ?? null;
|
||||
}, [activeChunkStart, activeSequence, chunks]);
|
||||
const availableFrames = useMemo(() => {
|
||||
const unique = new Map<number, M4ThreatTimelineFrame>();
|
||||
for (const chunk of chunks.values()) {
|
||||
for (const frame of chunk.frames) unique.set(frame.sequence, frame);
|
||||
}
|
||||
return [...unique.values()].sort((left, right) => left.sequence - right.sequence);
|
||||
}, [chunks]);
|
||||
|
||||
return {
|
||||
activeSequence,
|
||||
activeFrame,
|
||||
availableFrames,
|
||||
loading: error === null && Boolean(timeline) && !activeFrame,
|
||||
error,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,6 +105,35 @@ test("each device plugin contributes its own connection pipeline component", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("selected-model shell leaves the model name to the connection heading", () => {
|
||||
const workspace = readFileSync(
|
||||
join(coreSourceRoot, "workspaces/DeviceWorkspace.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const app = readFileSync(join(coreSourceRoot, "App.tsx"), "utf8");
|
||||
|
||||
const selectedSlot = workspace.slice(workspace.indexOf("const ConnectionView ="));
|
||||
assert.match(workspace, /<h3>\{model\.displayName\}<\/h3>/);
|
||||
assert.match(selectedSlot, /СЦЕНАРИЙ ПОДКЛЮЧЕНИЯ/);
|
||||
assert.match(selectedSlot, /<strong>Модель выбрана<\/strong>/);
|
||||
assert.doesNotMatch(selectedSlot, /selection\.model\.displayName/);
|
||||
assert.doesNotMatch(
|
||||
selectedSlot,
|
||||
/selection\.plugin\.manifest\.metadata\.displayName/,
|
||||
);
|
||||
|
||||
const localContour = app.slice(
|
||||
app.indexOf('id: "local-contour"'),
|
||||
app.indexOf("items={rootWorkspaces", app.indexOf('id: "local-contour"')),
|
||||
);
|
||||
assert.match(
|
||||
localContour,
|
||||
/description: selection \? "Подключение" : "Модель не выбрана"/,
|
||||
);
|
||||
assert.doesNotMatch(localContour, /activeDevice\?\.endpointLabel/);
|
||||
assert.doesNotMatch(localContour, /selection\?\.model\.displayName/);
|
||||
});
|
||||
|
||||
test("registry exposes an optional model-scoped spatial controls contribution", () => {
|
||||
const connectionView = () => null;
|
||||
const spatialControlsView = () => null;
|
||||
@@ -146,6 +175,7 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
|
||||
"components/K1AcquisitionPipeline.tsx",
|
||||
"components/K1SpatialControls.tsx",
|
||||
"components/K1Diagnostics.tsx",
|
||||
"physicalCommandConfirmation.ts",
|
||||
"projectName.ts",
|
||||
]) {
|
||||
assert.equal(existsSync(join(pluginFrontendRoot, relativePath)), true, relativePath);
|
||||
@@ -159,7 +189,956 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
|
||||
assert.match(spatialControls, /cleanup_pending/);
|
||||
assert.match(spatialControls, /spatialActionFailure/);
|
||||
assert.match(spatialControls, /role="alert"/);
|
||||
assert.match(spatialControls, /Повторить остановку/);
|
||||
assert.doesNotMatch(spatialControls, /Повторить остановку/);
|
||||
assert.match(spatialControls, /stopLocalReceiver/);
|
||||
assert.match(spatialControls, /<ActivityIndicator size="compact"/);
|
||||
assert.match(spatialControls, /aria-busy=\{phase\.busy\}/);
|
||||
assert.match(spatialControls, /K1 калибруется и готовит облако точек/);
|
||||
assert.match(spatialControls, /Первые данные могут появиться через десятки секунд/);
|
||||
assert.match(spatialControls, /Не перемещайте устройство/);
|
||||
|
||||
const styles = readFileSync(join(pluginFrontendRoot, "styles.css"), "utf8");
|
||||
assert.doesNotMatch(styles, /@keyframes xgrids-k1-spin/);
|
||||
});
|
||||
|
||||
test("K1 connection surface enforces one scan, local selection, and one Apply", () => {
|
||||
const provisioning = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const lifecycle = readFileSync(join(pluginFrontendRoot, "lifecycle.ts"), "utf8");
|
||||
const submitPrerequisites = lifecycle.slice(
|
||||
lifecycle.indexOf("export function canSubmitProvisioningMutation"),
|
||||
lifecycle.indexOf("export function canAdmitProvisioningConnection"),
|
||||
);
|
||||
assert.match(provisioning, /bridge:\s*\{/);
|
||||
assert.match(
|
||||
provisioning,
|
||||
/compatibility_attestation: profileSelectionForConnectionMode\([\s\S]*attemptedConnectionMode/,
|
||||
);
|
||||
assert.match(provisioning, /scanSecondsRemaining/);
|
||||
assert.match(provisioning, /setInterval\(updateCountdown, 250\)/);
|
||||
assert.match(provisioning, /Поиск Bluetooth · \{scanSecondsRemaining \?\? 6\} с/);
|
||||
assert.match(provisioning, /scanWithResult\(\{[^}]*durationSeconds:\s*6/);
|
||||
assert.doesNotMatch(provisioning, /K1 уже доступен|Сетевой адрес K1 доступен/);
|
||||
assert.doesNotMatch(
|
||||
provisioning,
|
||||
/automaticRecovery|automaticDiscovery|reconnectFallbackAction/,
|
||||
);
|
||||
assert.match(provisioning, /Переподключиться/);
|
||||
assert.match(provisioning, /Подключить новый K1/);
|
||||
assert.doesNotMatch(provisioning, /Вернуть прежний K1 и проверить/);
|
||||
assert.match(provisioning, /explicitProvisioningDraftMatches/);
|
||||
assert.doesNotMatch(
|
||||
provisioning,
|
||||
/powerConfirmed|powerConfirmationEpoch|resetPowerConfirmation|Питание включено|title="Питание"/,
|
||||
);
|
||||
assert.doesNotMatch(submitPrerequisites, /power|питани/i);
|
||||
assert.match(provisioning, /ПОДКЛЮЧЕНИЕ · ШАГИ 01–02/);
|
||||
assert.match(
|
||||
provisioning,
|
||||
/number="01"[\s\S]*?title="Подключение"/,
|
||||
);
|
||||
assert.match(provisioning, /number="02"[\s\S]*?title="Сеть"/);
|
||||
assert.doesNotMatch(provisioning, /number="03"|showDeviceStep/);
|
||||
assert.match(
|
||||
provisioning,
|
||||
/const backendScanAllowed = scanAllowedByPolicy\s*&& !isBusy\s*&& !networkRecoveryRequired/,
|
||||
);
|
||||
assert.match(provisioning, /const showNetworkStep = Boolean\([\s\S]*explicitProvisioningDraftRetained/);
|
||||
assert.match(
|
||||
provisioning,
|
||||
/if \(result\.networkIntentCompleted\)[\s\S]*setExplicitProvisioningDraft\(null\)/,
|
||||
);
|
||||
assert.equal(
|
||||
(provisioning.match(/buttonLabel:\s*"Применить"/g) ?? []).length,
|
||||
3,
|
||||
);
|
||||
assert.doesNotMatch(provisioning, /<(?:button|input|select|textarea)\b/);
|
||||
assert.doesNotMatch(provisioning, /allow_host_wifi_switch/);
|
||||
assert.doesNotMatch(provisioning, /(?:color|background(?:-color)?):\s*(?:#[0-9a-f]{3,8}|rgba?\()/i);
|
||||
for (const sharedControl of ["Button", "IconButton", "TextField", "ActivityIndicator", "StatusBadge"]) {
|
||||
assert.match(provisioning, new RegExp(`<${sharedControl}\\b`), sharedControl);
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
provisioning,
|
||||
/Подключиться к сохранённому|Исходный K1|Проверить связь с K1|Проверить прежнее подключение/,
|
||||
);
|
||||
});
|
||||
|
||||
test("K1 click-owned actions are fenced without hidden frontend continuations", () => {
|
||||
const provisioning = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const runtime = readFileSync(
|
||||
join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const search = provisioning.slice(
|
||||
provisioning.indexOf("const repeatDeviceScan"),
|
||||
provisioning.indexOf("const submitConnect"),
|
||||
);
|
||||
const apply = provisioning.slice(
|
||||
provisioning.indexOf("const submitConnect"),
|
||||
provisioning.indexOf("const verifyAppliedNetwork"),
|
||||
);
|
||||
assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1);
|
||||
assert.match(search, /durationSeconds:\s*6/);
|
||||
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(
|
||||
apply,
|
||||
/scanWithResult\(|verifyConnection\(|candidateRefresh|void submitConnect/,
|
||||
);
|
||||
assert.match(runtime, /class SnapshotRuntimeActionArbiter/);
|
||||
assert.match(runtime, /runtimeActionArbiter\.current\.isCurrent\(actionToken\)/);
|
||||
assert.match(runtime, /runtimeActionArbiter\.current\.settle\(actionToken\)/);
|
||||
});
|
||||
|
||||
test("connected presentation uses canonical process copy", () => {
|
||||
const connection = readFileSync(
|
||||
join(pluginFrontendRoot, "XgridsK1Connection.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
connection,
|
||||
/connectionTopology\?\.status === "active"\s*\? "Подключение установлено"/,
|
||||
);
|
||||
assert.match(connection, /"Готово к новой сессии\."/);
|
||||
assert.match(connection, /<h2>Подключение \{model\.displayName\}<\/h2>/);
|
||||
assert.match(
|
||||
connection,
|
||||
/const operationalPanelsVisible = shouldRenderK1OperationalPanels\(\s*state,\s*controller\.pendingAction,\s*\)/,
|
||||
);
|
||||
assert.match(
|
||||
connection,
|
||||
/operationalPanelsVisible \? <K1Metrics controller=\{controller\} \/> : null/,
|
||||
);
|
||||
assert.match(
|
||||
connection,
|
||||
/<K1ProvisioningPipeline[\s\S]*?operationalPanelsVisible \? \([\s\S]*?<K1AcquisitionPipeline[\s\S]*?<K1Diagnostics/,
|
||||
);
|
||||
assert.match(connection, /hasControlAuthority\(state\)/);
|
||||
assert.match(connection, /requiresCanonicalStopAfterTerminalLocalFailure\(state\)/);
|
||||
assert.match(connection, /state\?\.acquisition\?\.cleanup_pending === true/);
|
||||
assert.doesNotMatch(
|
||||
connection,
|
||||
/Связь с K1 подтверждена|Требуется переподключение к K1|Устройство готово/,
|
||||
);
|
||||
});
|
||||
|
||||
test("local connection page owns model naming and process-only shell copy", () => {
|
||||
const app = readFileSync(join(coreSourceRoot, "App.tsx"), "utf8");
|
||||
const shellPresentation = readFileSync(
|
||||
join(coreSourceRoot, "presentation.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const connection = readFileSync(
|
||||
join(pluginFrontendRoot, "XgridsK1Connection.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const operatorError = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1OperatorError.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const acquisition = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1AcquisitionPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const metrics = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1Metrics.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(connection, /<h2>Подключение \{model\.displayName\}<\/h2>/);
|
||||
assert.doesNotMatch(connection, /:\s*message\}/);
|
||||
assert.match(
|
||||
operatorError,
|
||||
/Подключение не завершено\. Автоматического повтора не было/,
|
||||
);
|
||||
|
||||
const deviceHeader = app.slice(
|
||||
app.indexOf('activeDefinition.kind === "device" ? ('),
|
||||
app.indexOf('activeDefinition.kind === "spatial"', app.indexOf('activeDefinition.kind === "device" ? (')),
|
||||
);
|
||||
assert.match(deviceHeader, /localConnectionPhaseLabel\(runtime\.state\?\.phase\)/);
|
||||
assert.doesNotMatch(deviceHeader, /phaseLabel\(runtime\.state\?\.phase\)/);
|
||||
assert.match(
|
||||
shellPresentation,
|
||||
/phase === "configuring"\) return "Подключение"/,
|
||||
);
|
||||
assert.match(
|
||||
shellPresentation,
|
||||
/phase === "connected"\) return "Подключение установлено"/,
|
||||
);
|
||||
assert.match(shellPresentation, /configuring: "Настройка устройства"/);
|
||||
assert.match(shellPresentation, /connected: "Устройство подключено"/);
|
||||
|
||||
assert.match(acquisition, /hint="Локальный файл записи"/);
|
||||
assert.match(acquisition, /Состояние сканирования остаётся неизвестным/);
|
||||
assert.doesNotMatch(
|
||||
acquisition,
|
||||
/Локальный файл исходных данных|Физическое состояние сканера/,
|
||||
);
|
||||
assert.match(metrics, /Данные потока при этом сохраняются/);
|
||||
assert.doesNotMatch(metrics, /Исходные данные при этом сохраняются/);
|
||||
});
|
||||
|
||||
test("K1 START renders an in-button spinner for the complete live orchestration", () => {
|
||||
const acquisition = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1AcquisitionPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(acquisition, /pendingAction === "live"[\s\S]*<ActivityIndicator size="compact"/);
|
||||
assert.match(acquisition, /aria-busy=\{pendingAction === "live"\}/);
|
||||
assert.doesNotMatch(acquisition, /readOnlyConnectionObservationTarget\(state\)/);
|
||||
assert.match(acquisition, /appliedTopology\?\.status === "active"\s*&& desiredModeMatchesActive/);
|
||||
assert.match(acquisition, /if \(!draftPreparationTarget\) return;/);
|
||||
assert.doesNotMatch(acquisition, /START не используется для установки связи/);
|
||||
assert.match(acquisition, /Синхронизация…/);
|
||||
});
|
||||
|
||||
test("Connect accepts exact network-applied REST state while START still requires exact ready", () => {
|
||||
const runtime = readFileSync(
|
||||
join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const provisioning = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const exactReady = runtime.slice(
|
||||
runtime.indexOf("function hasExactConnectionReady"),
|
||||
runtime.indexOf("async function waitForPhysicalReconciliationProof"),
|
||||
);
|
||||
const connectFlow = runtime.slice(
|
||||
runtime.indexOf("const connect = useCallback"),
|
||||
runtime.indexOf("const verifyConnection = useCallback"),
|
||||
);
|
||||
const appliedProof = runtime.slice(
|
||||
runtime.indexOf("export function exactAppliedNetworkIntentCompleted"),
|
||||
runtime.indexOf("function requireExactReadOnlyVerificationOutcome"),
|
||||
);
|
||||
|
||||
assert.match(exactReady, /state\.desired_connection_mode === connectionMode/);
|
||||
assert.match(exactReady, /state\.active_connection_mode === connectionMode/);
|
||||
assert.match(exactReady, /application_control_session\?\.state === "connection-ready"/);
|
||||
assert.match(exactReady, /currentAppliedConnectionTopology\(state, connectionMode\)\?\.status === "active"/);
|
||||
assert.doesNotMatch(connectFlow, /openApplicationControlSession|waitForControlPhase/);
|
||||
assert.doesNotMatch(connectFlow, /verifyConnection\(|scanWithResult\(/);
|
||||
assert.doesNotMatch(connectFlow, /startAcquisition|startPreparedAcquisition|START acquisition/);
|
||||
assert.match(connectFlow, /networkIntentCompleted/);
|
||||
assert.match(appliedProof, /attempt\.phase === "network_applied"/);
|
||||
assert.match(appliedProof, /operation\.status === "succeeded"/);
|
||||
assert.match(appliedProof, /operationPhase === "network_applied"/);
|
||||
assert.match(appliedProof, /ledger\.resolution === "target-observed"/);
|
||||
assert.match(appliedProof, /deviceNetwork\?\.state === "applied"/);
|
||||
assert.doesNotMatch(appliedProof, /control_state/);
|
||||
assert.match(
|
||||
connectFlow,
|
||||
/const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\([\s\S]*?if \(\s*!exactNetworkIntentCompleted\s*&& !hasExactConnectionReady\([\s\S]*?return requireExactConnectionReady\([\s\S]*?nextState = acceptSuccessfulConnectState\(nextState\)/,
|
||||
);
|
||||
assert.match(provisioning, /Подключение установлено/);
|
||||
assert.doesNotMatch(
|
||||
provisioning,
|
||||
/Подключиться к сохранённому|Проверить связь с K1|Проверить прежнее подключение/,
|
||||
);
|
||||
assert.match(provisioning, /Переподключиться/);
|
||||
assert.match(provisioning, /Подключить новый K1/);
|
||||
assert.match(provisioning, /expected_mode_revision: modeAuthority\.desiredModeRevision/);
|
||||
assert.match(
|
||||
provisioning,
|
||||
/expected_discovery_generation: modeAuthority\.discoveryGeneration/,
|
||||
);
|
||||
assert.doesNotMatch(provisioning, /Настройки сети применены/);
|
||||
});
|
||||
|
||||
test("K1 mode reset is explicit while Scan and Apply keep exact backend CAS", () => {
|
||||
const api = readFileSync(join(pluginFrontendRoot, "api.ts"), "utf8");
|
||||
const manifest = readFileSync(join(pluginFrontendRoot, "manifest.ts"), "utf8");
|
||||
const connection = readFileSync(join(pluginFrontendRoot, "XgridsK1Connection.tsx"), "utf8");
|
||||
const runtime = readFileSync(join(pluginFrontendRoot, "useXgridsK1Runtime.ts"), "utf8");
|
||||
const acquisition = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1AcquisitionPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const provisioning = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(manifest, /connectionModeSelect:[\s\S]*"connection\.mode\.select"/);
|
||||
assert.match(api, /interface SelectConnectionModeRequest/);
|
||||
assert.match(api, /expected_revision: number/);
|
||||
assert.match(api, /reset_scenario\?: true/);
|
||||
assert.match(api, /reset_id\?: string/);
|
||||
assert.match(api, /expected_mode_revision: number/);
|
||||
assert.match(api, /expected_discovery_generation: number/);
|
||||
const localModeHandler = connection.slice(
|
||||
connection.indexOf("const updateDesiredConnectionMode"),
|
||||
connection.indexOf("const sourceTone"),
|
||||
);
|
||||
assert.match(localModeHandler, /setDesiredConnectionMode\(mode\)/);
|
||||
assert.doesNotMatch(localModeHandler, /await|selectConnectionMode\(|refresh\(|connect\(/);
|
||||
assert.match(provisioning, /const commitDesiredModeForExplicitAction = useCallback\(async/);
|
||||
assert.match(provisioning, /await selectConnectionMode\(\{/);
|
||||
assert.match(provisioning, /expected_revision: expectedRevision as number/);
|
||||
assert.match(provisioning, /reset_scenario: true/);
|
||||
assert.match(provisioning, /const resetId = newOperationId\(\)/);
|
||||
assert.match(provisioning, /reset_id: resetId/);
|
||||
assert.match(provisioning, /Подключить новый K1/);
|
||||
const configurationAnchor = provisioning.slice(
|
||||
provisioning.indexOf('<div className="configuration-anchor">'),
|
||||
provisioning.indexOf('<div className="wizard-list">'),
|
||||
);
|
||||
assert.doesNotMatch(configurationAnchor, /Подключить новый K1/);
|
||||
assert.match(provisioning, /value=\{connectionMode\}/);
|
||||
assert.doesNotMatch(
|
||||
provisioning,
|
||||
/disabled=\{\s*isBusy\s*\|\|\s*networkRecoveryRequired\s*\|\|\s*physicalRecoveryRequired\s*\|\|\s*connectionRecoveryRequired/,
|
||||
);
|
||||
assert.match(runtime, /catch \(selectionError\)[\s\S]*xgridsK1Api\.getState\(\)/);
|
||||
assert.match(acquisition, /state\?\.active_connection_mode/);
|
||||
assert.match(acquisition, /desiredSelectionCommitted/);
|
||||
assert.match(acquisition, /configuredConnectionMode !== desiredConnectionMode/);
|
||||
assert.doesNotMatch(acquisition, /Выбран другой способ связи/);
|
||||
});
|
||||
|
||||
test("top-right device utility is an explicit pending-aware K1 scenario reset", async () => {
|
||||
const { deviceRuntimeUtilityAction } = await server.ssrLoadModule(
|
||||
"/src/components/useApplicationPanelActions.ts",
|
||||
);
|
||||
let resetCalls = 0;
|
||||
let refreshCalls = 0;
|
||||
const reset = deviceRuntimeUtilityAction({
|
||||
refreshRuntime: () => {
|
||||
refreshCalls += 1;
|
||||
},
|
||||
resetConnectionScenario: async () => {
|
||||
resetCalls += 1;
|
||||
return true;
|
||||
},
|
||||
connectionScenarioResetting: false,
|
||||
});
|
||||
|
||||
assert.equal(reset.label, "Сбросить подключение");
|
||||
assert.equal(reset.icon, "refresh");
|
||||
assert.equal(reset.disabled, undefined);
|
||||
reset.onClick();
|
||||
await Promise.resolve();
|
||||
assert.equal(resetCalls, 1);
|
||||
assert.equal(refreshCalls, 0);
|
||||
|
||||
const pending = deviceRuntimeUtilityAction({
|
||||
refreshRuntime: () => {
|
||||
refreshCalls += 1;
|
||||
},
|
||||
resetConnectionScenario: async () => true,
|
||||
connectionScenarioResetting: true,
|
||||
});
|
||||
assert.equal(pending.label, "Сбрасываем подключение");
|
||||
assert.equal(pending.icon, "activity");
|
||||
assert.equal(pending.disabled, true);
|
||||
pending.onClick();
|
||||
await Promise.resolve();
|
||||
assert.equal(resetCalls, 1);
|
||||
assert.equal(refreshCalls, 0);
|
||||
|
||||
const app = readFileSync(join(coreSourceRoot, "App.tsx"), "utf8");
|
||||
const contracts = readFileSync(
|
||||
join(coreSourceRoot, "core/runtime/contracts.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const runtimeContext = readFileSync(
|
||||
join(pluginFrontendRoot, "runtimeContext.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const runtime = readFileSync(
|
||||
join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const provisioning = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const connection = readFileSync(
|
||||
join(pluginFrontendRoot, "XgridsK1Connection.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(contracts, /resetConnectionScenario\?: \(\) => Promise<boolean>/);
|
||||
assert.match(app, /resetConnectionScenario: runtime\.resetConnectionScenario/);
|
||||
assert.match(app, /connectionScenarioResetting: runtime\.pendingAction === "mode"/);
|
||||
assert.match(
|
||||
runtimeContext,
|
||||
/resetConnectionScenario: controller\.resetConnectionScenario/,
|
||||
);
|
||||
|
||||
const resetStart = runtime.indexOf("const resetConnectionScenario");
|
||||
const resetEnd = runtime.indexOf(
|
||||
"const prepareConnectionReconfigurationWithResult",
|
||||
resetStart,
|
||||
);
|
||||
assert.notEqual(resetStart, -1);
|
||||
assert.notEqual(resetEnd, -1);
|
||||
const resetFlow = runtime.slice(resetStart, resetEnd);
|
||||
assert.equal((resetFlow.match(/selectConnectionMode\(/g) ?? []).length, 1);
|
||||
assert.match(resetFlow, /connection_mode: DEFAULT_CONNECTION_MODE/);
|
||||
assert.match(resetFlow, /expected_revision: expectedRevision as number/);
|
||||
assert.match(resetFlow, /reset_scenario: true/);
|
||||
assert.match(resetFlow, /reset_id: newOperationId\(\)/);
|
||||
assert.doesNotMatch(
|
||||
resetFlow,
|
||||
/refresh\(|getState\(|scan|verify|connect\(|prepare|start|stop|camera/i,
|
||||
);
|
||||
|
||||
const selectStart = runtime.indexOf("const selectConnectionMode");
|
||||
const selectEnd = runtime.indexOf("const resetConnectionScenario", selectStart);
|
||||
const selectFlow = runtime.slice(selectStart, selectEnd);
|
||||
assert.match(selectFlow, /expected_snapshot_runtime_id: expectedSnapshotRuntimeId\(\)/);
|
||||
assert.match(selectFlow, /supersedePending: request\.reset_scenario === true/);
|
||||
|
||||
const runStart = runtime.indexOf("const run = useCallback");
|
||||
const runEnd = runtime.indexOf("const scanWithResult", runStart);
|
||||
const runFlow = runtime.slice(runStart, runEnd);
|
||||
assert.match(runFlow, /setPendingAction\(action\)/);
|
||||
assert.match(runFlow, /setPresentedErrorCorrelation\(null\)/);
|
||||
assert.match(runFlow, /setError\(null\)/);
|
||||
assert.match(runFlow, /setErrorDiagnostic\(null\)/);
|
||||
assert.match(runFlow, /setPendingAction\(null\)/);
|
||||
|
||||
const draftFenceStart = provisioning.indexOf("const nextFence = localProvisioningDraftFenceKey");
|
||||
assert.notEqual(draftFenceStart, -1);
|
||||
const draftFence = provisioning.slice(draftFenceStart, draftFenceStart + 2_700);
|
||||
assert.match(draftFence, /reconfigurationRevision/);
|
||||
assert.match(draftFence, /setSelectedDeviceId\(""\)/);
|
||||
assert.match(draftFence, /setSelectedDeviceSnapshot\(null\)/);
|
||||
assert.match(draftFence, /setExplicitProvisioningDraft\(null\)/);
|
||||
assert.match(draftFence, /setSsid\(""\)/);
|
||||
assert.match(draftFence, /setPassword\(""\)/);
|
||||
assert.match(draftFence, /setCandidateUnavailableMessage\(null\)/);
|
||||
assert.match(draftFence, /resetSearchPresentation\(\)/);
|
||||
assert.match(
|
||||
provisioning,
|
||||
/const hydratedScenarioResetPresentationKey = useRef<string \| null>\(null\)/,
|
||||
);
|
||||
assert.match(
|
||||
provisioning,
|
||||
/hydratedScenarioResetPresentationKey\.current = scenarioResetPresentationKey/,
|
||||
);
|
||||
assert.match(provisioning, /setConnectionAttemptPresentation\(null\)/);
|
||||
assert.match(
|
||||
provisioning,
|
||||
/const modeResetInFlight = pendingAction === "mode" \|\| modeResetPending !== null/,
|
||||
);
|
||||
assert.match(provisioning, /if \(modeResetInFlight\) return/);
|
||||
assert.equal((provisioning.match(/disabled=\{modeResetInFlight\}/g) ?? []).length, 1);
|
||||
assert.match(provisioning, /disabled=\{isBusy \|\| modeResetInFlight\}/);
|
||||
|
||||
assert.match(connection, /const hydratedScenarioResetKey = useRef<string \| null>\(null\)/);
|
||||
assert.match(
|
||||
connection,
|
||||
/scenarioReset\.revision === state\?\.desired_connection_mode_revision/,
|
||||
);
|
||||
assert.match(connection, /scenarioReset\.desired_mode === backendDesiredMode/);
|
||||
assert.match(connection, /desiredModeLocallyDirty\.current = false/);
|
||||
assert.match(connection, /setDesiredConnectionMode\(backendDesiredMode\)/);
|
||||
});
|
||||
|
||||
test("background polling stays read-only while backend state reconciliation retires terminal control", () => {
|
||||
const runtime = readFileSync(
|
||||
join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const refreshFlow = runtime.slice(
|
||||
runtime.indexOf("const refresh = useCallback"),
|
||||
runtime.indexOf("const run = useCallback"),
|
||||
);
|
||||
assert.match(runtime, /refresh\(false\)/);
|
||||
assert.match(runtime, /refresh\(true\)/);
|
||||
assert.doesNotMatch(runtime, /terminalControlCleanupInFlight/);
|
||||
assert.doesNotMatch(
|
||||
refreshFlow,
|
||||
/closeApplicationControlSession|startAcquisition|stopAcquisition|networkProvision/,
|
||||
);
|
||||
assert.match(refreshFlow, /xgridsK1Api\.getState\(\)/);
|
||||
});
|
||||
|
||||
test("automatic K1 live start opens the selected delivered camera despite an older saved layout", () => {
|
||||
const app = readFileSync(join(coreSourceRoot, "App.tsx"), "utf8");
|
||||
const layout = readFileSync(
|
||||
join(coreSourceRoot, "core/observation/useObservationLayout.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(app, /observationLayout\.activateAutomaticDefaults\(\)/);
|
||||
assert.match(layout, /const activateAutomaticDefaults = useCallback/);
|
||||
assert.match(layout, /restoredLayoutAuthorityRef\.current = false/);
|
||||
assert.match(layout, /sources\.filter\(canOpenByDefault\)/);
|
||||
assert.match(layout, /source\.capabilities\.overlay/);
|
||||
});
|
||||
|
||||
test("K1 connect errors reset the UI session without exposing reconciliation ceremony", () => {
|
||||
const runtime = readFileSync(
|
||||
join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const connectStart = runtime.indexOf("const connect = useCallback(");
|
||||
const connectEnd = runtime.indexOf("const verifyConnection = useCallback(", connectStart);
|
||||
assert.notEqual(connectStart, -1);
|
||||
assert.notEqual(connectEnd, -1);
|
||||
const connectFlow = runtime.slice(connectStart, connectEnd);
|
||||
|
||||
assert.match(connectFlow, /operationByIdempotencyKey\(/);
|
||||
assert.match(connectFlow, /failedOperation\?\.status === "succeeded"/);
|
||||
assert.match(connectFlow, /resetConnectSessionMessage\(/);
|
||||
assert.doesNotMatch(
|
||||
connectFlow,
|
||||
/требует ручной проверки|измените параметры только после проверки устройства|Сохранён тот же ключ/,
|
||||
);
|
||||
assert.match(runtime, /Сессия подключения в интерфейсе сброшена/);
|
||||
assert.match(runtime, /новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз/);
|
||||
const verifyStart = runtime.indexOf("const verifyConnection = useCallback(");
|
||||
const verifyEnd = runtime.indexOf("const probeConfiguredEndpoint = useCallback(", verifyStart);
|
||||
const verifyFlow = runtime.slice(verifyStart, verifyEnd);
|
||||
assert.match(verifyFlow, /failedOperation\?\.status === "succeeded"/);
|
||||
assert.doesNotMatch(verifyFlow, /xgridsK1Api\.verifyConnection\([^)]*\)[\s\S]*xgridsK1Api\.verifyConnection/);
|
||||
assert.doesNotMatch(runtime, /automatic.?retry\s*:\s*true/);
|
||||
});
|
||||
|
||||
test("K1 provisioning keeps the operator draft separate from the backend lease", () => {
|
||||
const provisioning = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const selection = provisioning.slice(
|
||||
provisioning.indexOf("const selectFreshDevice"),
|
||||
provisioning.indexOf("const chooseAnother"),
|
||||
);
|
||||
const apply = provisioning.slice(
|
||||
provisioning.indexOf("const submitConnect"),
|
||||
provisioning.indexOf("const verifyAppliedNetwork"),
|
||||
);
|
||||
assert.match(provisioning, /explicitProvisioningDraftRetained/);
|
||||
assert.match(provisioning, /localProvisioningDraftFenceKey/);
|
||||
assert.match(provisioning, /provisioningIntentKey\(null\)/);
|
||||
assert.match(provisioning, /const \[selectedDeviceSnapshot, setSelectedDeviceSnapshot\]/);
|
||||
assert.doesNotMatch(selection, /await|scanWithResult\(|verifyConnection\(|connect\(/);
|
||||
assert.match(selection, /requestExplicitProvisioning\(device\.device_id/);
|
||||
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(apply, /scanWithResult\(|verifyConnection\(|candidateRefresh/);
|
||||
assert.match(provisioning, /scanWithResult\(\{ durationSeconds: 6 \}\)/);
|
||||
assert.match(
|
||||
provisioning,
|
||||
/onChange=\{\(event\) => setPassword\(event\.target\.value\)\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("K1 plugin layout follows its contribution width and contains long topology text", () => {
|
||||
const styles = readFileSync(join(pluginFrontendRoot, "styles.css"), "utf8");
|
||||
const baseGrid = styles.slice(
|
||||
styles.indexOf(".device-workspace__grid"),
|
||||
styles.indexOf(".device-workspace__side"),
|
||||
);
|
||||
const splitThreshold = styles.match(
|
||||
/@container xgrids-k1 \(min-width:\s*([0-9.]+)rem\)/,
|
||||
);
|
||||
const splitColumns = styles.match(
|
||||
/grid-template-columns:\s*minmax\(([0-9.]+)rem,\s*0\.8fr\)\s*minmax\(([0-9.]+)rem,\s*1\.2fr\)/,
|
||||
);
|
||||
|
||||
assert.match(styles, /container:\s*xgrids-k1\s*\/\s*inline-size/);
|
||||
assert.match(styles, /container:\s*k1-connection-panel\s*\/\s*inline-size/);
|
||||
assert.match(styles, /container:\s*k1-session-panel\s*\/\s*inline-size/);
|
||||
assert.match(styles, /@container xgrids-k1 \(min-width:\s*78rem\)/);
|
||||
assert.match(
|
||||
styles,
|
||||
/grid-template-columns:\s*minmax\(32rem,\s*0\.8fr\)\s*minmax\(38rem,\s*1\.2fr\)/,
|
||||
);
|
||||
assert.match(styles, /@container k1-connection-panel \(max-width:\s*48rem\)/);
|
||||
assert.match(styles, /@container k1-session-panel \(max-width:\s*48rem\)/);
|
||||
assert.match(styles, /@container xgrids-k1 \(max-width:\s*48rem\)/);
|
||||
assert.match(styles, /overflow-wrap:\s*anywhere/);
|
||||
assert.match(baseGrid, /grid-template-columns:\s*minmax\(0,\s*1fr\)/);
|
||||
assert.ok(splitThreshold);
|
||||
assert.ok(splitColumns);
|
||||
const splitThresholdPixels = Number(splitThreshold[1]) * 16;
|
||||
const minimumSplitPixels = (Number(splitColumns[1]) + Number(splitColumns[2]) + 0.85) * 16;
|
||||
assert.equal(splitThresholdPixels, 1248);
|
||||
assert.ok(minimumSplitPixels < splitThresholdPixels);
|
||||
assert.ok(390 < splitThresholdPixels);
|
||||
assert.ok(760 < splitThresholdPixels);
|
||||
assert.ok(1280 > splitThresholdPixels);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.wizard-list,[\s\S]*?\.wizard-step,[\s\S]*?\.session-form,[\s\S]*?\.device-row,[\s\S]*?\.detail-list\s*\{[^}]*min-width:\s*0[^}]*max-width:\s*100%/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/> \*\s*\{[^}]*min-width:\s*0[^}]*max-width:\s*100%/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.metrics-grid > \*,[\s\S]*?\.device-workspace__grid > \*,[\s\S]*?\.diagnostics-grid > \*\s*\{[^}]*min-width:\s*0[^}]*max-width:\s*100%/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.device-row code,[\s\S]*?\.detail-row code\s*\{[^}]*overflow-wrap:\s*anywhere[^}]*white-space:\s*normal/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.detail-row dd\s*\{[^}]*overflow-wrap:\s*anywhere[^}]*text-overflow:\s*clip[^}]*white-space:\s*normal/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.error-banner__actions > \.nodedc-button\s*\{[^}]*min-width:\s*0[^}]*max-width:\s*100%[^}]*overflow-wrap:\s*anywhere/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
styles,
|
||||
/\.connection-panel,[\s\S]*?\.session-panel\s*\{[^}]*overflow:\s*(?:clip|hidden)/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/@container k1-connection-panel \(max-width:\s*48rem\)[\s\S]*?\.wizard-step__content > header[^}]*flex-wrap:\s*wrap/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/@container k1-connection-panel \(max-width:\s*48rem\)[\s\S]*?\.retained-recovery-target > div[^}]*flex-direction:\s*column/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/@container k1-session-panel \(max-width:\s*48rem\)[\s\S]*?\.panel-heading[^}]*flex-wrap:\s*wrap/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.connection-summary__value > \.nodedc-status,[\s\S]*?white-space:\s*normal/,
|
||||
);
|
||||
assert.doesNotMatch(styles, /\.nodedc-checker(?:__copy|__label)?\s*\{/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.workspace-lead__status > span,[\s\S]*?\.nodedc-field__description,[\s\S]*?\.retained-recovery-target small,[\s\S]*?\.session-footer p\s*\{[^}]*overflow-wrap:\s*anywhere/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/@container xgrids-k1 \(max-width:\s*32rem\)[\s\S]*?\.error-banner__actions\s*\{[^}]*align-items:\s*stretch[^}]*flex-direction:\s*column/,
|
||||
);
|
||||
assert.doesNotMatch(styles, /@media \(max-width:\s*(?:1280|1480)px\)/);
|
||||
assert.doesNotMatch(styles, /device-recovery-choice/);
|
||||
});
|
||||
|
||||
test("Mission Core shell protects the device workspace before the shared mobile breakpoint", () => {
|
||||
const responsive = readFileSync(join(coreSourceRoot, "styles/responsive.css"), "utf8");
|
||||
|
||||
assert.match(
|
||||
responsive,
|
||||
/@media \(min-width:\s*761px\) and \(max-width:\s*929px\)/,
|
||||
);
|
||||
assert.match(
|
||||
responsive,
|
||||
/\.nodedc-app-shell__navigation,\s*\.nodedc-app-shell__content\s*\{[^}]*left:\s*var\(--nodedc-app-page-pad\)[^}]*width:\s*auto/s,
|
||||
);
|
||||
assert.match(
|
||||
responsive,
|
||||
/\[data-content-open="true"\] \.nodedc-app-shell__navigation\s*\{[^}]*opacity:\s*0[^}]*pointer-events:\s*none/s,
|
||||
);
|
||||
assert.match(
|
||||
responsive,
|
||||
/@media \(max-width:\s*760px\)[\s\S]*?\.nodedc-application-panel__head,\s*\.nodedc-application-panel__body\s*\{[^}]*width:\s*auto[^}]*min-width:\s*0[^}]*max-width:\s*100%/,
|
||||
);
|
||||
assert.match(
|
||||
responsive,
|
||||
/\.nodedc-application-panel__head\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s*auto/,
|
||||
);
|
||||
});
|
||||
|
||||
test("K1 frontend state models durable mutation and connection supervision facts", () => {
|
||||
const api = readFileSync(join(pluginFrontendRoot, "api.ts"), "utf8");
|
||||
const lifecycle = readFileSync(join(pluginFrontendRoot, "lifecycle.ts"), "utf8");
|
||||
|
||||
assert.match(api, /interface XgridsNetworkMutationLedger/);
|
||||
assert.match(api, /scope:\s*"durable-ledger"/);
|
||||
assert.match(api, /network_mutation_ledger\?: XgridsNetworkMutationLedger \| null/);
|
||||
assert.match(api, /handle_retained\?: boolean/);
|
||||
assert.match(api, /gatt_validated_recently\?: boolean/);
|
||||
assert.match(api, /interface XgridsConnectionSupervisor/);
|
||||
assert.match(api, /interface XgridsConnectionSupervisorDeviceNetwork/);
|
||||
assert.match(api, /device_network: XgridsConnectionSupervisorDeviceNetwork/);
|
||||
assert.match(api, /missioncore\.k1-connection-supervisor\/v1/);
|
||||
assert.match(api, /connection_supervisor\?: XgridsConnectionSupervisor \| null/);
|
||||
assert.match(api, /interface XgridsConnectionPolicy/);
|
||||
assert.match(api, /missioncore\.xgrids-k1-connection-policy\/v1/);
|
||||
assert.match(api, /connection_policy\?: XgridsConnectionPolicy \| null/);
|
||||
assert.match(api, /interface XgridsSemanticTopologyStore/);
|
||||
assert.match(api, /configured_offline_evidence: boolean/);
|
||||
assert.match(api, /live_connection_authority: false/);
|
||||
assert.match(api, /semantic_topology_store\?: XgridsSemanticTopologyStore \| null/);
|
||||
|
||||
const reconciliation = lifecycle.slice(
|
||||
lifecycle.indexOf("export function readOnlyVerificationClearedReconciliation"),
|
||||
lifecycle.indexOf("export function provisioningCandidateById"),
|
||||
);
|
||||
assert.match(reconciliation, /nextState\?\.network_mutation_ledger/);
|
||||
assert.match(reconciliation, /nextLedger\.status === "resolved"/);
|
||||
assert.match(reconciliation, /nextLedger\.operation_id === previousOperationId/);
|
||||
assert.doesNotMatch(reconciliation, /snapshot_runtime_id/);
|
||||
});
|
||||
|
||||
test("Bridge device and network reconfiguration stays backend-owned and CAS-fenced", () => {
|
||||
const api = readFileSync(join(pluginFrontendRoot, "api.ts"), "utf8");
|
||||
const manifest = readFileSync(join(pluginFrontendRoot, "manifest.ts"), "utf8");
|
||||
const lifecycle = readFileSync(join(pluginFrontendRoot, "lifecycle.ts"), "utf8");
|
||||
const runtime = readFileSync(
|
||||
join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const provisioning = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const prepare = provisioning.slice(
|
||||
provisioning.indexOf("const prepareReconfiguration"),
|
||||
provisioning.indexOf("const changeDesiredConnectionMode"),
|
||||
);
|
||||
const selection = provisioning.slice(
|
||||
provisioning.indexOf("const selectFreshDevice"),
|
||||
provisioning.indexOf("const chooseAnother"),
|
||||
);
|
||||
assert.match(manifest, /connectionReconfigurePrepare:[\s\S]*"connection\.reconfigure\.prepare"/);
|
||||
assert.match(api, /expected_reconfiguration_revision: number/);
|
||||
assert.match(api, /expected_reconfiguration_intent_id/);
|
||||
assert.match(prepare, /prepareConnectionReconfigurationWithResult\(\{/);
|
||||
assert.doesNotMatch(prepare, /scanWithResult\(|verifyConnection\(|connect\(/);
|
||||
assert.doesNotMatch(selection, /await|scanWithResult\(|verifyConnection\(|connect\(/);
|
||||
assert.match(provisioning, /showNetworkStep = Boolean\([\s\S]*changeNetworkDialogue/);
|
||||
assert.match(provisioning, /localProvisioningDraftFenceKey/);
|
||||
assert.match(lifecycle, /reconfigurationAllowsFreshDevice/);
|
||||
assert.match(runtime, /prepareConnectionReconfiguration/);
|
||||
});
|
||||
|
||||
test("shared runtime exposes only a reachable K1 endpoint as active", () => {
|
||||
const runtimeContext = readFileSync(
|
||||
join(pluginFrontendRoot, "runtimeContext.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(
|
||||
runtimeContext,
|
||||
/endpointLabel: activeConnectionEndpointLabel\(state\)/,
|
||||
);
|
||||
assert.doesNotMatch(runtimeContext, /endpointLabel: state\.k1_ip/);
|
||||
});
|
||||
|
||||
test("every K1 connection and acquisition action is fenced to the accepted backend runtime", () => {
|
||||
const runtime = readFileSync(
|
||||
join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(runtime, /latestState\.current\?\.snapshot_runtime_id/);
|
||||
assert.equal(
|
||||
[...runtime.matchAll(
|
||||
/expected_snapshot_runtime_id: expectedSnapshotRuntimeId\(\)/g,
|
||||
)].length,
|
||||
14,
|
||||
);
|
||||
assert.match(
|
||||
runtime,
|
||||
/mode:\s*"graceful",[\s\S]*?expected_snapshot_runtime_id: checkpoint\.snapshotRuntimeId/,
|
||||
);
|
||||
assert.match(
|
||||
runtime,
|
||||
/xgridsK1Api\.scanBle\([\s\S]*?expected_snapshot_runtime_id/,
|
||||
);
|
||||
assert.match(
|
||||
runtime,
|
||||
/xgridsK1Api\.verifyConnection\([\s\S]*?expected_snapshot_runtime_id/,
|
||||
);
|
||||
assert.match(
|
||||
runtime,
|
||||
/xgridsK1Api\.connect\([\s\S]*?expected_snapshot_runtime_id/,
|
||||
);
|
||||
assert.match(
|
||||
runtime,
|
||||
/xgridsK1Api\.retireUnavailablePhysicalCommand\([\s\S]*?expected_snapshot_runtime_id:\s*exactSnapshotRuntimeId/,
|
||||
);
|
||||
assert.match(
|
||||
runtime,
|
||||
/run\("retire",[\s\S]*?surfaceErrors: false[\s\S]*?await refresh\(false\)/,
|
||||
);
|
||||
assert.match(
|
||||
runtime,
|
||||
/const exactSnapshotRuntimeId = actionSnapshotRuntimeId\.trim\(\)[\s\S]*?isSnapshotRuntimeCurrent\(exactSnapshotRuntimeId\)[\s\S]*?xgridsK1Api\.reopenRetiredPhysicalReconciliation\([\s\S]*?expected_snapshot_runtime_id: exactSnapshotRuntimeId/,
|
||||
);
|
||||
for (const action of [
|
||||
"openApplicationControlSession",
|
||||
"enterApplicationWorkspace",
|
||||
"closeApplicationControlSession",
|
||||
"prepareAcquisition",
|
||||
"startAcquisition",
|
||||
"abortAcquisition",
|
||||
"reconcilePhysicalCommand",
|
||||
]) {
|
||||
assert.match(
|
||||
runtime,
|
||||
new RegExp(`xgridsK1Api\\.${action}\\([\\s\\S]*?expected_snapshot_runtime_id`),
|
||||
action,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
[...runtime.matchAll(
|
||||
/expected_snapshot_runtime_id: actionSnapshotRuntimeId/g,
|
||||
)].length,
|
||||
4,
|
||||
);
|
||||
assert.match(
|
||||
runtime,
|
||||
/stopSessionCompatibility\(\{[\s\S]*?expected_snapshot_runtime_id/,
|
||||
);
|
||||
assert.match(
|
||||
runtime,
|
||||
/run\([\s\S]*?"reopen"[\s\S]*?surfaceErrors: false, supersedePending: true[\s\S]*?await refresh\(false\)/,
|
||||
);
|
||||
assert.match(
|
||||
runtime,
|
||||
/const actionSnapshotRuntimeId =\s*options\.expectedSnapshotRuntimeId\?\.trim\(\) \|\| null[\s\S]*?expected_snapshot_runtime_id:\s*actionSnapshotRuntimeId \?\? expectedSnapshotRuntimeId\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("fresh Scan treats an exact prior K1 as one local Select action", () => {
|
||||
const provisioning = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const lifecycle = readFileSync(join(pluginFrontendRoot, "lifecycle.ts"), "utf8");
|
||||
const runtime = readFileSync(
|
||||
join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const selection = provisioning.slice(
|
||||
provisioning.indexOf("const selectFreshDevice"),
|
||||
provisioning.indexOf("const chooseAnother"),
|
||||
);
|
||||
const apply = provisioning.slice(
|
||||
provisioning.indexOf("const submitConnect"),
|
||||
provisioning.indexOf("const verifyAppliedNetwork"),
|
||||
);
|
||||
assert.match(selection, /requestExplicitProvisioning\(device\.device_id/);
|
||||
assert.match(selection, /const actionableDevices = devices\.filter\(candidateSelectionAllowed\)/);
|
||||
assert.doesNotMatch(
|
||||
selection,
|
||||
/physicallyRetired|retiredPhysical|await|verifyConnection\(|retireUnavailable|reopenRetired/,
|
||||
);
|
||||
const resultRows = provisioning.slice(
|
||||
provisioning.indexOf('<div className="device-list" aria-label="Результаты Bluetooth">'),
|
||||
provisioning.indexOf('<div className="empty-state" role="status">'),
|
||||
);
|
||||
assert.match(resultRows, /actionLabel="Выбрать"/);
|
||||
assert.match(resultRows, /onSelect=\{\(\) => selectCandidate\(device\)\}/);
|
||||
assert.doesNotMatch(resultRows, /Переподключиться|reopen|verifyConnection/);
|
||||
assert.doesNotMatch(provisioning, /recoverRetiredPhysicalCandidate/);
|
||||
assert.doesNotMatch(apply, /verifyConnection\(|retireUnavailable|reopenRetired/);
|
||||
assert.match(provisioning, /Переподключиться/);
|
||||
assert.match(provisioning, /Подключить новый K1/);
|
||||
assert.doesNotMatch(provisioning, /Вернуть прежний K1 и проверить/);
|
||||
assert.match(runtime, /reopenRetiredPhysicalReconciliation/);
|
||||
assert.match(runtime, /retireUnavailablePhysicalCommand/);
|
||||
assert.match(lifecycle, /retiredPhysicalReopenAuthority/);
|
||||
});
|
||||
|
||||
test("K1 orchestration accepts backend recovery but still requires exact topology before physical START", () => {
|
||||
const acquisition = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1AcquisitionPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const diagnostics = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1Diagnostics.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(acquisition, /currentAppliedConnectionTopology\(state\)/);
|
||||
assert.ok(
|
||||
[...acquisition.matchAll(/!connectionConfigured/g)].length >= 2,
|
||||
"handler and button must both reject absent or configured-offline topology",
|
||||
);
|
||||
assert.match(acquisition, /desiredModeMatchesActive/);
|
||||
assert.match(acquisition, /modeSwitchRequired/);
|
||||
assert.doesNotMatch(acquisition, /Выбран другой способ связи/);
|
||||
assert.match(acquisition, /prepareCanonicalAcquisition/);
|
||||
assert.match(acquisition, /startPreparedAcquisition/);
|
||||
assert.match(acquisition, /operatorActionPhysicalAcceptance\(\)/);
|
||||
assert.doesNotMatch(acquisition, /K1PhysicalCommandConfirmation/);
|
||||
assert.match(acquisition, /connectionPolicyAllows\(state, "start-acquisition"\)/);
|
||||
assert.match(acquisition, /canIssueCanonicalStop\(state, physicalStopIntentSpent\)/);
|
||||
assert.match(acquisition, /physicalStopInFlight \|\| physicalStopExecutable/);
|
||||
assert.match(acquisition, /connectionPolicyAllows\(state, "stop-local-receiver"\)/);
|
||||
assert.match(acquisition, /if \(finalStartTarget\)/);
|
||||
assert.match(acquisition, /if \(physicalStopExecutable\)/);
|
||||
assert.match(acquisition, /if \(!physicalStartAllowed\) return;/);
|
||||
assert.doesNotMatch(acquisition, /physicalStopAllowed/);
|
||||
const replaySubmit = acquisition.slice(
|
||||
acquisition.indexOf("const submitReplay ="),
|
||||
acquisition.indexOf("return (", acquisition.indexOf("const submitReplay =")),
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
replaySubmit,
|
||||
/connectionPolicyAllows|physicalStartAllowed|physicalStopExecutable/,
|
||||
);
|
||||
assert.match(acquisition, /void stopLocalReceiver\(\);/);
|
||||
assert.match(acquisition, /onClick=\{\(\) => void abort\(\)\}/);
|
||||
assert.doesNotMatch(acquisition, /acknowledge-data-loss/);
|
||||
assert.doesNotMatch(acquisition, /PHYSICAL_ACCEPTANCE/);
|
||||
assert.doesNotMatch(acquisition, /!state\?\.k1_ip/);
|
||||
assert.match(diagnostics, /activeConnectionEndpointLabel\(state\)/);
|
||||
assert.match(diagnostics, /Адрес конфигурации/);
|
||||
assert.match(diagnostics, /связь не подтверждена/);
|
||||
assert.doesNotMatch(diagnostics, /state\?\.k1_ip/);
|
||||
});
|
||||
|
||||
test("one explicit K1 action performs START or STOP without a redundant checklist modal", () => {
|
||||
const acquisition = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1AcquisitionPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const spatial = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1SpatialControls.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const confirmation = readFileSync(
|
||||
join(pluginFrontendRoot, "physicalCommandConfirmation.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const styles = readFileSync(join(pluginFrontendRoot, "styles.css"), "utf8");
|
||||
|
||||
assert.equal(
|
||||
existsSync(join(pluginFrontendRoot, "components/K1PhysicalCommandConfirmation.tsx")),
|
||||
false,
|
||||
);
|
||||
assert.doesNotMatch(acquisition, /K1PhysicalCommandConfirmation|ConfirmationModal/);
|
||||
assert.doesNotMatch(spatial, /K1PhysicalCommandConfirmation|ConfirmationModal/);
|
||||
assert.match(acquisition, /operatorActionPhysicalAcceptance\(\)/);
|
||||
assert.match(acquisition, /await submitFinalStart\(\)/);
|
||||
assert.match(spatial, /stop\(operatorActionPhysicalAcceptance\(\)\)/);
|
||||
assert.match(spatial, /physicalStopExecutable/);
|
||||
assert.match(spatial, /canIssueCanonicalStop\(state, physicalStopIntentSpent\)/);
|
||||
assert.match(spatial, /physicalStopInFlight \|\| physicalStopExecutable/);
|
||||
assert.match(spatial, /connectionPolicyAllows\(state, "stop-local-receiver"\)/);
|
||||
assert.doesNotMatch(spatial, /Физическая остановка K1 недоступна/);
|
||||
assert.match(spatial, /onClick=\{\(\) => void stopLocalReceiver\(\)\}/);
|
||||
assert.match(spatial, /Завершить локальный приём/);
|
||||
assert.doesNotMatch(spatial, /Повторить остановку/);
|
||||
assert.match(acquisition, /canIssueCanonicalStop\(state, physicalStopIntentSpent\)/);
|
||||
assert.match(acquisition, /stopLocalReceiver/);
|
||||
assert.match(acquisition, /Повторная команда устройству не отправляется/);
|
||||
assert.doesNotMatch(spatial, /acknowledge-data-loss/);
|
||||
assert.match(confirmation, /operatorActionPhysicalAcceptance/);
|
||||
assert.match(confirmation, /operator_present:\s*true/);
|
||||
assert.match(confirmation, /acquisition\.state !== "prepared"/);
|
||||
assert.match(confirmation, /control\.state !== "project-ready"/);
|
||||
assert.match(confirmation, /latest_device_session_state/);
|
||||
assert.match(confirmation, /acquisition_start_allowed !== true/);
|
||||
assert.doesNotMatch(styles, /xgrids-k1-physical-confirmation/);
|
||||
});
|
||||
|
||||
test("generic Control Station has one composition import and no K1 implementation knowledge", () => {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import React, { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY;
|
||||
let DevicePluginHostProvider;
|
||||
let commitPersistedDeviceModelId;
|
||||
let restorePersistedDeviceModelId;
|
||||
|
||||
const hostSourceUrl = new URL(
|
||||
"../src/core/device-plugins/DevicePluginHost.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY,
|
||||
DevicePluginHostProvider,
|
||||
commitPersistedDeviceModelId,
|
||||
restorePersistedDeviceModelId,
|
||||
} = await server.ssrLoadModule("/src/core/device-plugins/DevicePluginHost.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function memoryStorage(seed = {}) {
|
||||
const values = new Map(Object.entries(seed));
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
getItem(key) {
|
||||
calls.push(["get", key]);
|
||||
return values.get(key) ?? null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
calls.push(["set", key, value]);
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem(key) {
|
||||
calls.push(["remove", key]);
|
||||
values.delete(key);
|
||||
},
|
||||
value(key) {
|
||||
return values.get(key) ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registryWith(...modelIds) {
|
||||
const registered = new Set(modelIds);
|
||||
return {
|
||||
resolveModel(modelId) {
|
||||
return registered.has(modelId) ? { model: { id: modelId } } : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakePlugin(modelId) {
|
||||
function RuntimeProvider({ activeModel, children }) {
|
||||
return createElement(
|
||||
"div",
|
||||
{ "data-active-model": activeModel?.id ?? "" },
|
||||
children,
|
||||
);
|
||||
}
|
||||
function ConnectionView() {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
manifest: {
|
||||
apiVersion: "missioncore.nodedc/v1alpha1",
|
||||
kind: "DevicePlugin",
|
||||
metadata: {
|
||||
id: "test.device.plugin",
|
||||
version: "1.0.0",
|
||||
displayName: "Test device",
|
||||
},
|
||||
spec: {
|
||||
hostApiRange: "v1alpha1",
|
||||
runtime: {
|
||||
backendEntrypoint: "test.device:plugin",
|
||||
isolation: "transitional-in-process",
|
||||
},
|
||||
permissions: [],
|
||||
actions: [{ id: "state.read", mutating: false, secretFields: [] }],
|
||||
models: [{
|
||||
id: modelId,
|
||||
vendor: "Test",
|
||||
displayName: "Test model",
|
||||
category: "test",
|
||||
description: "test",
|
||||
verified: true,
|
||||
capabilities: [],
|
||||
ui: {
|
||||
slot: "device.connection",
|
||||
componentKey: "test.connection",
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
RuntimeProvider,
|
||||
connectionViews: { "test.connection": ConnectionView },
|
||||
};
|
||||
}
|
||||
|
||||
test("persisted model restore admits only an id in the current plugin registry", () => {
|
||||
const key = DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY;
|
||||
const registry = registryWith("xgrids.lixelkity-k1");
|
||||
const valid = memoryStorage({ [key]: " xgrids.lixelkity-k1 " });
|
||||
|
||||
assert.equal(
|
||||
restorePersistedDeviceModelId(registry, valid),
|
||||
"xgrids.lixelkity-k1",
|
||||
);
|
||||
assert.equal(valid.calls.some(([operation]) => operation === "remove"), false);
|
||||
|
||||
for (const staleValue of ["removed.model", " "]) {
|
||||
const stale = memoryStorage({ [key]: staleValue });
|
||||
assert.equal(restorePersistedDeviceModelId(registry, stale), null);
|
||||
assert.equal(stale.value(key), null, "a stale model id must be cleared");
|
||||
}
|
||||
assert.equal(restorePersistedDeviceModelId(registry, null), null);
|
||||
});
|
||||
|
||||
test("fresh provider mount immediately activates the registry-validated persisted model", () => {
|
||||
const modelId = "test.model.one";
|
||||
const storage = memoryStorage({
|
||||
[DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY]: modelId,
|
||||
});
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = { localStorage: storage };
|
||||
try {
|
||||
const markup = renderToStaticMarkup(createElement(
|
||||
DevicePluginHostProvider,
|
||||
{ plugins: [fakePlugin(modelId)] },
|
||||
createElement("span", null, "runtime child"),
|
||||
));
|
||||
assert.match(markup, /data-active-model="test\.model\.one"/);
|
||||
} finally {
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test("successful selection commits and explicit clear removes the same durable key", () => {
|
||||
const storage = memoryStorage();
|
||||
commitPersistedDeviceModelId("xgrids.lixelkity-k1", storage);
|
||||
assert.equal(
|
||||
storage.value(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY),
|
||||
"xgrids.lixelkity-k1",
|
||||
);
|
||||
|
||||
commitPersistedDeviceModelId(null, storage);
|
||||
assert.equal(storage.value(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY), null);
|
||||
assert.deepEqual(
|
||||
storage.calls.slice(-1)[0],
|
||||
["remove", DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY],
|
||||
);
|
||||
});
|
||||
|
||||
test("unavailable browser storage fails closed without blocking host state", () => {
|
||||
const denied = {
|
||||
getItem() {
|
||||
throw new Error("storage denied");
|
||||
},
|
||||
setItem() {
|
||||
throw new Error("storage denied");
|
||||
},
|
||||
removeItem() {
|
||||
throw new Error("storage denied");
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
restorePersistedDeviceModelId(registryWith("xgrids.lixelkity-k1"), denied),
|
||||
null,
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
commitPersistedDeviceModelId("xgrids.lixelkity-k1", denied)
|
||||
);
|
||||
assert.doesNotThrow(() => commitPersistedDeviceModelId(null, denied));
|
||||
});
|
||||
|
||||
test("host writes persistence only after plugin deactivation succeeds", () => {
|
||||
const source = readFileSync(hostSourceUrl, "utf8");
|
||||
const failedDeactivation = source.indexOf("if (!(await deactivate()))");
|
||||
const admittedState = source.indexOf("setSelectedModelId(nextModelId);");
|
||||
const durableCommit = source.indexOf(
|
||||
"commitPersistedDeviceModelId(nextModelId, selectionStorage);",
|
||||
admittedState,
|
||||
);
|
||||
|
||||
assert.ok(failedDeactivation >= 0);
|
||||
assert.ok(
|
||||
failedDeactivation < admittedState && admittedState < durableCommit,
|
||||
"failed deactivation branches must return before in-memory and durable commit",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(nextModelId === selectedModelId\) \{[\s\S]*?commitPersistedDeviceModelId\(nextModelId, selectionStorage\);[\s\S]*?return true;/,
|
||||
"explicit clear must remove stale persistence even from an already-empty host",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/useState<string \| null>\(\(\) =>\s*restorePersistedDeviceModelId\(registry, selectionStorage\)/,
|
||||
"a fresh provider mount must restore before runtime providers receive activeModel",
|
||||
);
|
||||
});
|
||||
@@ -91,8 +91,8 @@ test("E46C decodes the complete path-free temporal video overlay", async () => {
|
||||
assert.equal(selectE46CVideoFrame(overlay.frames, overlay.frames[20].sessionSeconds).frameIndex, 20);
|
||||
});
|
||||
|
||||
test("E46C viewer opens with full VIDEO and reuses the admitted recorded player", async () => {
|
||||
const [visual, videoScene, player] = await Promise.all([
|
||||
test("E46C viewer opens with full VIDEO and reuses the shared recorded overlay scene", async () => {
|
||||
const [visual, videoScene, sharedScene, player] = await Promise.all([
|
||||
readFile(
|
||||
new URL(
|
||||
"../src/workspaces/laboratory/E46CFullReplayWorldTracksVisual.tsx",
|
||||
@@ -107,14 +107,22 @@ test("E46C viewer opens with full VIDEO and reuses the admitted recorded player"
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
new URL(
|
||||
"../src/components/laboratory/RecordedEvidenceVideoScene.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
readFile(new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(visual, /useState<E46CViewMode>\("video"\)/);
|
||||
assert.match(visual, /\{ value: "video", label: "VIDEO" \}/);
|
||||
assert.match(visual, /replayObservationSession\(overlay\.recordedSourceSessionId/);
|
||||
assert.match(videoScene, /<RecordedFmp4Player/);
|
||||
assert.match(videoScene, /<RecordedEvidenceVideoScene/);
|
||||
assert.match(videoScene, /selectE46CVideoFrame/);
|
||||
assert.match(sharedScene, /<RecordedFmp4Player/);
|
||||
assert.match(player, /requestVideoFrameCallback/);
|
||||
assert.match(player, /controls=\{interactive\}/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchE47SemanticSlamResult;
|
||||
let fetchE47SemanticTimelineChunk;
|
||||
let e47SemanticMaskUrl;
|
||||
|
||||
const resultId = `e47-semantic-slam-${"a".repeat(64)}`;
|
||||
const baseM4ResultId = `m4-threat-replay-${"b".repeat(64)}`;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
fetchE47SemanticSlamResult,
|
||||
fetchE47SemanticTimelineChunk,
|
||||
e47SemanticMaskUrl,
|
||||
} = await server.ssrLoadModule("/src/core/laboratory/e47SemanticSlam.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function resultView(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.e47-semantic-slam-view/v1",
|
||||
result_id: resultId,
|
||||
created_at_utc: "2026-08-06T10:00:00.000Z",
|
||||
status: "diagnostic-semantic-slam-shadow",
|
||||
profile_id: "ravnoves00-eomt-kb4-slam-shadow/v1",
|
||||
base_m4_result_id: baseM4ResultId,
|
||||
semantic_result_id: `result-${"c".repeat(64)}`,
|
||||
geometry_result_id: `m4-geometry-replay-${"d".repeat(64)}`,
|
||||
source_pack_id: `e10-lidar-pack-${"e".repeat(64)}`,
|
||||
calibration_content_sha256: "f".repeat(64),
|
||||
provider: {
|
||||
provider_id: "eomt-cityscapes-semantic-control/v1",
|
||||
model_id: "tue-mps/cityscapes_semantic_eomt_large_1024",
|
||||
model_revision: "revision-1",
|
||||
model_weights_sha256: "1".repeat(64),
|
||||
preprocess_id: "raw-kb4-valid-fov-semantic/v1",
|
||||
},
|
||||
temporal_binding: {
|
||||
semantic_to_camera: "exact-sequence-and-session-time",
|
||||
camera_to_lidar: "accepted-e6-nearest-host-arrival-best-effort",
|
||||
clock_basis: "recorded-host-monotonic-arrival",
|
||||
maximum_lidar_camera_delta_ms: 100,
|
||||
maximum_pose_point_delta_ms: 100,
|
||||
physical_synchronization_proven: false,
|
||||
},
|
||||
taxonomy: [
|
||||
{
|
||||
class_id: 0,
|
||||
label: "outside_valid_fov",
|
||||
disposition: "ambiguous",
|
||||
color_rgb: [0, 0, 0],
|
||||
},
|
||||
{
|
||||
class_id: 7,
|
||||
label: "paved_road",
|
||||
disposition: "labeled",
|
||||
color_rgb: [128, 64, 128],
|
||||
},
|
||||
],
|
||||
metrics: {
|
||||
frames: { total: 4489, mask_available: 4489, source_available: 4489 },
|
||||
points: {
|
||||
total: 4,
|
||||
projected: 2,
|
||||
labeled: 1,
|
||||
ambiguous: 1,
|
||||
unprojected: 2,
|
||||
absent: 0,
|
||||
},
|
||||
observations: {
|
||||
total: 2,
|
||||
labeled: 1,
|
||||
ambiguous: 0,
|
||||
unprojected: 1,
|
||||
absent: 0,
|
||||
},
|
||||
runtime: { elapsed_ms: 1000, frames_per_second: 4.489 },
|
||||
},
|
||||
acceptance: {
|
||||
artifact_contract_passed: true,
|
||||
frame_accounting_passed: true,
|
||||
point_accounting_passed: true,
|
||||
observation_binding_passed: true,
|
||||
temporal_binding_passed: true,
|
||||
independent_semantic_truth_passed: false,
|
||||
provider_promoted: false,
|
||||
},
|
||||
limitations: ["diagnostic only"],
|
||||
ground_truth: false,
|
||||
semantic_authority: "diagnostic-only",
|
||||
navigation_or_safety_accepted: false,
|
||||
actuation_allowed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("E47 accepts only a fully accounted diagnostic semantic/SLAM view", async () => {
|
||||
const result = await fetchE47SemanticSlamResult({
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.e47-semantic-slam-catalog/v1",
|
||||
items: [resultView()],
|
||||
})),
|
||||
});
|
||||
assert.equal(result.resultId, resultId);
|
||||
assert.equal(result.baseM4ResultId, baseM4ResultId);
|
||||
assert.equal(result.metrics.points.projected, 2);
|
||||
assert.equal(result.acceptance.independentSemanticTruthPassed, false);
|
||||
assert.equal(result.temporalBinding.physicalSynchronizationProven, false);
|
||||
assert.equal(result.temporalBinding.maximumLidarCameraDeltaMs, 100);
|
||||
assert.equal(result.acceptance.temporalBindingPassed, true);
|
||||
assert.equal(result.taxonomy[0].disposition, "ambiguous");
|
||||
assert.equal(
|
||||
e47SemanticMaskUrl(resultId, 14),
|
||||
`/api/v1/laboratory/e47-semantic-slam/results/${resultId}/masks/14`,
|
||||
);
|
||||
});
|
||||
|
||||
test("E47 rejects result-level point accounting drift", async () => {
|
||||
await assert.rejects(
|
||||
fetchE47SemanticSlamResult({
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.e47-semantic-slam-catalog/v1",
|
||||
items: [resultView({
|
||||
metrics: {
|
||||
...resultView().metrics,
|
||||
points: {
|
||||
...resultView().metrics.points,
|
||||
total: 5,
|
||||
},
|
||||
},
|
||||
})],
|
||||
})),
|
||||
}),
|
||||
/point accounting/,
|
||||
);
|
||||
});
|
||||
|
||||
test("E47 chunk preserves unavailable sentinel and verifies the status histogram", async () => {
|
||||
const validFrame = {
|
||||
schema_version: "missioncore.e47-semantic-slam-frame/v1",
|
||||
sequence: 14,
|
||||
source_point_count: 4,
|
||||
class_ids: [7, 0, -1, -1],
|
||||
status_codes: [3, 2, 1, 1],
|
||||
counts: { labeled: 1, ambiguous: 1, unprojected: 2, absent: 0 },
|
||||
};
|
||||
const chunk = await fetchE47SemanticTimelineChunk(resultId, 14, 1, {
|
||||
taxonomy: [
|
||||
{ classId: 0, label: "outside_valid_fov", disposition: "ambiguous", colorRgb: [0, 0, 0] },
|
||||
{ classId: 7, label: "paved_road", disposition: "labeled", colorRgb: [128, 64, 128] },
|
||||
],
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.e47-semantic-slam-chunk/v1",
|
||||
result_id: resultId,
|
||||
start_sequence: 14,
|
||||
frame_count: 1,
|
||||
next_sequence: 15,
|
||||
frames: [validFrame],
|
||||
})),
|
||||
});
|
||||
assert.deepEqual(chunk.frames[0].classIds, [7, 0, -1, -1]);
|
||||
|
||||
await assert.rejects(
|
||||
fetchE47SemanticTimelineChunk(resultId, 14, 1, {
|
||||
taxonomy: [
|
||||
{ classId: 0, label: "outside_valid_fov", disposition: "ambiguous", colorRgb: [0, 0, 0] },
|
||||
{ classId: 7, label: "paved_road", disposition: "labeled", colorRgb: [128, 64, 128] },
|
||||
],
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.e47-semantic-slam-chunk/v1",
|
||||
result_id: resultId,
|
||||
start_sequence: 14,
|
||||
frame_count: 1,
|
||||
next_sequence: 15,
|
||||
frames: [{ ...validFrame, class_ids: [7, 0, 7, -1] }],
|
||||
})),
|
||||
}),
|
||||
/class\/status binding/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,977 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import React, { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let activeStreamForceFinishAuthority;
|
||||
let activeStreamForceFinishAuthorityMatches;
|
||||
let activeStreamRecoveredBrowserAuthority;
|
||||
let activeStreamRecoveryPresentation;
|
||||
let activeStreamRecoveryPresentationAuthority;
|
||||
let activeStreamRecoveryOwnsPresentationDecision;
|
||||
let exactActiveStreamRecoveryLineage;
|
||||
let formatActiveStreamRecoveryElapsed;
|
||||
let suppressGenericErrorDuringActiveStreamRecovery;
|
||||
let isXgridsActiveStreamRecovery;
|
||||
let K1AcquisitionPipeline;
|
||||
let K1SpatialControlsView;
|
||||
let runSpatialActiveStreamForceFinish;
|
||||
let shouldRenderK1GenericRuntimeError;
|
||||
let shouldRenderK1OperationalPanels;
|
||||
let xgridsK1Actions;
|
||||
let xgridsK1Api;
|
||||
|
||||
const hookSourceUrl = new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts",
|
||||
import.meta.url,
|
||||
);
|
||||
const acquisitionSourceUrl = new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const recoverySurfaceSourceUrl = new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/ActiveStreamRecoverySurface.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const spatialControlsSourceUrl = new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const connectionSourceUrl = new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
activeStreamForceFinishAuthority,
|
||||
activeStreamForceFinishAuthorityMatches,
|
||||
activeStreamRecoveredBrowserAuthority,
|
||||
activeStreamRecoveryPresentation,
|
||||
activeStreamRecoveryPresentationAuthority,
|
||||
activeStreamRecoveryOwnsPresentationDecision,
|
||||
exactActiveStreamRecoveryLineage,
|
||||
formatActiveStreamRecoveryElapsed,
|
||||
suppressGenericErrorDuringActiveStreamRecovery,
|
||||
} = await server.ssrLoadModule("@xgrids-k1/frontend/activeStreamRecovery.ts"));
|
||||
({ isXgridsActiveStreamRecovery, xgridsK1Api } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/api.ts",
|
||||
));
|
||||
({ xgridsK1Actions } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/manifest.ts",
|
||||
));
|
||||
({ K1AcquisitionPipeline } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/components/K1AcquisitionPipeline.tsx",
|
||||
));
|
||||
({
|
||||
K1SpatialControlsView,
|
||||
runSpatialActiveStreamForceFinish,
|
||||
} = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/components/K1SpatialControls.tsx",
|
||||
));
|
||||
({
|
||||
shouldRenderK1GenericRuntimeError,
|
||||
shouldRenderK1OperationalPanels,
|
||||
} = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/XgridsK1Connection.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function recoveryContract(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.xgrids-k1-active-stream-recovery/v1",
|
||||
state: "reconnecting",
|
||||
generation: 7,
|
||||
acquisition_id: "acquisition-recovery-001",
|
||||
attempt: 3,
|
||||
started_at_utc: "2026-08-11T19:31:00Z",
|
||||
elapsed_ms: 12_400,
|
||||
reason_code: "read-only-rebind-in-progress",
|
||||
force_finish_allowed: true,
|
||||
automatic_read_only_rebind: true,
|
||||
automatic_command_retry: false,
|
||||
start_performed: false,
|
||||
stop_performed: false,
|
||||
ble_operation_performed: false,
|
||||
network_mutation_performed: false,
|
||||
runtime_producer_generation: 11,
|
||||
camera_recovery: "owned",
|
||||
camera_media_state: "pending-first-media",
|
||||
camera_media_ready: false,
|
||||
camera_epoch: {
|
||||
generation: 7,
|
||||
init_committed: true,
|
||||
init_committed_age_ms: 250,
|
||||
first_media_committed: false,
|
||||
first_media_committed_age_ms: null,
|
||||
committed_media_segment_count: 0,
|
||||
last_media_segment_age_ms: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function recoveryState(recoveryOverrides = {}, stateOverrides = {}) {
|
||||
return {
|
||||
snapshot_runtime_id: "snapshot-runtime-recovery-001",
|
||||
snapshot_revision: 43,
|
||||
producer_generation: 11,
|
||||
phase: "reconnecting",
|
||||
source_mode: "live",
|
||||
acquisition: {
|
||||
acquisition_id: "acquisition-recovery-001",
|
||||
device_id: "device-k1-001",
|
||||
device_session_id: "device-session-001",
|
||||
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
|
||||
control_mode: "plugin-commanded",
|
||||
requested_streams: ["spatial.point-cloud.live"],
|
||||
target_host: "127.0.0.1",
|
||||
duration_seconds: 0,
|
||||
evidence_policy: "required",
|
||||
state: "acquiring",
|
||||
state_revision: 9,
|
||||
cleanup_pending: false,
|
||||
},
|
||||
connection_recovery: recoveryContract(recoveryOverrides),
|
||||
...stateOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
function coldRestartPrePclState() {
|
||||
const state = recoveryState({
|
||||
generation: 1,
|
||||
acquisition_id: "acquisition-before-backend-restart-001",
|
||||
attempt: 0,
|
||||
started_at_utc: "2026-08-14T00:31:00Z",
|
||||
elapsed_ms: 450,
|
||||
reason_code: "restart-receiver-awaiting-first-pcl",
|
||||
runtime_producer_generation: 1,
|
||||
camera_recovery: "inactive",
|
||||
camera_media_state: "inactive",
|
||||
camera_media_ready: false,
|
||||
camera_epoch: null,
|
||||
}, {
|
||||
snapshot_runtime_id: "snapshot-runtime-after-cold-restart-001",
|
||||
snapshot_revision: 2,
|
||||
producer_generation: 1,
|
||||
acquisition: {
|
||||
...recoveryState().acquisition,
|
||||
acquisition_id: "acquisition-before-backend-restart-001",
|
||||
state: "awaiting_external_start",
|
||||
state_revision: 4,
|
||||
},
|
||||
camera_preview: {
|
||||
activation_admission: {
|
||||
state: "waiting-for-first-authoritative-pcl",
|
||||
basis: "post-rerun-publish-pcl-frame",
|
||||
runtime_producer_generation: 1,
|
||||
device_command_sent: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
function coldRestartRecoveredState() {
|
||||
const state = coldRestartPrePclState();
|
||||
return {
|
||||
...state,
|
||||
snapshot_revision: state.snapshot_revision + 1,
|
||||
phase: "live",
|
||||
acquisition: {
|
||||
...state.acquisition,
|
||||
state: "acquiring",
|
||||
state_revision: state.acquisition.state_revision + 1,
|
||||
},
|
||||
connection_recovery: {
|
||||
...state.connection_recovery,
|
||||
state: "recovered",
|
||||
elapsed_ms: null,
|
||||
reason_code: null,
|
||||
force_finish_allowed: false,
|
||||
camera_recovery: "owned",
|
||||
camera_media_state: "pending-epoch",
|
||||
camera_media_ready: false,
|
||||
camera_epoch: null,
|
||||
},
|
||||
camera_preview: {
|
||||
activation_admission: {
|
||||
state: "activating",
|
||||
basis: "post-rerun-publish-pcl-frame",
|
||||
runtime_producer_generation: 1,
|
||||
device_command_sent: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function controller(state, overrides = {}) {
|
||||
return {
|
||||
state,
|
||||
pendingAction: null,
|
||||
error: null,
|
||||
physicalStopIntentSpent: false,
|
||||
physicalStopInFlight: false,
|
||||
closeApplicationControlSession: async () => false,
|
||||
prepareCanonicalAcquisition: async () => false,
|
||||
startPreparedAcquisition: async () => false,
|
||||
startReplay: async () => false,
|
||||
stop: async () => false,
|
||||
stopLocalReceiver: async () => false,
|
||||
forceFinishActiveStreamLocally: async () => false,
|
||||
abort: async () => false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPipeline(state, overrides = {}) {
|
||||
return renderToStaticMarkup(createElement(K1AcquisitionPipeline, {
|
||||
controller: controller(state, overrides),
|
||||
desiredConnectionMode: "bridge",
|
||||
openSpatialScene() {},
|
||||
activateAutomaticSpatialSource() {},
|
||||
}));
|
||||
}
|
||||
|
||||
function renderSpatialControls(state, overrides = {}) {
|
||||
return renderToStaticMarkup(createElement(K1SpatialControlsView, {
|
||||
controller: controller(state, overrides),
|
||||
}));
|
||||
}
|
||||
|
||||
function buttonsWithText(markup, text) {
|
||||
return (markup.match(/<button\b[\s\S]*?<\/button>/g) ?? [])
|
||||
.filter((button) => button.includes(text));
|
||||
}
|
||||
|
||||
function sourceSlice(source, startMarker, endMarker) {
|
||||
const start = source.indexOf(startMarker);
|
||||
const end = source.indexOf(endMarker, start + startMarker.length);
|
||||
assert.notEqual(start, -1, `missing source marker: ${startMarker}`);
|
||||
assert.notEqual(end, -1, `missing source marker: ${endMarker}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test("active recovery contract is strict about all no-write invariants", () => {
|
||||
assert.equal(isXgridsActiveStreamRecovery(recoveryContract()), true);
|
||||
for (const field of [
|
||||
"automatic_command_retry",
|
||||
"start_performed",
|
||||
"stop_performed",
|
||||
"ble_operation_performed",
|
||||
"network_mutation_performed",
|
||||
]) {
|
||||
assert.equal(
|
||||
isXgridsActiveStreamRecovery(recoveryContract({ [field]: true })),
|
||||
false,
|
||||
field,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
isXgridsActiveStreamRecovery(recoveryContract({ state: "retrying-command" })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isXgridsActiveStreamRecovery(recoveryContract({ elapsed_ms: -1 })),
|
||||
false,
|
||||
);
|
||||
const missingMediaState = recoveryContract();
|
||||
delete missingMediaState.camera_media_state;
|
||||
assert.equal(isXgridsActiveStreamRecovery(missingMediaState), false);
|
||||
assert.equal(
|
||||
isXgridsActiveStreamRecovery(recoveryContract({ camera_media_ready: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isXgridsActiveStreamRecovery(recoveryContract({
|
||||
camera_media_state: "ready",
|
||||
camera_media_ready: true,
|
||||
camera_epoch: {
|
||||
...recoveryContract().camera_epoch,
|
||||
generation: 0,
|
||||
first_media_committed: true,
|
||||
first_media_committed_age_ms: 1,
|
||||
committed_media_segment_count: 1,
|
||||
last_media_segment_age_ms: 1,
|
||||
},
|
||||
})),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("cold restart before first PCL owns exact reconnect UI without camera or physical actions", () => {
|
||||
const state = coldRestartPrePclState();
|
||||
assert.equal(isXgridsActiveStreamRecovery(state.connection_recovery), true);
|
||||
|
||||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||||
assert.deepEqual(lineage && {
|
||||
runtime: lineage.snapshotRuntimeId,
|
||||
acquisition: lineage.acquisitionId,
|
||||
revision: lineage.acquisitionStateRevision,
|
||||
recovery: lineage.recoveryGeneration,
|
||||
producer: lineage.runtimeProducerGeneration,
|
||||
}, {
|
||||
runtime: "snapshot-runtime-after-cold-restart-001",
|
||||
acquisition: "acquisition-before-backend-restart-001",
|
||||
revision: 4,
|
||||
recovery: 1,
|
||||
producer: 1,
|
||||
});
|
||||
assert.notEqual(activeStreamRecoveryPresentationAuthority(state), null);
|
||||
assert.notEqual(activeStreamForceFinishAuthority(state), null);
|
||||
assert.equal(activeStreamRecoveredBrowserAuthority(state), null);
|
||||
assert.equal(state.connection_recovery.camera_recovery, "inactive");
|
||||
assert.equal(state.connection_recovery.camera_media_state, "inactive");
|
||||
assert.equal(state.connection_recovery.camera_media_ready, false);
|
||||
assert.equal(state.connection_recovery.camera_epoch, null);
|
||||
assert.deepEqual(state.camera_preview.activation_admission, {
|
||||
state: "waiting-for-first-authoritative-pcl",
|
||||
basis: "post-rerun-publish-pcl-frame",
|
||||
runtime_producer_generation: 1,
|
||||
device_command_sent: false,
|
||||
});
|
||||
|
||||
const pipeline = renderPipeline(state);
|
||||
assert.match(pipeline, /Восстанавливаем соединение/);
|
||||
assert.match(pipeline, /START, STOP, Bluetooth и настройки устройства не отправляются/);
|
||||
assert.equal(buttonsWithText(pipeline, "Прервать соединение").length, 1);
|
||||
assert.equal(buttonsWithText(pipeline, "Запустить приём").length, 0);
|
||||
assert.equal(buttonsWithText(pipeline, "Остановить устройство и запись").length, 0);
|
||||
assert.equal(buttonsWithText(pipeline, "Остановить сканирование").length, 0);
|
||||
assert.doesNotMatch(
|
||||
pipeline,
|
||||
/СВЯЗЬ ВОССТАНОВЛЕНА|Связь восстановлена · приём продолжается|Продолжаем тот же приём/,
|
||||
);
|
||||
|
||||
const spatial = renderSpatialControls(state);
|
||||
assert.match(spatial, /Восстанавливаем соединение/);
|
||||
assert.equal(buttonsWithText(spatial, "Прервать соединение").length, 1);
|
||||
assert.doesNotMatch(
|
||||
spatial,
|
||||
/Остановить устройство|Остановить K1|Завершить локальный приём/,
|
||||
);
|
||||
});
|
||||
|
||||
test("cold restart first PCL preserves lineage and renders recovered continuation copy", () => {
|
||||
const beforePcl = coldRestartPrePclState();
|
||||
const state = coldRestartRecoveredState();
|
||||
assert.equal(isXgridsActiveStreamRecovery(state.connection_recovery), true);
|
||||
assert.equal(
|
||||
state.connection_recovery.acquisition_id,
|
||||
beforePcl.connection_recovery.acquisition_id,
|
||||
);
|
||||
assert.equal(
|
||||
state.connection_recovery.generation,
|
||||
beforePcl.connection_recovery.generation,
|
||||
);
|
||||
assert.equal(
|
||||
state.connection_recovery.runtime_producer_generation,
|
||||
beforePcl.connection_recovery.runtime_producer_generation,
|
||||
);
|
||||
|
||||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||||
const browserAuthority = activeStreamRecoveredBrowserAuthority(state);
|
||||
assert.deepEqual(lineage && {
|
||||
runtime: lineage.snapshotRuntimeId,
|
||||
acquisition: lineage.acquisitionId,
|
||||
revision: lineage.acquisitionStateRevision,
|
||||
recovery: lineage.recoveryGeneration,
|
||||
producer: lineage.runtimeProducerGeneration,
|
||||
}, {
|
||||
runtime: "snapshot-runtime-after-cold-restart-001",
|
||||
acquisition: "acquisition-before-backend-restart-001",
|
||||
revision: 5,
|
||||
recovery: 1,
|
||||
producer: 1,
|
||||
});
|
||||
assert.deepEqual(browserAuthority, lineage);
|
||||
assert.equal(activeStreamRecoveryPresentationAuthority(state), null);
|
||||
assert.equal(activeStreamForceFinishAuthority(state), null);
|
||||
assert.equal(state.connection_recovery.camera_recovery, "owned");
|
||||
assert.equal(state.camera_preview.activation_admission.device_command_sent, false);
|
||||
|
||||
const pipeline = renderPipeline(state);
|
||||
assert.doesNotMatch(pipeline, /Восстанавливаем соединение|Прервать соединение/);
|
||||
assert.match(pipeline, /СВЯЗЬ ВОССТАНОВЛЕНА · АКТИВНЫЙ ПРИЁМ/);
|
||||
assert.match(pipeline, /Связь восстановлена · приём продолжается/);
|
||||
assert.match(pipeline, /Продолжаем тот же приём без нового START/);
|
||||
assert.doesNotMatch(pipeline, /Назовите проект и запустите приём|Запустить приём/);
|
||||
});
|
||||
|
||||
test("force-finish authority requires exact runtime, acquisition, revision and generations", () => {
|
||||
const state = recoveryState();
|
||||
const authority = activeStreamForceFinishAuthority(state);
|
||||
assert.deepEqual(authority && {
|
||||
snapshotRuntimeId: authority.snapshotRuntimeId,
|
||||
acquisitionId: authority.acquisitionId,
|
||||
acquisitionStateRevision: authority.acquisitionStateRevision,
|
||||
recoveryGeneration: authority.recoveryGeneration,
|
||||
runtimeProducerGeneration: authority.runtimeProducerGeneration,
|
||||
}, {
|
||||
snapshotRuntimeId: "snapshot-runtime-recovery-001",
|
||||
acquisitionId: "acquisition-recovery-001",
|
||||
acquisitionStateRevision: 9,
|
||||
recoveryGeneration: 7,
|
||||
runtimeProducerGeneration: 11,
|
||||
});
|
||||
assert.equal(activeStreamForceFinishAuthorityMatches(authority, state), true);
|
||||
|
||||
const staleCases = [
|
||||
(() => {
|
||||
const value = structuredClone(state);
|
||||
value.snapshot_runtime_id = "snapshot-runtime-recovery-002";
|
||||
return value;
|
||||
})(),
|
||||
(() => {
|
||||
const value = structuredClone(state);
|
||||
value.acquisition.acquisition_id = "acquisition-recovery-002";
|
||||
return value;
|
||||
})(),
|
||||
(() => {
|
||||
const value = structuredClone(state);
|
||||
value.acquisition.state_revision += 1;
|
||||
return value;
|
||||
})(),
|
||||
(() => {
|
||||
const value = structuredClone(state);
|
||||
value.connection_recovery.generation += 1;
|
||||
return value;
|
||||
})(),
|
||||
(() => {
|
||||
const value = structuredClone(state);
|
||||
value.producer_generation += 1;
|
||||
return value;
|
||||
})(),
|
||||
];
|
||||
for (const stale of staleCases) {
|
||||
assert.equal(activeStreamForceFinishAuthorityMatches(authority, stale), false);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
activeStreamForceFinishAuthority(recoveryState({ state: "recovered", force_finish_allowed: false })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
activeStreamForceFinishAuthority(recoveryState({ force_finish_allowed: false })),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("reconnecting presentation is exact and owns stale supervisor projection", () => {
|
||||
const state = recoveryState({ force_finish_allowed: false });
|
||||
const authority = activeStreamRecoveryPresentationAuthority(state);
|
||||
assert.deepEqual(authority && {
|
||||
runtime: authority.snapshotRuntimeId,
|
||||
acquisition: authority.acquisitionId,
|
||||
producer: authority.runtimeProducerGeneration,
|
||||
recovery: authority.recoveryGeneration,
|
||||
}, {
|
||||
runtime: "snapshot-runtime-recovery-001",
|
||||
acquisition: "acquisition-recovery-001",
|
||||
producer: 11,
|
||||
recovery: 7,
|
||||
});
|
||||
assert.equal(activeStreamRecoveryOwnsPresentationDecision(state), true);
|
||||
|
||||
const wrongProducer = structuredClone(state);
|
||||
wrongProducer.producer_generation += 1;
|
||||
assert.equal(activeStreamRecoveryPresentationAuthority(wrongProducer), null);
|
||||
assert.equal(
|
||||
activeStreamRecoveryOwnsPresentationDecision(wrongProducer),
|
||||
true,
|
||||
"a valid reconnect contract must block stale ordinary data projection",
|
||||
);
|
||||
|
||||
for (const recoveryStateName of [
|
||||
"blocked",
|
||||
"standby",
|
||||
"fault",
|
||||
"force-finishing",
|
||||
"force-finished",
|
||||
]) {
|
||||
const terminal = recoveryState({ state: recoveryStateName });
|
||||
assert.equal(activeStreamRecoveryPresentationAuthority(terminal), null);
|
||||
assert.equal(activeStreamRecoveryOwnsPresentationDecision(terminal), true);
|
||||
}
|
||||
assert.equal(
|
||||
activeStreamRecoveryOwnsPresentationDecision(recoveryState({ state: "recovered" })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("recovered browser carryover keeps exact lineage without restoring recovery controls", () => {
|
||||
const state = recoveryState({
|
||||
state: "recovered",
|
||||
force_finish_allowed: false,
|
||||
elapsed_ms: null,
|
||||
reason_code: null,
|
||||
}, {
|
||||
phase: "live",
|
||||
});
|
||||
const authority = activeStreamRecoveredBrowserAuthority(state);
|
||||
assert.deepEqual(authority && {
|
||||
runtime: authority.snapshotRuntimeId,
|
||||
acquisition: authority.acquisitionId,
|
||||
revision: authority.acquisitionStateRevision,
|
||||
producer: authority.runtimeProducerGeneration,
|
||||
recovery: authority.recoveryGeneration,
|
||||
}, {
|
||||
runtime: "snapshot-runtime-recovery-001",
|
||||
acquisition: "acquisition-recovery-001",
|
||||
revision: 9,
|
||||
producer: 11,
|
||||
recovery: 7,
|
||||
});
|
||||
assert.equal(activeStreamRecoveryPresentation(state), null);
|
||||
assert.equal(activeStreamForceFinishAuthority(state), null);
|
||||
|
||||
const staleProducer = structuredClone(state);
|
||||
staleProducer.producer_generation += 1;
|
||||
assert.equal(activeStreamRecoveredBrowserAuthority(staleProducer), null);
|
||||
assert.equal(
|
||||
activeStreamRecoveredBrowserAuthority(recoveryState({
|
||||
state: "recovered",
|
||||
camera_recovery: "blocked",
|
||||
force_finish_allowed: false,
|
||||
}, { phase: "live" })),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("only an exact reconnecting lineage suppresses the generic red error", () => {
|
||||
const state = recoveryState();
|
||||
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(state, null), true);
|
||||
assert.equal(
|
||||
shouldRenderK1GenericRuntimeError("Ошибка локального приёмника", false, state, null),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
suppressGenericErrorDuringActiveStreamRecovery(state, "force-finish"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderK1GenericRuntimeError(
|
||||
"Локальное завершение не выполнено",
|
||||
false,
|
||||
state,
|
||||
"force-finish",
|
||||
),
|
||||
true,
|
||||
"a failed explicit local finish must keep the generic error banner visible",
|
||||
);
|
||||
|
||||
const wrongProducer = structuredClone(state);
|
||||
wrongProducer.producer_generation += 1;
|
||||
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(wrongProducer), false);
|
||||
|
||||
const wrongAcquisition = structuredClone(state);
|
||||
wrongAcquisition.connection_recovery.acquisition_id = "acquisition-stale";
|
||||
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(wrongAcquisition), false);
|
||||
|
||||
const missingRuntime = structuredClone(state);
|
||||
delete missingRuntime.snapshot_runtime_id;
|
||||
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(missingRuntime), false);
|
||||
|
||||
const blocked = recoveryState({ state: "blocked" });
|
||||
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(blocked), false);
|
||||
|
||||
const nonOwned = recoveryState({ automatic_read_only_rebind: false });
|
||||
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(nonOwned), false);
|
||||
|
||||
const inactiveCases = [
|
||||
recoveryState({}, { phase: "error" }),
|
||||
recoveryState({}, { source_mode: "idle" }),
|
||||
recoveryState({}, {
|
||||
acquisition: {
|
||||
...state.acquisition,
|
||||
state: "failed",
|
||||
},
|
||||
}),
|
||||
];
|
||||
for (const inactive of inactiveCases) {
|
||||
assert.equal(
|
||||
suppressGenericErrorDuringActiveStreamRecovery(inactive, null),
|
||||
false,
|
||||
"stale recovery projection must fail open to the error banner",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("reconnecting presentation is neutral, timed and exposes explicit local finish", () => {
|
||||
const state = recoveryState();
|
||||
const presentation = activeStreamRecoveryPresentation(state);
|
||||
assert.equal(presentation?.state, "reconnecting");
|
||||
assert.equal(presentation?.tone, "neutral");
|
||||
assert.equal(presentation?.progressLabel, "Попытка 3 · 12 с");
|
||||
assert.equal(presentation?.forceFinishAvailable, true);
|
||||
|
||||
const markup = renderPipeline(state);
|
||||
assert.match(markup, /Восстанавливаем соединение/);
|
||||
assert.match(markup, /Попытка 3 · 12 с/);
|
||||
assert.match(markup, /class="nodedc-activity-indicator/);
|
||||
assert.equal(buttonsWithText(markup, "Прервать соединение").length, 1);
|
||||
assert.match(markup, /START, STOP, Bluetooth и настройки устройства не отправляются/);
|
||||
assert.doesNotMatch(markup, /Ошибка локального приёмника/);
|
||||
assert.equal(shouldRenderK1OperationalPanels(state), true);
|
||||
});
|
||||
|
||||
test("spatial scene owns the same recovery spinner and explicit local finish", () => {
|
||||
const markup = renderSpatialControls(recoveryState());
|
||||
assert.match(markup, /Восстанавливаем соединение/);
|
||||
assert.match(markup, /Попытка 3 · 12 с/);
|
||||
assert.match(markup, /class="nodedc-activity-indicator/);
|
||||
assert.match(markup, /data-recovery-state="reconnecting"/);
|
||||
assert.equal(buttonsWithText(markup, "Прервать соединение").length, 1);
|
||||
assert.match(markup, /локальный front\/back-приём/);
|
||||
assert.doesNotMatch(
|
||||
markup,
|
||||
/Остановить устройство|Остановить K1|Завершить локальный приём/,
|
||||
"recovery must not expose canonical STOP or the generic receiver stop",
|
||||
);
|
||||
});
|
||||
|
||||
test("spatial blocked and fault recovery copy is terminal and truthful", () => {
|
||||
const blocked = renderSpatialControls(recoveryState({
|
||||
state: "blocked",
|
||||
reason_code: "exact-binding-changed",
|
||||
}));
|
||||
assert.match(blocked, /Связь не восстановлена/);
|
||||
assert.match(blocked, /Восстановление остановлено/);
|
||||
assert.doesNotMatch(blocked, /class="nodedc-activity-indicator/);
|
||||
assert.equal(buttonsWithText(blocked, "Прервать соединение").length, 1);
|
||||
|
||||
const fault = renderSpatialControls(recoveryState({
|
||||
state: "fault",
|
||||
force_finish_allowed: false,
|
||||
reason_code: "active-stream-recovery-system-error",
|
||||
}));
|
||||
assert.match(fault, /K1 сообщил об ошибке/);
|
||||
assert.match(fault, /Автоматических команд и повторов нет/);
|
||||
assert.equal(buttonsWithText(fault, "Прервать соединение").length, 0);
|
||||
});
|
||||
|
||||
test("spatial recovery interaction routes only to exact local force-finish", async () => {
|
||||
let forceFinishCalls = 0;
|
||||
const current = recoveryState();
|
||||
const invoked = await runSpatialActiveStreamForceFinish({
|
||||
state: current,
|
||||
forceFinishActiveStreamLocally: async () => {
|
||||
forceFinishCalls += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.equal(invoked, true);
|
||||
assert.equal(forceFinishCalls, 1);
|
||||
|
||||
const stale = structuredClone(current);
|
||||
stale.connection_recovery.runtime_producer_generation += 1;
|
||||
const rejected = await runSpatialActiveStreamForceFinish({
|
||||
state: stale,
|
||||
forceFinishActiveStreamLocally: async () => {
|
||||
forceFinishCalls += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.equal(rejected, false);
|
||||
assert.equal(forceFinishCalls, 1, "stale lineage must not dispatch any action");
|
||||
});
|
||||
|
||||
test("spatial force-finish pending owns the surface without a second action", () => {
|
||||
const markup = renderSpatialControls(
|
||||
recoveryState({
|
||||
state: "force-finishing",
|
||||
acquisition_id: null,
|
||||
force_finish_allowed: false,
|
||||
automatic_read_only_rebind: false,
|
||||
camera_recovery: "inactive",
|
||||
}),
|
||||
{ pendingAction: "force-finish" },
|
||||
);
|
||||
assert.match(markup, /Завершение локального приёма/);
|
||||
assert.match(markup, /data-recovery-state="force-finishing"/);
|
||||
assert.match(markup, /Команда STOP устройству не отправляется/);
|
||||
assert.equal(buttonsWithText(markup, "Прервать соединение").length, 0);
|
||||
});
|
||||
|
||||
test("blocked, camera-blocked, standby and fault copy stay truthful", () => {
|
||||
const blocked = recoveryState({ state: "blocked", reason_code: "exact-binding-changed" });
|
||||
const blockedMarkup = renderPipeline(blocked);
|
||||
assert.match(blockedMarkup, /Связь не восстановлена/);
|
||||
assert.match(blockedMarkup, /Восстановление остановлено/);
|
||||
assert.doesNotMatch(blockedMarkup, /class="nodedc-activity-indicator/);
|
||||
assert.equal(buttonsWithText(blockedMarkup, "Прервать соединение").length, 1);
|
||||
|
||||
const cameraBlocked = recoveryState({
|
||||
state: "blocked",
|
||||
camera_recovery: "blocked",
|
||||
reason_code: "camera-recovery-failed",
|
||||
});
|
||||
assert.match(renderPipeline(cameraBlocked), /Видеопоток не восстановлен/);
|
||||
|
||||
const standby = recoveryState({
|
||||
state: "standby",
|
||||
force_finish_allowed: false,
|
||||
reason_code: "device-reported-standby",
|
||||
});
|
||||
const standbyMarkup = renderPipeline(standby);
|
||||
assert.match(standbyMarkup, /Устройство перешло в ожидание/);
|
||||
assert.match(standbyMarkup, /без команды STOP/);
|
||||
assert.equal(buttonsWithText(standbyMarkup, "Прервать соединение").length, 0);
|
||||
assert.equal(shouldRenderK1OperationalPanels(standby), true);
|
||||
|
||||
const fault = recoveryState({
|
||||
state: "fault",
|
||||
force_finish_allowed: false,
|
||||
reason_code: "active-stream-recovery-system-error",
|
||||
});
|
||||
const faultMarkup = renderPipeline(fault);
|
||||
assert.match(faultMarkup, /K1 сообщил об ошибке/);
|
||||
assert.match(faultMarkup, /Автоматических команд и повторов нет/);
|
||||
assert.equal(buttonsWithText(faultMarkup, "Прервать соединение").length, 0);
|
||||
assert.equal(shouldRenderK1OperationalPanels(fault), true);
|
||||
});
|
||||
|
||||
test("recovered active lineage renders the continued session and one exact STOP", () => {
|
||||
const state = recoveryState({
|
||||
state: "recovered",
|
||||
force_finish_allowed: false,
|
||||
elapsed_ms: null,
|
||||
reason_code: null,
|
||||
}, {
|
||||
phase: "live",
|
||||
compatibility: {
|
||||
vendor_writes_enabled: true,
|
||||
permitted_mode: "active-control",
|
||||
},
|
||||
application_control_session: {
|
||||
session_generation: 5,
|
||||
state_revision: 8,
|
||||
state: "scanning",
|
||||
can_stop: true,
|
||||
control_socket_open: true,
|
||||
},
|
||||
connection_policy: {
|
||||
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
|
||||
facts: { retained_context_is_presence: false },
|
||||
allowed_actions: ["stop-acquisition"],
|
||||
actions: {
|
||||
"stop-acquisition": {
|
||||
allowed: true,
|
||||
reason_codes: [],
|
||||
target_source: "connection-supervisor",
|
||||
required_transport_ref: null,
|
||||
required_connection_mode: null,
|
||||
requires_live_gatt_validation: false,
|
||||
automatic_retry: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(activeStreamRecoveryPresentation(state), null);
|
||||
const markup = renderPipeline(state);
|
||||
assert.doesNotMatch(markup, /Восстанавливаем соединение|Прервать соединение/);
|
||||
assert.match(markup, /СВЯЗЬ ВОССТАНОВЛЕНА · АКТИВНЫЙ ПРИЁМ/);
|
||||
assert.match(markup, /Связь восстановлена · приём продолжается/);
|
||||
assert.match(markup, /Продолжаем тот же приём без нового START/);
|
||||
assert.doesNotMatch(markup, /Назовите проект и запустите приём|Запустить приём/);
|
||||
|
||||
const stopButtons = buttonsWithText(markup, "Остановить устройство и запись");
|
||||
assert.equal(stopButtons.length, 1);
|
||||
assert.doesNotMatch(stopButtons[0], /\bdisabled(?:=|\s|>)/);
|
||||
});
|
||||
|
||||
test("stale recovered marker on an idle released runtime fails closed to idle UI", () => {
|
||||
const state = recoveryState({
|
||||
state: "recovered",
|
||||
force_finish_allowed: false,
|
||||
elapsed_ms: null,
|
||||
reason_code: null,
|
||||
}, {
|
||||
phase: "idle",
|
||||
source_mode: "idle",
|
||||
acquisition: {
|
||||
...recoveryState().acquisition,
|
||||
state: "completed",
|
||||
cleanup_pending: false,
|
||||
},
|
||||
application_control_session: {
|
||||
session_generation: 5,
|
||||
state_revision: 9,
|
||||
state: "completed",
|
||||
can_stop: false,
|
||||
control_socket_open: false,
|
||||
},
|
||||
});
|
||||
assert.equal(activeStreamRecoveryPresentation(state), null);
|
||||
const markup = renderPipeline(state);
|
||||
assert.doesNotMatch(
|
||||
markup,
|
||||
/СВЯЗЬ ВОССТАНОВЛЕНА|Связь восстановлена · приём продолжается|Продолжаем тот же приём/,
|
||||
);
|
||||
assert.match(markup, /Назовите проект и запустите приём/);
|
||||
assert.equal(buttonsWithText(markup, "Остановить устройство и запись").length, 0);
|
||||
assert.equal(buttonsWithText(markup, "Остановить сканирование").length, 0);
|
||||
});
|
||||
|
||||
test("force-finishing shows one local-only pending owner and no second action", () => {
|
||||
const state = recoveryState({
|
||||
state: "force-finishing",
|
||||
acquisition_id: null,
|
||||
force_finish_allowed: false,
|
||||
automatic_read_only_rebind: false,
|
||||
runtime_producer_generation: 11,
|
||||
camera_recovery: "inactive",
|
||||
});
|
||||
const markup = renderPipeline(state, { pendingAction: "force-finish" });
|
||||
assert.match(markup, /Завершение локального приёма/);
|
||||
assert.match(markup, /Команда STOP устройству не отправляется/);
|
||||
assert.match(markup, /class="nodedc-activity-indicator/);
|
||||
assert.equal(buttonsWithText(markup, "Прервать соединение").length, 0);
|
||||
});
|
||||
|
||||
test("elapsed presentation is deterministic", () => {
|
||||
assert.equal(formatActiveStreamRecoveryElapsed(null), null);
|
||||
assert.equal(formatActiveStreamRecoveryElapsed(-1), null);
|
||||
assert.equal(formatActiveStreamRecoveryElapsed(999), "0 с");
|
||||
assert.equal(formatActiveStreamRecoveryElapsed(59_999), "59 с");
|
||||
assert.equal(formatActiveStreamRecoveryElapsed(60_000), "1 мин");
|
||||
assert.equal(formatActiveStreamRecoveryElapsed(125_900), "2 мин 5 с");
|
||||
});
|
||||
|
||||
test("force-finish manifest/API sends the exact fenced local-only request", async () => {
|
||||
assert.equal(
|
||||
xgridsK1Actions.acquisitionForceFinishLocal,
|
||||
"acquisition.force-finish-local",
|
||||
);
|
||||
const request = {
|
||||
expected_snapshot_runtime_id: "snapshot-runtime-recovery-001",
|
||||
acquisition_id: "acquisition-recovery-001",
|
||||
expected_state_revision: 9,
|
||||
expected_recovery_generation: 7,
|
||||
operator_confirmed: true,
|
||||
operation_id: "op-00000000-0000-4000-8000-000000000321",
|
||||
idempotency_key:
|
||||
"acquisition.force-finish-local:op-00000000-0000-4000-8000-000000000321",
|
||||
deadline_seconds: 30,
|
||||
};
|
||||
let capturedUrl = null;
|
||||
let capturedInit = null;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
capturedUrl = String(input);
|
||||
capturedInit = init;
|
||||
return new Response(JSON.stringify({ state: recoveryState() }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
await xgridsK1Api.forceFinishAcquisitionLocally(request);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
assert.match(
|
||||
capturedUrl,
|
||||
/\/actions\/acquisition\.force-finish-local$/,
|
||||
);
|
||||
assert.equal(capturedInit.method, "POST");
|
||||
assert.deepEqual(JSON.parse(capturedInit.body), { input: request });
|
||||
});
|
||||
|
||||
test("state API rejects a drifted active recovery contract", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
state: recoveryState({ stop_performed: true }),
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => xgridsK1Api.getState(),
|
||||
/некорректное состояние/,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("frontend boundary keeps recovery automatic work read-only and local finish explicit", () => {
|
||||
const hookSource = readFileSync(hookSourceUrl, "utf8");
|
||||
const forceFinish = sourceSlice(
|
||||
hookSource,
|
||||
"const forceFinishActiveStreamLocally",
|
||||
"const abort",
|
||||
);
|
||||
assert.match(forceFinish, /run\("force-finish"/);
|
||||
assert.match(forceFinish, /activeStreamForceFinishAuthority\(latestState\.current\)/);
|
||||
assert.match(forceFinish, /expected_snapshot_runtime_id:\s*authority\.snapshotRuntimeId/);
|
||||
assert.match(forceFinish, /acquisition_id:\s*authority\.acquisitionId/);
|
||||
assert.match(forceFinish, /expected_state_revision:\s*authority\.acquisitionStateRevision/);
|
||||
assert.match(forceFinish, /expected_recovery_generation:\s*authority\.recoveryGeneration/);
|
||||
assert.match(forceFinish, /operator_confirmed:\s*true/);
|
||||
assert.match(
|
||||
forceFinish,
|
||||
/newMutationContext\("acquisition\.force-finish-local"\)/,
|
||||
);
|
||||
assert.equal(
|
||||
(forceFinish.match(/forceFinishAcquisitionLocally\(/g) ?? []).length,
|
||||
1,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
forceFinish,
|
||||
/startAcquisition|stopAcquisition|scanBle|selectCameraPreview|connect\(/,
|
||||
);
|
||||
|
||||
const acquisitionSource = readFileSync(acquisitionSourceUrl, "utf8");
|
||||
assert.equal(
|
||||
(acquisitionSource.match(/forceFinishActiveStreamLocally\(\)/g) ?? []).length,
|
||||
1,
|
||||
"the explicit recovery button is the only frontend caller",
|
||||
);
|
||||
const recoverySurface = readFileSync(recoverySurfaceSourceUrl, "utf8");
|
||||
assert.match(recoverySurface, /<ActivityIndicator/);
|
||||
assert.match(recoverySurface, /<Button[\s\S]*?variant="secondary"/);
|
||||
assert.doesNotMatch(recoverySurface, /<button\b|style=\{/);
|
||||
|
||||
const spatialSource = readFileSync(spatialControlsSourceUrl, "utf8");
|
||||
assert.match(spatialSource, /variant="compact"/);
|
||||
assert.match(
|
||||
spatialSource,
|
||||
/runSpatialActiveStreamForceFinish\(\{[\s\S]*?forceFinishActiveStreamLocally/,
|
||||
);
|
||||
const spatialForceFinish = sourceSlice(
|
||||
spatialSource,
|
||||
"export function runSpatialActiveStreamForceFinish",
|
||||
"export function K1SpatialControlsView",
|
||||
);
|
||||
assert.match(spatialForceFinish, /activeStreamForceFinishAuthority\(controller\.state\)/);
|
||||
assert.equal(
|
||||
(spatialForceFinish.match(/controller\.forceFinishActiveStreamLocally\(\)/g) ?? []).length,
|
||||
1,
|
||||
);
|
||||
assert.doesNotMatch(spatialForceFinish, /\bstop\(|stopLocalReceiver|start|connect|scanBle/);
|
||||
|
||||
const connectionSource = readFileSync(connectionSourceUrl, "utf8");
|
||||
assert.match(
|
||||
connectionSource,
|
||||
/shouldRenderK1GenericRuntimeError\([\s\S]*?errorCorrelation\?\.action/,
|
||||
);
|
||||
assert.match(
|
||||
connectionSource,
|
||||
/\{showGenericRuntimeError && error \? \([\s\S]*?<K1OperatorError/,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,14 @@ import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let advanceLiveReceiverOpenWatchdog;
|
||||
let advanceLiveReceiverWatchdog;
|
||||
let initialLiveReceiverOpenWatchdogState;
|
||||
let initialLiveReceiverWatchdogState;
|
||||
let initialLiveReceiverRecoveryState;
|
||||
let liveReceiverRecoveryAuthorityIsCurrent;
|
||||
let liveReceiverRecoveryRetryDelay;
|
||||
let liveRerunRecoveryAuthorityIdentity;
|
||||
let requestLiveReceiverRecovery;
|
||||
|
||||
before(async () => {
|
||||
@@ -16,9 +21,14 @@ before(async () => {
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
advanceLiveReceiverOpenWatchdog,
|
||||
advanceLiveReceiverWatchdog,
|
||||
initialLiveReceiverOpenWatchdogState,
|
||||
initialLiveReceiverRecoveryState,
|
||||
initialLiveReceiverWatchdogState,
|
||||
liveReceiverRecoveryAuthorityIsCurrent,
|
||||
liveReceiverRecoveryRetryDelay,
|
||||
liveRerunRecoveryAuthorityIdentity,
|
||||
requestLiveReceiverRecovery,
|
||||
} = await server.ssrLoadModule("/src/core/observation/liveReceiverWatchdog.ts"));
|
||||
});
|
||||
@@ -92,3 +102,217 @@ test("startup failures request only three bounded viewer restarts", () => {
|
||||
assert.equal(exhausted.attempt, 3);
|
||||
assert.equal(exhausted.state.awaitingRecovery, false);
|
||||
});
|
||||
|
||||
function livePointCloudDescriptor(overrides = {}) {
|
||||
return {
|
||||
id: "xgrids-k1:lixelkity-k1:sensor.lidar.primary",
|
||||
sourceId: "sensor.lidar.primary",
|
||||
semanticChannelId: "spatial.point-cloud.live",
|
||||
label: "K1 point cloud",
|
||||
description: "live",
|
||||
modality: "point-cloud",
|
||||
role: "primary",
|
||||
availability: "streaming",
|
||||
transport: "rerun-grpc",
|
||||
endpointLabel: "Rerun gRPC",
|
||||
previewUrl: "rerun+http://127.0.0.1:9877/proxy",
|
||||
delivery: null,
|
||||
activation: null,
|
||||
presentationLease: null,
|
||||
provider: {
|
||||
pluginId: "xgrids-k1",
|
||||
pluginVersion: "0.1.0",
|
||||
modelId: "lixelkity-k1",
|
||||
compatibilityProfileId: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
|
||||
},
|
||||
binding: {
|
||||
deviceId: "device-k1-001",
|
||||
deviceSessionId: "device-session-001",
|
||||
acquisitionId: "acquisition-001",
|
||||
},
|
||||
capabilities: {
|
||||
overlay: false,
|
||||
fullscreen: true,
|
||||
resizable: false,
|
||||
defaultVisible: true,
|
||||
timelineMode: "live-only",
|
||||
seekable: false,
|
||||
sessionRecording: false,
|
||||
clockId: "acquisition-001",
|
||||
spatialRegistration: "native",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const liveSpatialSource = {
|
||||
id: "acquisition-001",
|
||||
url: "rerun+http://127.0.0.1:9877/proxy",
|
||||
label: "Live",
|
||||
kind: "rerun-grpc",
|
||||
};
|
||||
|
||||
test("exact live Rerun authority gets durable retries with capped delay", () => {
|
||||
const authority = liveRerunRecoveryAuthorityIdentity(
|
||||
livePointCloudDescriptor(),
|
||||
liveSpatialSource,
|
||||
);
|
||||
assert.ok(authority);
|
||||
assert.equal(liveReceiverRecoveryAuthorityIsCurrent(authority, authority), true);
|
||||
|
||||
let state = initialLiveReceiverRecoveryState();
|
||||
const delays = [];
|
||||
for (let attempt = 1; attempt <= 8; attempt += 1) {
|
||||
const recovery = requestLiveReceiverRecovery(state, {
|
||||
activeAuthorityIdentity: authority,
|
||||
expectedAuthorityIdentity: authority,
|
||||
});
|
||||
assert.equal(recovery.signal, "retry");
|
||||
assert.equal(recovery.attempt, attempt);
|
||||
delays.push(recovery.delayMs);
|
||||
state = recovery.state;
|
||||
}
|
||||
assert.deepEqual(delays, [400, 1_000, 2_000, 5_000, 5_000, 5_000, 5_000, 5_000]);
|
||||
assert.equal(state.attempts, 8);
|
||||
assert.equal(liveReceiverRecoveryRetryDelay(100), 5_000);
|
||||
});
|
||||
|
||||
test("Rerun durable retry fails closed when exact authority is replaced", () => {
|
||||
const authority = liveRerunRecoveryAuthorityIdentity(
|
||||
livePointCloudDescriptor(),
|
||||
liveSpatialSource,
|
||||
);
|
||||
assert.ok(authority);
|
||||
const replacement = liveRerunRecoveryAuthorityIdentity(
|
||||
livePointCloudDescriptor({
|
||||
binding: {
|
||||
deviceId: "device-k1-001",
|
||||
deviceSessionId: "device-session-002",
|
||||
acquisitionId: "acquisition-002",
|
||||
},
|
||||
capabilities: {
|
||||
...livePointCloudDescriptor().capabilities,
|
||||
clockId: "acquisition-002",
|
||||
},
|
||||
}),
|
||||
{ ...liveSpatialSource, id: "acquisition-002" },
|
||||
);
|
||||
assert.ok(replacement);
|
||||
|
||||
const stale = requestLiveReceiverRecovery(initialLiveReceiverRecoveryState(), {
|
||||
activeAuthorityIdentity: replacement,
|
||||
expectedAuthorityIdentity: authority,
|
||||
});
|
||||
assert.equal(stale.signal, "stale");
|
||||
assert.equal(stale.delayMs, null);
|
||||
assert.deepEqual(stale.state, initialLiveReceiverRecoveryState());
|
||||
});
|
||||
|
||||
test("connecting Rerun authority requires an exact recovery generation lease", () => {
|
||||
const recoveryLease = {
|
||||
kind: "active-stream-recovery",
|
||||
runtimeId: "runtime-recovery-001",
|
||||
acquisitionId: "acquisition-001",
|
||||
acquisitionStateRevision: 4,
|
||||
producerGeneration: 17,
|
||||
recoveryGeneration: 6,
|
||||
};
|
||||
const recoveredAuthority = liveRerunRecoveryAuthorityIdentity(
|
||||
livePointCloudDescriptor({
|
||||
availability: "connecting",
|
||||
presentationLease: recoveryLease,
|
||||
}),
|
||||
liveSpatialSource,
|
||||
);
|
||||
assert.ok(recoveredAuthority);
|
||||
assert.equal(
|
||||
liveRerunRecoveryAuthorityIdentity(
|
||||
livePointCloudDescriptor({ availability: "connecting" }),
|
||||
liveSpatialSource,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
liveRerunRecoveryAuthorityIdentity(
|
||||
livePointCloudDescriptor({
|
||||
availability: "connecting",
|
||||
presentationLease: { ...recoveryLease, producerGeneration: 0 },
|
||||
}),
|
||||
liveSpatialSource,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("opening receiver gets bounded rolling patience while backend publication advances", () => {
|
||||
let openState = initialLiveReceiverOpenWatchdogState(0, 0);
|
||||
let recoveryState = initialLiveReceiverRecoveryState();
|
||||
|
||||
const samples = [
|
||||
[134, 3_999, "wait-for-store"],
|
||||
[266, 4_000, "refresh-receiver"],
|
||||
];
|
||||
for (const [backendActivitySequence, nowMs, expectedSignal] of samples) {
|
||||
const observed = advanceLiveReceiverOpenWatchdog(
|
||||
openState,
|
||||
recoveryState,
|
||||
backendActivitySequence,
|
||||
nowMs,
|
||||
);
|
||||
assert.equal(observed.signal, expectedSignal);
|
||||
assert.equal(observed.state.lastBackendActivitySequence, backendActivitySequence);
|
||||
assert.deepEqual(observed.recoveryState, {
|
||||
attempts: 0,
|
||||
awaitingRecovery: false,
|
||||
});
|
||||
openState = observed.state;
|
||||
recoveryState = observed.recoveryState;
|
||||
}
|
||||
});
|
||||
|
||||
test("unchanged opening sequence delegates to bounded receiver restart", () => {
|
||||
const openState = initialLiveReceiverOpenWatchdogState(486);
|
||||
const recoveryState = initialLiveReceiverRecoveryState();
|
||||
const unchanged = advanceLiveReceiverOpenWatchdog(
|
||||
openState,
|
||||
recoveryState,
|
||||
486,
|
||||
);
|
||||
|
||||
assert.equal(unchanged.signal, "restart-receiver");
|
||||
const restart = requestLiveReceiverRecovery(unchanged.recoveryState);
|
||||
assert.equal(restart.signal, "retry");
|
||||
assert.equal(restart.attempt, 1);
|
||||
});
|
||||
|
||||
test("fresh backend progress preserves earlier restart debt until viewer admission", () => {
|
||||
const openState = initialLiveReceiverOpenWatchdogState(486, 0);
|
||||
const consumedRestart = requestLiveReceiverRecovery(initialLiveReceiverRecoveryState());
|
||||
const observed = advanceLiveReceiverOpenWatchdog(
|
||||
openState,
|
||||
consumedRestart.state,
|
||||
600,
|
||||
3_999,
|
||||
);
|
||||
|
||||
assert.equal(observed.signal, "wait-for-store");
|
||||
assert.deepEqual(observed.recoveryState, {
|
||||
attempts: 1,
|
||||
awaitingRecovery: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("aged active receiver refresh does not spend or erase restart debt", () => {
|
||||
const openState = initialLiveReceiverOpenWatchdogState(486, 0);
|
||||
const consumedRestart = requestLiveReceiverRecovery(initialLiveReceiverRecoveryState());
|
||||
const observed = advanceLiveReceiverOpenWatchdog(
|
||||
openState,
|
||||
consumedRestart.state,
|
||||
900,
|
||||
4_000,
|
||||
);
|
||||
|
||||
assert.equal(observed.signal, "refresh-receiver");
|
||||
assert.deepEqual(observed.recoveryState, consumedRestart.state);
|
||||
assert.equal(observed.openForMs, 4_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let createLiveViewerDiagnosticLifecycle;
|
||||
let createAbortFencedBuildVerifier;
|
||||
let createUiBuildStaleCoordinator;
|
||||
let liveViewerDiagnosticBody;
|
||||
let server;
|
||||
let uiBuildIdFromModuleScripts;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
createAbortFencedBuildVerifier,
|
||||
createLiveViewerDiagnosticLifecycle,
|
||||
createUiBuildStaleCoordinator,
|
||||
liveViewerDiagnosticBody,
|
||||
uiBuildIdFromModuleScripts,
|
||||
} = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function createFakeScheduler() {
|
||||
let now = 0;
|
||||
let nextHandle = 1;
|
||||
const jobs = new Map();
|
||||
const schedule = (callback, delay, interval) => {
|
||||
const handle = nextHandle;
|
||||
nextHandle += 1;
|
||||
jobs.set(handle, { callback, due: now + delay, interval });
|
||||
return handle;
|
||||
};
|
||||
const clear = (handle) => jobs.delete(handle);
|
||||
return {
|
||||
scheduler: {
|
||||
setTimeout: (callback, delay) => schedule(callback, delay, null),
|
||||
clearTimeout: clear,
|
||||
setInterval: (callback, delay) => schedule(callback, delay, delay),
|
||||
clearInterval: clear,
|
||||
},
|
||||
advance(milliseconds) {
|
||||
const target = now + milliseconds;
|
||||
while (true) {
|
||||
const next = [...jobs.entries()]
|
||||
.filter(([, job]) => job.due <= target)
|
||||
.sort((left, right) => left[1].due - right[1].due)[0];
|
||||
if (!next) break;
|
||||
const [handle, job] = next;
|
||||
now = job.due;
|
||||
if (job.interval === null) jobs.delete(handle);
|
||||
else job.due += job.interval;
|
||||
job.callback();
|
||||
}
|
||||
now = target;
|
||||
},
|
||||
pending: () => jobs.size,
|
||||
};
|
||||
}
|
||||
|
||||
const lineage = (viewerInstanceId, lifecycleGeneration = 1) => ({
|
||||
uiBuildId: "/assets/index-abcdefgh.js",
|
||||
documentInstanceId: "00000000-0000-4000-8000-000000000001",
|
||||
viewerInstanceId,
|
||||
lifecycleGeneration,
|
||||
});
|
||||
|
||||
test("mounted viewer admission terminally fences 60 seconds of stale timers", () => {
|
||||
const clock = createFakeScheduler();
|
||||
const callbacks = [];
|
||||
const posts = [];
|
||||
const lifecycle = createLiveViewerDiagnosticLifecycle({
|
||||
lineage: lineage("00000000-0000-4000-8000-000000000011"),
|
||||
scheduler: clock.scheduler,
|
||||
diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }),
|
||||
buildVerifier: () => undefined,
|
||||
});
|
||||
|
||||
lifecycle.armAdmissionTimeout(() => callbacks.push("timeout"), 12_000);
|
||||
lifecycle.armAdmissionInterval(() => callbacks.push("discovery"), 100);
|
||||
lifecycle.markAdmitted();
|
||||
lifecycle.post({ eventCode: "live_receiver_active_store_admitted" });
|
||||
clock.advance(60_000);
|
||||
|
||||
assert.deepEqual(callbacks, []);
|
||||
assert.equal(clock.pending(), 0);
|
||||
assert.equal(posts.length, 1);
|
||||
assert.equal(posts[0].eventLineage.lifecycleGeneration, 1);
|
||||
});
|
||||
|
||||
test("two mounted viewers keep timer and diagnostic lineage isolated", () => {
|
||||
const clock = createFakeScheduler();
|
||||
const posts = [];
|
||||
const first = createLiveViewerDiagnosticLifecycle({
|
||||
lineage: lineage("00000000-0000-4000-8000-000000000021"),
|
||||
scheduler: clock.scheduler,
|
||||
diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }),
|
||||
buildVerifier: () => undefined,
|
||||
});
|
||||
const second = createLiveViewerDiagnosticLifecycle({
|
||||
lineage: lineage("00000000-0000-4000-8000-000000000022", 7),
|
||||
scheduler: clock.scheduler,
|
||||
diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }),
|
||||
buildVerifier: () => undefined,
|
||||
});
|
||||
|
||||
first.armAdmissionTimeout(() => {
|
||||
first.post({ eventCode: "live_receiver_error" });
|
||||
}, 12_000);
|
||||
second.armAdmissionTimeout(() => {
|
||||
second.post({ eventCode: "live_receiver_error" });
|
||||
}, 12_000);
|
||||
first.markAdmitted();
|
||||
clock.advance(12_000);
|
||||
|
||||
assert.equal(posts.length, 1);
|
||||
assert.equal(
|
||||
posts[0].eventLineage.viewerInstanceId,
|
||||
"00000000-0000-4000-8000-000000000022",
|
||||
);
|
||||
assert.equal(posts[0].eventLineage.lifecycleGeneration, 7);
|
||||
});
|
||||
|
||||
test("stale-build and unmount fence callbacks before one reload", () => {
|
||||
const clock = createFakeScheduler();
|
||||
const order = [];
|
||||
const posts = [];
|
||||
const lifecycle = createLiveViewerDiagnosticLifecycle({
|
||||
lineage: lineage("00000000-0000-4000-8000-000000000031"),
|
||||
scheduler: clock.scheduler,
|
||||
diagnosticPoster: (event) => posts.push(event),
|
||||
buildVerifier: () => undefined,
|
||||
});
|
||||
lifecycle.armAdmissionTimeout(() => {
|
||||
lifecycle.post({ eventCode: "live_receiver_error" });
|
||||
}, 12_000);
|
||||
const coordinator = createUiBuildStaleCoordinator({
|
||||
scheduleReload: (callback, delay) => {
|
||||
order.push(`scheduled:${delay}`);
|
||||
clock.scheduler.setTimeout(callback, delay);
|
||||
},
|
||||
reload: () => order.push("reload"),
|
||||
});
|
||||
coordinator.subscribe(() => {
|
||||
order.push("local-transports-closed");
|
||||
lifecycle.dispose();
|
||||
});
|
||||
|
||||
coordinator.report({
|
||||
loadedUiBuildId: "/assets/index-abcdefgh.js",
|
||||
expectedUiBuildId: "/assets/index-ijklmnop.js",
|
||||
});
|
||||
coordinator.report({
|
||||
loadedUiBuildId: "/assets/index-abcdefgh.js",
|
||||
expectedUiBuildId: "/assets/index-qrstuvwx.js",
|
||||
});
|
||||
clock.advance(60_000);
|
||||
lifecycle.post({ eventCode: "live_receiver_error" });
|
||||
|
||||
assert.deepEqual(order, ["local-transports-closed", "scheduled:50", "reload"]);
|
||||
assert.deepEqual(posts, []);
|
||||
assert.equal(lifecycle.active(), false);
|
||||
});
|
||||
|
||||
test("last unsubscribe fences an already queued build verification callback", () => {
|
||||
const controller = new AbortController();
|
||||
const observedSignals = [];
|
||||
const queuedVerify = createAbortFencedBuildVerifier(
|
||||
controller.signal,
|
||||
(signal) => observedSignals.push(signal),
|
||||
);
|
||||
|
||||
queuedVerify();
|
||||
// stopBuildMonitor aborts the locally captured controller when the last
|
||||
// mounted viewer unsubscribes. A browser callback already queued before the
|
||||
// interval/listener removal can still run once, but cannot start a fetch.
|
||||
controller.abort();
|
||||
queuedVerify();
|
||||
|
||||
assert.deepEqual(observedSignals, [controller.signal]);
|
||||
assert.equal(observedSignals[0].aborted, true);
|
||||
});
|
||||
|
||||
test("diagnostic body and build id retain exact document/viewer/build lineage", () => {
|
||||
const eventLineage = lineage("00000000-0000-4000-8000-000000000041", 9);
|
||||
assert.deepEqual(
|
||||
liveViewerDiagnosticBody(
|
||||
{ eventCode: "live_receiver_recovered", streamId: "acquisition-42" },
|
||||
eventLineage,
|
||||
),
|
||||
{
|
||||
schema_version: "missioncore.live-viewer-diagnostic/v2",
|
||||
event_code: "live_receiver_recovered",
|
||||
ui_build_id: "/assets/index-abcdefgh.js",
|
||||
document_instance_id: "00000000-0000-4000-8000-000000000001",
|
||||
viewer_instance_id: "00000000-0000-4000-8000-000000000041",
|
||||
lifecycle_generation: 9,
|
||||
stream_id: "acquisition-42",
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
uiBuildIdFromModuleScripts(
|
||||
["https://mission.local/assets/index-dT7dN-y4.js"],
|
||||
"https://mission.local/park",
|
||||
),
|
||||
"/assets/index-dT7dN-y4.js",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchM4ThreatReplayResult;
|
||||
let fetchM4ThreatVisual;
|
||||
let fetchM4ThreatTimeline;
|
||||
let fetchM4ThreatTimelineChunk;
|
||||
let selectM4ThreatTimelineFrame;
|
||||
let selectM4ThreatTimelineSequence;
|
||||
let advanceRecordedEvidencePlayback;
|
||||
let m4ThreatChunkWindowStarts;
|
||||
let buildM4LocalSurface;
|
||||
|
||||
const resultId = `m4-threat-replay-${"a".repeat(64)}`;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
fetchM4ThreatReplayResult,
|
||||
fetchM4ThreatVisual,
|
||||
fetchM4ThreatTimeline,
|
||||
fetchM4ThreatTimelineChunk,
|
||||
selectM4ThreatTimelineFrame,
|
||||
selectM4ThreatTimelineSequence,
|
||||
} = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts"));
|
||||
({ advanceRecordedEvidencePlayback } = await server.ssrLoadModule(
|
||||
"/src/components/laboratory/useRecordedEvidencePlayback.ts",
|
||||
));
|
||||
({ m4ThreatChunkWindowStarts } = await server.ssrLoadModule(
|
||||
"/src/workspaces/laboratory/useM4ThreatTimeline.ts",
|
||||
));
|
||||
({ buildM4LocalSurface } = await server.ssrLoadModule(
|
||||
"/src/core/laboratory/m4LocalSurface.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function proposal(overrides = {}) {
|
||||
return {
|
||||
proposal_id: "proposal-1",
|
||||
bbox_xyxy: [100, 120, 240, 360],
|
||||
objectness: 0.91,
|
||||
semantic_hint: "person",
|
||||
occupied_support: false,
|
||||
range_m: null,
|
||||
threat_decision: "unknown",
|
||||
threat_reason_codes: ["camera-only-no-metric-geometry"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function timelineFrame(sequence, sessionSeconds, overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.recorded-spatial-evidence-frame/v1",
|
||||
sequence,
|
||||
frame_id: `frame-${String(sequence).padStart(6, "0")}`,
|
||||
source_time_ns: Math.round(sessionSeconds * 1_000_000_000),
|
||||
session_seconds: sessionSeconds,
|
||||
source_available: true,
|
||||
spatial_available: true,
|
||||
body_frame: {
|
||||
origin_map_xyz_m: [sequence, 0, 0],
|
||||
basis_map_from_body: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
|
||||
},
|
||||
point_cloud_body_xyz_m: [[1, 0, 0.1]],
|
||||
point_cloud_source_count: 1,
|
||||
point_cloud_sample_count: 1,
|
||||
point_cloud_layer: "current-increment",
|
||||
rolling_map_component_count: 0,
|
||||
metric_obstacles: [],
|
||||
camera_proposals: [],
|
||||
decision_counts: { threat: 0, "not-threat": 0, unknown: 0 },
|
||||
camera_url: `/api/v1/laboratory/m4-threat/results/${resultId}/timeline/frames/${sequence}/camera`,
|
||||
authority: "replay-simulated",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("M4.6 decodes accepted dual-evidence result without physical authority", async () => {
|
||||
const result = await fetchM4ThreatReplayResult({
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.m4-threat-replay-catalog/v1",
|
||||
items: [{
|
||||
schema_version: "missioncore.m4-threat-replay-view/v1",
|
||||
result_id: resultId,
|
||||
created_at_utc: "2026-08-05T15:36:01.553Z",
|
||||
status: "accepted",
|
||||
profile_id: "m4-ravnoves00-virtual-corridor/v1",
|
||||
rig_profile_id: "virtual-base-footprint-1000x600/v2",
|
||||
corridor_profile_id: "ravnoves00-forward-corridor-8m/v2",
|
||||
source_result_ids: {
|
||||
detector: `m4-detector-replay-${"b".repeat(64)}`,
|
||||
geometry: `m4-geometry-replay-${"c".repeat(64)}`,
|
||||
temporal: `m4-temporal-replay-${"d".repeat(64)}`,
|
||||
},
|
||||
metrics: {
|
||||
decisions: { threat: 8010, "not-threat": 6610, unknown: 60832 },
|
||||
evidence: { "camera-only": 10158, "current-metric": 28081, "rolling-map-retained": 70989, "stale-or-held": 38025 },
|
||||
fixtures: { critical: 4, critical_false_not_threat: 0, passed: 9, total: 9 },
|
||||
runtime: {
|
||||
frames_per_second: 116.4,
|
||||
provider_latency_p50_ms: 4.3,
|
||||
provider_latency_p95_ms: 19.8,
|
||||
provider_latency_max_ms: 194.3,
|
||||
},
|
||||
body_frame: {
|
||||
available: 3928,
|
||||
qualified: 3861,
|
||||
rejected: 67,
|
||||
camera_forward_alignment_deg: { p95: 8.439, maximum: 24.252 },
|
||||
},
|
||||
reason_counts: { "geometry-only-evidence": 21958 },
|
||||
},
|
||||
configuration: {
|
||||
virtual_body_m: [1, 0.6],
|
||||
nominal_sensor_height_m: 1.25,
|
||||
forward_corridor_m: 8,
|
||||
prediction_horizon_seconds: 5,
|
||||
body_frame: {
|
||||
origin: "local-surface-vertical-projection",
|
||||
up: "vendor-slam-map-gravity-axis",
|
||||
forward: "smoothed-slam-trajectory-validated-by-camera-axis",
|
||||
},
|
||||
},
|
||||
limitations: ["replay only"],
|
||||
accepted: true,
|
||||
authority: "replay-simulated",
|
||||
physical_collision_accepted: false,
|
||||
actuation_allowed: false,
|
||||
}],
|
||||
}), { status: 200 }),
|
||||
});
|
||||
assert.equal(result.resultId, resultId);
|
||||
assert.equal(result.metrics.evidence.currentMetric, 28081);
|
||||
assert.equal(result.metrics.fixtures.criticalFalseNotThreat, 0);
|
||||
assert.equal(result.metrics.bodyFrame.qualified, 3861);
|
||||
assert.equal(result.metrics.bodyFrame.cameraForwardAlignmentDeg.p95, 8.439);
|
||||
assert.deepEqual(result.configuration.virtualBodyM, [1, 0.6]);
|
||||
assert.equal(result.configuration.bodyFrame.up, "vendor-slam-map-gravity-axis");
|
||||
});
|
||||
|
||||
test("M4.6 binds exact CAMERA and metric 3D evidence to one replay frame", async () => {
|
||||
const frame = await fetchM4ThreatVisual(resultId, 1, {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.perception-threat-visual-frame/v1",
|
||||
result_id: resultId,
|
||||
camera_url: `/api/v1/laboratory/m4-threat/results/${resultId}/visuals/1/camera`,
|
||||
ordinal: 1,
|
||||
sequence: 20,
|
||||
frame_id: "frame-000020",
|
||||
source_time_ns: 37421857292,
|
||||
point_cloud_body_xyz_m: [[1, 0, 0.1], [2, 0.2, 0.3]],
|
||||
point_cloud_source_count: 12000,
|
||||
point_cloud_sample_count: 2,
|
||||
metric_obstacles: [{
|
||||
component_id: "temporal-20-1",
|
||||
state: "current",
|
||||
motion: "moving",
|
||||
centroid_body_xyz_m: [2, 0.1, 0.4],
|
||||
cell_centers_body_xyz_m: [[2, 0.1, 0.4]],
|
||||
assessment: {
|
||||
component_id: "temporal-20-1",
|
||||
decision: "threat",
|
||||
corridor_intersection: "intersects",
|
||||
relative_speed_mps: 1.2,
|
||||
closest_approach_m: 0.4,
|
||||
ttc_seconds: 1.6,
|
||||
reason_codes: ["geometry-only-evidence"],
|
||||
},
|
||||
}],
|
||||
camera_proposals: [proposal()],
|
||||
rig: { length_m: 1, width_m: 0.6, nominal_sensor_height_m: 1.25 },
|
||||
corridor: {
|
||||
forward_length_m: 8,
|
||||
rear_margin_m: 0.5,
|
||||
half_width_m: 0.5,
|
||||
prediction_horizon_seconds: 5,
|
||||
},
|
||||
}), { status: 200 }),
|
||||
});
|
||||
assert.equal(frame.sequence, 20);
|
||||
assert.match(frame.cameraUrl, /\/visuals\/1\/camera$/);
|
||||
assert.equal(frame.metricObstacles[0].assessment.decision, "threat");
|
||||
assert.equal(frame.cameraProposals[0].threatDecision, "unknown");
|
||||
assert.equal(frame.pointCloudSampleCount, 2);
|
||||
});
|
||||
|
||||
test("M4.6 v2 keeps CURRENT INCREMENT separate from ROLLING MAP", async () => {
|
||||
const frame = await fetchM4ThreatVisual(resultId, 14, {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.perception-threat-visual-frame/v2",
|
||||
result_id: resultId,
|
||||
camera_url: `/api/v1/laboratory/m4-threat/results/${resultId}/visuals/14/camera`,
|
||||
ordinal: 14,
|
||||
sequence: 1880,
|
||||
frame_id: "frame-001880",
|
||||
source_time_ns: 223304857292,
|
||||
point_cloud_body_xyz_m: [[1, 0, 0.1]],
|
||||
point_cloud_source_count: 1652,
|
||||
point_cloud_sample_count: 1652,
|
||||
point_cloud_layer: "current-increment",
|
||||
rolling_map_component_count: 10,
|
||||
metric_obstacles: [{
|
||||
component_id: "rolling-sphere-near",
|
||||
state: "retained",
|
||||
motion: "unknown",
|
||||
centroid_body_xyz_m: [0.76, -0.27, 0.38],
|
||||
cell_centers_body_xyz_m: [[0.52, -0.3, 0.08]],
|
||||
assessment: {
|
||||
component_id: "rolling-sphere-near",
|
||||
decision: "threat",
|
||||
corridor_intersection: "intersects",
|
||||
relative_speed_mps: null,
|
||||
closest_approach_m: 0.1,
|
||||
ttc_seconds: null,
|
||||
reason_codes: ["retained-corridor-intersection"],
|
||||
},
|
||||
}],
|
||||
camera_proposals: [],
|
||||
rig: { length_m: 1, width_m: 0.6, nominal_sensor_height_m: 1.25 },
|
||||
corridor: {
|
||||
forward_length_m: 8,
|
||||
rear_margin_m: 0.5,
|
||||
half_width_m: 0.5,
|
||||
prediction_horizon_seconds: 5,
|
||||
},
|
||||
}), { status: 200 }),
|
||||
});
|
||||
assert.equal(frame.pointCloudLayer, "current-increment");
|
||||
assert.equal(frame.rollingMapComponentCount, 10);
|
||||
assert.equal(frame.metricObstacles[0].state, "retained");
|
||||
assert.equal(frame.metricObstacles[0].assessment.decision, "threat");
|
||||
});
|
||||
|
||||
test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunks", async () => {
|
||||
const frameTimesNs = Array.from(
|
||||
{ length: 4489 },
|
||||
(_, index) => 35_421_857_292 + index * 100_000_000,
|
||||
);
|
||||
const timeline = await fetchM4ThreatTimeline(resultId, {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.recorded-spatial-evidence-timeline/v1",
|
||||
result_id: resultId,
|
||||
recorded_source: {
|
||||
session_id: "20260720T065719Z_viewer_live",
|
||||
source_id: "RAVNOVES00",
|
||||
representation_id: "registered-map-increment-v1",
|
||||
synchronization: "host-arrival-best-effort",
|
||||
},
|
||||
image_width: 800,
|
||||
image_height: 600,
|
||||
frame_count: 4489,
|
||||
frame_times_ns: frameTimesNs,
|
||||
timeline_start_seconds: 35.421857292,
|
||||
timeline_end_seconds: 484.221857292,
|
||||
nominal_frame_interval_seconds: 0.1,
|
||||
nominal_rate_hz: 10,
|
||||
max_chunk_frames: 24,
|
||||
point_sample_limit: 4096,
|
||||
maximum_source_points_per_frame: 3092,
|
||||
point_delivery: "exact-current-increment",
|
||||
local_surface_visualization: {
|
||||
derivation: "bounded-registered-increment-accumulation",
|
||||
window_seconds: 2,
|
||||
voxel_size_m: 0.1,
|
||||
radius_m: 12,
|
||||
point_limit: 20000,
|
||||
authority: "visual-derived",
|
||||
},
|
||||
rig: { length_m: 1, width_m: 0.6, nominal_sensor_height_m: 1.25 },
|
||||
corridor: {
|
||||
forward_length_m: 8,
|
||||
rear_margin_m: 0.5,
|
||||
half_width_m: 0.5,
|
||||
occupied_voxel_size_m: 0.45,
|
||||
prediction_horizon_seconds: 5,
|
||||
},
|
||||
authority: "replay-simulated",
|
||||
}), { status: 200 }),
|
||||
});
|
||||
assert.equal(timeline.frameTimesNs.length, 4489);
|
||||
assert.equal(timeline.pointDelivery, "exact-current-increment");
|
||||
assert.equal(timeline.maximumSourcePointsPerFrame, 3092);
|
||||
assert.equal(timeline.occupiedVoxelSizeM, 0.45);
|
||||
assert.equal(timeline.localSurfaceVisualization.windowSeconds, 2);
|
||||
assert.equal(timeline.localSurfaceVisualization.authority, "visual-derived");
|
||||
assert.equal(selectM4ThreatTimelineSequence(timeline.frameTimesNs, 35.50), 1);
|
||||
|
||||
const chunk = await fetchM4ThreatTimelineChunk(resultId, 0, 2, {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.recorded-spatial-evidence-chunk/v1",
|
||||
result_id: resultId,
|
||||
start_sequence: 0,
|
||||
frame_count: 2,
|
||||
next_sequence: 2,
|
||||
frames: [
|
||||
timelineFrame(0, 35.421857292),
|
||||
timelineFrame(1, 35.521857292, {
|
||||
camera_proposals: [proposal()],
|
||||
decision_counts: { threat: 0, "not-threat": 0, unknown: 1 },
|
||||
}),
|
||||
],
|
||||
authority: "replay-simulated",
|
||||
}), { status: 200 }),
|
||||
});
|
||||
assert.equal(chunk.frames[1].cameraProposals[0].rangeM, null);
|
||||
assert.equal(selectM4ThreatTimelineFrame(chunk.frames, 35.50).sequence, 1);
|
||||
});
|
||||
|
||||
test("M4.6 local SLAM surface reprojects registered increments into the active body frame", () => {
|
||||
const frames = [
|
||||
timelineFrame(0, 10, {
|
||||
body_frame: {
|
||||
origin_map_xyz_m: [0, 0, 0],
|
||||
basis_map_from_body: [[0, -1, 0], [1, 0, 0], [0, 0, 1]],
|
||||
},
|
||||
point_cloud_body_xyz_m: [[1, 0, 0]],
|
||||
}),
|
||||
timelineFrame(1, 10.1, {
|
||||
body_frame: {
|
||||
origin_map_xyz_m: [0, 0, 0],
|
||||
basis_map_from_body: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
|
||||
},
|
||||
point_cloud_body_xyz_m: [[1, 0, 0]],
|
||||
}),
|
||||
].map((raw, index) => ({
|
||||
sequence: raw.sequence,
|
||||
frameId: raw.frame_id,
|
||||
sourceTimeNs: raw.source_time_ns,
|
||||
sessionSeconds: raw.session_seconds,
|
||||
sourceAvailable: true,
|
||||
spatialAvailable: true,
|
||||
bodyFrame: {
|
||||
originMapXyzM: raw.body_frame.origin_map_xyz_m,
|
||||
basisMapFromBody: raw.body_frame.basis_map_from_body,
|
||||
},
|
||||
pointCloudBodyXyzM: raw.point_cloud_body_xyz_m,
|
||||
pointCloudSourceCount: 1,
|
||||
pointCloudSampleCount: 1,
|
||||
pointCloudLayer: "current-increment",
|
||||
rollingMapComponentCount: 0,
|
||||
metricObstacles: [],
|
||||
cameraProposals: [],
|
||||
decisionCounts: { threat: 0, "not-threat": 0, unknown: 0 },
|
||||
cameraUrl: raw.camera_url,
|
||||
}));
|
||||
const surface = buildM4LocalSurface(frames, frames[1], {
|
||||
windowSeconds: 2,
|
||||
voxelSizeM: 0.1,
|
||||
radiusM: 12,
|
||||
pointLimit: 20_000,
|
||||
});
|
||||
assert.equal(surface.sourceFrameCount, 2);
|
||||
assert.equal(surface.sourcePointCount, 2);
|
||||
assert.deepEqual(surface.pointsBodyXyzM, [[0, 1, 0], [1, 0, 0]]);
|
||||
});
|
||||
|
||||
test("M4.6 spatial buffering keeps previous, active and two future chunks", () => {
|
||||
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [24, 48, 72, 96]);
|
||||
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24, 48]);
|
||||
});
|
||||
|
||||
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
|
||||
const range = { startSeconds: 10, endSeconds: 20 };
|
||||
assert.deepEqual(
|
||||
advanceRecordedEvidencePlayback(
|
||||
{ currentSeconds: 12, playing: true, rate: 2 },
|
||||
1.5,
|
||||
range,
|
||||
),
|
||||
{ currentSeconds: 15, playing: true, rate: 2 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
advanceRecordedEvidencePlayback(
|
||||
{ currentSeconds: 19.5, playing: true, rate: 1 },
|
||||
1,
|
||||
range,
|
||||
),
|
||||
{ currentSeconds: 20, playing: false, rate: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("M4.6 viewer reuses shared camera, video and metric evidence renderers", async () => {
|
||||
const [visual, imageScene, videoScene, metricScene] = await Promise.all([
|
||||
readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/components/laboratory/RecordedEvidenceImageScene.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/components/laboratory/RecordedEvidenceVideoScene.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/components/laboratory/LaboratoryMetricEvidenceScene.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
assert.match(visual, /<RecordedEvidenceVideoScene/);
|
||||
assert.match(visual, /<RecordedEvidenceImageScene/);
|
||||
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
|
||||
assert.match(visual, /m4-replay-threat-visual__deck/);
|
||||
assert.match(visual, /lastFrameRef/);
|
||||
assert.match(visual, /<ObservationTimeline/);
|
||||
assert.match(visual, /useM4ThreatTimelineFrame/);
|
||||
assert.match(visual, /label: "VIDEO"/);
|
||||
assert.match(visual, /label: "CAMERA"/);
|
||||
assert.match(visual, /label: "3D"/);
|
||||
assert.match(videoScene, /<RecordedFmp4Player/);
|
||||
assert.match(imageScene, /<RecordedEvidenceBoxOverlay/);
|
||||
assert.match(metricScene, /OrbitControls/);
|
||||
assert.match(visual, /LOCAL SLAM/);
|
||||
assert.match(visual, /showLocalSurface/);
|
||||
assert.match(metricScene, /Local SLAM surface/);
|
||||
assert.match(visual, /showJumpToEnd=\{false\}/);
|
||||
assert.doesNotMatch(metricScene, /ЛКМ · вращение/);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,15 @@ import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let claimExclusiveLiveViewer;
|
||||
let createRecordedOpenWatchdog;
|
||||
let createReentrantViewerDisposer;
|
||||
let isLiveRerunPresentationReady;
|
||||
let liveTimelineNeedsSynchronization;
|
||||
let liveRerunReceiverBindingIdentity;
|
||||
let recordedOpenWatchdogTimeoutMs;
|
||||
let rerunViewerInitialSource;
|
||||
let rerunViewerOpenOptions;
|
||||
let resolveRecordedViewerSourceUrl;
|
||||
|
||||
before(async () => {
|
||||
@@ -17,9 +23,15 @@ before(async () => {
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
claimExclusiveLiveViewer,
|
||||
createRecordedOpenWatchdog,
|
||||
createReentrantViewerDisposer,
|
||||
isLiveRerunPresentationReady,
|
||||
liveTimelineNeedsSynchronization,
|
||||
liveRerunReceiverBindingIdentity,
|
||||
recordedOpenWatchdogTimeoutMs,
|
||||
rerunViewerInitialSource,
|
||||
rerunViewerOpenOptions,
|
||||
resolveRecordedViewerSourceUrl,
|
||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
@@ -59,6 +71,44 @@ test("only the canonical digest-bound generation URL reaches the native Rerun re
|
||||
}
|
||||
});
|
||||
|
||||
test("only the live receiver opens on the native following edge", () => {
|
||||
assert.deepEqual(rerunViewerOpenOptions(true), { follow_if_http: true });
|
||||
assert.equal(rerunViewerOpenOptions(false), null);
|
||||
});
|
||||
|
||||
test("live presentation waits for the exact receiver to expose a usable range", () => {
|
||||
assert.equal(isLiveRerunPresentationReady(false, { min: 1, max: 2 }, 1), false);
|
||||
assert.equal(isLiveRerunPresentationReady(true, null, 1), false);
|
||||
assert.equal(isLiveRerunPresentationReady(true, { min: 2, max: 1 }, 1), false);
|
||||
assert.equal(isLiveRerunPresentationReady(true, { min: 1, max: 2 }, 0), false);
|
||||
assert.equal(isLiveRerunPresentationReady(true, { min: 1, max: 1 }, 1), true);
|
||||
});
|
||||
|
||||
test("live timeline is retried only after stream_time exists and until it is active", () => {
|
||||
assert.equal(liveTimelineNeedsSynchronization(false, undefined, { min: 1, max: 2 }), false);
|
||||
assert.equal(liveTimelineNeedsSynchronization(true, undefined, null), false);
|
||||
assert.equal(liveTimelineNeedsSynchronization(true, undefined, { min: 1, max: 2 }), true);
|
||||
assert.equal(liveTimelineNeedsSynchronization(true, "log_time", { min: 1, max: 2 }), true);
|
||||
assert.equal(liveTimelineNeedsSynchronization(true, "stream_time", { min: 1, max: 2 }), false);
|
||||
});
|
||||
|
||||
test("live receiver binding stays stable across recovery authority projections", () => {
|
||||
const sourceUrl = "rerun+http://127.0.0.1:9877/proxy";
|
||||
const streamId = "acq-001";
|
||||
assert.equal(
|
||||
liveRerunReceiverBindingIdentity(sourceUrl, streamId, true),
|
||||
liveRerunReceiverBindingIdentity(` ${sourceUrl} `, streamId, true),
|
||||
);
|
||||
assert.notEqual(
|
||||
liveRerunReceiverBindingIdentity(sourceUrl, streamId, true),
|
||||
liveRerunReceiverBindingIdentity(sourceUrl, "acq-002", true),
|
||||
);
|
||||
assert.notEqual(
|
||||
liveRerunReceiverBindingIdentity(sourceUrl, streamId, true),
|
||||
liveRerunReceiverBindingIdentity(sourceUrl, streamId, false),
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded admission watchdog is size-aware and exits an incomplete load", () => {
|
||||
const smallDelay = recordedOpenWatchdogTimeoutMs(4);
|
||||
const currentDelay = recordedOpenWatchdogTimeoutMs(246_331_680);
|
||||
@@ -118,6 +168,45 @@ test("complete recorded admission clears its watchdog", () => {
|
||||
assert.deepEqual(cancelled, [23]);
|
||||
});
|
||||
|
||||
test("deferred viewer start cannot reopen after stale unmount", async () => {
|
||||
let resolveStart;
|
||||
const start = new Promise((resolve) => {
|
||||
resolveStart = resolve;
|
||||
});
|
||||
let disposed = false;
|
||||
let cleanupCount = 0;
|
||||
let closeCount = 0;
|
||||
let stopCount = 0;
|
||||
const diagnostics = [];
|
||||
const disposeViewer = createReentrantViewerDisposer(
|
||||
() => {
|
||||
cleanupCount += 1;
|
||||
},
|
||||
() => {
|
||||
closeCount += 1;
|
||||
stopCount += 1;
|
||||
},
|
||||
);
|
||||
const pendingMount = (async () => {
|
||||
await start;
|
||||
if (disposed) {
|
||||
disposeViewer();
|
||||
return;
|
||||
}
|
||||
diagnostics.push("admitted");
|
||||
})();
|
||||
|
||||
disposed = true;
|
||||
disposeViewer();
|
||||
resolveStart();
|
||||
await pendingMount;
|
||||
|
||||
assert.equal(cleanupCount, 1);
|
||||
assert.equal(closeCount, 2);
|
||||
assert.equal(stopCount, 2);
|
||||
assert.deepEqual(diagnostics, []);
|
||||
});
|
||||
|
||||
test("recorded RRD bytes are never split across LogChannel.send_rrd calls", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/RerunViewport.tsx", import.meta.url),
|
||||
@@ -127,13 +216,30 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
||||
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
|
||||
assert.doesNotMatch(source, /recordedChannel/);
|
||||
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
||||
assert.match(source, /rerunViewerOpenOptions\(followLive\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/recordingOpened = true;[\s\S]*clearLiveRecordingOpenTimer\(\);[\s\S]*clearLiveRecordingDiscoveryTimer\(\);/,
|
||||
/recordingOpened = true;[\s\S]*diagnosticLifecycle\.markAdmitted\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/viewer\.get_active_recording_id\(\)[\s\S]*eventCode: "live_receiver_active_store_admitted"[\s\S]*admitRecording\(\{[\s\S]*application_id: "nodedc_mission_core_spatial"/,
|
||||
/viewer\.get_active_recording_id\(\)[\s\S]*admitRecording\(\{[\s\S]*application_id: "nodedc_mission_core_spatial"/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const readyToRender = followLive\s*\? liveTimelineSynchronized && isLiveRerunPresentationReady\(/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/viewer\.get_active_timeline\(event\.recording_id\)[\s\S]*viewer\.set_active_timeline\(event\.recording_id, timeline\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(readyToRender && !readyPublished\)[\s\S]*eventCode: "live_receiver_active_store_admitted"[\s\S]*diagnosticLifecycle\.markAdmitted\(\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/\], \[followLive, liveRecoveryAuthorityIdentity, liveStreamId, sourceUrl\]\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -145,12 +251,59 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
||||
);
|
||||
});
|
||||
|
||||
test("one live document owns one native Rerun receiver", async () => {
|
||||
const releases = [];
|
||||
const releaseFirstClaim = claimExclusiveLiveViewer(() => releases.push("first"));
|
||||
const releaseSecondClaim = claimExclusiveLiveViewer(() => releases.push("second"));
|
||||
|
||||
assert.deepEqual(releases, ["first"]);
|
||||
releaseFirstClaim();
|
||||
assert.deepEqual(releases, ["first"]);
|
||||
releaseSecondClaim();
|
||||
|
||||
const releaseThirdClaim = claimExclusiveLiveViewer(() => releases.push("third"));
|
||||
assert.deepEqual(releases, ["first"]);
|
||||
releaseThirdClaim();
|
||||
});
|
||||
|
||||
test("raw replay exercises the same streaming receiver lifecycle as a live scan", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /followLive=\{!recordedReplay && streamActive\}/);
|
||||
assert.match(
|
||||
source,
|
||||
/const liveRerunSource = !recordedSource && \/\^rerun\\\+https\?:\\\/\\\//,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const livePresentationActivitySequence = metrics\?\.publishedFrameCount \?\?[\s\S]*liveRerunSource && !streamActive \? 1 : null/,
|
||||
);
|
||||
assert.match(source, /followLive=\{liveRerunSource\}/);
|
||||
assert.match(source, /liveActivitySequence=\{livePresentationActivitySequence\}/);
|
||||
assert.match(
|
||||
source,
|
||||
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const recordedSource = Boolean\(recordedReplay\) \|\| \/\\\.rrd/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/recordedSource = state\?\.sourceMode === ["']replay["']/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the spatial header reports raw replay as an active source", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/App.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/sourceMode === "replay"[\s\S]*\? "Повтор записи"[\s\S]*: "Ожидание эфира"/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
|
||||
|
||||
@@ -42,6 +42,113 @@ function snapshot(revision, generation, phase = "streaming", sessionId = "device
|
||||
};
|
||||
}
|
||||
|
||||
function stampedSnapshot({
|
||||
runtimeStartedAt = "2026-08-06T10:00:00Z",
|
||||
runtimeStartedMonotonicNs = "1000000",
|
||||
runtimeId = "runtime-a",
|
||||
snapshotRevision = 1,
|
||||
cameraRevision = snapshotRevision,
|
||||
generation = 1,
|
||||
} = {}) {
|
||||
return {
|
||||
...snapshot(cameraRevision, generation),
|
||||
snapshot_runtime_started_at_utc: runtimeStartedAt,
|
||||
snapshot_runtime_started_monotonic_ns: runtimeStartedMonotonicNs,
|
||||
snapshot_runtime_id: runtimeId,
|
||||
snapshot_revision: snapshotRevision,
|
||||
};
|
||||
}
|
||||
|
||||
test("uses the process snapshot revision before camera-local counters", () => {
|
||||
const current = stampedSnapshot({ snapshotRevision: 8, cameraRevision: 2 });
|
||||
const stale = stampedSnapshot({ snapshotRevision: 7, cameraRevision: 99 });
|
||||
const newer = stampedSnapshot({ snapshotRevision: 9, cameraRevision: 1 });
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(current, stale), current);
|
||||
assert.equal(selectMonotonicXgridsState(current, newer), newer);
|
||||
});
|
||||
|
||||
test("accepts a newer runtime and rejects a delayed snapshot from the old runtime", () => {
|
||||
const oldRuntime = stampedSnapshot({
|
||||
runtimeStartedAt: "2026-08-06T10:00:00Z",
|
||||
runtimeStartedMonotonicNs: "1000000",
|
||||
runtimeId: "runtime-old",
|
||||
snapshotRevision: 300,
|
||||
});
|
||||
const newRuntime = stampedSnapshot({
|
||||
runtimeStartedAt: "2026-08-06T10:05:00Z",
|
||||
runtimeStartedMonotonicNs: "2000000",
|
||||
runtimeId: "runtime-new",
|
||||
snapshotRevision: 1,
|
||||
});
|
||||
const delayedOldRuntime = stampedSnapshot({
|
||||
runtimeStartedAt: "2026-08-06T10:00:00Z",
|
||||
runtimeStartedMonotonicNs: "1000000",
|
||||
runtimeId: "runtime-old",
|
||||
snapshotRevision: 301,
|
||||
});
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(oldRuntime, newRuntime), newRuntime);
|
||||
assert.equal(
|
||||
selectMonotonicXgridsState(newRuntime, delayedOldRuntime),
|
||||
newRuntime,
|
||||
);
|
||||
});
|
||||
|
||||
test("orders restarts by monotonic time even when UTC moves backwards", () => {
|
||||
const oldRuntime = stampedSnapshot({
|
||||
runtimeStartedAt: "2026-08-06T10:05:00Z",
|
||||
runtimeStartedMonotonicNs: "2000000",
|
||||
runtimeId: "runtime-old",
|
||||
snapshotRevision: 900,
|
||||
});
|
||||
const newRuntime = stampedSnapshot({
|
||||
runtimeStartedAt: "2026-08-06T09:55:00Z",
|
||||
runtimeStartedMonotonicNs: "3000000",
|
||||
runtimeId: "runtime-new",
|
||||
snapshotRevision: 1,
|
||||
});
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(oldRuntime, newRuntime), newRuntime);
|
||||
assert.equal(selectMonotonicXgridsState(newRuntime, oldRuntime), newRuntime);
|
||||
});
|
||||
|
||||
test("orders two runtimes sharing the same UTC millisecond", () => {
|
||||
const first = stampedSnapshot({
|
||||
runtimeStartedMonotonicNs: "4000000",
|
||||
runtimeId: "runtime-first",
|
||||
});
|
||||
const second = stampedSnapshot({
|
||||
runtimeStartedMonotonicNs: "4000001",
|
||||
runtimeId: "runtime-second",
|
||||
});
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(first, second), second);
|
||||
});
|
||||
|
||||
test("a malformed monotonic stamp cannot replace valid runtime authority", () => {
|
||||
const current = stampedSnapshot({
|
||||
runtimeStartedMonotonicNs: "5000000",
|
||||
runtimeId: "runtime-current",
|
||||
});
|
||||
const malformed = stampedSnapshot({
|
||||
runtimeStartedAt: "2026-08-06T11:00:00Z",
|
||||
runtimeStartedMonotonicNs: "not-a-number",
|
||||
runtimeId: "runtime-malformed",
|
||||
snapshotRevision: 9999,
|
||||
});
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(current, malformed), current);
|
||||
});
|
||||
|
||||
test("does not let an unstamped legacy response replace stamped authority", () => {
|
||||
const current = stampedSnapshot({ snapshotRevision: 8 });
|
||||
const legacy = snapshot(99, 99);
|
||||
|
||||
assert.equal(selectMonotonicXgridsState(current, legacy), current);
|
||||
assert.equal(selectMonotonicXgridsState(legacy, current), current);
|
||||
});
|
||||
|
||||
test("accepts the first camera preview snapshot", () => {
|
||||
const incoming = snapshot(1, 1);
|
||||
assert.equal(selectMonotonicXgridsState(null, incoming), incoming);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { test } from "node:test";
|
||||
|
||||
const component = (name) => new URL(`../src/components/laboratory/${name}`, import.meta.url);
|
||||
|
||||
test("semantic evidence mask is decoded, cached, cancelled and object-contained client-side", async () => {
|
||||
const source = await readFile(component("RecordedEvidenceSemanticMaskOverlay.tsx"), "utf8");
|
||||
assert.match(source, /decodedMaskCache/);
|
||||
assert.match(source, /pendingMaskCache/);
|
||||
assert.match(source, /AbortController/);
|
||||
assert.match(source, /getImageData/);
|
||||
assert.match(source, /8-bit grayscale class-id PNG/);
|
||||
assert.match(source, /ResizeObserver/);
|
||||
assert.match(source, /Math\.min\(width \/ imageWidth, height \/ imageHeight\)/);
|
||||
assert.match(source, /decoded\.key !== expectedKey/);
|
||||
assert.match(source, /mask\?\.key === expectedKey/);
|
||||
assert.match(source, /необъявленный class ID/);
|
||||
assert.match(source, /recorded-evidence-semantic-mask-overlay__error/);
|
||||
assert.match(source, /role="alert"/);
|
||||
});
|
||||
|
||||
test("shared recorded scenes place optional semantic masks beneath boxes", async () => {
|
||||
const [imageScene, videoScene] = await Promise.all([
|
||||
readFile(component("RecordedEvidenceImageScene.tsx"), "utf8"),
|
||||
readFile(component("RecordedEvidenceVideoScene.tsx"), "utf8"),
|
||||
]);
|
||||
for (const source of [imageScene, videoScene]) {
|
||||
assert.match(source, /semanticOverlay\?: RecordedEvidenceSemanticOverlay/);
|
||||
assert.ok(
|
||||
source.indexOf("<RecordedEvidenceSemanticMaskOverlay")
|
||||
< source.indexOf("<RecordedEvidenceBoxOverlay"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("metric evidence keeps missing semantic assignments as context and exposes a taxonomy legend", async () => {
|
||||
const source = await readFile(component("LaboratoryMetricEvidenceScene.tsx"), "utf8");
|
||||
assert.match(source, /pointSemanticClassIds\?: readonly \(number \| null\)\[\]/);
|
||||
assert.match(source, /pointSemanticClassIds\.length === pointCloudBodyXyzM\.length/);
|
||||
assert.match(source, /classId === null \? undefined : colorsByClassId\.get\(classId\)/);
|
||||
assert.match(source, /data-decision="semantic"/);
|
||||
assert.match(source, /recordedEvidenceSemanticCssColor/);
|
||||
});
|
||||
|
||||
test("semantic point alignment is enforced only when M4 exposes the exact spatial increment", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /semantic && frame\?\.spatialAvailable && semanticFrame/);
|
||||
assert.match(source, /\|\| !frame\.spatialAvailable\s*\|\| !semanticFrame/);
|
||||
assert.match(source, /semanticFrame\.sourcePointCount !== frame\.pointCloudSourceCount/);
|
||||
});
|
||||
|
||||
test("M4 mounts semantic mask overlays only for the active VIDEO or CAMERA layer", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/semanticOverlay=\{mode === "video" \? semanticOverlay : undefined\}/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/semanticOverlay=\{mode === "camera" \? semanticOverlay : undefined\}/,
|
||||
);
|
||||
});
|
||||
@@ -13,6 +13,9 @@ let projectObservationLayoutSnapshot;
|
||||
let saveObservationWorkspaceLayoutProfile;
|
||||
let WorkspaceLayoutApiError;
|
||||
let WorkspaceLayoutContractError;
|
||||
let admitLiveDefaultPresentations;
|
||||
let automaticLivePresentationIdentity;
|
||||
let livePresentationCloseFence;
|
||||
let observationPresentationSourceAfterLayoutApply;
|
||||
let visibleSourceIdsAfterRecordedCatalogActivation;
|
||||
|
||||
@@ -33,6 +36,9 @@ before(async () => {
|
||||
WorkspaceLayoutContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
|
||||
({
|
||||
admitLiveDefaultPresentations,
|
||||
automaticLivePresentationIdentity,
|
||||
livePresentationCloseFence,
|
||||
observationPresentationSourceAfterLayoutApply,
|
||||
visibleSourceIdsAfterRecordedCatalogActivation,
|
||||
} = await server.ssrLoadModule("/src/core/observation/useObservationLayout.ts"));
|
||||
@@ -213,6 +219,95 @@ test("opening a recorded catalog reveals its sealed cameras beside the point clo
|
||||
);
|
||||
});
|
||||
|
||||
test("a sequential live acquisition re-arms the same camera without reopening a deliberate close", () => {
|
||||
const pointCloud = {
|
||||
id: "k1:sensor.lidar.primary",
|
||||
sourceId: "sensor.lidar.primary",
|
||||
modality: "point-cloud",
|
||||
availability: "streaming",
|
||||
transport: "rerun-grpc",
|
||||
previewUrl: "grpc://127.0.0.1:9876/proxy",
|
||||
delivery: null,
|
||||
activation: null,
|
||||
binding: {
|
||||
deviceId: "k1-a",
|
||||
deviceSessionId: "device-session-reused",
|
||||
acquisitionId: "acquisition-a",
|
||||
},
|
||||
capabilities: { defaultVisible: true, overlay: false },
|
||||
};
|
||||
const camera = (acquisitionId, deliveryId) => ({
|
||||
id: "k1:sensor.camera.right",
|
||||
sourceId: "sensor.camera.right",
|
||||
modality: "video",
|
||||
availability: "streaming",
|
||||
transport: "websocket",
|
||||
previewUrl: null,
|
||||
delivery: {
|
||||
id: deliveryId,
|
||||
kind: "mse-fmp4-websocket",
|
||||
url: "/camera-preview/reused",
|
||||
mediaType: 'video/mp4; codecs="avc1.641028"',
|
||||
},
|
||||
activation: {
|
||||
groupId: "k1:device-session-reused:camera.preview.decoder",
|
||||
maxActive: 1,
|
||||
selected: true,
|
||||
controllable: true,
|
||||
},
|
||||
binding: {
|
||||
deviceId: "k1-a",
|
||||
deviceSessionId: "device-session-reused",
|
||||
acquisitionId,
|
||||
},
|
||||
capabilities: { defaultVisible: true, overlay: true },
|
||||
});
|
||||
|
||||
const firstCamera = camera("acquisition-a", "camera-preview-2");
|
||||
const first = admitLiveDefaultPresentations(
|
||||
[pointCloud.id],
|
||||
[pointCloud, firstCamera],
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
assert.deepEqual(first.visibleIds, [pointCloud.id, firstCamera.id]);
|
||||
assert.deepEqual(first.admittedIdentities, [
|
||||
automaticLivePresentationIdentity(firstCamera),
|
||||
]);
|
||||
|
||||
const sameAcquisitionNewDelivery = camera("acquisition-a", "camera-preview-3");
|
||||
const closedInFirstAcquisition = new Set([
|
||||
livePresentationCloseFence(firstCamera),
|
||||
]);
|
||||
const afterDeliberateClose = admitLiveDefaultPresentations(
|
||||
[pointCloud.id],
|
||||
[pointCloud, sameAcquisitionNewDelivery],
|
||||
new Set(first.admittedIdentities),
|
||||
closedInFirstAcquisition,
|
||||
);
|
||||
assert.deepEqual(afterDeliberateClose.visibleIds, [pointCloud.id]);
|
||||
assert.deepEqual(afterDeliberateClose.admittedIdentities, []);
|
||||
|
||||
const nextAcquisitionSameDelivery = camera("acquisition-b", "camera-preview-3");
|
||||
assert.notEqual(
|
||||
automaticLivePresentationIdentity(nextAcquisitionSameDelivery),
|
||||
automaticLivePresentationIdentity(sameAcquisitionNewDelivery),
|
||||
);
|
||||
const second = admitLiveDefaultPresentations(
|
||||
[pointCloud.id],
|
||||
[
|
||||
{ ...pointCloud, binding: { ...pointCloud.binding, acquisitionId: "acquisition-b" } },
|
||||
nextAcquisitionSameDelivery,
|
||||
],
|
||||
new Set(first.admittedIdentities),
|
||||
closedInFirstAcquisition,
|
||||
);
|
||||
assert.deepEqual(second.visibleIds, [pointCloud.id, nextAcquisitionSameDelivery.id]);
|
||||
assert.deepEqual(second.admittedIdentities, [
|
||||
automaticLivePresentationIdentity(nextAcquisitionSameDelivery),
|
||||
]);
|
||||
});
|
||||
|
||||
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
|
||||
const calls = [];
|
||||
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
},
|
||||
"wheel": {
|
||||
"name": "nodedc_mission_core-0.1.0-py3-none-any.whl",
|
||||
"sha256": "19d8caf9a522747c461fb3ca30aafe54169959d8bd8e671fa6fc8c0ac107875d"
|
||||
"sha256": "__WHEEL_SHA256__"
|
||||
}
|
||||
},
|
||||
"rollback": {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema_version": "missioncore.laboratory-evidence-definition/v1",
|
||||
"work_id": "e47-semantic-slam-shadow",
|
||||
"evidence": {
|
||||
"runtime_relative_root": "e47/semantic-slam-results",
|
||||
"result_id_prefix": "e47-semantic-slam",
|
||||
"document_name": "manifest.json",
|
||||
"schema_version": "missioncore.e47-semantic-slam-result/v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema_version": "missioncore.laboratory-evidence-definition/v1",
|
||||
"work_id": "m4-replay-threat",
|
||||
"evidence": {
|
||||
"runtime_relative_root": "m4/replay-threat",
|
||||
"result_id_prefix": "m4-threat-replay",
|
||||
"document_name": "manifest.json",
|
||||
"schema_version": "missioncore.perception-threat-replay-result/v1"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,25 @@
|
||||
{
|
||||
"schema_version": "missioncore.laboratory-execution-registry/v1",
|
||||
"definitions": [
|
||||
{
|
||||
"work_id": "m4-replay-threat",
|
||||
"lifecycle": "canonical",
|
||||
"isolation": "core-adapter",
|
||||
"adapter_id": "canonical.m4-replay-threat/v1",
|
||||
"input_roles": [
|
||||
"repository_root",
|
||||
"temporal_result_root",
|
||||
"geometry_result_root",
|
||||
"detector_result_root"
|
||||
],
|
||||
"contracts": {
|
||||
"source": "missioncore.perception-temporal-replay-result/v1",
|
||||
"provider": "missioncore.dual-evidence-threat-provider/v1",
|
||||
"graph": "missioncore.perception-threat-replay-graph/v1",
|
||||
"run": "missioncore.laboratory-run/v1",
|
||||
"evidence": "missioncore.perception-threat-replay-result/v1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"work_id": "e33-worker-shadow",
|
||||
"lifecycle": "canonical",
|
||||
@@ -48,6 +67,25 @@
|
||||
"run": "missioncore.laboratory-run/v1",
|
||||
"evidence": "missioncore.e46j-raw-fisheye-realtime-result/v1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"work_id": "e47-semantic-slam-shadow",
|
||||
"lifecycle": "experimental",
|
||||
"isolation": "bounded-adapter",
|
||||
"adapter_id": "experimental.e47-semantic-slam-shadow/v1",
|
||||
"input_roles": [
|
||||
"repository_root",
|
||||
"semantic_result_root",
|
||||
"threat_result_root",
|
||||
"geometry_result_root"
|
||||
],
|
||||
"contracts": {
|
||||
"source": "missioncore.e47-semantic-slam-source-set/v1",
|
||||
"provider": "missioncore.semantic-slam-diagnostic-provider/v1",
|
||||
"graph": "missioncore.e47-semantic-slam-shadow-graph/v1",
|
||||
"run": "missioncore.laboratory-run/v1",
|
||||
"evidence": "missioncore.e47-semantic-slam-result/v1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"legacy_work_ids": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "missioncore.laboratory-value-review-registry/v1",
|
||||
"reviewed_at_utc": "2026-08-05T08:30:00Z",
|
||||
"reviewed_at_utc": "2026-08-05T15:34:00Z",
|
||||
"entries": [
|
||||
{
|
||||
"catalog_id": "e28-local-surface",
|
||||
@@ -191,6 +191,13 @@
|
||||
"lifecycle": "current",
|
||||
"visual_evidence": "available"
|
||||
},
|
||||
{
|
||||
"catalog_id": "m4-replay-threat",
|
||||
"evidence_id": "m4-threat-replay-2a953c5f27f2a5b1dddc5c658c1de2c323d7796084a099c024987a1da03aa324",
|
||||
"signal": "progress",
|
||||
"lifecycle": "current",
|
||||
"visual_evidence": "available"
|
||||
},
|
||||
{
|
||||
"catalog_id": "l34-right-yolox-truth-island-freeze",
|
||||
"evidence_id": "l34-right-yolox-truth-island-freeze-5175a03144978b25130019da6d37bceb8c6ed6aa3d0d3a4d2df4483e1e27ae76",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"schema_version": "missioncore.e47-semantic-slam-profile/v1",
|
||||
"profile_id": "ravnoves00-eomt-kb4-slam-shadow/v1",
|
||||
"source": {
|
||||
"source_id": "RAVNOVES00",
|
||||
"session_id": "20260720T065719Z_viewer_live",
|
||||
"frame_count": 4489,
|
||||
"image_width": 800,
|
||||
"image_height": 600,
|
||||
"source_pack_id": "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b",
|
||||
"source_pack_sha256": "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944",
|
||||
"calibration_content_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"
|
||||
},
|
||||
"semantic_provider": {
|
||||
"provider_id": "eomt-cityscapes-semantic-control/v1",
|
||||
"model_id": "tue-mps/cityscapes_semantic_eomt_large_1024",
|
||||
"model_revision": "8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f",
|
||||
"model_weights_sha256": "c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782",
|
||||
"preprocess_id": "raw-kb4-valid-fov-semantic/v1",
|
||||
"mask_metadata_schema_version": "missioncore.panoptic-frame/v1",
|
||||
"mask_payload": {
|
||||
"media_type": "image/png",
|
||||
"encoding": "uint8-class-id",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"sequence_binding": "sequence-0-to-frame-000001"
|
||||
},
|
||||
"role": "fixed-control-not-selected-production-provider"
|
||||
},
|
||||
"taxonomy": [
|
||||
{"class_id": 0, "label": "outside_valid_fov", "disposition": "ambiguous", "color_rgb": [0, 0, 0]},
|
||||
{"class_id": 1, "label": "person", "disposition": "labeled", "color_rgb": [220, 20, 60]},
|
||||
{"class_id": 2, "label": "bicycle", "disposition": "labeled", "color_rgb": [119, 11, 32]},
|
||||
{"class_id": 3, "label": "motorcycle", "disposition": "labeled", "color_rgb": [0, 0, 230]},
|
||||
{"class_id": 4, "label": "car", "disposition": "labeled", "color_rgb": [0, 0, 142]},
|
||||
{"class_id": 5, "label": "heavy_vehicle", "disposition": "labeled", "color_rgb": [0, 0, 70]},
|
||||
{"class_id": 6, "label": "building_structure", "disposition": "labeled", "color_rgb": [70, 70, 70]},
|
||||
{"class_id": 7, "label": "paved_road", "disposition": "labeled", "color_rgb": [128, 64, 128]},
|
||||
{"class_id": 8, "label": "sidewalk_curb", "disposition": "labeled", "color_rgb": [244, 35, 232]},
|
||||
{"class_id": 9, "label": "ground_dirt", "disposition": "labeled", "color_rgb": [81, 0, 81]},
|
||||
{"class_id": 10, "label": "grass_low_vegetation", "disposition": "labeled", "color_rgb": [152, 251, 152]},
|
||||
{"class_id": 11, "label": "tree_woody_vegetation", "disposition": "labeled", "color_rgb": [107, 142, 35]},
|
||||
{"class_id": 12, "label": "sky", "disposition": "labeled", "color_rgb": [70, 130, 180]},
|
||||
{"class_id": 13, "label": "static_obstacle", "disposition": "labeled", "color_rgb": [220, 220, 0]},
|
||||
{"class_id": 14, "label": "animal", "disposition": "labeled", "color_rgb": [255, 127, 80]},
|
||||
{"class_id": 15, "label": "other_background", "disposition": "labeled", "color_rgb": [153, 153, 153]}
|
||||
],
|
||||
"fusion": {
|
||||
"projection": "factory-kb4-current-increment/v1",
|
||||
"point_index_space": "frame-local-source-point-id/v1",
|
||||
"observation_aggregation": "dominant-labeled-majority-diagnostic/v1",
|
||||
"unprojected_status": "unprojected",
|
||||
"semantic_absence_means_free": false,
|
||||
"semantic_can_create_obstacle": false,
|
||||
"semantic_can_change_identity": false,
|
||||
"semantic_can_change_metric_geometry": false,
|
||||
"semantic_can_change_occupancy": false,
|
||||
"semantic_can_change_motion": false,
|
||||
"semantic_can_change_threat": false
|
||||
},
|
||||
"temporal_binding": {
|
||||
"semantic_to_camera": "exact-sequence-and-session-time",
|
||||
"camera_to_lidar": "accepted-e6-nearest-host-arrival-best-effort",
|
||||
"clock_basis": "recorded-host-monotonic-arrival",
|
||||
"maximum_lidar_camera_delta_ms": 100.0,
|
||||
"maximum_pose_point_delta_ms": 100.0,
|
||||
"physical_synchronization_proven": false
|
||||
},
|
||||
"acceptance": {
|
||||
"full_frame_accounting_required": true,
|
||||
"point_accounting_required": true,
|
||||
"observation_binding_required": true,
|
||||
"exact_mask_archive_required": true,
|
||||
"independent_semantic_truth_required_for_provider_promotion": true
|
||||
},
|
||||
"authority": {
|
||||
"ground_truth": false,
|
||||
"physical_live": false,
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false,
|
||||
"semantic_authority": "diagnostic-only"
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
"geometry_local_radius_m": 10.0,
|
||||
"geometry_voxel_size_m": 0.45,
|
||||
"geometry_minimum_cluster_points": 4,
|
||||
"geometry_minimum_cluster_voxels": 2,
|
||||
"geometry_minimum_cluster_voxels": 1,
|
||||
"maximum_geometry_clusters_per_frame": 64
|
||||
},
|
||||
"policy": {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"schema_version": "missioncore.replay-threat-profile/v2",
|
||||
"profile_id": "m4-ravnoves00-virtual-corridor/v2",
|
||||
"provider_id": "dual-evidence-replay-threat/v2",
|
||||
"source": {
|
||||
"source_id": "RAVNOVES00",
|
||||
"session_id": "20260720T065719Z_viewer_live",
|
||||
"temporal_result_id": "m4-temporal-replay-9ed5dcd249ed3bcb81661dd18e2b854a7ffedf3fd2b92b9c994c3c70c34533f2",
|
||||
"temporal_frames_sha256": "1bf1365bdb3f20214443d3f8b87a0fa88f9848af8ca0456b7ca364d37631c3fc",
|
||||
"geometry_result_id": "m4-geometry-replay-8daf3109e3cf30b960b4b376032ff3b5ec58ca42a1e5899b841cf29fbcf14ad8",
|
||||
"geometry_frames_sha256": "b4db5d0ebaba4d6268a1006707dc313c229f3dbdfd73b1d863cad5d853be8ac4",
|
||||
"detector_result_id": "m4-detector-replay-11f83f2e0b81758ac2a5a5fc54e9d293b501678df5f6ef97b5c6069ba08605c5",
|
||||
"detector_frames_sha256": "9bf5ae17938cd57c112278781b38d54a7187cf2dabc7bb0332acdb1efad721f5",
|
||||
"source_pack_id": "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b",
|
||||
"source_pack_sha256": "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944"
|
||||
},
|
||||
"calibration": {
|
||||
"calibration_id": "camera-1-kb4-05f3ad9b",
|
||||
"content_identity_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9",
|
||||
"usage": "projection-and-forward-axis-binding"
|
||||
},
|
||||
"body_frame": {
|
||||
"schema_version": "missioncore.replay-body-frame-profile/v1",
|
||||
"origin": "local-surface-vertical-projection",
|
||||
"up": "vendor-slam-map-gravity-axis",
|
||||
"forward": "smoothed-slam-trajectory-validated-by-camera-axis",
|
||||
"trajectory_half_window_frames": 20,
|
||||
"minimum_trajectory_displacement_m": 0.2,
|
||||
"maximum_camera_route_misalignment_deg": 25.0,
|
||||
"maximum_sensor_height_deviation_m": 0.45,
|
||||
"maximum_surface_slope_deg": 10.0
|
||||
},
|
||||
"virtual_rig": {
|
||||
"profile_id": "virtual-base-footprint-1000x600/v2",
|
||||
"body_length_m": 1.0,
|
||||
"body_width_m": 0.6,
|
||||
"lidar_reference": "virtual-body-center",
|
||||
"nominal_sensor_height_m": 1.25,
|
||||
"physical_mount_claimed": false
|
||||
},
|
||||
"corridor": {
|
||||
"profile_id": "ravnoves00-forward-corridor-8m/v2",
|
||||
"forward_length_m": 8.0,
|
||||
"rear_margin_m": 0.5,
|
||||
"lateral_clearance_m": 0.2,
|
||||
"prediction_horizon_seconds": 5.0,
|
||||
"occupied_voxel_size_m": 0.45,
|
||||
"minimum_motion_span_seconds": 0.2
|
||||
},
|
||||
"policy": {
|
||||
"camera_only_decision": "unknown",
|
||||
"held_or_stale_decision": "unknown",
|
||||
"semantic_class_used": false,
|
||||
"detector_identity_used": false,
|
||||
"absence_of_points_means_free": false,
|
||||
"geometry_only_is_eligible": true
|
||||
},
|
||||
"authority": {
|
||||
"mode": "replay-simulated",
|
||||
"physical_live": false,
|
||||
"physical_collision_accepted": false,
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema_version": "missioncore.replay-threat-profile/v3",
|
||||
"profile_id": "m4-ravnoves00-virtual-corridor/v3",
|
||||
"provider_id": "dual-evidence-replay-threat/v3",
|
||||
"source": {
|
||||
"source_id": "RAVNOVES00",
|
||||
"session_id": "20260720T065719Z_viewer_live",
|
||||
"temporal_result_id": "m4-temporal-replay-81e13d5654ac8d1219f6937dd425f7dbcdbacd5748d23fe653297134f0de6c22",
|
||||
"temporal_frames_sha256": "e83b80ea06b3462c2d04c5a1b74289a0ec401596c7150ae90f5a1b748b639c3a",
|
||||
"geometry_result_id": "m4-geometry-replay-b7d72e9e411fd576243d10ab39717e90daa10703b67c6e32ab284f6bd78d344a",
|
||||
"geometry_frames_sha256": "d4d4c0a98e09c0f26251284747108651cf4b21e9c9733e3a14963999b0300772",
|
||||
"detector_result_id": "m4-detector-replay-11f83f2e0b81758ac2a5a5fc54e9d293b501678df5f6ef97b5c6069ba08605c5",
|
||||
"detector_frames_sha256": "9bf5ae17938cd57c112278781b38d54a7187cf2dabc7bb0332acdb1efad721f5",
|
||||
"source_pack_id": "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b",
|
||||
"source_pack_sha256": "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944"
|
||||
},
|
||||
"calibration": {
|
||||
"calibration_id": "camera-1-kb4-05f3ad9b",
|
||||
"content_identity_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9",
|
||||
"usage": "projection-and-forward-axis-binding"
|
||||
},
|
||||
"body_frame": {
|
||||
"schema_version": "missioncore.replay-body-frame-profile/v1",
|
||||
"origin": "local-surface-vertical-projection",
|
||||
"up": "vendor-slam-map-gravity-axis",
|
||||
"forward": "smoothed-slam-trajectory-validated-by-camera-axis",
|
||||
"trajectory_half_window_frames": 20,
|
||||
"minimum_trajectory_displacement_m": 0.2,
|
||||
"maximum_camera_route_misalignment_deg": 25.0,
|
||||
"maximum_sensor_height_deviation_m": 0.45,
|
||||
"maximum_surface_slope_deg": 10.0
|
||||
},
|
||||
"virtual_rig": {
|
||||
"profile_id": "virtual-base-footprint-1000x600/v3",
|
||||
"body_length_m": 1.0,
|
||||
"body_width_m": 0.6,
|
||||
"lidar_reference": "virtual-body-center",
|
||||
"nominal_sensor_height_m": 1.25,
|
||||
"physical_mount_claimed": false
|
||||
},
|
||||
"corridor": {
|
||||
"profile_id": "ravnoves00-forward-corridor-8m/v3",
|
||||
"forward_length_m": 8.0,
|
||||
"rear_margin_m": 0.5,
|
||||
"lateral_clearance_m": 0.2,
|
||||
"prediction_horizon_seconds": 5.0,
|
||||
"occupied_voxel_size_m": 0.45,
|
||||
"minimum_motion_span_seconds": 0.2
|
||||
},
|
||||
"policy": {
|
||||
"camera_only_decision": "unknown",
|
||||
"held_or_stale_decision": "unknown",
|
||||
"semantic_class_used": false,
|
||||
"detector_identity_used": false,
|
||||
"absence_of_points_means_free": false,
|
||||
"geometry_only_is_eligible": true,
|
||||
"retained_map_intersection_decision": "threat"
|
||||
},
|
||||
"authority": {
|
||||
"mode": "replay-simulated",
|
||||
"physical_live": false,
|
||||
"physical_collision_accepted": false,
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"schema_version": "missioncore.rolling-local-map-profile/v1",
|
||||
"profile_id": "ravnoves00-rolling-local-obstacle-map/v1",
|
||||
"provider_id": "rolling-local-obstacle-map/v1",
|
||||
"source": {
|
||||
"source_id": "RAVNOVES00",
|
||||
"session_id": "20260720T065719Z_viewer_live",
|
||||
"representation_id": "registered-map-increment-v1"
|
||||
},
|
||||
"bounds": {
|
||||
"coordinate_frame": "map",
|
||||
"voxel_size_m": 0.45,
|
||||
"retention_seconds": 3.0,
|
||||
"local_radius_m": 12.0,
|
||||
"maximum_cells": 65536,
|
||||
"maximum_cells_per_component": 4096,
|
||||
"maximum_components": 1024,
|
||||
"neighbor_radius_cells": 1
|
||||
},
|
||||
"policy": {
|
||||
"input_is_complete_scan": false,
|
||||
"input_is_registered_map_increment": true,
|
||||
"absence_of_republication_means_free": false,
|
||||
"clearing_from_missing_points": false,
|
||||
"retained_occupancy_can_assert_threat": true,
|
||||
"retained_motion_claimed": false,
|
||||
"local_radius_eviction": true,
|
||||
"time_bound_eviction": true,
|
||||
"capacity_eviction_allowed": false
|
||||
},
|
||||
"authority": {
|
||||
"ground_truth": false,
|
||||
"physical_live": false,
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
"source": {
|
||||
"source_id": "RAVNOVES00",
|
||||
"session_id": "20260720T065719Z_viewer_live",
|
||||
"geometry_result_id": "m4-geometry-replay-8daf3109e3cf30b960b4b376032ff3b5ec58ca42a1e5899b841cf29fbcf14ad8",
|
||||
"geometry_frames_sha256": "b4db5d0ebaba4d6268a1006707dc313c229f3dbdfd73b1d863cad5d853be8ac4"
|
||||
"geometry_result_id": "m4-geometry-replay-b7d72e9e411fd576243d10ab39717e90daa10703b67c6e32ab284f6bd78d344a",
|
||||
"geometry_frames_sha256": "d4d4c0a98e09c0f26251284747108651cf4b21e9c9733e3a14963999b0300772"
|
||||
},
|
||||
"temporal": {
|
||||
"coordinate_frame": "map",
|
||||
|
||||
@@ -111,6 +111,27 @@ handle from the operator's scan and connects that exact selected handle in the
|
||||
following network action. A fallback lookup remains only for non-UI callers
|
||||
that did not perform discovery first.
|
||||
|
||||
The public advertisement cache is deliberately separate from an admitted
|
||||
device session. Rows from the latest explicit scan generation remain stable
|
||||
without wall-clock expiry while the operator completes the form. They still
|
||||
grant no mutation authority without exact retained-handle capture and live
|
||||
GATT validation. An admitted selected session ends only on proven disconnect,
|
||||
explicit stop, app/backend restart, selection of another K1, or connection-mode
|
||||
switch. A later scan may replace unselected candidates but never auto-connects
|
||||
any of them.
|
||||
|
||||
An explicit Quick Connect to Bridge request for that same device can therefore
|
||||
continue when K1 no longer advertises after AP activation. The preconditions
|
||||
are: no active acquisition, no pending evidence cleanup, no active local source,
|
||||
and a terminal/released control session. A mode switch closes the old selected
|
||||
session first; the operator then scans, selects the K1, and creates a clean new
|
||||
GATT session for Bridge. Before the station command the code performs the
|
||||
normal internal `7f02` baseline read and allows exactly one 99-byte `7f01`
|
||||
write. A powered-off or unreachable peripheral ends that attempt. An
|
||||
unobserved post-write result is recorded as terminal `outcome-unknown`; it is
|
||||
never retried automatically and never blocks a later distinct explicit
|
||||
scan-select-connect attempt.
|
||||
|
||||
The 2026-07-20 prepared-host acceptance installed the exact firmware provider,
|
||||
found one expected K1 candidate, emitted one AP-enable write, observed AP-ready
|
||||
and completed one CoreWLAN association without an iPhone or manual credential.
|
||||
@@ -136,12 +157,19 @@ mode. It never fragments or retries the payload automatically.
|
||||
|
||||
A completed GATT write only proves transport completion. It does not prove that
|
||||
the K1 joined Wi-Fi or began beaconing. The application polls `7f02`; the
|
||||
observed response frame contains a fixed-width mode slot, an address slot, a
|
||||
status byte at offset 50 and the AP-ready flag at offset 51. The stale AP
|
||||
observed response frame contains a fixed-width text slot, an address slot, a
|
||||
status byte at offset 50 and the AP-ready flag at offset 51. The text slot is
|
||||
not a uniform mode enum: AP state uses the `WIFI_AP` control literal, while the
|
||||
2026-08-08 FW 3.0.2 Bridge observation returned the joined network name. The stale AP
|
||||
baseline reports `WIFI_AP / 192.168.56.1 / byte51=0`; the physically observed
|
||||
ready transition reports the same mode/address with `byte51=1`.
|
||||
|
||||
For Bridge/Direct Connect, acceptance requires at least one of:
|
||||
For Bridge/Direct, acceptance requires the post-write `7f02` text slot to match
|
||||
the exact requested network name and the address slot to contain a valid
|
||||
non-AP private IPv4. This proves the desired target even when the K1 was
|
||||
already joined to the same network before the explicit idempotent command. A
|
||||
legacy literal-only `WIFI_CLIENT` observation retains the older conservative
|
||||
cross-family rules and requires at least one of:
|
||||
|
||||
1. `7f02` reports a non-AP IPv4 address;
|
||||
2. the same address appears as a new router/ARP client after the write;
|
||||
@@ -150,6 +178,15 @@ For Bridge/Direct Connect, acceptance requires at least one of:
|
||||
|
||||
Do not infer success from a write callback alone.
|
||||
|
||||
An interrupted attempt with no exact post-write network-name observation is not
|
||||
made successful by a write callback, changed DHCP address, router/ARP row or
|
||||
reachable endpoint. Likewise, an already AP-ready baseline alone cannot prove
|
||||
the outcome of an interrupted Quick-to-Quick attempt. Such an attempt remains
|
||||
`outcome-unknown` in historical audit and is never replayed automatically. It
|
||||
does not create a permanent mutation barrier: after the old active operation
|
||||
and cleanup have terminated, a later explicit operator scan, selection, and
|
||||
connect is a distinct session with its own single reviewed write.
|
||||
|
||||
The Bridge/Direct Connect address is a DHCP lease, not configuration and not
|
||||
device identity. Mission Core re-reads `7f02` without writing before every new
|
||||
LAN control session, implicit-host acquisition and factory-calibration read.
|
||||
@@ -157,6 +194,10 @@ If the value changes, it rotates `device_session_id`; it never retargets an
|
||||
active acquisition. A correlated MQTT `DeviceInfo` response supplies the live
|
||||
model/firmware/serial identity barrier.
|
||||
|
||||
The joined network name is used only for exact in-process comparison with the
|
||||
current explicit request. Durable network audit stores the normalized semantic
|
||||
family and never stores or publishes the raw network name.
|
||||
|
||||
The 2026-07-20 reboot/power-cycle check observed the startup race directly:
|
||||
one read returned the earlier `.54` lease while that exact address had no ARP or
|
||||
application endpoint; a later read returned `.52`, where exact probes found
|
||||
@@ -166,7 +207,9 @@ and why a BLE lease observation alone is not reported as live DeviceInfo.
|
||||
|
||||
For Quick Connect, host association is not admitted until the canonical
|
||||
byte-51 ready flag is observed. CoreWLAN then searches only for the exact
|
||||
device-profile SSID for at most 15 seconds and performs at most one association.
|
||||
device-profile SSID for at most 30 seconds and performs at most one association.
|
||||
AP-ready is a device-state barrier, not proof that the host has already observed
|
||||
the RF beacon; a retained successful run required 18.142 seconds of discovery.
|
||||
|
||||
## Safety, recovery and stop conditions
|
||||
|
||||
@@ -176,9 +219,17 @@ device-profile SSID for at most 15 seconds and performs at most one association.
|
||||
secure store. Missing or mismatched firmware material fails before the AP
|
||||
write. Never extrapolate this provider to another firmware or model.
|
||||
- The macOS adapter materializes a device-scoped Keychain item from the exact
|
||||
firmware source, then performs one association. Standard Wi-Fi Keychain and
|
||||
native prompt paths remain compatibility fallbacks, not the reviewed
|
||||
zero-touch path. It never asks the browser for a password.
|
||||
firmware source before the BLE write, then performs one association using
|
||||
only that exact profile. Standard Wi-Fi Keychain lookup, native password
|
||||
prompts and post-write profile rewrites are prohibited. It never asks the
|
||||
browser for a password. Preflight reads are non-interactive and validate the
|
||||
exact SSID/source inside the helper before K1 changes network state.
|
||||
- The prepared-host laboratory adapter launches the reviewed Swift source only
|
||||
through `/usr/bin/xcrun swift`. Runtime `swiftc` compilation to an ad-hoc
|
||||
executable is prohibited because its unstable process identity regressed
|
||||
Keychain ACL and CoreWLAN behavior. Product packaging still requires a
|
||||
prebuilt, properly signed helper with a stable designated identity and
|
||||
explicit CoreWLAN authorization.
|
||||
- Do not alter Deco settings, scan the subnet, or guess any credential.
|
||||
- If the status does not change, do not retry automatically.
|
||||
- If the supplied credentials are wrong, reconnect over BLE and overwrite them
|
||||
|
||||
@@ -110,12 +110,11 @@ directly and use their sibling metadata receive timestamps when present.
|
||||
## Connect and stream live
|
||||
|
||||
1. Power K1 to its normal steady-green standby state.
|
||||
2. Confirm the manual power checklist in **Парк → Локальное устройство**.
|
||||
3. Run the real six-second BLE scan and select the intended device from the
|
||||
2. Run the real six-second BLE scan and select the intended device from the
|
||||
complete visible-device list.
|
||||
4. Enter the existing router SSID/password and explicitly authorize the reviewed
|
||||
3. Enter the existing router SSID/password and explicitly authorize the reviewed
|
||||
provisioning write. The backend does not retry the write automatically.
|
||||
5. Enter the required project name, confirm operator presence, closed LixelGO,
|
||||
4. Enter the required project name, confirm operator presence, closed LixelGO,
|
||||
storage/power and steady green, then choose **Запустить сканирование и
|
||||
локальный приём** once.
|
||||
6. Mission Core emits operations 1–6, waits for their correlated device
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
Date: 2026-08-05
|
||||
|
||||
Status: in progress; M4.0–M4.5 accepted, M4.6 replay threat is next
|
||||
Status: in progress; M4.0–M4.6 accepted, E47 diagnostic semantic/SLAM shadow is parallel, M4.7 Worker 006 cutover remains next
|
||||
|
||||
Audit base: `1b3e0b3` on `feat/simulation-polygon-s1`
|
||||
|
||||
@@ -107,12 +107,12 @@ Those systems remain separate platform workstreams.
|
||||
| Degradation | E35 executes six deterministic full-source variants; maximum recovery is 0.102 s against a 0.25 s gate | Accepted reusable regression primitive |
|
||||
| Recorded pacing | E33 processes all 4,489 frames at 10.006 FPS with depth-two queues, zero replacement/drop/deadline miss and 2.668 ms result-age p95 | Accepted stage runner evidence, not end-to-end perception evidence |
|
||||
| Motion | E51 emits 22,885 bounded motion candidates with complete accounting and no map-frame jump candidate | Candidate implementation exists; moving/static correctness is not accepted |
|
||||
| Threat/collision | E51/E53 intentionally publish collision unavailable because body and LiDAR-to-body geometry are unbound | Replay simulation is possible with an explicit virtual rig; physical threat acceptance remains deferred |
|
||||
| Threat/collision | M4.6 evaluates all 4,489 RAVNOVES00 frames with an explicit 1.0 × 0.6 m virtual body, 1.25 m sensor height and 8 m corridor | Accepted only as `replay-simulated`; physical threat/collision acceptance remains deferred |
|
||||
| Realtime worker | Worker 006 has healthy Triton and persistent perception containers plus live Telegraf | Infrastructure exists |
|
||||
| Worker graph | The persistent process still executes `run_e15_shadow_inference.py serve` with E15/E19/E8/E3/E23 profiles | Open architectural blocker: runtime remains LAB-generation-specific |
|
||||
| Model service | Canonical Triton currently exposes pinned `yolox_s` and `pointpillars`; PointPillars was rejected as a K1 product candidate | Reuse `yolox_s`; do not reopen PointPillars |
|
||||
| CV provider interfaces | No executable `DetectorProvider`, `GeometryProvider`, `TemporalProvider` or object-map contract exists in the product package | Open architectural blocker |
|
||||
| LAB execution | 31 evidence definitions are fully classified; E33, E35 and E46J are canonical canaries, the remaining 28 are bounded legacy | Closed for LAB governance; no bulk legacy migration |
|
||||
| LAB execution | 33 evidence definitions are fully classified; M4, E33, E35 and E46J are canonical canaries, E47 is one bounded experimental adapter, and 28 remain read-only legacy | Closed for LAB governance; no bulk legacy migration |
|
||||
| Production import graph | 93 compute modules (72,456 lines), 51 web modules and many direct `web → compute.e*/l*` imports remain | Do not clean wholesale; prohibit new dependencies and isolate the new graph |
|
||||
| Telemetry | Generic pipeline telemetry, multi-contour enrollment, bounded journal and current Worker 006 Telegraf service exist | Reuse; add graph-stage and object-state metrics |
|
||||
| Independent truth | E46/E47/L3.4 freeze paths exist, but the two real reviews/adjudication are incomplete | Existing class truth is not a Milestone 4 blocker; a smaller object-centric truth contract is required |
|
||||
@@ -148,6 +148,51 @@ The following lines of research remain frozen unless a later measured gate fails
|
||||
- a DeepStream rewrite without a demonstrated runtime need;
|
||||
- mass migration or deletion of historical LAB implementations.
|
||||
|
||||
## E47 parallel semantic/SLAM shadow (2026-08-06)
|
||||
|
||||
E47 is an isolated experiment over the accepted M4.6 replay, not a new M4 stage
|
||||
and not a reason to renumber or postpone M4.7. It answers one narrower question:
|
||||
can an immutable semantic mask be bound to the exact RAVNOVES00 camera frame,
|
||||
projected through the admitted factory KB4 calibration onto the complete
|
||||
frame-local SLAM/LiDAR point index space, and aggregated for observations which
|
||||
already exist in the M4 geometry ledger?
|
||||
|
||||
The seam is deliberately model-neutral:
|
||||
|
||||
- a provider supplies a source/frame/model/preprocess-bound `uint8` class-ID mask;
|
||||
- the admitted KB4 projection assigns a semantic status to each source point;
|
||||
- the four statuses remain distinct: `labeled`, `ambiguous`, `unprojected` and
|
||||
`absent`;
|
||||
- observation semantics are detached diagnostics over immutable observation and
|
||||
source-point IDs;
|
||||
- semantic absence never means free and semantic output cannot create an
|
||||
obstacle or change identity, metric geometry, occupancy, motion or threat;
|
||||
- navigation, safety, command, physical-live and actuation authority remain
|
||||
false.
|
||||
|
||||
The full-route E4 EoMT result is the fixed control provider because its 4,489
|
||||
masks, model revision, weights digest and input identity already exist as sealed
|
||||
evidence. This does not promote EoMT to the production model. A later NVIDIA
|
||||
CitySemSegFormer or other candidate must enter through the same provider seam and
|
||||
be compared on frozen independent truth. `supervision` may be used as rendering
|
||||
or evaluation tooling, but it is not itself a segmentation model.
|
||||
|
||||
The temporal claim is intentionally narrower than physical synchronization.
|
||||
All `4,489` E4 mask rows and the sealed E10 source-pack entries have identical
|
||||
camera ordinals and identical recorded `session_seconds` (zero difference after
|
||||
conversion to nanoseconds), so E47 now verifies that binding fail-closed for
|
||||
every frame. The LiDAR and pose entries inside E10 still inherit the admitted E6
|
||||
`nearest-host-arrival-best-effort` binding. Their recorded per-frame deltas are
|
||||
checked against the existing `100 ms` limits, but this does not prove a shared
|
||||
hardware clock or zero camera-to-LiDAR skew. E47 therefore remains warning-grade
|
||||
diagnostic evidence and cannot be described as physically synchronized.
|
||||
|
||||
The reusable UI reads the same recorded clock in `VIDEO`, `CAMERA`, `3D` and
|
||||
`PLAN`: the mask is drawn below existing boxes and the matching semantic class
|
||||
IDs color only the exact current point increment. Any unknown mask class,
|
||||
point-index mismatch or artifact-accounting drift is an explicit integrity
|
||||
failure rather than an empty or cosmetically clean layer.
|
||||
|
||||
## Target reference graph
|
||||
|
||||
```text
|
||||
@@ -425,6 +470,10 @@ Exit:
|
||||
|
||||
### M4.6 — implement replay-only threat assessment
|
||||
|
||||
Status: accepted on 2026-08-05. See the implementation record below and ADR
|
||||
0040. The virtual dimensions are a replay hypothesis, not a retroactive physical
|
||||
rig measurement.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- define a versioned virtual rig and corridor profile for RAVNOVES00 replay;
|
||||
@@ -613,7 +662,7 @@ Milestone 4 is complete only when all of the following are true:
|
||||
contract.
|
||||
- [ ] Current, held, stale, unavailable and conflict states are explicit.
|
||||
- [ ] Moving/static/unknown state is measured without semantic-class dependence.
|
||||
- [ ] Replay-only threat assessment is explicit and cannot claim physical authority.
|
||||
- [x] Replay-only threat assessment is explicit and cannot claim physical authority.
|
||||
- [ ] Worker 006 runs the canonical graph instead of the E15-specific server.
|
||||
- [ ] Full source-paced replay, deterministic replay, degradation, recovery and
|
||||
soak gates pass.
|
||||
@@ -908,8 +957,183 @@ canonical temporal or motion bytes.
|
||||
|
||||
All fifteen M4.5 acceptance requirements are true. Synthetic tests cover ID and
|
||||
semantic-hint changes, moving, stationary, held, expiry, camera-only uncertainty
|
||||
and map-frame discontinuity. M4.5 is closed; M4.6 replay-only threat assessment
|
||||
is the next implementation phase.
|
||||
and map-frame discontinuity. This closed M4.5 and supplied the immutable input to
|
||||
the following M4.6 replay-only threat phase.
|
||||
|
||||
### 2026-08-05 — M4.6 dual-evidence replay threat
|
||||
|
||||
M4.6 is closed by the corrected v2 implementation in
|
||||
`k1link.perception.threat` and the immutable replay builder in
|
||||
`k1link.perception.threat_replay`:
|
||||
|
||||
- `DualEvidenceReplayThreatProvider` consumes the canonical `LocalObstacleMap`;
|
||||
it does not select camera-first or LiDAR-first execution;
|
||||
- current LiDAR metric components are eligible for corridor assessment even when
|
||||
they have no semantic class or camera association;
|
||||
- camera-only observations and held/expired metric evidence publish `unknown`,
|
||||
never `not-threat`;
|
||||
- semantic hint and ephemeral detector/component identity do not participate in
|
||||
corridor intersection, closest approach or TTC;
|
||||
- the versioned replay profile fixes a virtual `1.0 × 0.6 m` body, nominal
|
||||
`1.25 m` sensor height, `8 m` forward corridor and `5 s` bounded prediction
|
||||
horizon; all documents retain `replay-simulated`, physical-collision false and
|
||||
actuation false authority;
|
||||
- the collision frame is a gravity-stable virtual `base_footprint`: its vertical
|
||||
origin comes from the recorded local surface, its up axis remains the vendor
|
||||
SLAM map gravity axis, and its forward axis follows the smoothed recorded
|
||||
trajectory while being checked against the calibrated camera optical axis;
|
||||
- local surface height, slope or route/camera disagreement outside the admitted
|
||||
bounds rejects that replay frame instead of rotating the world or silently
|
||||
calculating a corridor from unqualified geometry.
|
||||
|
||||
The original result
|
||||
`m4-threat-replay-7e1613a3ea35638b5ea7a3f7c1c78fe9eba1a3adae540b652dec167f815d45b2`
|
||||
is withdrawn and superseded. It incorrectly used the instantaneous LiDAR frame
|
||||
as a virtual body frame, assumed LiDAR `+X` was vehicle forward even though the
|
||||
recorded K1 calibration places camera-forward near LiDAR `-Y`, and rendered the
|
||||
SLAM world with the handheld sensor roll and pitch. Its acceptance only proved
|
||||
artifact availability, not body/corridor geometric validity.
|
||||
|
||||
The corrected accepted immutable result is
|
||||
`m4-threat-replay-78a06d96c4db5263dc63fc4e6e067c07fc81370d3f5085ff43361af89cec1e9e`:
|
||||
|
||||
- `4,489 / 4,489` frames completed, zero failed;
|
||||
- `27,299` current metric, `37,995` stale/held and `10,158` camera-only evidence
|
||||
publications were each assessed exactly once;
|
||||
- `3,928` source-bound body-frame inputs were available, `3,861` qualified and
|
||||
`67` were rejected: `65` for unqualified sensor height and `2` for excessive
|
||||
route/camera disagreement; `561` source-unavailable frames remain explicitly
|
||||
accounted for;
|
||||
- calibrated camera-forward versus route-forward agreement was `8.439°` p95,
|
||||
with `24.252°` as the maximum accepted value under the fixed `25°` limit;
|
||||
- decisions: `2,716 threat`, `10,700 not-threat`, `62,036 unknown`;
|
||||
- `21,690` geometry-only assessments remained in the decision path without a
|
||||
class requirement;
|
||||
- deterministic fixtures passed `9 / 9`; all four critical fixtures avoided a
|
||||
false `not-threat` outcome;
|
||||
- local uncapped execution measured `278.601 FPS`; provider latency was
|
||||
`1.656 ms` p50 and `6.099 ms` p95;
|
||||
- deterministic frame, visual and fixture ledgers are sealed by SHA-256
|
||||
`d55e7651f0b16a62c6b61c5cb2358dd8dff87dbfa57a59e9ec350bc38b156bc1`,
|
||||
`957c35d46ae30143beb6b2f26f8f722853ef2a1e91a41d5dc1a03fbf723a54e0`
|
||||
and `ffa6f6a0f82faa7b6304aca5d8a62e1bb2730b484929d20d66005db9a2b4fa20`.
|
||||
|
||||
The standard LAB catalog exposes the exact result with a common evidence viewer:
|
||||
full recorded VIDEO, exact CAMERA samples with ranges/unknown boxes, and the same
|
||||
32 synchronized LiDAR point-cloud samples in interactive 3D and plan view. The
|
||||
recorded box overlay was extracted from E46C into a reusable component rather
|
||||
than copied into an M4-specific renderer. Regression frames `138` and `274`,
|
||||
which exposed the original rotated-world defect, are mandatory members of the
|
||||
visual ledger. Visual availability is evidence for inspection, not independent
|
||||
ground truth.
|
||||
|
||||
M4.6 does not close moving/static correctness or object-presence correctness;
|
||||
those remain the independent M4.8 gate. It also does not authorize a physical
|
||||
mount, live K1, navigation, collision safety or commands. On a physical vehicle,
|
||||
the replay-derived virtual frame must be replaced by one measured rigid
|
||||
`T_body_from_sensor`; this does not change the downstream obstacle or threat
|
||||
contracts. M4.7 is now the next implementation phase.
|
||||
|
||||
### 2026-08-05 — M4.5R representation correction and M4.6 reseal
|
||||
|
||||
Operator inspection of frame `1880` exposed a second independent defect in the
|
||||
accepted M4.6 evidence: the recorded K1 `lio_pcl` message had been rendered and
|
||||
processed as a complete current scan. It is actually a registered SLAM-map
|
||||
increment. The camera visibly retained two concrete hemispheres while that
|
||||
single increment did not republish their points. Result
|
||||
`m4-threat-replay-78a06d96c4db5263dc63fc4e6e067c07fc81370d3f5085ff43361af89cec1e9e`
|
||||
is therefore withdrawn. Its gravity-stable body-frame correction remains valid,
|
||||
but its obstacle-presence interpretation does not.
|
||||
|
||||
ADR 0041 adds a product-owned rolling local occupancy provider without changing
|
||||
the detector, geometry or body-frame seams. The exact current increment,
|
||||
temporal motion state and bounded retained occupancy are separate contracts.
|
||||
Missing republication never clears a cell. Retained occupancy is bounded to
|
||||
`3.0 s`, `12.0 m`, `65,536` cells and zero allowed capacity drops; it can block
|
||||
an intersecting corridor but cannot claim current motion or safe clearance.
|
||||
|
||||
Review of exact CAMERA frame `2584` then exposed a separate geometry defect:
|
||||
the visible near concrete hemisphere produced six occupied points in one
|
||||
`0.45 m` voxel at frame `2572`, but `geometry_minimum_cluster_voxels = 2`
|
||||
discarded it before both temporal products. This was a real false negative.
|
||||
The minimum remains four source points, while a compact component may occupy
|
||||
one voxel. The former M4.5R/M4.6 results are withdrawn as current evidence.
|
||||
|
||||
The accepted corrected M4.4 geometry result is
|
||||
`m4-geometry-replay-b7d72e9e411fd576243d10ab39717e90daa10703b67c6e32ab284f6bd78d344a`.
|
||||
It publishes `22,740` geometry-only observations and `2,173,778` source-point
|
||||
rows. Its frame ledger SHA-256 is
|
||||
`d4d4c0a98e09c0f26251284747108651cf4b21e9c9733e3a14963999b0300772`.
|
||||
|
||||
The accepted corrected M4.5R result is
|
||||
`m4-temporal-replay-81e13d5654ac8d1219f6937dd425f7dbcdbacd5748d23fe653297134f0de6c22`:
|
||||
|
||||
- frame ledger SHA-256
|
||||
`e83b80ea06b3462c2d04c5a1b74289a0ec401596c7150ae90f5a1b748b639c3a`;
|
||||
- manifest/report SHA-256
|
||||
`540640dacb10f0215133f3a2c833b0502856e132350fa262751894a7f62cdb84` /
|
||||
`461bedc3ae03cda90630c92e6b4c0c637ae4b6514a90d956abfb1be592230ed0`;
|
||||
- `4,489 / 4,489` frames, zero failed;
|
||||
- `849,650` current increment cells;
|
||||
- `70,989` retained component and `1,779,135` retained cell publications;
|
||||
- peak `907` active cells and `35` retained components;
|
||||
- `29,720` time evictions, `6,513` radius evictions, zero capacity drops and
|
||||
maximum retained age exactly `3.0 s`.
|
||||
|
||||
The accepted corrected M4.6 result is
|
||||
`m4-threat-replay-2a953c5f27f2a5b1dddc5c658c1de2c323d7796084a099c024987a1da03aa324`:
|
||||
|
||||
- manifest/report SHA-256
|
||||
`87c58e8123deede1de2908af98752fbe03b94785858df2492e817c77cf8b02c5` /
|
||||
`8def86903c8d37e3112cf6452d6a22a5a835ccc542e9e01714868dd3a9831864`;
|
||||
- frame/visual SHA-256
|
||||
`b57be1839f5915e3b80b54355b694e0bd8c9ac318d0cbe6de2bff713082cfa4e` /
|
||||
`14b9679b1df4d50aaceeb3e018e46398a92b47727aa83339179395db4dfcd005`;
|
||||
- evidence: `28,081` current metric, `70,989` rolling-map, `38,025`
|
||||
stale/held and `10,158` camera-only publications;
|
||||
- decisions: `7,606 threat`, `10,769 not-threat`, `128,878 unknown`;
|
||||
- deterministic fixtures `10 / 10`, five critical, zero critical false-safe;
|
||||
- body-frame qualification unchanged at `3,861 / 3,928`, confirming the
|
||||
compact-component fix did not regress the earlier route/gravity correction;
|
||||
- frames `1880` and `2584` bind reviewed body-frame windows to produced occupied
|
||||
cells/components. At `2584` the compact near hemisphere survives as retained
|
||||
threat and the far hemisphere is accounted for inside a larger current
|
||||
component. These windows are regression evidence, not independent truth.
|
||||
|
||||
The common viewer keeps VIDEO/CAMERA/3D/PLAN synchronized and independently
|
||||
toggles `CURRENT INCREMENT` and `ROLLING MAP`. CAMERA requests one exact JPEG
|
||||
decoded from the bounded fMP4 GOP instead of preparing the complete video.
|
||||
Regression frames are now `138`, `274`, `1880` and `2584`.
|
||||
|
||||
### 2026-08-05 — M4.6 recorded-realtime visual timeline
|
||||
|
||||
The 32 sealed visual samples remain immutable regression checkpoints, but they
|
||||
are no longer the playback mechanism. A product-owned recorded spatial evidence
|
||||
timeline now exposes every one of the `4,489` source frames without loading or
|
||||
copying the complete `345 MB` frame ledger into the browser:
|
||||
|
||||
- timeline metadata contains only the exact ordered source timestamps and the
|
||||
frozen source, rig, corridor and authority identities;
|
||||
- spatial evidence is read on demand from indexed ledger offsets in bounded
|
||||
chunks of at most `24` frames; the GUI requests `12` and retains at most four
|
||||
chunks;
|
||||
- each frame carries the bounded current point sample, metric/rolling obstacles,
|
||||
camera proposals, decision counts and one exact CAMERA URL;
|
||||
- VIDEO, 3D and PLAN run from one source-time clock at `0.5×`, `1×` or `2×`;
|
||||
CAMERA pauses that clock and decodes exactly the selected sequence;
|
||||
- the Three.js scene separates immutable rig/corridor/grid objects from dynamic
|
||||
point and obstacle layers, so a frame update does not rebuild the scene or
|
||||
reset the operator view;
|
||||
- playback never changes the sealed M4.6 result, its visual ledger or its
|
||||
`replay-simulated`/no-actuation authority.
|
||||
|
||||
Browser acceptance verified live frame advance and changing current/rolling
|
||||
geometry in both 3D and PLAN, exact CAMERA sequence binding, VIDEO clock drift
|
||||
below `0.1 s` after initial decode, `2×` pacing, fullscreen layout and an empty
|
||||
browser error log. The first cold timeline index took approximately `3.5 s`;
|
||||
after indexing, a 12-frame spatial chunk was served in approximately `33 ms` and
|
||||
was about `1.27 MB`. M4.7 may proceed only from the corrected identities above;
|
||||
physical-live, collision, navigation and actuation authority remain false.
|
||||
|
||||
## Implementation order
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ The current XGRIDS contribution maps its proven internal workflow into those
|
||||
platform states without changing the wire protocol:
|
||||
|
||||
```text
|
||||
confirm power -> scan BLE -> select candidate -> enter Wi-Fi
|
||||
scan BLE -> select candidate -> enter Wi-Fi
|
||||
-> provision once -> receive LAN address -> start source
|
||||
-> wait for first point frame -> streaming
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR 0013: explicit K1 local connection matrix
|
||||
|
||||
- Status: amended 2026-07-20; Bridge is the product path, Quick Connect retained as a prepared-host laboratory path
|
||||
- Status: amended 2026-08-08; Bridge is the product path, Quick Connect retained as a prepared-host laboratory path
|
||||
- Date: 2026-07-19
|
||||
- Extends: ADR 0004, ADR 0005 and ADR 0012
|
||||
|
||||
@@ -47,6 +47,23 @@ for the previous session. An active acquisition is never retargeted in place.
|
||||
The later correlated MQTT `DeviceInfo` response supplies model, firmware,
|
||||
serial and vendor identity; IP equality alone cannot identify a K1.
|
||||
|
||||
The 2026-08-08 physical Bridge trace corrected the earlier field model: the
|
||||
first `7f02` text slot is `WIFI_AP` in AP state but contains the joined network
|
||||
name in FW 3.0.2 station state. Mission Core therefore normalizes that station
|
||||
response to `WIFI_CLIENT` internally and admits Bridge/Direct only when the raw
|
||||
post-write name exactly matches the current explicit request plus a valid
|
||||
non-AP private address. The raw name is not persisted in the secret-free
|
||||
network audit or published through API state.
|
||||
|
||||
The limitation still applies when recording an interrupted attempt that has no
|
||||
exact post-write network-name observation. A changed private DHCP address alone
|
||||
cannot identify the selected network. An already AP-ready baseline likewise
|
||||
cannot prove the outcome of an interrupted Quick-to-Quick attempt. Mission Core
|
||||
therefore records that attempt as terminal `outcome-unknown` and never replays it automatically. The
|
||||
historical uncertainty is not a permanent barrier: after the old active
|
||||
operation and cleanup have ended, a later explicit operator scan, selection,
|
||||
and connect is a distinct session with its own one reviewed write.
|
||||
|
||||
Product decision on 2026-07-20: Bridge/direct-LAN is the continuing route.
|
||||
Quick Connect remains visible and executable on an already prepared host, but
|
||||
is not a deployment dependency or portability claim.
|
||||
@@ -86,12 +103,65 @@ and the following cold Swift/CoreWLAN process missed the beacon. The corrected
|
||||
implementation holds the selected `BleakClient` open through bounded native
|
||||
SSID discovery and the single association call.
|
||||
|
||||
BLE discovery and the selected device action form one host session. A physical
|
||||
run proved that immediately rediscovering the same K1 by its CoreBluetooth UUID
|
||||
can fail even though the preceding scan exposed it. Mission Core retains the
|
||||
non-serializable `BLEDevice` handle process-locally and uses that exact handle
|
||||
for the next selected network action; it never exposes the handle through API
|
||||
state or treats the macOS UUID as durable device identity.
|
||||
One explicit six-second BLE discovery and the later Apply action form one
|
||||
operator intent without a second discovery. A physical run proved that
|
||||
immediately rediscovering the same K1 by its CoreBluetooth UUID can fail even
|
||||
though the preceding scan exposed it. Mission Core retains the non-serializable
|
||||
`BLEDevice` handle process-locally and uses that exact handle for Apply; it never
|
||||
exposes the handle through API state or treats the macOS UUID as durable device
|
||||
identity. UI row selection itself performs no GATT or backend I/O.
|
||||
|
||||
The operator-visible candidate list, local selected draft and admitted active
|
||||
session are separate contracts. Every explicit scan replaces the candidate
|
||||
set. Selection only binds a local form to one result from the latest admitted
|
||||
generation. Wall-clock age does not remove that generation while the operator
|
||||
completes the form. Apply admits only its exact retained handle and live GATT
|
||||
validation may create the active session; a remembered UUID is never mutation
|
||||
authority. Proven disconnect, explicit stop, app/backend restart, another
|
||||
explicit Scan, or a committed mode transition revokes the applicable candidate
|
||||
or live session. Rediscovery never auto-connects.
|
||||
|
||||
Quick Connect to Bridge is an explicit topology transition, not another scan
|
||||
heuristic. Selecting Bridge — or choosing another K1 while Bridge is already
|
||||
selected — sends one idempotent local `reset_scenario` CAS. It seals retained
|
||||
receiver/camera/control ownership, invalidates candidates and credentials and
|
||||
retires old physical lineage truthfully, while sending no device command, BLE,
|
||||
host-network write or automatic Scan. The next explicit Scan starts the clean
|
||||
discovery flow, while Apply remains the topology and device-mutation boundary.
|
||||
The operator selects one discovered K1, sees the Bridge credentials immediately
|
||||
and submits once. The new GATT
|
||||
session reads internal baseline `7f02` and emits exactly one reviewed 99-byte
|
||||
station write. A connect failure ends that attempt. An ambiguous post-write
|
||||
failure is terminal `outcome-unknown` audit, not a permanent cross-session
|
||||
fence. There is no automatic BLE or network-write retry.
|
||||
|
||||
Likewise, an exact REST `network_applied` result with unready/unknown control
|
||||
spends that Apply and its credentials without making the read-model attempt a
|
||||
permanent topology lock. Recommended Verify is pinned to the backend
|
||||
current/configured target. A separately explicit new intent still requires
|
||||
current server policy: Bridge prepares `select-device` before a later fresh
|
||||
scan; Quick and Direct run an admitted fresh scan, select only its latest row
|
||||
and create a new idempotency Apply; a mode or same-mode new-device transition
|
||||
uses one idempotent local-only `reset_scenario` before that fresh scan. None of
|
||||
these new-intent UI paths reuses the old
|
||||
intent or runs as a hidden frontend/mutating continuation. The service-owned
|
||||
same-intent read-only bootstrap declared below is the sole post-ACK exception.
|
||||
|
||||
The Apply REST call returns as soon as the exact durable
|
||||
`network_applied` proof is available. The service may continue the same
|
||||
intent's supervised control bootstrap read-only after that ACK. This performs
|
||||
no BLE/host mutation or retry and creates no frontend Verify, Scan, Apply or
|
||||
blocking Apply loader. While the exact child is accepted/running, the UI may
|
||||
show only a passive **Сеть настроена · подтверждаем управление** indicator and
|
||||
must keep every recovery action disabled. Later control state arrives only as
|
||||
backend presentation convergence; terminal unready/unknown state then exposes
|
||||
the explicit server-policy recovery choices.
|
||||
|
||||
Bridge and Quick Connect were physically accepted as separate paths before
|
||||
this amendment. The combined Quick Connect to Bridge transition has automated
|
||||
contract coverage but remains a distinct physical acceptance gate; it must not
|
||||
be reported as field-accepted until one redacted live run records both sides of
|
||||
the transition.
|
||||
|
||||
The corrected host boundary derives a non-secret, device-scoped profile ID from
|
||||
the selected SSID. The reviewed client contains per-device `WiFiAP_SSID` and
|
||||
@@ -110,13 +180,40 @@ that opaque source before any BLE write. A missing provider fails closed. The
|
||||
browser, API, argv, logs, manifests and evidence never receive the secret; the
|
||||
importer's short-lived mutable buffer is zeroized after the Keychain handoff.
|
||||
|
||||
The 2026-08-06 field regression established that process identity is part of
|
||||
this prepared-host contract. Runtime `swiftc` compilation produced an ad-hoc
|
||||
helper with an unstable designated identity. macOS then requested Keychain
|
||||
authorization repeatedly and the same process context failed to expose the
|
||||
exact K1 SSID through CoreWLAN even after K1 had acknowledged AP-ready. That
|
||||
runtime-compiled route is rejected. The laboratory adapter uses the previously
|
||||
physically accepted Apple-signed interpreter path,
|
||||
`/usr/bin/xcrun swift <reviewed-source>`, and validates the source path before
|
||||
launch. A portable product implementation still requires a packaged,
|
||||
precompiled and properly signed helper with a stable bundle identifier,
|
||||
designated requirement, Location/CoreWLAN authorization and Keychain ACL; the
|
||||
current prepared-host path does not claim that packaging work is complete.
|
||||
|
||||
Before any BLE write, the helper's preflight is non-interactive. It first checks
|
||||
Keychain item existence through metadata, then validates the selected profile's
|
||||
SSID and `exact-firmware-profile` provenance inside the helper without returning
|
||||
secret data. Provider material is also read with interaction disabled if a
|
||||
missing device profile must be materialized. The association phase accepts only
|
||||
that already materialized exact profile. It never
|
||||
falls back to the system Wi-Fi Keychain, rewrites a profile opportunistically,
|
||||
or opens a password/authorization dialog after K1 has changed network state.
|
||||
An unavailable or unauthorized profile therefore fails closed with a precise
|
||||
reason code and no automatic device retry.
|
||||
|
||||
The host-network boundary, rather than the XGRIDS frontend, owns platform
|
||||
association. Browsers expose no Wi-Fi join API, and Apple's iOS
|
||||
`NEHotspotConfiguration` consent flow is unavailable on macOS. The current
|
||||
implementation therefore uses a short-lived Swift/CoreWLAN + macOS Keychain
|
||||
helper; Windows Credential Manager and Linux Secret Service adapters remain
|
||||
separate platform work. The helper performs repeated read-only exact-SSID scans
|
||||
inside one 15-second discovery window and at most one association. It never
|
||||
inside one 30-second discovery window and at most one association. The larger
|
||||
window covers the physically observed 18.142-second beacon-discovery case;
|
||||
AP-ready confirms K1 state but does not prove that macOS has already observed
|
||||
the RF beacon. It never
|
||||
repeats the BLE command, guesses a password or treats `7f01` as a credential-read
|
||||
command. The credential-bearing 99-byte station-provisioning frame and fixed
|
||||
100-byte AP-enable frame are separate reviewed payloads.
|
||||
@@ -128,12 +225,13 @@ The owner also observed no explicit device/account pairing in the normal
|
||||
LixelGo onboarding flow; this is consistent with a firmware-defined AP secret,
|
||||
but does not establish account-wide authorization for arbitrary scanners.
|
||||
|
||||
Connection verification refreshes the session-scoped lease with the same
|
||||
read-only BLE status operation. It does not write a characteristic, re-provision
|
||||
Apply may read BLE baseline internally while establishing a new selected
|
||||
session. Selection never does so, and the normal flow has no mandatory or hidden
|
||||
"verify without write" recovery step. The baseline read does not re-provision
|
||||
Wi-Fi, scan the subnet, change a host route, or touch VPN configuration. The
|
||||
later canonical MQTT session supplies the real data-plane connection and live
|
||||
`DeviceInfo` identity check. A BLE lease observation is therefore not by itself
|
||||
a claim that MQTT/RTSP is reachable.
|
||||
later canonical MQTT
|
||||
session supplies the real data-plane connection and live `DeviceInfo` identity
|
||||
check; BLE status alone is not a claim that MQTT/RTSP is reachable.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -148,6 +246,10 @@ a claim that MQTT/RTSP is reachable.
|
||||
therefore not scheduled for this Quick Connect path.
|
||||
- Direct Connect requires an already-running hotspot and a controller route;
|
||||
Mission Core does not create or manage that hotspot.
|
||||
- Discovery never auto-connects devices. Mode, selection and input are local
|
||||
only. App restart, disconnect, explicit stop and mode transition require a
|
||||
fresh explicit scan-select-Apply session. Apply performs no hidden rescan or
|
||||
Verify and may cross at most one device-mutation boundary.
|
||||
- The application-control, START/STOP and raw-first acquisition protocol is
|
||||
unchanged after a target address is admitted.
|
||||
- Direct Connect remains explicitly pending one owner-operated physical
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# ADR 0014: long-lived macOS host-association observer
|
||||
|
||||
Status: planned production boundary; software contract may be developed behind
|
||||
a disabled feature flag.
|
||||
|
||||
Related acceptance item: `CONN-66` in
|
||||
[`../20_K1_CONNECTION_SUPERVISION_CANON.md`](../20_K1_CONNECTION_SUPERVISION_CANON.md).
|
||||
|
||||
## Context
|
||||
|
||||
Mission Core must distinguish a K1 that is configured for a network from a Mac
|
||||
that is currently attached to the same network. Route, TCP, DeviceInfo,
|
||||
control and data evidence are bound to a host-path epoch; a Wi-Fi switch,
|
||||
sleep/wake cycle or observer restart must invalidate that epoch before any late
|
||||
TCP/MQTT result can restore command authority.
|
||||
|
||||
The current laboratory implementation is fail-closed but not a production
|
||||
observer. One normal connection-monitor poll samples the host path before and
|
||||
after its TCP probe. Each sample synchronously invokes:
|
||||
|
||||
```text
|
||||
/usr/bin/xcrun swift plugins/xgrids-k1/macos/associate_wifi.swift
|
||||
```
|
||||
|
||||
under one process-local lock with a 30-second timeout. At the one-second
|
||||
monitor interval this can launch two Swift processes per second. A failed
|
||||
cycle can occupy the lock for roughly sixty seconds, and cancellation of the
|
||||
Python `asyncio.to_thread()` waiter does not terminate the native process or
|
||||
thread. Physical-command validation shares this observation path. Shorter
|
||||
timeouts, cached shell output or automatic fallback would hide rather than
|
||||
remove the lifecycle defect.
|
||||
|
||||
## Decision
|
||||
|
||||
Production host-association evidence will come from one signed, long-lived,
|
||||
read-only agent in the user's macOS login session.
|
||||
|
||||
- The agent owns one `CWWiFiClient` for its process lifetime.
|
||||
- It observes CoreWLAN link/association/power events and macOS sleep/wake.
|
||||
- It never scans BLE, changes Wi-Fi, reads K1 credentials, reconnects MQTT or
|
||||
sends START/STOP.
|
||||
- It is packaged in a minimal container app and registered with `SMAppService`;
|
||||
it is not a `LaunchDaemon` and is not launched through `xcrun` at runtime.
|
||||
- The required Wi-Fi event entitlement and Location authorization are checked
|
||||
before K1 network mutation is offered. Missing authorization produces
|
||||
explicit unavailable evidence, not a crash loop or guessed association.
|
||||
- The existing Wi-Fi mutator remains a separate component under the exclusive
|
||||
network process lease. Observer authority and mutation authority are never
|
||||
combined.
|
||||
|
||||
The backend communicates with the observer through bounded local IPC. Each
|
||||
backend session supplies a random HMAC key. SSID and BSSID remain inside the
|
||||
agent; only an opaque continuity token is returned and it cannot be correlated
|
||||
between backend processes. The token material is interface plus BSSID; SSID is
|
||||
used only to report evidence quality. This keeps one AP identity stable when
|
||||
macOS alternates between `ssid+bssid` and `bssid-only` disclosure.
|
||||
|
||||
## Observer contract
|
||||
|
||||
```text
|
||||
schema_version: missioncore.macos-host-association/v2
|
||||
agent_instance_id: random 128-bit process instance
|
||||
sequence: uint64
|
||||
association_epoch: uint64
|
||||
interface_name: string | null
|
||||
wifi_interface: true | false | null
|
||||
state: associated | not-associated | inactive | not-wifi | unavailable
|
||||
evidence_quality: ssid+bssid | bssid-only | not-wifi | unavailable
|
||||
continuity_token: 64 lowercase hex | null
|
||||
reason_code: string | null
|
||||
observed_monotonic_ns: uint64
|
||||
sample_age_ms: uint32
|
||||
cause: initial | link-change | association-change | power-change |
|
||||
permission-change | will-sleep | did-wake | poll-correction |
|
||||
observer-restart
|
||||
```
|
||||
|
||||
`sequence` changes for every event or heartbeat. `association_epoch` changes
|
||||
when interface, power, state, SSID or BSSID changes. Sleep and wake each create
|
||||
a barrier even if the visible network looks unchanged afterward. A new agent
|
||||
instance, IPC reconnect, sequence rollback/gap, malformed frame or timeout is
|
||||
also a discontinuity.
|
||||
|
||||
The backend adds its own `observer_session_epoch`; the effective host-route
|
||||
fingerprint includes the agent instance, observer session, association epoch
|
||||
and opaque token. A response from an old session or sequence is discarded.
|
||||
Unknown schema/state or incomplete evidence is `unavailable` and immediately
|
||||
revokes host authority.
|
||||
|
||||
## Timing and failure semantics
|
||||
|
||||
- Heartbeat: 1 second.
|
||||
- Maximum cached-snapshot age: 750 ms.
|
||||
- Snapshot RPC deadline: 250 ms.
|
||||
- Initial handshake deadline: 2 seconds.
|
||||
- Two missed heartbeats or one invalid IPC frame revoke authority immediately.
|
||||
- Reconnect backoff: 250 ms, 500 ms, 1 s, 2 s, then at most 5 s.
|
||||
- There is no automatic fallback to the Swift source runner.
|
||||
- Agent loss affects only read-only host evidence. It never triggers a K1
|
||||
network write, MQTT reconnect or physical command.
|
||||
- A discontinuity first marks the supervisor host path unavailable and rotates
|
||||
its epoch. Recovery then requires fresh route, TCP and DeviceInfo/control
|
||||
evidence in that order.
|
||||
|
||||
## Delivery phases
|
||||
|
||||
Phase A is safe without signing or a physical K1:
|
||||
|
||||
1. Define the Python observer protocol and validate the v2 schema.
|
||||
2. Add a fake/in-memory transport and backend session/sequence validator.
|
||||
3. Implement immediate epoch invalidation and bounded cached lookup.
|
||||
4. Inject the observer into the monitor behind a disabled feature flag.
|
||||
5. Implement the Swift reducer and local transport as a testable Swift package.
|
||||
6. Test sleep/wake, timeout, event gaps, delayed replies, crash/restart and
|
||||
manual network changes.
|
||||
7. Expose secret-free observer health and next action to the UI.
|
||||
8. Prove 10,000 samples launch no child process and cause no lock starvation.
|
||||
|
||||
Phase B requires the actual Mac signing and permission environment:
|
||||
|
||||
1. Package and register the user-session agent.
|
||||
2. Obtain the Wi-Fi events entitlement and complete Location onboarding.
|
||||
3. Run the observer in shadow mode beside the current fail-closed probe.
|
||||
4. Cut over only after the physical fault matrix and an eight-hour soak show no
|
||||
unexplained divergence.
|
||||
|
||||
## Acceptance gate
|
||||
|
||||
- No `xcrun`, `swift` or `swiftc` occurs on the observer path.
|
||||
- One agent and one CoreWLAN client serve one login session.
|
||||
- Snapshot p99 is below 50 ms, hard deadline 250 ms, monitor-cycle p99 below
|
||||
1.5 seconds.
|
||||
- No mutex is held across native or IPC calls.
|
||||
- Sleep, wake, agent restart, sequence gap and timeout always invalidate the
|
||||
effective host epoch.
|
||||
- Late TCP/DeviceInfo evidence from an old epoch is rejected.
|
||||
- SSID, BSSID and credentials never enter IPC logs, API state or artifacts.
|
||||
- Quick-to-Bridge, Bridge-to-Quick, manual Wi-Fi switch, Wi-Fi off/on,
|
||||
router loss/return with the same SSID/IP, backend restart and Location denial
|
||||
all revoke control authority within two seconds and recover only through
|
||||
fresh route, TCP and DeviceInfo evidence.
|
||||
|
||||
Until this gate passes, the current association probe remains explicitly a
|
||||
laboratory implementation and `CONN-66` remains open.
|
||||
|
||||
## Laboratory containment while Location evidence is hidden
|
||||
|
||||
The source-runner helper can return `association-identity-unavailable` on a
|
||||
connected Mac when macOS privacy rules hide SSID and BSSID from the CLI child
|
||||
process. Rotating a random fallback token on every one-second poll made a
|
||||
stable route and a successful TCP probe mutually impossible: every following
|
||||
sample revoked the preceding endpoint result as a fictitious network switch.
|
||||
|
||||
Until the signed observer above replaces the source runner, the laboratory
|
||||
probe uses one random, process-scoped token for the same interface and
|
||||
unavailable-evidence scope. This is not promoted to association evidence:
|
||||
|
||||
- the public evidence quality remains `unavailable`;
|
||||
- interface, source address, kernel route, availability, a proven different
|
||||
BSSID and process restart remain epoch barriers;
|
||||
- endpoint reachability alone remains `configured-unverified`;
|
||||
- only fresh exact DeviceInfo/control evidence can grant control authority;
|
||||
- `CONN-66`, sleep/wake and same-subnet network-switch acceptance remain open.
|
||||
|
||||
For an already reachable lease whose exact DeviceInfo identity and control
|
||||
session remain healthy, a temporary helper timeout or privacy-limited
|
||||
association sample may retain the preceding proven association fingerprint
|
||||
only while the kernel route fingerprint, interface, source, intent and target
|
||||
are unchanged. That retained sample still performs TCP contact and a second
|
||||
kernel-route check, refreshing only route/TCP observation TTLs. Endpoint loss,
|
||||
control loss, control-proof expiry, target/intent change, a proven association
|
||||
identity change or any raw route change revokes immediately. A
|
||||
`configured-unverified` path does not receive this bridge and remains bounded
|
||||
by the existing technical-failure debounce and transport TTL.
|
||||
|
||||
This containment removes the false per-poll epoch churn observed on the field
|
||||
Mac without claiming that the planned production observer has been delivered.
|
||||
@@ -0,0 +1,309 @@
|
||||
# ADR 0015: explicit K1 recovery beside the one-intent connection flow
|
||||
|
||||
Status: accepted product, recovery and presentation contract; executable
|
||||
coverage and remaining hardware acceptance are tracked in
|
||||
`docs/k1-connection-acceptance.manifest.json`.
|
||||
|
||||
Related acceptance items: `CONN-16` through `CONN-19`, `CONN-28`, `CONN-29`,
|
||||
`CONN-65`, and `CONN-68` through `CONN-78` in
|
||||
[`../20_K1_CONNECTION_SUPERVISION_CANON.md`](../20_K1_CONNECTION_SUPERVISION_CANON.md).
|
||||
|
||||
## Problem
|
||||
|
||||
Loss of K1 power, the router, Mac Wi-Fi, MQTT control or the backend does not
|
||||
prove whether K1 is physically scanning. Retained points, an open TCP port and a
|
||||
historical START are insufficient. Replaying START or STOP after an ambiguous
|
||||
dispatch boundary can create a second physical edge.
|
||||
|
||||
The durable physical-command ledger, exact read-only classification and
|
||||
fail-closed supervisor must remain. They must not make ordinary connection slow
|
||||
or surprising. In particular, selecting a device must not secretly connect,
|
||||
Verify, retire/reopen history or delay network credentials.
|
||||
|
||||
## Decision
|
||||
|
||||
### Existing product surface
|
||||
|
||||
K1 connection stays in the existing device plugin section headed
|
||||
**Подключение XGRIDS LixelKity K1**. The surrounding job, entity and lifecycle
|
||||
models do not change. This is novelty A: an improvement to an existing product
|
||||
surface. A separate wizard, modal flow and mandatory preflight/recovery surface
|
||||
are rejected.
|
||||
|
||||
The section reuses canonical shared `Button`, `TextField`, `ActivityIndicator`
|
||||
and `StatusBadge`. It creates no shared entity and uses no raw local HTML
|
||||
controls or literal local status colors.
|
||||
|
||||
### One-intent normal flow
|
||||
|
||||
The normal flow is:
|
||||
|
||||
1. choose Bridge, Direct Connect or Quick Connect locally;
|
||||
2. press the explicit Bluetooth search action;
|
||||
3. wait for exactly one six-second discovery;
|
||||
4. press **Выбрать** on one result;
|
||||
5. enter Bridge/Direct credentials immediately, or review the Quick Connect
|
||||
summary;
|
||||
6. press **Применить** once.
|
||||
|
||||
Opening the section, changing mode, selecting a row and every
|
||||
SSID/password keystroke perform zero browser-controller, device or host I/O.
|
||||
They create no backend operation and show no operation loader. An admitted fresh
|
||||
selection retains the selected card and exposes applicable inputs immediately.
|
||||
A candidate without current draft authority is omitted or presented only as
|
||||
non-actionable evidence; it never receives a misleading disabled primary.
|
||||
|
||||
Each explicit search owns exactly one bounded six-second discovery. It performs
|
||||
no connect, Verify, selection or mutation. Results are never auto-selected.
|
||||
|
||||
One Apply owns the normal connection intent. It may commit the local desired-mode
|
||||
draft under backend CAS and may cross at most one reviewed K1 mutation boundary.
|
||||
Its frontend handler performs no hidden Scan, Verify, reconnect, retirement,
|
||||
reopen, candidate substitution or retry. Quick, Bridge and Direct use the same single primary
|
||||
**Применить** action; credentials are required only for Bridge and Direct.
|
||||
|
||||
Ordinary Bridge Apply never opts into changing the controlling Mac's Wi-Fi
|
||||
association. Host switching is a separate future consequential operator action,
|
||||
not an Apply substep. K1 provisioning can therefore succeed as
|
||||
`network_applied` while control is `control_not_ready`. That result must not
|
||||
repeat that intent's BLE write. Recommended separately explicit read-only
|
||||
Verify/recovery may establish route, endpoint and DeviceInfo/control evidence
|
||||
for the applied topology; a new intent remains separately policy-gated.
|
||||
|
||||
The exact REST response owns completion of the Apply mutation. A snapshot with
|
||||
`connection_attempt.phase=network_applied` is accepted immediately when
|
||||
`control_state` is `control_not_ready` or `unknown`; the controller does not
|
||||
wait for WebSocket/poll convergence or call the full connection-ready
|
||||
requirement. The service may continue supervised same-intent control bootstrap
|
||||
after this fast durable ACK, but only read-only: no BLE/host mutation, mutation
|
||||
retry, new UI action or second Apply. This is not a hidden frontend Scan or
|
||||
Verify. Exact connection-ready remains mandatory before control or physical
|
||||
START. This separation spends the old intent before a delayed state channel
|
||||
could invite its duplicate replay. `connection_attempt` is a read model, not
|
||||
permanent lifecycle authority; current server policy may admit a separately
|
||||
explicit new intent.
|
||||
|
||||
While the exact service-owned bootstrap child is `accepted` or `running` and
|
||||
projects `safe_next_action=wait-for-current-attempt`, the UI shows only one
|
||||
passive **Сеть настроена · подтверждаем управление** indicator. It enables no
|
||||
Verify, mode change, Scan, row or Apply action. Terminal unready/unknown child
|
||||
state then exposes the separately explicit policy-gated recovery choices.
|
||||
|
||||
`network_applied` plus unready or unknown control spends the old Apply and gates
|
||||
ordinary mode change, Scan, row selection and Apply. It first waits passively
|
||||
for an exact active service child; after terminal settlement it presents an
|
||||
explicit recovery choice, regardless of browser-local mode. It never authorizes
|
||||
automatic or same-intent replay. Recommended Verify is pinned to the backend
|
||||
`serverBound` current/configured transport and mode; it never falls back to a
|
||||
selected browser row and is not a prerequisite for every new intent.
|
||||
|
||||
Current server policy may admit a distinct, explicit new-intent path. Bridge
|
||||
uses `prepare-select-device`, a local-only CAS with zero device/host I/O; only
|
||||
after its success may the operator initiate a fresh six-second Scan. Quick and
|
||||
Direct use explicit policy-gated `scan-ble`, then the latest fresh row and a new
|
||||
idempotency Apply. A mode change requires backend `mode_selection` authority and
|
||||
then a fresh explicit Scan. No recovery choice performs hidden Scan, selection,
|
||||
Verify, provisioning or continuation of the old Apply, and the browser never
|
||||
manufactures authority.
|
||||
|
||||
### Freshness and outcome semantics
|
||||
|
||||
Apply is admitted only for the exact selected transport, completed discovery
|
||||
generation, backend runtime, desired-mode revision, reconfiguration intent and
|
||||
policy snapshot. Authority drift before dispatch is a terminal, zero-device-I/O
|
||||
`stale` result. The UI keeps the result understandable, labels it explicitly and
|
||||
offers a new explicit six-second search. It never starts that search itself.
|
||||
|
||||
A failure before the reviewed mutation boundary is `not-dispatched` or
|
||||
`failed`, with zero K1 mutation. A lost response, timeout, power failure or
|
||||
process death after dispatch is `outcome-unknown`, with
|
||||
`safe_to_retry=false`. The durable network-attempt ledger prevents replay.
|
||||
Credentials are never reused automatically. A later operator Apply is a new
|
||||
intent and must pass all current gates.
|
||||
|
||||
## Physical safety remains separate
|
||||
|
||||
Network attempts are disposable; physical START/STOP ambiguity is durable:
|
||||
|
||||
- START and STOP never replay automatically;
|
||||
- control loss does not prove scanning stopped;
|
||||
- local receiver/camera/ingress cleanup is not physical STOP;
|
||||
- a wrong K1/transport/profile/project cannot reconcile the record;
|
||||
- READY records cessation without rewriting historical command outcome;
|
||||
- exact same-project SCANNING may mint one single-use confirmed STOP permit on
|
||||
the still-open exact control binding;
|
||||
- accepted STOP without READY or SCAN_STOPPING by the backend deadline closes
|
||||
only host-owned resources, records `timed_out` / `standby-unknown`, preserves
|
||||
the unresolved ledger and keeps every mutation fenced.
|
||||
|
||||
The composite supervisor and physical-command ledger can disable Apply before
|
||||
device I/O. Their denial does not turn mode, selection or input into recovery.
|
||||
|
||||
### Explicit read-only recovery
|
||||
|
||||
Recovery is a distinct, explicitly requested exceptional action. It is never a
|
||||
continuation of row selection or Apply. The browser supplies neither endpoint,
|
||||
substitute transport nor ledger authority. The backend pins the durable record's
|
||||
exact transport, identity/profile, operation/revision, acquisition/project,
|
||||
topology revision and host epoch.
|
||||
|
||||
The non-reconnecting observation is:
|
||||
|
||||
```text
|
||||
topology-probed
|
||||
-> pre-start-control-opened
|
||||
-> device-info-requested (ordinal 1; exactly one publish)
|
||||
-> device-info-verified
|
||||
-> awaiting-passive-fresh-status
|
||||
-> cessation | active-same-project | foreign-active | inconclusive | failed
|
||||
```
|
||||
|
||||
It publishes exactly one canonical DeviceInfo request and then accepts only a
|
||||
fresh non-retained DeviceStatus from the same socket generation after that
|
||||
barrier. It publishes no status solicitation, DeviceConfig, time sync,
|
||||
workspace, project, START or STOP; it never scans, reconnects, provisions or
|
||||
continues into Apply.
|
||||
|
||||
Canonical READY records cessation/standby. Initialized SCANNING may rebind only
|
||||
when operation/acquisition, identity/profile, transport, host epoch and project
|
||||
all match; it exposes one separate single-use confirmed STOP checkpoint.
|
||||
Foreign, stale or inconclusive evidence changes no topology or authority.
|
||||
|
||||
### Explicit retirement and reopen
|
||||
|
||||
`physical-command.retire-unavailable` is a separately confirmed local durable
|
||||
recovery action for one unresolved target that is truly unavailable or replaced.
|
||||
Admission requires stable idempotency identity and exact backend runtime,
|
||||
operation, ledger revision and transport CAS plus safe lifecycle ownership. It
|
||||
preserves the original unknown outcome, activates the exact-transport deny,
|
||||
performs zero device/host I/O and starts no discovery.
|
||||
|
||||
`physical-command.reopen-retired-reconciliation` is also separately confirmed.
|
||||
It requires an exact fresh same-transport candidate, stable `reopening_id`, exact
|
||||
runtime/revision/retirement/transport/discovery CAS and safe lifecycle ownership.
|
||||
It preserves retirement audit, removes only that retirement's active deny and
|
||||
performs zero device/host I/O. The explicit recovery intent may then run one
|
||||
exact read-only observation. **Выбрать** never invokes retirement, reopen or
|
||||
Verify. The only Apply exception is an internal, request-bound local reopen
|
||||
checkpoint for an explicit scenario reset plus its exact successor Scan. It is
|
||||
ordered after network PREPARED and before the sole dispatch edge, remains
|
||||
invisible in the wizard and grants no command authority. The same applied
|
||||
intent may then settle it read-only from fresh DeviceInfo plus non-retained
|
||||
READY/SCANNING evidence.
|
||||
|
||||
FW 3.0.2 BLE `7f02` contains no stable DeviceInfo identity. Mission Core cannot
|
||||
prove during BLE-only discovery that the same physical unit has a new
|
||||
CoreBluetooth UUID. This remains an explicit protocol/hardware gap.
|
||||
|
||||
### Bounded durable audit rollover
|
||||
|
||||
An explicit local scenario reset must not become unavailable merely because
|
||||
closed retire/reopen history filled the 64 KiB hot ledger. Before a transition
|
||||
would exceed that bound, Mission Core durably publishes the complete previous
|
||||
ledger as a private, owner-only, content-addressed archive segment and then
|
||||
atomically publishes a compact v4 main record. The main record retains every
|
||||
active retirement deny, the newest lost-response retire/reopen checkpoint, and
|
||||
all reconciliation/confirmation proof required by the current physical
|
||||
operation. Compaction never changes a device outcome and performs no device,
|
||||
network or host I/O.
|
||||
|
||||
Archive segments form a predecessor hash chain with exact sequence and byte
|
||||
accounting. Reload verifies directory and file ownership/mode, rejects symlink
|
||||
traversal, bounds total segments and bytes, reparses every embedded ledger and
|
||||
fails closed for a missing, replayed, reordered or tampered segment. Operation,
|
||||
reconciliation, verification, confirmation, retirement and reopening identities
|
||||
remain globally one-use across the hot record and archive. The archive segment
|
||||
is fsynced before the main-file replace: a crash may leave only an inert orphan,
|
||||
while retry of the same CAS reuses identical bytes and cannot duplicate the
|
||||
referenced chain.
|
||||
|
||||
Scenario reset asks the ledger to build the exact prospective retirement or
|
||||
prepared→not-dispatched plan before closing any local receiver, camera,
|
||||
control-session or network ownership. That shared planner applies the same hot
|
||||
serialization, compaction, segment, count and total-byte bounds as commit. When
|
||||
rollover is required, preflight may idempotently prepublish only the immutable
|
||||
content-addressed predecessor; the main revision/CAS and physical disposition
|
||||
remain unchanged. This also proves owner/mode, symlink and content-collision
|
||||
conditions before teardown.
|
||||
|
||||
Archive publication is restart-safe at the hard-link boundary. A process death
|
||||
after destination link and directory fsync but before temporary-name unlink may
|
||||
leave exactly two private names for one inode. Retry removes only a strictly
|
||||
named, owner-only temporary alias whose bytes and inode exactly match the
|
||||
expected destination and whose link count is exactly two, fsyncs that cleanup,
|
||||
then reuses the destination. Any unrelated hard link, extra temporary, symlink,
|
||||
metadata mismatch or byte mismatch remains a fail-closed corruption condition.
|
||||
|
||||
## Failure and restart semantics
|
||||
|
||||
- UI entry, mode, selection, input, polling, refresh and layout changes
|
||||
start no device operation.
|
||||
- Search starts only when pressed, runs once for six seconds and terminalizes.
|
||||
- Apply starts only when pressed, uses one exact fresh candidate and may perform
|
||||
at most one K1 mutation.
|
||||
- Candidate/runtime/intent drift is explicit stale, never hidden rescan.
|
||||
- Post-dispatch uncertainty is explicit outcome-unknown, never automatic replay.
|
||||
- K1 power loss revokes the active session without inventing standby.
|
||||
- Wi-Fi loss and WAN loss are distinct: local LAN control may survive WAN loss;
|
||||
route/association loss revokes only dependent host/control evidence.
|
||||
- Browser refresh restores no live local selection and causes no I/O.
|
||||
- Backend restart restores durable audit and safety ledgers, but no live BLE,
|
||||
control or operator intent.
|
||||
- Mac sleep/restart rotates host/runtime authority and rejects late work.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Mode, selection and input result in zero controller calls.
|
||||
- Each Search click issues exactly one scan with duration `6`; no effect, timer,
|
||||
selection or Apply path calls Scan.
|
||||
- Every result keeps the same ordinary **Выбрать** action. Selection retains
|
||||
the card, shows applicable inputs immediately, shows no loader and calls no
|
||||
controller. After an explicit committed scenario reset and its successfully
|
||||
completed successor Scan, this includes the exact UUID used by the retired
|
||||
prior scenario; the row never exposes a reconnect/reopen/Verify CTA.
|
||||
- During unresolved physical recovery, a completed explicit Scan still renders
|
||||
passive BLE evidence but cannot substitute a foreign target for the durable
|
||||
recovery record. Exact recovery remains a separate established-session
|
||||
action outside the cold result list; ordinary **Выбрать** never invokes its
|
||||
reopen or read-only Verify. A new network flow first requires explicit reset
|
||||
and a successor Scan.
|
||||
- Bridge/Direct show SSID/password; Quick Connect does not.
|
||||
- Exactly one primary **Применить** owns the connection request. Its frontend
|
||||
handler calls no Scan/Verify/reopen helper and it permits at most one device
|
||||
mutation. For an exact reset-owned retired UUID, the backend may append only
|
||||
the internal local settlement checkpoint described above before dispatch.
|
||||
A later SCANNING settlement grants only explicit STOP authority and never
|
||||
restarts the reset-owned receiver, camera, writer or acquisition.
|
||||
- Stale/pre-dispatch and unknown/post-dispatch outcomes are visibly distinct.
|
||||
- Applied-but-unready/unknown spends the old Apply and gates ordinary mode,
|
||||
Scan, selection and Apply behind an explicit recovery choice; recommended
|
||||
Verify has only a server-bound backend target and no browser fallback.
|
||||
- A new intent remains possible only through current backend policy. Bridge
|
||||
uses explicit local-only `prepare-select-device`; Quick/Direct use an explicit
|
||||
admitted Scan and latest fresh row; mode change requires `mode_selection`.
|
||||
Each route starts no hidden frontend or mutating continuation and ends in a
|
||||
later fresh Scan/new idempotency Apply. The declared service-owned
|
||||
same-intent read-only bootstrap after the durable ACK is the sole continuation
|
||||
exception and creates no UI action.
|
||||
- The exact Apply REST snapshot with `phase=network_applied` completes the
|
||||
network intent for both `control_not_ready` and `unknown`, without requiring
|
||||
connection-ready or waiting for WebSocket/poll convergence.
|
||||
- A service-owned supervised control bootstrap may continue read-only after
|
||||
that ACK. It performs no BLE/host mutation or retry and creates no frontend
|
||||
Scan/Verify/new-Apply action or blocking Apply loader. Its exact
|
||||
accepted/running state may own one passive settling indicator only.
|
||||
- Operator error copy comes only from an allowlisted public error-code mapping;
|
||||
unknown/raw messages use a canonical secret-free fallback and never render
|
||||
credentials, SSIDs, payloads or stack traces.
|
||||
- No timeout, disconnect, refresh, restart or state update starts a continuation
|
||||
or replays an ended action.
|
||||
- Supervisor, identity pin, network-attempt ledger, physical-command ledger,
|
||||
process/BLE lease and one-use recovery STOP remain authoritative.
|
||||
- The plugin uses shared `Button`, `TextField`, `ActivityIndicator` and
|
||||
`StatusBadge`; contract tests reject raw local controls and literal colors.
|
||||
- Geometry and long-copy tests keep all actions reachable without overlap.
|
||||
- Bridge and Quick Connect retain separate real-hardware acceptance.
|
||||
|
||||
This ADR does not itself declare hardware coverage. The manifest may mark a
|
||||
scenario software-covered only when named executable tests cover the software
|
||||
invariant; remaining K1/macOS/router and Quick Connect gaps stay explicit.
|
||||
@@ -0,0 +1,112 @@
|
||||
# ADR 0040: Dual-evidence replay threat boundary
|
||||
|
||||
Date: 2026-08-05
|
||||
Status: accepted; representation handling amended by ADR 0041
|
||||
|
||||
## Context
|
||||
|
||||
Historical camera-first experiments correctly kept camera semantics separate
|
||||
from LiDAR metric support, but the phrase "camera-first" is not an acceptable
|
||||
product threat architecture. The RAVNOVES00 camera detector visibly misses some
|
||||
unclassified occupied structures, while camera proposals without qualified
|
||||
LiDAR support cannot establish metric clearance. Making either sensor a gate for
|
||||
the other would discard useful evidence.
|
||||
|
||||
The portable RAVNOVES00 recording also has no admitted measured vehicle body or
|
||||
qualified LiDAR-to-body mount. A recorded threat experiment therefore needs an
|
||||
explicit virtual geometry without weakening the physical rig contract in ADR
|
||||
0035.
|
||||
|
||||
The first M4.6 implementation incorrectly treated the instantaneous LiDAR frame
|
||||
as the virtual body frame. The K1 calibration proves that camera-forward is near
|
||||
LiDAR `-Y`, not `+X`, and the handheld pose contains real roll and pitch. That
|
||||
made the replay corridor approximately 90 degrees off the route and rotated the
|
||||
SLAM world with the operator's hand. Result `m4-threat-replay-7e1613...` is
|
||||
superseded and is not admissible M4.6 evidence.
|
||||
|
||||
The subsequent result `m4-threat-replay-78a06d...` corrected the body frame but
|
||||
still treated each recorded `lio_pcl` message as a complete current scan. ADR
|
||||
0041 withdraws that result and adds the missing rolling-map representation
|
||||
boundary. The dual-evidence and gravity-stable body-frame decisions below remain
|
||||
valid.
|
||||
|
||||
## Decision
|
||||
|
||||
Mission Core threat assessment consumes two independent evidence paths:
|
||||
|
||||
```text
|
||||
camera proposals ---------------------> camera-only uncertainty
|
||||
| |
|
||||
+---- optional association ----+ |
|
||||
v v
|
||||
LiDAR occupied geometry ----------> LocalObstacleMap ---> ThreatAssessment
|
||||
```
|
||||
|
||||
Neither path is called first:
|
||||
|
||||
- camera publishes image-space object proposals and optional semantics;
|
||||
- LiDAR publishes metric occupied components, including geometry with no class;
|
||||
- association enriches evidence but is not an admission gate;
|
||||
- current metric geometry may produce `threat` or `not-threat` from corridor
|
||||
geometry and bounded relative motion;
|
||||
- camera-only, held, expired or otherwise incomplete evidence produces
|
||||
`unknown`, never a safe decision;
|
||||
- semantic class, detector ID and persistent identity are excluded from the
|
||||
threat calculation.
|
||||
|
||||
M4.6 fixes a versioned replay hypothesis: body length `1.0 m`, width `0.6 m`,
|
||||
nominal sensor height `1.25 m`, forward corridor `8 m`, rear margin `0.5 m`,
|
||||
lateral clearance `0.2 m` and prediction horizon `5 s`. These values may be used
|
||||
only with `replay-simulated` authority. They do not populate or qualify
|
||||
`missioncore.rig-geometry/v1`, and they cannot support physical collision,
|
||||
navigation, safety or actuation claims.
|
||||
|
||||
The virtual collision frame is a gravity-stable `base_footprint`, not the
|
||||
instantaneous sensor frame:
|
||||
|
||||
- the K1 vendor SLAM map remains the stable world in which mapped points live;
|
||||
- the rolling local-surface model supplies only the vertical ground origin and
|
||||
a quality check, not a permanent level-world assumption;
|
||||
- forward is the smoothed SLAM trajectory tangent and is independently checked
|
||||
against the calibrated camera optical axis;
|
||||
- unavailable height, excessive local slope or camera/route disagreement makes
|
||||
that frame unqualified instead of silently rotating the corridor;
|
||||
- a mounted vehicle replaces this replay-only derivation with one measured,
|
||||
rigid `T_body_from_sensor`; the detector, obstacle map and threat policy do not
|
||||
change.
|
||||
|
||||
The sensor may therefore be mounted at a non-level angle or noncentral position
|
||||
as long as it is rigid and its one-time body extrinsic is known. Vehicle roll
|
||||
and pitch do not corrupt the SLAM map; a future 3D swept-volume planner may use
|
||||
`base_link`, while the current 2D corridor remains explicitly tied to
|
||||
`base_footprint`.
|
||||
|
||||
## Evidence and presentation
|
||||
|
||||
The accepted replay must publish immutable frame, fixture, report and visual
|
||||
ledgers. Visual evidence uses the common LAB viewer and reusable renderers:
|
||||
|
||||
- full recorded camera video with synchronized proposal boxes;
|
||||
- exact camera samples with metric range or explicit missing range;
|
||||
- synchronized point cloud, occupied cells, virtual body and corridor in 3D and
|
||||
plan view;
|
||||
- mandatory regression frames `138` and `274`, which exposed the original
|
||||
sensor/body-axis failure;
|
||||
- mandatory frame `1880`, which exposes loss of occupied structures when a
|
||||
registered map increment is mistaken for a complete scan;
|
||||
- visible threat/not-threat/unknown and `replay-simulated` authority.
|
||||
|
||||
Visuals are an inspection surface, not ground truth. Independent object-centric
|
||||
labels remain a separate gate.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Unclassified concrete, vegetation or road furniture can remain visible to the
|
||||
metric path without inventing a semantic label.
|
||||
- A camera detection cannot become safe merely because LiDAR support is absent.
|
||||
- New detectors and LiDAR geometry providers may replace either side behind the
|
||||
existing contracts without changing the threat provider.
|
||||
- Physical body/mount qualification and live acceptance remain intentional debt.
|
||||
- ADR 0035 remains valid for ownership of semantics, metric support and physical
|
||||
rig qualification; this ADR supersedes camera-first wording for the canonical
|
||||
product decision graph.
|
||||
@@ -0,0 +1,106 @@
|
||||
# ADR 0041: Registered map increment and rolling local occupancy
|
||||
|
||||
Date: 2026-08-05
|
||||
Status: accepted and implemented for M4.5R/M4.6
|
||||
|
||||
## Context
|
||||
|
||||
The recorded K1 topic `lixel/application/report/lio_pcl` is post-LIO data in the
|
||||
vendor SLAM map frame. In RAVNOVES00 it behaves as a registered map increment:
|
||||
one message contains points contributed at that moment, not a complete scan of
|
||||
everything currently visible. Therefore a structure can remain physically
|
||||
present while its points are absent from a later message.
|
||||
|
||||
M4.5 originally applied a `0.75 s` temporal object hold directly to those
|
||||
increments. M4.6 then rendered the current increment as if it were the complete
|
||||
LiDAR scene. At frame `1880` the exact camera frame visibly contains two
|
||||
concrete hemispheres, while the current `lio_pcl` increment has no corresponding
|
||||
points in the corridor. The old result `m4-threat-replay-78a06d...` consequently
|
||||
lost them from 3D. This was a representation error, not evidence that LiDAR had
|
||||
measured free space.
|
||||
|
||||
## Decision
|
||||
|
||||
Mission Core keeps three distinct products:
|
||||
|
||||
```text
|
||||
current registered increment ──> exact source context
|
||||
current occupied components ───> temporal identity and motion evidence
|
||||
bounded rolling occupancy ─────> conservative local obstacle presence
|
||||
```
|
||||
|
||||
`RollingLocalObstacleMapProvider` accumulates only qualified current occupied
|
||||
cells in the SLAM map frame. It never interprets missing republication as free
|
||||
space. Retained cells are evicted only by explicit bounds:
|
||||
|
||||
- voxel size `0.45 m`;
|
||||
- retention `3.0 s`;
|
||||
- local radius `12.0 m` around the recorded pose;
|
||||
- maximum `65,536` active cells, with fail-closed capacity handling;
|
||||
- bounded component size/count and deterministic 26-neighbour connectivity.
|
||||
|
||||
The provider publishes only cells not present in the current increment as
|
||||
`TemporalState.RETAINED`. Current and retained identities cannot overlap.
|
||||
Retained components have ephemeral identity, carry their last-hit age and never
|
||||
claim current motion. They may assert `threat` when their occupied cells
|
||||
intersect the current virtual corridor; outside the corridor they remain
|
||||
`unknown`, never `not-threat`.
|
||||
|
||||
The `0.75 s` temporal object state remains separate. It continues to answer
|
||||
whether a current component has enough bounded history for motion reasoning. It
|
||||
is not a map-clearing policy.
|
||||
|
||||
## Evidence and acceptance
|
||||
|
||||
The current accepted M4.5R result is
|
||||
`m4-temporal-replay-81e13d5654ac8d1219f6937dd425f7dbcdbacd5748d23fe653297134f0de6c22`.
|
||||
Its frame ledger SHA-256 is
|
||||
`e83b80ea06b3462c2d04c5a1b74289a0ec401596c7150ae90f5a1b748b639c3a`.
|
||||
Across all `4,489` source frames it records:
|
||||
|
||||
- `849,650` current increment occupied cells;
|
||||
- `70,989` retained component publications and `1,779,135` retained cell
|
||||
publications;
|
||||
- peak `907` active cells and `35` retained components;
|
||||
- `29,720` time evictions, `6,513` radius evictions and zero capacity drops;
|
||||
- maximum retained age exactly `3.0 s` and `587` active cells at replay end.
|
||||
|
||||
The current accepted M4.6 result is
|
||||
`m4-threat-replay-2a953c5f27f2a5b1dddc5c658c1de2c323d7796084a099c024987a1da03aa324`.
|
||||
All `4,489` frames completed with `28,081` current metric, `70,989` rolling-map,
|
||||
`38,025` stale/held and `10,158` camera-only evidence publications assessed
|
||||
exactly once. Decisions are `7,606 threat`, `10,769 not-threat` and `128,878
|
||||
unknown`. All `10/10` deterministic fixtures pass with zero critical false-safe
|
||||
outcomes.
|
||||
|
||||
Frame `1880` is a mandatory engineering visual regression. It binds two
|
||||
separate body-frame regions to two retained components. The near region must
|
||||
assert threat; both regions are present in the accepted result. These anchors
|
||||
encode a reproducible owner-reviewed visual case, not independent object truth.
|
||||
|
||||
Frame `2584` is a second mandatory visual regression added after operator
|
||||
review found that a compact hemisphere had six valid points in one voxel and
|
||||
was discarded by a two-voxel geometry filter. The corrected geometry contract
|
||||
retains the four-point minimum but permits one-voxel compact components. At
|
||||
`2584` the near hemisphere is a retained threat and occupied cells for the far
|
||||
hemisphere are present inside a larger current component. This is still an
|
||||
engineering regression window, not independent truth.
|
||||
|
||||
The common LAB viewer exposes `CURRENT INCREMENT` and `ROLLING MAP` as separate
|
||||
layers over the same selected frame. The current point count is not relabelled
|
||||
as a complete scan, and retained occupied cells remain independently hideable.
|
||||
|
||||
## Consequences and limitations
|
||||
|
||||
- Missing points cannot create free-space or navigation authority.
|
||||
- Short-lived obstacle disappearance caused solely by incremental publication
|
||||
is removed from the replay decision path.
|
||||
- A moving object may leave a conservative ghost for up to `3.0 s`; retained
|
||||
occupancy can block but cannot claim motion or clearance.
|
||||
- No ray-level free-space clearing is available in this recording. A future raw
|
||||
scan/ray provider must add explicit observation and clearing semantics rather
|
||||
than weakening this contract.
|
||||
- The frame-1880 and frame-2584 anchors are regression evidence, not independent truth,
|
||||
detector accuracy or physical collision acceptance.
|
||||
- Live K1, measured `T_body_from_sensor`, physical body geometry, navigation,
|
||||
commands and actuation remain outside M4.5R/M4.6 authority.
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"schema_version": "missioncore.k1-connection-acceptance/v1",
|
||||
"canonical_document": "docs/20_K1_CONNECTION_SUPERVISION_CANON.md",
|
||||
"meaning": {
|
||||
"software-covered": "The listed automated tests cover the software invariant; this is not hardware acceptance.",
|
||||
"partial": "At least one software layer is covered and an explicit remaining gap is listed.",
|
||||
"planned": "The scenario is specified but does not yet have adequate executable coverage."
|
||||
},
|
||||
"scenarios": [
|
||||
{"id":"CONN-01","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs"],"remaining":["real K1 Quick-to-Bridge evidence"]},
|
||||
{"id":"CONN-02","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs"],"remaining":["real K1 Bridge-to-Quick evidence"]},
|
||||
{"id":"CONN-03","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hardware pre-dispatch fault injection"]},
|
||||
{"id":"CONN-04","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hardware pre-dispatch fault injection"]},
|
||||
{"id":"CONN-05","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_device_identity_pin_store.py"],"remaining":["two-K1 hardware evidence"]},
|
||||
{"id":"CONN-06","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_ble_scanner.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["CoreBluetooth hardware evidence"]},
|
||||
{"id":"CONN-07","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real K1 wait-beyond-TTL acceptance"]},
|
||||
{"id":"CONN-08","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real app/backend restart reconnect acceptance"]},
|
||||
|
||||
{"id":"CONN-10","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hardware pre-prepare power-loss fault injection"]},
|
||||
{"id":"CONN-11","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["process-kill acceptance at the prepared boundary"]},
|
||||
{"id":"CONN-12","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real K1 post-dispatch power-loss acceptance"]},
|
||||
{"id":"CONN-13","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real K1 observation-loss evidence"]},
|
||||
{"id":"CONN-14","status":"software-covered","test_files":["tests/test_xgrids_semantic_topology_store.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hardware host-association loss"]},
|
||||
{"id":"CONN-15","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hard-power hardware evidence"]},
|
||||
{"id":"CONN-16","status":"partial","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_physical_command_ledger.py"],"remaining":["end-to-end passive Scan policy after acquisition power loss","exact-target read-only recovery/rebind integration","Bridge hardware power-loss acceptance","Quick Connect recovery not exercised"]},
|
||||
{"id":"CONN-17","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py"],"remaining":["transport dispatch integration","restart acceptance"]},
|
||||
{"id":"CONN-18","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_application_mqtt.py"],"remaining":["facade exact-target resolved-active rebind","single-use explicit recovery STOP presentation/action integration","same-project Bridge hardware acceptance","Quick Connect recovery not exercised"]},
|
||||
{"id":"CONN-19","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_application_mqtt.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["facade exact-target resolved-active READY cessation integration","Bridge reboot hardware acceptance","Quick Connect recovery not exercised"]},
|
||||
|
||||
{"id":"CONN-20","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_macos_wifi.py"],"remaining":["router-loss hardware evidence"]},
|
||||
{"id":"CONN-21","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_macos_wifi.py"],"remaining":["same-SSID router-return evidence"]},
|
||||
{"id":"CONN-22","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_macos_wifi.py"],"remaining":["manual macOS switch evidence"]},
|
||||
{"id":"CONN-23","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real Quick AP leave/return"]},
|
||||
{"id":"CONN-24","status":"partial","test_files":["tests/test_connection_supervisor.py","tests/test_ble_scanner.py"],"remaining":["macOS sleep/wake hardware acceptance"]},
|
||||
{"id":"CONN-25","status":"software-covered","test_files":["tests/test_connection_supervisor.py"],"remaining":["route-race integration evidence"]},
|
||||
{"id":"CONN-26","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["wrong-service integration evidence"]},
|
||||
{"id":"CONN-27","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_application_mqtt.py","tests/test_xgrids_application_session.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["half-open MQTT hardware acceptance"]},
|
||||
{"id":"CONN-28","status":"partial","test_files":["tests/test_xgrids_application_mqtt.py","tests/test_xgrids_physical_command_coordinator.py","tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["full facade policy for passive Scan with unknown/active physical state","physical-record transport_ref pinning across bounded observation and wrong-K1 no-topology-change","resolved-active SCANNING one-STOP integration","real K1 passive READY/SCANNING DeviceStatus acceptance"]},
|
||||
{"id":"CONN-29","status":"planned","test_files":[],"remaining":["durable external-active takeover contract","operator-confirmed same-binding STOP"]},
|
||||
|
||||
{"id":"CONN-30","status":"planned","test_files":[],"remaining":["browser/app close clean-session acceptance at every stage"]},
|
||||
{"id":"CONN-31","status":"software-covered","test_files":["tests/test_xgrids_network_mutation_ledger.py","tests/test_xgrids_semantic_topology_store.py"],"remaining":["restart integration acceptance"]},
|
||||
{"id":"CONN-32","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real backend restart acceptance from a prepared network mutation"]},
|
||||
{"id":"CONN-33","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real backend restart acceptance from a dispatching network mutation"]},
|
||||
{"id":"CONN-34","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real backend restart acceptance from an observing network mutation"]},
|
||||
{"id":"CONN-35","status":"software-covered","test_files":["tests/test_xgrids_network_mutation_ledger.py","tests/test_xgrids_semantic_topology_store.py"],"remaining":["restart integration acceptance"]},
|
||||
{"id":"CONN-36","status":"planned","test_files":[],"remaining":["restart acceptance proving no old live session restoration"]},
|
||||
{"id":"CONN-37","status":"planned","test_files":[],"remaining":["corrupt historical audit quarantine without permanent K1 block","operator diagnosis UI"]},
|
||||
{"id":"CONN-38","status":"software-covered","test_files":["tests/test_xgrids_network_mutation_ledger.py","tests/test_xgrids_ble_runtime_arbiter.py"],"remaining":["two-service integration acceptance"]},
|
||||
{"id":"CONN-39","status":"software-covered","test_files":["tests/test_xgrids_network_mutation_ledger.py"],"remaining":[]},
|
||||
|
||||
{"id":"CONN-40","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_application_session.py"],"remaining":["real wrong/failed DeviceInfo evidence"]},
|
||||
{"id":"CONN-41","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["MQTT fault-injection integration"]},
|
||||
{"id":"CONN-42","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["camera and point stall integration"]},
|
||||
{"id":"CONN-43","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["late packet integration evidence"]},
|
||||
{"id":"CONN-44","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_application_session.py"],"remaining":["late DeviceInfo integration evidence"]},
|
||||
{"id":"CONN-45","status":"partial","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_application_control_process_lease.py"],"remaining":["durable physical-command integration"]},
|
||||
{"id":"CONN-46","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_camera_gateway.py"],"remaining":["combined MQTT/camera late-producer integration"]},
|
||||
{"id":"CONN-47","status":"software-covered","test_files":["tests/test_xgrids_application_control_process_lease.py"],"remaining":["two-service integration acceptance"]},
|
||||
{"id":"CONN-48","status":"software-covered","test_files":["tests/test_xgrids_camera_gateway.py"],"remaining":["drain-timeout integration evidence"]},
|
||||
{"id":"CONN-49","status":"software-covered","test_files":["tests/test_connection_supervisor.py"],"remaining":["long-running fault-injection acceptance"]},
|
||||
|
||||
{"id":"CONN-50","status":"partial","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["macOS sleep/wake hardware acceptance"]},
|
||||
{"id":"CONN-51","status":"software-covered","test_files":["tests/test_xgrids_macos_wifi.py","tests/test_connection_supervisor.py"],"remaining":["compiled association observer"]},
|
||||
{"id":"CONN-52","status":"software-covered","test_files":["tests/test_xgrids_device_identity_pin_store.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["two-identity hardware evidence"]},
|
||||
{"id":"CONN-53","status":"software-covered","test_files":["tests/test_xgrids_semantic_topology_store.py","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["restart browser acceptance"]},
|
||||
{"id":"CONN-54","status":"planned","test_files":[],"remaining":["historical unknown audit does not block fresh explicit connect","restart browser acceptance"]},
|
||||
{"id":"CONN-55","status":"partial","test_files":["apps/control-station/test/devicePluginFrontendBoundary.test.mjs"],"remaining":["automated browser geometry matrix"]},
|
||||
{"id":"CONN-56","status":"partial","test_files":["apps/control-station/test/devicePluginFrontendBoundary.test.mjs"],"remaining":["automated long-copy browser geometry"]},
|
||||
{"id":"CONN-57","status":"planned","test_files":[],"remaining":["operator-confirmed physical-ledger archive and identity rotation"]},
|
||||
{"id":"CONN-58","status":"software-covered","test_files":["tests/test_xgrids_ble_runtime_arbiter.py","tests/test_xgrids_application_control_process_lease.py","tests/test_ble_scanner.py","tests/test_wifi_provisioning.py","tests/test_xgrids_ap_activation.py"],"remaining":["two-service CoreBluetooth hardware acceptance","native cleanup fault injection on macOS"]},
|
||||
{"id":"CONN-59","status":"partial","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_network_provisioning_idempotency_journal.py"],"remaining":["prove failed audit admission releases active ownership for a new explicit attempt"]},
|
||||
{"id":"CONN-60","status":"software-covered","test_files":["apps/control-station/test/devicePluginContracts.test.mjs"],"remaining":["manual browser confirmation-dismissal acceptance"]},
|
||||
{"id":"CONN-61","status":"planned","test_files":[],"remaining":["legacy unresolved record terminalization without BLE or cross-session block","process-kill acceptance"]},
|
||||
{"id":"CONN-62","status":"software-covered","test_files":["tests/test_web_validation_security.py"],"remaining":["manual browser refresh/close acceptance"]},
|
||||
{"id":"CONN-63","status":"planned","test_files":[],"remaining":["composite policy denies active contention but ignores terminal historical network audit","manual policy presentation acceptance"]},
|
||||
{"id":"CONN-64","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs"],"remaining":["real K1 control/data loss acceptance"]},
|
||||
{"id":"CONN-65","status":"partial","test_files":["tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["resolved-active same-project one-STOP UI action acceptance","real restart/browser host-route and passive DeviceStatus acceptance","Quick Connect recovery not exercised"]},
|
||||
{"id":"CONN-66","status":"planned","test_files":[],"remaining":["compiled or long-lived macOS association observer","long-running monitor latency/fault acceptance"]},
|
||||
{"id":"CONN-67","status":"partial","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real K1 repeated same-mode and cross-mode reconnect acceptance"]},
|
||||
{"id":"CONN-68","status":"partial","test_files":["tests/test_xgrids_application_session.py","tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["Bridge STOP-ack plus Wi-Fi-loss hardware rerun","Quick Connect recovery not exercised"]},
|
||||
{"id":"CONN-69","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["resolved-active same-project one explicit STOP browser acceptance","Bridge hardware rerun with redacted evidence","Quick Connect recovery not exercised"]},
|
||||
{"id":"CONN-70","status":"software-covered","test_files":["apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real Bridge one-scan/select/immediate-credentials/Apply acceptance","Quick Connect recovery not exercised"]},
|
||||
{"id":"CONN-71","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real Bridge STOP-deadline fault injection","Quick Connect recovery not exercised"]},
|
||||
{"id":"CONN-72","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_plugin_runtime.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real Bridge existing-plugin-section acceptance","manual two-tab browser acceptance","Quick Connect live acceptance remains separate"]},
|
||||
{"id":"CONN-73","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_semantic_topology_store.py","tests/test_xgrids_device_identity_pin_store.py"],"remaining":["real cold Bridge and two-K1 identity-mismatch/restart evidence","Quick Connect live acceptance remains separate"]},
|
||||
{"id":"CONN-74","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_network_mutation_ledger.py","tests/test_xgrids_network_provisioning_idempotency_journal.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real Bridge Apply with no hidden discovery/Verify","post-dispatch hardware fault injection","Quick Connect live acceptance remains separate"]},
|
||||
{"id":"CONN-75","status":"software-covered","test_files":["tests/test_xgrids_connection_scenario_reset.py","tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_application_control_process_lease.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs"],"remaining":["real disconnected/idle desired-mode draft no-I/O acceptance with unresolved durable physical history plus live-owner denial","real pre-START orphan and backend-runtime credential invalidation acceptance","manual top-right emergency-reset acceptance","Quick Connect live acceptance remains separate"]},
|
||||
{"id":"CONN-76","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_physical_command_coordinator.py","tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_application_control_process_lease.py","tests/test_xgrids_ble_runtime_arbiter.py","tests/test_xgrids_camera_gateway.py","tests/test_cli.py","tests/test_plugin_runtime.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real separate explicit retirement confirmation while selection and Apply remain mutation-free","same-hardware/new-CoreBluetooth-UUID cannot be identified before provisioning because FW 3.0.2 BLE 7f02 exposes no stable DeviceInfo identity","Quick Connect live acceptance remains separate"]},
|
||||
{"id":"CONN-77","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_physical_command_coordinator.py","tests/test_xgrids_acquisition_lifecycle.py","tests/test_plugin_runtime.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real retired exact-UUID selection remains local and Apply denied","real separately explicit reopen followed by READY and same-project SCANNING outcomes","Quick Connect live acceptance remains separate"]},
|
||||
{"id":"CONN-78","status":"software-covered","test_files":["apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["manual Bridge and Quick one-intent timing acceptance","manual top-right idle/pending accessible-label acceptance","real stale-before-dispatch and unknown-after-dispatch fault injection","real fast REST network_applied plus delayed service-owned read-only control-bootstrap convergence","real Bridge prepare-select-device and Quick/Direct scan-new-intent recovery acceptance","manual canonical shared-control visual acceptance"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
# K1 connection lifecycle and recovery runbook
|
||||
|
||||
Canonical model: [`../20_K1_CONNECTION_SUPERVISION_CANON.md`](../20_K1_CONNECTION_SUPERVISION_CANON.md).
|
||||
This runbook is the operator-facing projection of that model. Technical terms in
|
||||
the internal-safety sections are engineering evidence; they are not wizard copy.
|
||||
|
||||
## One operator wizard
|
||||
|
||||
The connection surface is one progressive wizard, not a recovery dashboard.
|
||||
Its only model-bearing heading is **Подключение XGRIDS LixelKity K1**. Inside
|
||||
the wizard the two step names are exactly **Подключение** and **Сеть**.
|
||||
|
||||
### Cold entry
|
||||
|
||||
On a clean cold entry show:
|
||||
|
||||
- the connection-mode selector;
|
||||
- Step 01 **Подключение**;
|
||||
- when an exact last confirmed K1 and unchanged topology are durable,
|
||||
**Переподключиться** and the separate **Найти по Bluetooth** path;
|
||||
- otherwise only the explicit Bluetooth search action.
|
||||
|
||||
**Переподключиться** is a read-only fast connection to the server-pinned last
|
||||
K1. It does not send network settings, START or STOP. Historical audit identity
|
||||
alone never creates this choice. A browser-cache reset or accepted
|
||||
`reset_scenario` closes the old live/local authority but does not erase the
|
||||
last confirmed target. Reset never starts reconnect or Scan itself.
|
||||
|
||||
Do not render Step 02 yet and do not start discovery automatically. The mode
|
||||
selector and same-mode **Подключить новый K1** escape remain available through
|
||||
every other lifecycle state. The first gesture may supersede another pending
|
||||
local action. While that one bounded scenario reset itself owns `mode`, all
|
||||
three reset entry points show pending and dispatch no B intent. Each accepted
|
||||
gesture sends one idempotent local `reset_scenario` CAS: it queues behind old
|
||||
local lifecycle ownership, seals retained receiver/camera/control resources,
|
||||
invalidates candidates/drafts/credentials and retires the old physical lineage
|
||||
without resolving its outcome. It sends no BLE, device or host network command,
|
||||
MQTT publish, Verify, provisioning, START, STOP or automatic Scan. Opening and
|
||||
polling the surface still do nothing. Scan, Verify, provisioning and START
|
||||
retain separate backend gates.
|
||||
|
||||
The top-right refresh-shaped utility is the same explicit emergency reset, not
|
||||
a passive state refresh. Its accessible label is **Сбросить подключение**; while
|
||||
the request owns the current action it reads **Сбрасываем подключение** and
|
||||
does not dispatch a second reset until that bounded request settles. It remains
|
||||
available to supersede any other local action. The accepted revision always
|
||||
returns the new local scenario and even a dirty browser selector to canonical
|
||||
**Bridge**, clears old browser drafts and source owners, and leaves both an
|
||||
eligible durable fast reconnect and Scan as separate clicks. It performs no
|
||||
hidden Scan, Verify, Connect,
|
||||
START, STOP, BLE or network write and never substitutes a passive `state.read`
|
||||
for the reset mutation. After settlement the product surface contains no stale
|
||||
result count, **Повторить поиск**, reconnect error, selected draft or
|
||||
credentials. It retains only the exact durable fast-reconnect card when one is
|
||||
eligible, alongside **Найти по Bluetooth**. Late
|
||||
Scan/Verify settlements from the retired scenario cannot repopulate it.
|
||||
The retained reset marker fences only work that belonged to the retired
|
||||
scenario. A newly correlated post-reset network attempt that fails or has an
|
||||
unknown outcome must immediately render its current recovery/error surface;
|
||||
reload must neither hide that new failure nor resurrect the prior prompt.
|
||||
|
||||
### Step 01 — Подключение
|
||||
|
||||
Step 01 **Подключение** is visible immediately.
|
||||
|
||||
1. Bluetooth discovery starts only after the operator presses the search
|
||||
action.
|
||||
2. For the full bounded search, show an activity indicator and a visible
|
||||
seconds countdown in the same step.
|
||||
3. After search completes, show the result count or the empty result. Every
|
||||
connectable result row keeps the same one enabled **Выбрать** action,
|
||||
including an exact UUID used in an earlier scenario. That action is
|
||||
local-only and never invokes reopen or Verify. Fresh results never render a
|
||||
reconnect CTA, a disabled competing primary or an old/new-device decision.
|
||||
A successful admitted Scan first settles only the reset marker whose
|
||||
id/revision/mode it captured at action entry, recording the later admitted
|
||||
discovery generation while retaining the marker for idempotent reset replay.
|
||||
Failed/cancelled Scan and an older Scan racing a newer pending reset leave
|
||||
the marker active and do not expose stale recovery authority.
|
||||
4. After ordinary **Выбрать**, retain the chosen device card and reveal only
|
||||
the applicable local draft inputs. Selection itself has no loader and makes
|
||||
no controller call.
|
||||
5. Only an authoritative successful connection outcome renders Step 01 green
|
||||
as **Подключение установлено** and advances the normal connection flow.
|
||||
|
||||
The wizard never labels a candidate as saved, original, retired or physically
|
||||
ambiguous and never exposes ledger, CAS, retirement, reopen or reconnect
|
||||
terminology in the search list. Exact recovery belongs only to a previously
|
||||
established session after an actual interruption.
|
||||
|
||||
### Step 02 — Сеть
|
||||
|
||||
Step 02 **Сеть** exists only after Step 01 has a confirmed green connection.
|
||||
|
||||
- If the selected device is already usable on the chosen connection path,
|
||||
show the network result without asking for credentials.
|
||||
- If backend policy has safely admitted explicit network setup, show the exact
|
||||
retained device context and SSID/password fields here, never beside the
|
||||
candidate list.
|
||||
- One explicit submit owns any exact hidden revalidation and at most one
|
||||
reviewed network write.
|
||||
- **Изменить сеть** belongs only to this step and starts no Bluetooth search
|
||||
when its form opens.
|
||||
- A stale tab, changed runtime/binding or policy denial fails before a write and
|
||||
never exposes a foreign candidate.
|
||||
|
||||
## Ordinary selection
|
||||
|
||||
The ordinary **Выбрать** action is presentation simplification, not relaxed
|
||||
safety. It creates only a browser-local candidate draft and performs no
|
||||
controller I/O. It never selects an internal recovery path:
|
||||
|
||||
- an ordinary fresh candidate becomes only the selected local draft;
|
||||
- an exact prior candidate uses the same **Выбрать** action as every row;
|
||||
- no candidate selection retires old authority, reopens a ledger record, calls
|
||||
Verify, connects GATT, scans again or changes topology or the device;
|
||||
- a foreign, stale, non-connectable or policy-denied candidate remains
|
||||
unavailable and changes no topology, ledger or device.
|
||||
|
||||
After an explicit committed scenario reset, only its successfully completed
|
||||
successor Scan may make an exact previously retired transport eligible for a
|
||||
new network draft. Apply still captures that exact current-generation handle,
|
||||
validates the live GATT baseline and crosses at most one reviewed write edge.
|
||||
Selection performs no recovery action. At the final Apply boundary, the backend
|
||||
may append one exact request-bound local reopen checkpoint after network
|
||||
PREPARED and before write dispatch. That checkpoint preserves physical
|
||||
retirement/original-outcome audit, performs no device I/O and authorizes no
|
||||
START or STOP. The same applied intent then uses fresh DeviceInfo plus a
|
||||
non-retained DeviceStatus to settle READY as standby or identity-bound SCANNING as
|
||||
active, without a visible Verify step or command replay. In the SCANNING case
|
||||
it materializes only explicit STOP authority; it does not restart the retired
|
||||
receiver, camera, evidence writer or acquisition.
|
||||
|
||||
### Exact internal recovery for an established session
|
||||
|
||||
For an exact target belonging to a previously established session with one
|
||||
active retirement,
|
||||
`physical-command.reopen-retired-reconciliation` requires:
|
||||
|
||||
- `operator_confirmed=true`, bound to a separately explicit session-recovery
|
||||
action outside cold entry and the Bluetooth result list;
|
||||
- a stable `reopening_id` and reason
|
||||
`device-returned-for-explicit-reconciliation`;
|
||||
- exact `expected_snapshot_runtime_id`, `expected_revision`,
|
||||
`expected_retirement_id`, `expected_transport_ref` and
|
||||
`expected_discovery_generation` CAS;
|
||||
- a current connectable candidate and safe lifecycle/process ownership.
|
||||
|
||||
The transaction changes only the local durable ledger. It appends reopen audit,
|
||||
preserves the retirement and unknown command outcome as history, restores the
|
||||
original unresolved `dispatching` or `observing` stage and removes only that
|
||||
retirement's active deny. It performs zero BLE, Wi-Fi, MQTT, DeviceConfig,
|
||||
ModelingStatus, workspace, project, START or STOP I/O and starts no Scan.
|
||||
|
||||
The same still-current session-recovery action may then own one exact read-only
|
||||
Verify. Ordinary **Выбрать** never invokes either half. Recovery never replays
|
||||
historical START/STOP and never silently provisions:
|
||||
|
||||
- fresh non-retained READY resolves standby;
|
||||
- fresh exact same-project SCANNING resolves active and permits only the
|
||||
separately guarded stop path;
|
||||
- identity, GATT, CAS, route/control or policy failure leaves the outcome
|
||||
unknown and ends the established-session recovery without entering the new
|
||||
connection wizard.
|
||||
|
||||
If the action response is lost, refreshed state may continue the same click
|
||||
only when it proves that exact `reopening_id` audit was committed and every
|
||||
original runtime, candidate and authority fence still matches. A second tab,
|
||||
new discovery generation, new retirement or different reopening identity cannot
|
||||
inherit the continuation.
|
||||
|
||||
### Internal retirement
|
||||
|
||||
`physical-command.retire-unavailable` is a local durable primitive for an
|
||||
unresolved target that is truly unavailable or replaced. It may run only from
|
||||
its separately confirmed recovery/reset path, never from ordinary candidate
|
||||
selection, UI entry, polling or a timer. Admission requires explicit
|
||||
confirmation, stable `retirement_id` and exact backend runtime,
|
||||
operation, revision and transport CAS while every local owner is safe.
|
||||
|
||||
Retirement preserves the complete old attempt and unknown command outcome,
|
||||
activates an exact-transport deny and performs zero device I/O or automatic
|
||||
discovery. Retirement history remains durable even if an exact later recovery
|
||||
action uses the reopen transaction. The wizard exposes no retirement
|
||||
transaction or historical label. Any plain-language exact recovery CTA belongs
|
||||
only to the established-session surface when backend authority permits it.
|
||||
|
||||
Current FW 3.0.2 BLE `7f02` does not expose stable DeviceInfo identity. The same
|
||||
hardware under a new CoreBluetooth UUID cannot be recognized before DeviceInfo
|
||||
becomes available. This remains an explicit protocol/hardware acceptance gap;
|
||||
the wizard must not speculate.
|
||||
|
||||
## Session and freshness rules
|
||||
|
||||
A scan result is an unselected presence candidate owned by the latest explicit
|
||||
scan generation. Wall-clock age does not remove its row while the operator is
|
||||
reading or completing the form. A successor Scan, explicit scenario reset,
|
||||
runtime-owner teardown or proven exact-target GATT failure invalidates it. The
|
||||
row itself is never network authority: Apply still requires the exact captured
|
||||
CoreBluetooth object and live GATT validation before any write.
|
||||
|
||||
The selected session ends on proven disconnect, explicit lifecycle stop, a
|
||||
committed network transition, selection of another device, backend restart or
|
||||
proven native cleanup. A later new-device connection requires an explicit search
|
||||
and **Выбрать**. An unchanged exact last confirmed K1 may instead use the
|
||||
explicit read-only fast reconnect. Polling can update presentation but starts
|
||||
neither operation.
|
||||
|
||||
Bridge, Quick Connect and Direct Connect are separate topologies. In
|
||||
any state, changing the mode or choosing another K1 in the same mode sends one
|
||||
local scenario-reset CAS. It can wait for and supersede live/recovery ownership,
|
||||
seal retained local producers and retire unresolved old lineage, but performs
|
||||
zero device/host I/O and starts no Scan. Scan, Verify, provisioning and START
|
||||
remain independently fenced until an explicit candidate intent crosses its
|
||||
reviewed transition. No old host route, endpoint, control, data or BLE authority
|
||||
crosses a committed reset boundary.
|
||||
|
||||
## Active scanning: transient host-path recovery
|
||||
|
||||
This is the sole automatic read-only rebind exception. It exists only after
|
||||
Mission Core itself has a composite-confirmed START and still owns the exact
|
||||
acquisition/runtime/device/connection/evidence lineage. It does not apply on a
|
||||
cold connection screen, after backend restart, to an external SCANNING K1 or to
|
||||
an unresolved/foreign START.
|
||||
|
||||
When the Mac loses Wi-Fi/route or the data socket while that acquisition is
|
||||
running, the active scanning pane shows a neutral spinner and
|
||||
**Восстанавливаем соединение** with attempt/elapsed time. Do not show a red
|
||||
terminal operation banner for the expected late failure of the superseded old
|
||||
control socket. Keep the acquisition and evidence session owned while the
|
||||
backend retries exact route/TCP and inspection-only DeviceInfo/status proof.
|
||||
|
||||
The recovery loop never sends BLE, changes Wi-Fi, writes DeviceConfig, repeats
|
||||
START or sends STOP. Outcomes are:
|
||||
|
||||
- exact same-device/same-project initialized `SCANNING`: silently resume the
|
||||
point stream/control binding and, when necessary, CAS-restart the dead or
|
||||
stalled acquisition-owned right-camera FFmpeg epoch;
|
||||
- fresh `READY`: interrupt/seal host-owned acquisition resources truthfully,
|
||||
without STOP;
|
||||
- fresh `SCAN_OVER`: persist cessation, interrupt/seal locally and retain a
|
||||
read-only `awaiting READY` fence that denies a new START;
|
||||
- wrong identity/same IP, changed lineage or failed camera CAS: remain blocked
|
||||
for explicit operator handling; and
|
||||
- device/system fault or unsafe status: show a truthful terminal fault, with no
|
||||
command retry.
|
||||
|
||||
While state is `reconnecting` or `blocked`, expose **Завершить локально**. The
|
||||
action `acquisition.force-finish-local` requires the current snapshot runtime,
|
||||
acquisition id/state revision, recovery generation, producer generation match,
|
||||
an idempotency key and explicit confirmation. It cancels recovery first, then
|
||||
seals only local receiver/camera/control/perception owners. It preserves the
|
||||
physical START ledger and sends no STOP. If a connection-mode reset races this
|
||||
action, the shared lifecycle gate makes cleanup idempotent; the loser cannot
|
||||
overwrite the new mode or revive the old acquisition.
|
||||
|
||||
If receiver, camera or evidence sealing fails, the recovery generation is
|
||||
still irrevocably cancelled first. The force-finish operation ends with a
|
||||
visible `local-cleanup-failed` result whose retryability applies only to local
|
||||
finalization; the terminal acquisition retains `cleanup_pending` and blocks a
|
||||
replacement session. A later explicit local stop or exact connection-scenario
|
||||
reset may retry those host resources. It must not retry START, STOP, BLE or a
|
||||
network write, and a late success from the retired recovery generation remains
|
||||
fenced.
|
||||
|
||||
## Failure matrix
|
||||
|
||||
| Event | Product result | Operator path |
|
||||
| --- | --- | --- |
|
||||
| Cold entry with an exact last confirmed K1/topology | Mode plus Step 01, read-only **Переподключиться**, and separate explicit Scan; zero device I/O before a click | Fast reconnect or start search explicitly |
|
||||
| Cold entry without an eligible durable target | Mode plus Step 01 and explicit Scan; zero device I/O before Scan | Start search explicitly |
|
||||
| Disconnected/idle mode or same-mode new-device request with unresolved durable physical history | Local session/audit lineage is retired under one reset CAS; zero device/host I/O and no automatic Scan | Start the clean Step 01 search explicitly; the old physical outcome remains auditable |
|
||||
| Mode reset while live, reconnecting or terminal cleanup still owns local sources | Reset supersedes recovery and locally seals receiver/camera/control; previous K1 may still scan | Wait for the bounded local cleanup or retry the same reset if local sealing fails |
|
||||
| Search running | Step 01 spinner and visible countdown | Wait or let the bounded search end |
|
||||
| Search finds no candidates | Step 01 reports no matches | Repeat search explicitly |
|
||||
| Search finds one or many candidates | Every connectable row has one enabled **Выбрать**, including the exact prior UUID | Select one row; no reconnect or recovery action appears in search results |
|
||||
| Wall-clock time passes after Scan before selection | Latest-generation rows remain stable; no operation starts | Select normally; exact capture and live GATT will gate Apply |
|
||||
| A new Scan/reset/runtime teardown or exact-target GATT failure invalidates the generation | Old rows disappear or the attempted action fails cleanly before mutation | Run one explicit new search if needed |
|
||||
| Selection is rejected by identity, GATT, CAS, lifecycle or safety policy | Loader ends; nothing changed; Step 02 remains absent | **Повторить** or **Выбрать другое** |
|
||||
| Selection completes exact device connection | Step 01 turns green | Continue in Step 02 **Сеть** |
|
||||
| Network setup is safely required | Credentials appear only in Step 02 | Submit once |
|
||||
| Network write becomes ambiguous after dispatch | Attempt ends unknown; no replay | Wait for cleanup, then create a distinct explicit attempt |
|
||||
| Device powers off or BLE disconnects | Live selection and authority revoke after proof | Search and select explicitly after the device is available |
|
||||
| Router, Mac Wi-Fi or MQTT control is lost while idle/pre-START | Host/control authority revokes; data may remain evidence only | Restore reachability, then use the same wizard flow |
|
||||
| Mac Wi-Fi/route is briefly lost during one composite-confirmed owned acquisition | Active pane remains neutral **Восстанавливаем соединение**; no START/STOP/network retry | Wait for exact automatic read-only rebind or press **Завершить локально** |
|
||||
| Active recovery returns READY or SCAN_OVER | Local receiver/camera seal without STOP; SCAN_OVER remains fenced until fresh READY | Start another scenario only after backend policy reports it safe |
|
||||
| Active recovery sees another K1 on the same IP or changed lineage | Recovery blocks fail-closed; no camera/data resurrection | Finish locally or explicitly choose/reset connection scenario |
|
||||
| A physical START/STOP edge is unresolved | Mutation stays fenced; no technical wizard ceremony | Search/select remains explicit; backend admits only a safe exact path |
|
||||
| Exact actively retired UUID is present after committed reset and successor Scan | The row exposes the same enabled **Выбрать** as every candidate | Select locally; Apply remains exact-handle/live-GATT gated and may append one internal local settlement checkpoint before its sole write; audit remains append-only and START/STOP stay denied until fresh read-only classification |
|
||||
| Another candidate is selected while old authority is unavailable and no reset-owned new scenario exists | Selection stays local and Apply remains denied | Start an explicit new connection scenario, then Scan and select again |
|
||||
| Browser refresh or cache reset | No automatic operation and no restored live authority; exact durable last target remains available for read-only fast reconnect | Reconnect explicitly or begin a fresh Bluetooth search |
|
||||
| Backend restart without an eligible durable target | No automatic operation and no restored live selection | Begin from the cold progressive wizard |
|
||||
|
||||
## Physical START/STOP safety remains separate
|
||||
|
||||
The simplified wizard never weakens physical-command safety:
|
||||
|
||||
- loss of control does not prove that K1 stopped recording;
|
||||
- START and STOP are never replayed automatically;
|
||||
- local receiver/camera/ingress cleanup is not physical STOP;
|
||||
- an ambiguous post-dispatch command remains unknown until exact fresh proof;
|
||||
- read-only recovery is pinned to the durable transport, identity/profile,
|
||||
host epoch and project;
|
||||
- each observation publishes exactly one DeviceInfo request and may classify
|
||||
only a fresh non-retained DeviceStatus after that barrier;
|
||||
- READY records cessation without inventing a successful STOP;
|
||||
- SCAN_OVER records cessation without inventing STOP, but keeps a durable
|
||||
read-only fence until a later fresh unbound READY observation;
|
||||
- exact same-project SCANNING may mint one single-use, separately confirmed STOP
|
||||
checkpoint; it does not send STOP automatically;
|
||||
- a wrong transport/device/project changes no topology or ledger state;
|
||||
- accepted STOP without READY or SCAN_STOPPING by the backend deadline closes
|
||||
only host-owned resources, yields `timed_out` / `standby-unknown`, preserves
|
||||
the unresolved ledger and keeps mutation fenced.
|
||||
|
||||
Engineering logs and state APIs retain these distinctions. The connection
|
||||
wizard projects only the ordinary progressive flow and a non-technical terminal
|
||||
selection result.
|
||||
|
||||
## No automatic action rule
|
||||
|
||||
None of these events may scan, select, reconnect, Verify, provision, START or
|
||||
STOP:
|
||||
|
||||
- opening or resizing the connection surface;
|
||||
- backend event delivery or state polling;
|
||||
- an acknowledged scenario reset (it may perform only its explicit local
|
||||
retirement, never any listed device/network action or automatic Scan);
|
||||
- candidate list refresh after an ended search;
|
||||
- browser refresh, sleep/wake or backend restart;
|
||||
- timeout, disconnect or a historical audit record.
|
||||
|
||||
The only exception is the service-owned active-stream read-only rebind above.
|
||||
It is triggered by the already-owned receiver's transport loss, not UI entry or
|
||||
polling, and is limited to route/TCP, DeviceInfo/status inspection, receiver
|
||||
resubscribe and exact local camera-epoch restart. It never performs discovery,
|
||||
provisioning, START, STOP or any device/network write.
|
||||
|
||||
Only the currently pressed search, distinct exact recovery CTA, network submit
|
||||
or separately guarded acquisition control may own corresponding I/O. Ordinary
|
||||
**Выбрать** owns only a browser-local draft and never owns a loader. Every
|
||||
loader belongs to the explicit action that created it and ends with it.
|
||||
|
||||
## Hardware acceptance order
|
||||
|
||||
Software tests do not replace a real K1/macOS/router run. Accept sequentially:
|
||||
|
||||
1. Open cold and prove mode plus Step 01 are visible, while Step 02 is absent
|
||||
and no discovery starts automatically. With an exact last confirmed K1 and
|
||||
unchanged topology, prove **Переподключиться** and **Найти по Bluetooth** are
|
||||
both available after reload and browser-cache reset; reconnect performs only
|
||||
read-only verification. With no eligible durable target, prove only explicit
|
||||
Scan is available. With both empty and
|
||||
unresolved durable physical history, change the mode and prove one local
|
||||
reset CAS, zero device/host calls and no automatic Scan. Repeat from active,
|
||||
reconnecting and terminal `cleanup_pending` states; prove local sources are
|
||||
sealed, the old K1 is not claimed stopped, and a local cleanup failure leaves
|
||||
the exact reset retryable. With an exact prior connection, prove one reset
|
||||
CAS and zero Scan preserve its fast-reconnect card and a separate clean
|
||||
**Найти по Bluetooth** action after reload.
|
||||
2. Start discovery and prove the spinner and seconds countdown remain visible
|
||||
for the bounded search, then the exact result count appears.
|
||||
3. With multiple advertisements, prove every ordinary connectable row keeps
|
||||
exactly one enabled **Выбрать** action and none auto-selects or auto-connects.
|
||||
Repeat with the exact prior UUID after reset and prove it has the same
|
||||
**Выбрать** action, with no reconnect/reopen/Verify path.
|
||||
4. Select a Bridge device, including that prior UUID, and prove the card remains visible through
|
||||
**Подключение…**, then Step 01 turns green before Step 02 **Сеть** appears.
|
||||
5. Prove network fields never coexist with candidate rows, and one explicit
|
||||
submit owns at most one write.
|
||||
6. Wait beyond the legacy candidate TTL and prove both the latest-generation
|
||||
unselected rows and an admitted selected session remain stable; then prove a
|
||||
missing exact handle/live GATT failure blocks Apply before any write.
|
||||
7. Exercise identity, GATT, stale-CAS, lifecycle-busy, disconnect and power-off
|
||||
failures; each ends the loader, leaves Step 02 absent and offers only ordinary
|
||||
retry/choose-another copy.
|
||||
8. Retire an unresolved target in controlled fault injection, perform one
|
||||
scenario reset and rediscover its exact UUID in the successor Scan. Prove its
|
||||
sole action is **Выбрать**, selection performs no I/O and Step 02 appears
|
||||
immediately. Apply once and prove exact current-generation handle capture,
|
||||
live GATT baseline, exactly one request-bound append-only physical reopen
|
||||
checkpoint and at most one network write. The original retirement/outcome
|
||||
audit remains immutable; the service-owned continuation uses only DeviceInfo
|
||||
and non-retained status, with zero START/STOP and no browser Verify. Inject
|
||||
failed and outcome-unknown network results; each current error/recovery
|
||||
surface remains visible after reload.
|
||||
9. Try a different device while old authority is unavailable and prove its
|
||||
ordinary selection triggers no hidden retirement/reopen/Verify and cannot
|
||||
bypass the durable target.
|
||||
10. Prove no row labels a device saved/original/retired, says
|
||||
**Переподключиться**, or exposes physical-state/ledger terminology. The model name
|
||||
appears only in the top heading; step names remain **Подключение / Сеть**.
|
||||
11. During a composite-confirmed live acquisition, remove host Wi-Fi for longer
|
||||
than the old control keepalive and restore it. Prove neutral reconnecting,
|
||||
same-lineage SCANNING resume, raw-writer continuity, exact camera epoch
|
||||
restart when stalled, and zero START/STOP/BLE/network mutation. Repeat with
|
||||
READY, SCAN_OVER, wrong identity and permanent loss plus
|
||||
**Завершить локально**.
|
||||
12. Repeat idle/pre-START Bridge network loss, Mac Wi-Fi switch, sleep/wake,
|
||||
hard K1 power loss, STOP deadline and backend restart; prove zero automatic
|
||||
command or retry outside the sole active-stream exception.
|
||||
13. Repeat the entire acceptance separately for Quick Connect before claiming
|
||||
Quick coverage.
|
||||
|
||||
The current software contract is not real-hardware acceptance. The acceptance
|
||||
manifest lists executable coverage and the remaining Bridge/Quick field gaps.
|
||||
@@ -0,0 +1,94 @@
|
||||
# K1 operator flow: incremental acceptance
|
||||
|
||||
Status: working acceptance ledger, updated 2026-08-20.
|
||||
|
||||
This is the short regression anchor for changes to the existing K1 operator
|
||||
flow. The authoritative connection and safety model remains
|
||||
[`../20_K1_CONNECTION_SUPERVISION_CANON.md`](../20_K1_CONNECTION_SUPERVISION_CANON.md).
|
||||
Every K1 fix must name one row below, add a reducer test, and preserve all
|
||||
previously accepted rows. No change in this ledger authorizes a new K1 command.
|
||||
|
||||
## Non-negotiable device boundary
|
||||
|
||||
- START and STOP keep the captured vendor payload, topic, QoS and one-shot
|
||||
dispatch contract. No automatic replay, substitute command or inferred ACK.
|
||||
- Reconnect and Verify are read-only. Scenario reset changes local Mission Core
|
||||
state only; it sends no BLE, Wi-Fi, START or STOP operation.
|
||||
- `READY` / `SCAN_OVER` from a fresh, identity-bound, non-retained status is
|
||||
physical standby truth. Local recovery state must converge to that fact
|
||||
before a new START can be admitted.
|
||||
- An unknown command outcome is never presented as success. A later exact
|
||||
read-only reconciliation may settle it, but may not rewrite its audit history.
|
||||
|
||||
## Operator-state contract
|
||||
|
||||
| State | Stable operator surface | Required feedback | Accepted exit | Forbidden |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Saved connection needs checking | Saved K1 card plus `Переподключиться` and `Подключить новый K1` | None before action | One explicit action | Hidden Scan, Verify or reconnect |
|
||||
| Reconnect in progress | The same saved K1 card and same button geometry | Disabled action with canonical `ActivityIndicator`; neutral copy `Переподключение` | Connected or an actionable terminal result | Replacing the whole panel, blank wait state, first-person copy |
|
||||
| Bluetooth search | The same connection step | Visible activity and search phase | Results or terminal no-result state | Empty disabled panel with no progress |
|
||||
| Device selected | Selected-device card and network fields | None while editing | One explicit Apply or choose another device | Background provisioning or hidden second discovery |
|
||||
| Apply in progress | The same selected-device/network form | Disabled Apply with canonical `ActivityIndicator` and named phase | `network_applied`, or a truthful terminal failure | Container-size jumps, screen substitution, automatic Apply replay |
|
||||
| Network applied, control checking | Connection card remains visible | Passive `Подтверждение управления` activity | Control ready or explicit recovery choice | Calling the provisioning result a network failure |
|
||||
| Ready to start | Project form | No activity | One explicit START | Green readiness without exact control authority |
|
||||
| START in progress | The same project form | Disabled START with named phase | Active acquisition or truthful terminal state | Second START, unexplained switch to viewer |
|
||||
| Active acquisition | Spatial viewer and one stable STOP action | Stream/visualizer status separately | One explicit STOP | STOP enabled/disabled oscillation before click |
|
||||
| STOP in progress | Same viewer and same STOP position | Disabled STOP with named phase | Physical standby or explicit outcome-unknown recovery | Second STOP or button disappearance |
|
||||
| Physical standby | Clean local idle/new-session state | None | Reconnect or new acquisition | Active recovery checkpoint blocking a proven `READY` |
|
||||
|
||||
## Presentation invariants
|
||||
|
||||
- Any operator-owned operation lasting longer than one rendered frame has an
|
||||
`ActivityIndicator`, visible neutral process noun, `aria-busy`, and a stable
|
||||
action/container position.
|
||||
- Pending copy describes the process, not the application or operator:
|
||||
`Переподключение`, `Поиск устройства`, `Подтверждение управления`,
|
||||
`Подготовка приёма`, `Остановка записи`.
|
||||
- One action never flashes an unrelated complete screen between its pending and
|
||||
terminal states.
|
||||
- Wi-Fi mutation, control bootstrap, physical command and visualization are
|
||||
separate facts. Failure of a later fact must not relabel an earlier success.
|
||||
- If decoded PCL frames exist but the browser has not admitted the exact Rerun
|
||||
store, the viewer remains `Подключение визуального источника` and must become
|
||||
an actionable visualization error at its deadline. It must not show
|
||||
`Визуализатор готов` over an empty scene.
|
||||
|
||||
## Increment gate
|
||||
|
||||
For each patch, record four facts in the handoff:
|
||||
|
||||
1. **Fix** — one named broken transition.
|
||||
2. **Reducer** — the smallest offline sequence that failed before the patch.
|
||||
3. **Visual acceptance** — exactly what the operator should see.
|
||||
4. **Not accepted yet** — adjacent known violations that remain out of scope.
|
||||
|
||||
Current violations observed on 2026-08-14:
|
||||
|
||||
- `K1-P0-CHECKPOINT`: fresh standby reconciliation left an ACTIVE recovery
|
||||
checkpoint and blocked the next connection. Code fix and same-process/restart
|
||||
reducers are green. Live startup convergence accepted: checkpoint revision 16
|
||||
is `ceased`, physical head remains `physical-standby-observed`, and the public
|
||||
policy again admits Scan plus read-only configured-device observation.
|
||||
- `K1-P0-RERUN-ADMISSION`: backend decoded and published PCL, but the browser
|
||||
never admitted the active Rerun store; camera/counters were visible over an
|
||||
empty point scene. Offline fix and reducer are green: store discovery is only
|
||||
a candidate, presentation requires a usable range plus a backend-published
|
||||
frame, and recovery-authority churn no longer remounts the same receiver.
|
||||
Production raw replay of `20260814T145329Z_viewer_live` rendered the real
|
||||
point scene with `Визуализатор готов` and `Повтор записи`. One live K1
|
||||
READY → START → point cloud/camera → STOP acceptance remains required; the
|
||||
2026-08-20 attempt was blocked before discovery by the unceased checkpoint
|
||||
described below.
|
||||
- `K1-P0-RESET-CHECKPOINT`: an explicit local scenario reset resolved a
|
||||
zero-dispatch START as `not-dispatched`, but left its exact recovery
|
||||
checkpoint revision 17 in `prepared`. The reset now ceases that exact
|
||||
checkpoint before publishing its replay marker; its reducer and the complete
|
||||
scenario-reset suite are green. Live startup convergence is accepted:
|
||||
checkpoint revision 18 is `ceased` against the unchanged physical
|
||||
`not-dispatched` head, with no device/network command. Clean Bluetooth
|
||||
selection and the next START remain to be accepted separately.
|
||||
- `K1-P1-STOP-STABILITY`: STOP authority visibly oscillated before the operator
|
||||
clicked. Not fixed in the checkpoint increment.
|
||||
- `K1-P1-PENDING-FEEDBACK`: reconnect/search/select/provision transitions replace
|
||||
panels or show disabled controls without an activity indicator. Not fixed in
|
||||
the checkpoint increment.
|
||||
@@ -254,13 +254,11 @@ def read_live_profile(path: Path) -> tuple[dict[str, Any], str]:
|
||||
}
|
||||
if (
|
||||
not isinstance(replay_source, dict)
|
||||
or replay_source.get("session_id")
|
||||
!= "20260720T065719Z_viewer_live"
|
||||
or replay_source.get("session_id") != "20260720T065719Z_viewer_live"
|
||||
or replay_source.get("display_name") != "RAVNOVES00"
|
||||
or replay_source.get("selection") != "complete-recording"
|
||||
or float(replay_source.get("speed", 0)) != 1.0
|
||||
or float(replay_source.get("minimum_source_span_seconds", 0))
|
||||
< 450
|
||||
or float(replay_source.get("minimum_source_span_seconds", 0)) < 450
|
||||
or replay_source.get("look_ahead") is not False
|
||||
or any(
|
||||
not isinstance(replay_source.get(key), int)
|
||||
@@ -269,9 +267,7 @@ def read_live_profile(path: Path) -> tuple[dict[str, Any], str]:
|
||||
for key, expected in replay_integer_contract.items()
|
||||
)
|
||||
):
|
||||
raise RuntimeError(
|
||||
"LAB E28 complete-recording worker replay contract is invalid"
|
||||
)
|
||||
raise RuntimeError("LAB E28 complete-recording worker replay contract is invalid")
|
||||
elif replay_source is not None:
|
||||
raise RuntimeError("LAB E15 non-replay profile carries replay source state")
|
||||
if local_surface is not None:
|
||||
@@ -280,9 +276,7 @@ def read_live_profile(path: Path) -> tuple[dict[str, Any], str]:
|
||||
)
|
||||
|
||||
local_acceptance = (
|
||||
local_surface.get("acceptance")
|
||||
if isinstance(local_surface, dict)
|
||||
else None
|
||||
local_surface.get("acceptance") if isinstance(local_surface, dict) else None
|
||||
)
|
||||
expected_profile_sha256 = hashlib.sha256(
|
||||
canonical_json(DEFAULT_K1_LOCAL_SURFACE_PROFILE.to_dict())
|
||||
@@ -293,29 +287,20 @@ def read_live_profile(path: Path) -> tuple[dict[str, Any], str]:
|
||||
"maximum_runtime_drop_fraction",
|
||||
)
|
||||
point_capacity = (
|
||||
local_surface.get("point_queue_capacity")
|
||||
if isinstance(local_surface, dict)
|
||||
else None
|
||||
local_surface.get("point_queue_capacity") if isinstance(local_surface, dict) else None
|
||||
)
|
||||
pose_capacity = (
|
||||
local_surface.get("pose_buffer_capacity")
|
||||
if isinstance(local_surface, dict)
|
||||
else None
|
||||
local_surface.get("pose_buffer_capacity") if isinstance(local_surface, dict) else None
|
||||
)
|
||||
result_capacity = (
|
||||
local_surface.get("result_capacity")
|
||||
if isinstance(local_surface, dict)
|
||||
else None
|
||||
local_surface.get("result_capacity") if isinstance(local_surface, dict) else None
|
||||
)
|
||||
if (
|
||||
profile.get("mode")
|
||||
not in {"worker-replay-gate", "physical-shadow-gate"}
|
||||
profile.get("mode") not in {"worker-replay-gate", "physical-shadow-gate"}
|
||||
or not isinstance(local_surface, dict)
|
||||
or local_surface.get("enabled") is not True
|
||||
or local_surface.get("profile_id")
|
||||
!= DEFAULT_K1_LOCAL_SURFACE_PROFILE.profile_id
|
||||
or local_surface.get("profile_sha256")
|
||||
!= expected_profile_sha256
|
||||
or local_surface.get("profile_id") != DEFAULT_K1_LOCAL_SURFACE_PROFILE.profile_id
|
||||
or local_surface.get("profile_sha256") != expected_profile_sha256
|
||||
or not isinstance(point_capacity, int)
|
||||
or isinstance(point_capacity, bool)
|
||||
or point_capacity not in range(1, 9)
|
||||
@@ -328,27 +313,16 @@ def read_live_profile(path: Path) -> tuple[dict[str, Any], str]:
|
||||
or not 0
|
||||
<= float(local_surface.get("future_pose_wait_ms", -1))
|
||||
<= DEFAULT_K1_LOCAL_SURFACE_PROFILE.maximum_pose_binding_ms
|
||||
or not 0.1
|
||||
<= float(local_surface.get("retention_seconds", 0))
|
||||
<= 30
|
||||
or not 0.1 <= float(local_surface.get("retention_seconds", 0)) <= 30
|
||||
or float(temporal.get("maximum_pose_point_delta_ms", 0))
|
||||
!= DEFAULT_K1_LOCAL_SURFACE_PROFILE.maximum_pose_binding_ms
|
||||
or not isinstance(local_acceptance, dict)
|
||||
or int(local_acceptance.get("minimum_bound_frames", 0)) < 2
|
||||
or any(
|
||||
not 0 <= float(local_acceptance.get(key, -1)) <= 1
|
||||
for key in local_fractions
|
||||
)
|
||||
or float(
|
||||
local_acceptance.get("maximum_p95_result_age_ms", 0)
|
||||
)
|
||||
<= 0
|
||||
or float(local_acceptance.get("minimum_effective_fps", 0))
|
||||
<= 0
|
||||
or any(not 0 <= float(local_acceptance.get(key, -1)) <= 1 for key in local_fractions)
|
||||
or float(local_acceptance.get("maximum_p95_result_age_ms", 0)) <= 0
|
||||
or float(local_acceptance.get("minimum_effective_fps", 0)) <= 0
|
||||
):
|
||||
raise RuntimeError(
|
||||
"LAB E28 worker local-surface profile contract is invalid"
|
||||
)
|
||||
raise RuntimeError("LAB E28 worker local-surface profile contract is invalid")
|
||||
fractions = (
|
||||
"detector_maximum_drop_fraction",
|
||||
"semantic_maximum_drop_fraction",
|
||||
@@ -429,64 +403,45 @@ def _local_surface_acceptance_checks(
|
||||
|
||||
return {
|
||||
"local_surface_session_initialized": bool(runtime),
|
||||
"local_surface_closed": snapshot.get("closed") is True
|
||||
and runtime.get("closed") is True,
|
||||
"local_surface_closed": snapshot.get("closed") is True and runtime.get("closed") is True,
|
||||
"local_surface_minimum_bound_frames": point_bound
|
||||
>= int(acceptance["minimum_bound_frames"]),
|
||||
"local_surface_binder_accounting": point_bound
|
||||
+ point_missed
|
||||
+ point_dropped
|
||||
+ point_depth
|
||||
"local_surface_binder_accounting": point_bound + point_missed + point_dropped + point_depth
|
||||
== point_published,
|
||||
"local_surface_binder_to_runtime_accounting": point_bound
|
||||
== runtime_published,
|
||||
"local_surface_binder_to_runtime_accounting": point_bound == runtime_published,
|
||||
"local_surface_point_buffer_bound": (
|
||||
int(points.get("capacity", 0)) == int(config["point_queue_capacity"])
|
||||
and int(points.get("maximum_depth", 0))
|
||||
<= int(points.get("capacity", 0))
|
||||
and int(points.get("maximum_depth", 0)) <= int(points.get("capacity", 0))
|
||||
and point_depth == 0
|
||||
),
|
||||
"local_surface_pose_buffer_bound": (
|
||||
int(poses.get("capacity", 0)) == int(config["pose_buffer_capacity"])
|
||||
and int(poses.get("maximum_depth", 0))
|
||||
<= int(poses.get("capacity", 0))
|
||||
and int(poses.get("maximum_depth", 0)) <= int(poses.get("capacity", 0))
|
||||
),
|
||||
"local_surface_maximum_pose_miss_fraction": point_missed
|
||||
/ max(1, point_published)
|
||||
"local_surface_maximum_pose_miss_fraction": point_missed / max(1, point_published)
|
||||
<= float(acceptance["maximum_pose_miss_fraction"]),
|
||||
"local_surface_maximum_point_drop_fraction": point_dropped
|
||||
/ max(1, point_published)
|
||||
"local_surface_maximum_point_drop_fraction": point_dropped / max(1, point_published)
|
||||
<= float(acceptance["maximum_point_drop_fraction"]),
|
||||
"local_surface_runtime_accounting": runtime_consumed
|
||||
+ runtime_dropped
|
||||
+ runtime_depth
|
||||
"local_surface_runtime_accounting": runtime_consumed + runtime_dropped + runtime_depth
|
||||
== runtime_published,
|
||||
"local_surface_runtime_result_accounting": result_published
|
||||
+ result_failed
|
||||
"local_surface_runtime_result_accounting": result_published + result_failed
|
||||
== runtime_consumed,
|
||||
"local_surface_runtime_queue_bound": (
|
||||
int(queue_state.get("capacity", 0)) == int(config["point_queue_capacity"])
|
||||
and int(queue_state.get("maximum_depth", 0))
|
||||
<= int(queue_state.get("capacity", 0))
|
||||
and int(queue_state.get("maximum_depth", 0)) <= int(queue_state.get("capacity", 0))
|
||||
and runtime_depth == 0
|
||||
),
|
||||
"local_surface_maximum_runtime_drop_fraction": runtime_dropped
|
||||
/ max(1, runtime_published)
|
||||
"local_surface_maximum_runtime_drop_fraction": runtime_dropped / max(1, runtime_published)
|
||||
<= float(acceptance["maximum_runtime_drop_fraction"]),
|
||||
"local_surface_minimum_effective_fps": float(
|
||||
delivery.get("effective_fps", 0)
|
||||
)
|
||||
"local_surface_minimum_effective_fps": float(delivery.get("effective_fps", 0))
|
||||
>= float(acceptance["minimum_effective_fps"]),
|
||||
"local_surface_zero_runtime_failures": result_failed == 0,
|
||||
"local_surface_maximum_p95_result_age_ms": (
|
||||
isinstance(p95_result_age, (int, float))
|
||||
and not isinstance(p95_result_age, bool)
|
||||
and float(p95_result_age)
|
||||
<= float(acceptance["maximum_p95_result_age_ms"])
|
||||
),
|
||||
"local_surface_profile_pinned": (
|
||||
runtime_profile.get("profile_id") == config["profile_id"]
|
||||
and float(p95_result_age) <= float(acceptance["maximum_p95_result_age_ms"])
|
||||
),
|
||||
"local_surface_profile_pinned": (runtime_profile.get("profile_id") == config["profile_id"]),
|
||||
"local_surface_shadow_authority_only": (
|
||||
snapshot.get("authority")
|
||||
== {
|
||||
@@ -622,6 +577,7 @@ class _TransportState:
|
||||
camera_sequence_gaps: int = 0
|
||||
last_camera_source_sequence: int | None = None
|
||||
session_id: str | None = None
|
||||
session_generation: int | None = None
|
||||
session_end_seen: bool = False
|
||||
timed_out: bool = False
|
||||
results_published: int = 0
|
||||
@@ -956,8 +912,7 @@ class _StageExecutionTelemetry:
|
||||
self._last_frame_by_stage.get(stage_id),
|
||||
)
|
||||
for stage_id in self._stage_ids
|
||||
if stage_id in self._native_started
|
||||
and stage_id not in self._native_failed
|
||||
if stage_id in self._native_started and stage_id not in self._native_failed
|
||||
]
|
||||
for stage_id, elapsed_seconds, activations, frame_index in rows:
|
||||
self._emit_native(
|
||||
@@ -1012,9 +967,7 @@ class _StageExecutionTelemetry:
|
||||
"elapsed_seconds": round(elapsed[stage_id], 6),
|
||||
"activations": self._activations[stage_id],
|
||||
"share_percent": (
|
||||
round(elapsed[stage_id] / total * 100, 6)
|
||||
if total > 0
|
||||
else None
|
||||
round(elapsed[stage_id] / total * 100, 6) if total > 0 else None
|
||||
),
|
||||
}
|
||||
for stage_id in self._stage_ids
|
||||
@@ -1127,11 +1080,20 @@ def _receiver(
|
||||
state.first_ingress_sequence = sequence
|
||||
state.last_ingress_sequence = sequence
|
||||
session_id = str(header["session_id"])
|
||||
session_generation_value = header["session_generation"]
|
||||
if (
|
||||
not isinstance(session_generation_value, int)
|
||||
or isinstance(session_generation_value, bool)
|
||||
or session_generation_value < 1
|
||||
):
|
||||
raise ShadowRuntimeError("shadow session generation is invalid")
|
||||
session_generation = session_generation_value
|
||||
if state.session_id is None:
|
||||
state.session_id = session_id
|
||||
state.session_generation = session_generation
|
||||
if local_surface is not None:
|
||||
local_surface.begin_session(session_id)
|
||||
elif state.session_id != session_id:
|
||||
elif state.session_id != session_id or state.session_generation != session_generation:
|
||||
raise ShadowRuntimeError("shadow session identity changed")
|
||||
modality = str(header["modality"])
|
||||
state.counts[modality] += 1
|
||||
@@ -1272,9 +1234,7 @@ def _common(args: argparse.Namespace) -> dict[str, Any]:
|
||||
"k1link/ground_segmentation.py",
|
||||
}
|
||||
if not required_surface_sources <= worker_sources:
|
||||
raise RuntimeError(
|
||||
"LAB E28 worker package lacks local-surface runtime"
|
||||
)
|
||||
raise RuntimeError("LAB E28 worker package lacks local-surface runtime")
|
||||
stability = None
|
||||
stability_sha256 = None
|
||||
if args.stability_profile is not None:
|
||||
@@ -1400,11 +1360,7 @@ def run(
|
||||
if not token or len(token) < 40:
|
||||
raise RuntimeError("LAB E15 shadow token is missing")
|
||||
|
||||
stage_telemetry = (
|
||||
runtime_state.get("_stage_telemetry")
|
||||
if runtime_state is not None
|
||||
else None
|
||||
)
|
||||
stage_telemetry = runtime_state.get("_stage_telemetry") if runtime_state is not None else None
|
||||
if not isinstance(stage_telemetry, _StageExecutionTelemetry):
|
||||
stage_telemetry = _StageExecutionTelemetry()
|
||||
if runtime_state is not None:
|
||||
@@ -1461,9 +1417,7 @@ def run(
|
||||
local_surface = K1LocalSurfaceShadowCoordinator(
|
||||
point_capacity=int(local_surface_config["point_queue_capacity"]),
|
||||
pose_capacity=int(local_surface_config["pose_buffer_capacity"]),
|
||||
future_pose_wait_ms=float(
|
||||
local_surface_config["future_pose_wait_ms"]
|
||||
),
|
||||
future_pose_wait_ms=float(local_surface_config["future_pose_wait_ms"]),
|
||||
retention_seconds=float(local_surface_config["retention_seconds"]),
|
||||
result_capacity=int(local_surface_config["result_capacity"]),
|
||||
)
|
||||
@@ -1890,7 +1844,11 @@ def run(
|
||||
optimize=False,
|
||||
)
|
||||
with stage_telemetry.measure("result-publication", envelope.frame_index):
|
||||
if transport.session_id is None or transport.session_generation is None:
|
||||
raise ShadowRuntimeError("shadow result session identity is unavailable")
|
||||
live_result = encode_live_perception_result(
|
||||
session_id=transport.session_id,
|
||||
session_generation=transport.session_generation,
|
||||
frame_index=envelope.frame_index,
|
||||
source_frame_index=int(envelope.timeline["source_frame_index"]),
|
||||
session_seconds=frame_seconds,
|
||||
@@ -1969,9 +1927,7 @@ def run(
|
||||
temporal_semantic_summary = (
|
||||
None if semantic_stabilizer is None else semantic_stabilizer.snapshot()
|
||||
)
|
||||
local_surface_snapshot = (
|
||||
None if local_surface is None else local_surface.snapshot()
|
||||
)
|
||||
local_surface_snapshot = None if local_surface is None else local_surface.snapshot()
|
||||
acceptance = live["acceptance"]
|
||||
checks = {
|
||||
"minimum_camera_frames": decoded_frame_count >= int(acceptance["minimum_camera_frames"]),
|
||||
@@ -2356,9 +2312,7 @@ def _persistent_run_telemetry_identity(
|
||||
if isinstance(stability, dict) and isinstance(stability.get("profile_id"), str)
|
||||
else "lab-e15-shadow-inference-v1"
|
||||
)
|
||||
method_id = (
|
||||
INLINE_TEMPORAL_PIPELINE_ID if isinstance(stability, dict) else PIPELINE_ID
|
||||
)
|
||||
method_id = INLINE_TEMPORAL_PIPELINE_ID if isinstance(stability, dict) else PIPELINE_ID
|
||||
return PipelineTelemetryIdentity(
|
||||
contour_id=telemetry.get("contour_id"),
|
||||
agent_id=telemetry.get("agent_id"),
|
||||
@@ -2545,9 +2499,7 @@ def serve(args: argparse.Namespace) -> int:
|
||||
state["last_run_outcome"] = {
|
||||
"request_id": request_id,
|
||||
"state": "failed",
|
||||
"duration_ms": (
|
||||
round(duration_ms, 6) if duration_ms is not None else None
|
||||
),
|
||||
"duration_ms": (round(duration_ms, 6) if duration_ms is not None else None),
|
||||
"exit_code": None,
|
||||
"error_type": type(exc).__name__,
|
||||
}
|
||||
|
||||
@@ -95,6 +95,14 @@ Validate the current exact-match profile without device I/O with:
|
||||
uv run python plugins/xgrids-k1/profile_loader.py
|
||||
```
|
||||
|
||||
Plugin v0.7.0 adds the backend-owned supervised connection lifecycle. Operator
|
||||
mode choice is a CAS-fenced draft; an explicit Scan commits a safe pre-START
|
||||
mode switch, while Connect reaches Ready only after the exact current
|
||||
`DeviceInfo` authority is confirmed. Configured, active and desired modes are
|
||||
separate facts. Terminal pre-START failures and purely local prepared sessions
|
||||
self-retire without a device command, and an applied network configuration is
|
||||
recovered through a separate read-only Verify instead of replaying Wi-Fi.
|
||||
|
||||
Plugin v0.6.0 retains the physically accepted v0.5.0 control transport and adds
|
||||
the connection matrix behind the existing explicit `network.provision` action.
|
||||
Bridge remains the default. Direct Connect sends the same single reviewed
|
||||
@@ -102,13 +110,19 @@ Bridge remains the default. Direct Connect sends the same single reviewed
|
||||
Connect accepts no browser/API credential: it sends one reviewed fixed 100-byte
|
||||
AP-enable frame to the selected K1, waits up to 15 seconds for the canonical
|
||||
byte-51 AP-ready flag, and keeps that BLE session alive while the macOS adapter
|
||||
performs bounded exact-SSID CoreWLAN discovery and one association. Credentials
|
||||
performs up to 30 seconds of exact-SSID CoreWLAN discovery and one association.
|
||||
AP-ready does not imply that macOS has already observed the RF beacon. Credentials
|
||||
are resolved by a preinstalled exact `3.0.2` firmware provider. Its optional laboratory importer
|
||||
validates the reviewed official archive, extracts the single AP declaration and
|
||||
installs firmware-scoped material in the OS secure store. The macOS helper then
|
||||
materializes the selected device profile entirely inside Keychain before any
|
||||
BLE write. The secret never enters the browser, API, argv, logs or evidence;
|
||||
the importer's short-lived mutable buffer is zeroized after the stdin handoff.
|
||||
The prepared-host adapter uses the accepted Apple-signed
|
||||
`/usr/bin/xcrun swift` runner. It does not runtime-compile an ad-hoc executable,
|
||||
query the standard Wi-Fi Keychain or open a password dialog after the K1 write.
|
||||
Production portability still requires a packaged, properly signed helper with
|
||||
a stable designated identity and explicit CoreWLAN authorization.
|
||||
There is no automatic BLE-write or association retry. A clean host cannot
|
||||
obtain the provider from BLE and the product does not download firmware during
|
||||
connection. Windows/Linux Quick Connect adapters are not planned while that
|
||||
|
||||
@@ -6,17 +6,23 @@ generic application source tree.
|
||||
|
||||
The contribution contains:
|
||||
|
||||
- `K1ProvisioningPipeline` for power confirmation, BLE discovery and the three
|
||||
explicit local connection directions: Bridge, Quick Connect and Direct
|
||||
Connect;
|
||||
- `K1ProvisioningPipeline` for explicit BLE discovery and the three local
|
||||
connection directions: Bridge, Quick Connect and Direct Connect;
|
||||
- `K1AcquisitionPipeline` for explicit canonical connection/workspace/project/
|
||||
START checkpoints, local receiver preparation and compatibility file replay;
|
||||
- `K1SpatialControls` for an explicit no-retry STOP followed by the separate
|
||||
READY plus steady-green completion gate;
|
||||
- plugin-local diagnostics, metrics, API state, lifecycle mapping,
|
||||
observation-source mapping and scoped styles;
|
||||
- typed v0.6.0 local-network and interactive application-control state plus legacy shadow
|
||||
- typed v0.7.0 supervised connection lifecycle and interactive application-control state plus legacy shadow
|
||||
inspection contracts;
|
||||
- a click-correlated, non-secret provisioning presentation latch: after Apply,
|
||||
Steps 01–02 keep their selected-device/form anatomy with disabled controls
|
||||
until the exact connection attempt becomes reachable or reaches bounded
|
||||
recovery; the Wi-Fi password is cleared before asynchronous dispatch;
|
||||
- policy-gated retirement of an unavailable historical K1 as an explicit
|
||||
local ledger action; it never emits a device command and never bypasses the
|
||||
public `retire-unavailable-physical-target` decision;
|
||||
- `plugin.ts`, which binds the manifest `device.connection` component key to
|
||||
the runtime provider and connection view.
|
||||
|
||||
|
||||
@@ -1,89 +1,374 @@
|
||||
import { Button, StatusBadge, type StatusTone } from "@nodedc/ui-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { StatusBadge, type StatusTone } from "@nodedc/ui-react";
|
||||
|
||||
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
|
||||
import {
|
||||
activeStreamRecoveryPresentation,
|
||||
suppressGenericErrorDuringActiveStreamRecovery,
|
||||
} from "./activeStreamRecovery";
|
||||
import { K1AcquisitionPipeline } from "./components/K1AcquisitionPipeline";
|
||||
import { K1Diagnostics } from "./components/K1Diagnostics";
|
||||
import { K1Metrics } from "./components/K1Metrics";
|
||||
import { K1ProvisioningPipeline } from "./components/K1ProvisioningPipeline";
|
||||
import { K1OperatorError } from "./components/K1OperatorError";
|
||||
import {
|
||||
K1ProvisioningPipeline,
|
||||
unavailablePhysicalRetirementAuthority,
|
||||
} from "./components/K1ProvisioningPipeline";
|
||||
import {
|
||||
backendConnectionTopology,
|
||||
connectionAttemptForRuntimeError,
|
||||
hasControlAuthority,
|
||||
isConfirmedLiveState,
|
||||
isPhysicalStopRecoverySettling,
|
||||
isRecoveredPhysicalScanning,
|
||||
isReleasedTerminalAcquisitionFailure,
|
||||
isSourceRuntimeBusy,
|
||||
readOnlyConnectionObservationTarget,
|
||||
recoverableAcquisition,
|
||||
requiresCanonicalStopAfterTerminalLocalFailure,
|
||||
requiresReadOnlyPhysicalRecovery,
|
||||
savedBridgeRequiresNetworkSetup,
|
||||
sourceStatusLabel,
|
||||
} from "./lifecycle";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import { phaseLabel, phaseTone } from "./presentation";
|
||||
import { useXgridsK1Controller } from "./runtimeContext";
|
||||
import {
|
||||
useXgridsK1Controller,
|
||||
type XgridsK1Controller,
|
||||
} from "./runtimeContext";
|
||||
import type { PendingAction } from "./useXgridsK1Runtime";
|
||||
import type { XgridsK1State } from "./api";
|
||||
import {
|
||||
DEFAULT_CONNECTION_MODE,
|
||||
type ConnectionMode,
|
||||
} from "./configuration";
|
||||
|
||||
export { K1OperatorError };
|
||||
|
||||
export function shouldRenderK1GenericRuntimeError(
|
||||
error: string | null | undefined,
|
||||
hasCorrelatedConnectionAttempt: boolean,
|
||||
state: XgridsK1State | null | undefined,
|
||||
errorAction?: string | null,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
error
|
||||
&& !hasCorrelatedConnectionAttempt
|
||||
&& !suppressGenericErrorDuringActiveStreamRecovery(state, errorAction),
|
||||
);
|
||||
}
|
||||
|
||||
export function physicalRecoveryConnectionDetail(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): string | null {
|
||||
if (!requiresReadOnlyPhysicalRecovery(state)) return null;
|
||||
if (savedBridgeRequiresNetworkSetup(state)) {
|
||||
return "K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Настройки устройства не изменялись; подключите K1 к общей сети заново.";
|
||||
}
|
||||
const retirementAvailable = Boolean(
|
||||
unavailablePhysicalRetirementAuthority(state),
|
||||
);
|
||||
const readOnlyVerificationAvailable = Boolean(
|
||||
readOnlyConnectionObservationTarget(state)?.serverBound,
|
||||
);
|
||||
if (retirementAvailable && readOnlyVerificationAvailable) {
|
||||
return "Если прежний K1 снова доступен, проверьте его состояние без изменений: проверка читает состояние и не отправляет START, STOP или настройки сети. Если K1 недоступен постоянно или заменён, его можно локально исключить без связи с устройством.";
|
||||
}
|
||||
if (readOnlyVerificationAvailable) {
|
||||
return "Проверьте состояние прежнего K1 без изменений устройства. Проверка использует сохранённое системой подключение и не отправляет START, STOP или настройки сети.";
|
||||
}
|
||||
if (retirementAvailable) {
|
||||
return "Прежний K1 можно локально исключить без связи с устройством: действие не отправляет START, STOP или настройки сети. После этого можно отдельно выбрать другой K1.";
|
||||
}
|
||||
return "Безопасная сверка прежнего K1 сейчас недоступна. Обновите состояние; новые команды устройству заблокированы.";
|
||||
}
|
||||
|
||||
function connectionPhaseFallbackLabel(phase: string | null | undefined): string {
|
||||
if (phase === "device_selected") return "Выбор выполнен";
|
||||
if (phase === "connected") return "Сетевой адрес получен";
|
||||
return phaseLabel(phase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the disconnected connection job focused on its progressive pipeline.
|
||||
* Persisted topology is evidence, not live control authority. Operational
|
||||
* panels return only when they are actionable or required to finish an
|
||||
* already-started lifecycle, especially STOP and recovery.
|
||||
*/
|
||||
export function shouldRenderK1OperationalPanels(
|
||||
state: XgridsK1State | null | undefined,
|
||||
pendingAction: PendingAction | null = null,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
pendingAction === "live"
|
||||
|| hasControlAuthority(state)
|
||||
|| state?.source_mode === "live"
|
||||
|| state?.source_mode === "replay"
|
||||
|| recoverableAcquisition(state)
|
||||
|| state?.acquisition?.cleanup_pending === true
|
||||
|| requiresCanonicalStopAfterTerminalLocalFailure(state)
|
||||
|| isRecoveredPhysicalScanning(state)
|
||||
|| isPhysicalStopRecoverySettling(state)
|
||||
|| activeStreamRecoveryPresentation(state) !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function K1ConnectionPipelines({
|
||||
controller,
|
||||
desiredConnectionMode,
|
||||
onDesiredConnectionModeChange,
|
||||
operationalPanelsVisible,
|
||||
openSpatialScene,
|
||||
activateAutomaticSpatialSource,
|
||||
sourceLabel,
|
||||
}: {
|
||||
controller: XgridsK1Controller;
|
||||
desiredConnectionMode: ConnectionMode;
|
||||
onDesiredConnectionModeChange: (mode: ConnectionMode) => void | Promise<void>;
|
||||
operationalPanelsVisible: boolean;
|
||||
openSpatialScene: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
sourceLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{operationalPanelsVisible ? <K1Metrics controller={controller} /> : null}
|
||||
|
||||
<div className="device-workspace__grid">
|
||||
<K1ProvisioningPipeline
|
||||
controller={controller}
|
||||
desiredMode={desiredConnectionMode}
|
||||
onDesiredModeChange={onDesiredConnectionModeChange}
|
||||
/>
|
||||
{operationalPanelsVisible ? (
|
||||
<div className="device-workspace__side">
|
||||
<K1AcquisitionPipeline
|
||||
controller={controller}
|
||||
desiredConnectionMode={desiredConnectionMode}
|
||||
openSpatialScene={openSpatialScene}
|
||||
activateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
||||
/>
|
||||
<K1Diagnostics controller={controller} sourceLabel={sourceLabel} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
|
||||
const controller = useXgridsK1Controller();
|
||||
const { state, error, refresh, clearError } = controller;
|
||||
const {
|
||||
state,
|
||||
error,
|
||||
errorDiagnostic,
|
||||
errorCorrelation,
|
||||
refresh,
|
||||
clearError,
|
||||
} = controller;
|
||||
const [desiredConnectionMode, setDesiredConnectionMode] = useState<ConnectionMode>(
|
||||
DEFAULT_CONNECTION_MODE,
|
||||
);
|
||||
const desiredModeInitialized = useRef(false);
|
||||
const desiredModeLocallyDirty = useRef(false);
|
||||
const hydratedScenarioResetKey = useRef<string | null>(null);
|
||||
|
||||
const confirmedLive = isConfirmedLiveState(state);
|
||||
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
|
||||
const livePreparationPending = controller.pendingAction === "live";
|
||||
const preparedAcquisition = recoverableAcquisition(state)?.state === "prepared";
|
||||
const sourceLabel = sourceStatusLabel(state);
|
||||
const relevantAcquisitionFailed = state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
|
||||
const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
|
||||
const sourceLabel = activeRecoveryPresentation?.title ?? sourceStatusLabel(state);
|
||||
const releasedAcquisitionFailure = isReleasedTerminalAcquisitionFailure(state);
|
||||
const physicalRecoveryRequired = requiresReadOnlyPhysicalRecovery(state);
|
||||
const savedBridgeNetworkSetupRequired = savedBridgeRequiresNetworkSetup(state);
|
||||
const physicalStopRecoverySettling = isPhysicalStopRecoverySettling(state);
|
||||
const recoveredPhysicalScanning = physicalRecoveryRequired
|
||||
&& state?.application_control_session?.state === "scanning"
|
||||
&& state.application_control_session.can_stop === true;
|
||||
const physicalRecoveryDetail = physicalRecoveryConnectionDetail(state);
|
||||
const correlatedConnectionAttempt = connectionAttemptForRuntimeError(
|
||||
errorCorrelation,
|
||||
state,
|
||||
);
|
||||
const showGenericRuntimeError = shouldRenderK1GenericRuntimeError(
|
||||
error,
|
||||
Boolean(correlatedConnectionAttempt),
|
||||
state,
|
||||
errorCorrelation?.action,
|
||||
);
|
||||
const relevantAcquisitionFailed = state?.source_mode !== "replay"
|
||||
&& state?.acquisition?.state === "failed"
|
||||
&& !releasedAcquisitionFailure;
|
||||
const projectedPhase = releasedAcquisitionFailure && state?.phase === "error"
|
||||
? "idle"
|
||||
: state?.phase;
|
||||
const connectionTopology = backendConnectionTopology(state);
|
||||
const effectiveDesiredConnectionMode = desiredModeInitialized.current
|
||||
? desiredConnectionMode
|
||||
: state?.desired_connection_mode
|
||||
?? (connectionTopology?.status === "active"
|
||||
? connectionTopology.connectionMode
|
||||
: DEFAULT_CONNECTION_MODE);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state || desiredModeInitialized.current) return;
|
||||
desiredModeInitialized.current = true;
|
||||
setDesiredConnectionMode(
|
||||
state.desired_connection_mode
|
||||
?? (connectionTopology?.status === "active"
|
||||
? connectionTopology.connectionMode
|
||||
: DEFAULT_CONNECTION_MODE),
|
||||
);
|
||||
}, [connectionTopology, state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!desiredModeInitialized.current) return;
|
||||
const backendDesiredMode = state?.desired_connection_mode;
|
||||
if (!backendDesiredMode) return;
|
||||
const scenarioReset = state?.connection_scenario_reset;
|
||||
const scenarioResetKey = scenarioReset
|
||||
&& scenarioReset.revision === state?.desired_connection_mode_revision
|
||||
&& scenarioReset.desired_mode === backendDesiredMode
|
||||
&& state?.snapshot_runtime_id?.trim()
|
||||
? `${state.snapshot_runtime_id}:${scenarioReset.revision}`
|
||||
: null;
|
||||
if (scenarioResetKey && hydratedScenarioResetKey.current !== scenarioResetKey) {
|
||||
// The shell emergency reset is an authoritative new backend revision.
|
||||
// It must retire a locally dirty selector too; an older dirty browser
|
||||
// draft cannot keep showing Quick/Direct after canonical Bridge won.
|
||||
hydratedScenarioResetKey.current = scenarioResetKey;
|
||||
desiredModeLocallyDirty.current = false;
|
||||
setDesiredConnectionMode(backendDesiredMode);
|
||||
return;
|
||||
}
|
||||
if (backendDesiredMode === desiredConnectionMode) {
|
||||
desiredModeLocallyDirty.current = false;
|
||||
return;
|
||||
}
|
||||
// Every dropdown gesture is now an explicit backend scenario-reset CAS.
|
||||
// The callback may publish its accepted mode one render before the hook's
|
||||
// authoritative snapshot arrives, so passive polling must not overwrite
|
||||
// that in-flight acknowledgement. Once the backend echoes the exact mode
|
||||
// above, the dirty fence clears and later authoritative changes hydrate it.
|
||||
if (desiredModeLocallyDirty.current) return;
|
||||
setDesiredConnectionMode(backendDesiredMode);
|
||||
}, [
|
||||
desiredConnectionMode,
|
||||
state?.connection_scenario_reset,
|
||||
state?.desired_connection_mode,
|
||||
state?.desired_connection_mode_revision,
|
||||
state?.snapshot_runtime_id,
|
||||
]);
|
||||
|
||||
const updateDesiredConnectionMode = (mode: ConnectionMode) => {
|
||||
desiredModeLocallyDirty.current = mode !== state?.desired_connection_mode;
|
||||
setDesiredConnectionMode(mode);
|
||||
};
|
||||
const sourceTone: StatusTone =
|
||||
state?.phase === "error" || relevantAcquisitionFailed
|
||||
activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.tone
|
||||
: projectedPhase === "error" || relevantAcquisitionFailed
|
||||
? "danger"
|
||||
: confirmedLive || state?.source_mode === "replay"
|
||||
? "success"
|
||||
: sourceRuntimeBusy || preparedAcquisition
|
||||
? "warning"
|
||||
: "neutral";
|
||||
const connectionPhaseLabel = sourceRuntimeBusy || preparedAcquisition
|
||||
const connectionPhaseLabel = livePreparationPending
|
||||
? "Подготовка приёма"
|
||||
: sourceRuntimeBusy || preparedAcquisition
|
||||
? sourceLabel
|
||||
: phaseLabel(state?.phase);
|
||||
const connectionPhaseTone = sourceRuntimeBusy || preparedAcquisition
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.title
|
||||
: physicalStopRecoverySettling
|
||||
? "Завершение остановки"
|
||||
: recoveredPhysicalScanning
|
||||
? "Сканирование продолжается"
|
||||
: physicalRecoveryRequired
|
||||
? savedBridgeNetworkSetupRequired
|
||||
? "Нужна настройка сети"
|
||||
: "Требуется действие"
|
||||
: projectedPhase === "error"
|
||||
? connectionPhaseFallbackLabel(projectedPhase)
|
||||
: connectionTopology?.status === "active"
|
||||
? "Подключение установлено"
|
||||
: connectionTopology?.status === "configured-unverified"
|
||||
? "Подключение отсутствует"
|
||||
: connectionTopology?.source === "durable"
|
||||
? "Подключение отсутствует"
|
||||
: connectionTopology?.source === "applied"
|
||||
? "Подключение отсутствует"
|
||||
: connectionTopology?.source === "last-known"
|
||||
? "Подключение отсутствует"
|
||||
: connectionPhaseFallbackLabel(projectedPhase);
|
||||
const connectionPhaseTone = livePreparationPending
|
||||
? "warning"
|
||||
: sourceRuntimeBusy || preparedAcquisition
|
||||
? sourceTone
|
||||
: phaseTone(state?.phase);
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.tone
|
||||
: physicalRecoveryRequired
|
||||
? "warning"
|
||||
: projectedPhase === "error"
|
||||
? phaseTone(projectedPhase)
|
||||
: connectionTopology?.status === "active"
|
||||
? "success"
|
||||
: connectionTopology?.status === "configured-unverified"
|
||||
? "neutral"
|
||||
: "neutral";
|
||||
const connectionPhaseDetail = livePreparationPending
|
||||
? "Подготовка продолжается."
|
||||
: physicalStopRecoverySettling
|
||||
? "Команда остановки уже принята. Завершение выполняется без повторной команды."
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.detail
|
||||
: recoveredPhysicalScanning
|
||||
? "Локальная запись остановлена, но сканирование ещё продолжается."
|
||||
: physicalRecoveryRequired
|
||||
? physicalRecoveryDetail
|
||||
?? "Безопасное восстановление прежнего K1 сейчас недоступно."
|
||||
: !sourceRuntimeBusy && connectionTopology?.status === "configured-unverified"
|
||||
? "Начните новое подключение."
|
||||
: !sourceRuntimeBusy && connectionTopology?.status === "active"
|
||||
? "Готово к новой сессии."
|
||||
: "Ожидается состояние локального контура.";
|
||||
const operationalPanelsVisible = shouldRenderK1OperationalPanels(
|
||||
state,
|
||||
controller.pendingAction,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="device-workspace xgrids-k1-plugin">
|
||||
{error ? (
|
||||
<aside className="error-banner" role="alert">
|
||||
<span className="error-banner__dot" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Локальная операция завершилась ошибкой</strong>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<div className="error-banner__actions">
|
||||
<Button size="compact" variant="secondary" onClick={() => void refresh()}>Обновить состояние</Button>
|
||||
<Button size="compact" variant="ghost" onClick={clearError}>Закрыть</Button>
|
||||
</div>
|
||||
</aside>
|
||||
{showGenericRuntimeError && error ? (
|
||||
<K1OperatorError
|
||||
message={error}
|
||||
diagnostic={errorDiagnostic}
|
||||
onRefresh={() => void refresh()}
|
||||
onClear={clearError}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">XGRIDS K1 · PLUGIN UI</span>
|
||||
<span className="section-eyebrow">ЛОКАЛЬНОЕ ПОДКЛЮЧЕНИЕ</span>
|
||||
<h2>Подключение {model.displayName}</h2>
|
||||
<p>BLE/Wi‑Fi provisioning и acquisition pipeline принадлежат этому device plugin; Control Station предоставляет только host slot и переход в пространственную сцену.</p>
|
||||
<p>Выберите способ связи и последовательно установите подключение.</p>
|
||||
</div>
|
||||
<div className="workspace-lead__status">
|
||||
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
|
||||
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
|
||||
<span>{connectionPhaseDetail}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<K1Metrics controller={controller} />
|
||||
|
||||
<div className="device-workspace__grid">
|
||||
<K1ProvisioningPipeline
|
||||
controller={controller}
|
||||
phaseLabel={connectionPhaseLabel}
|
||||
phaseTone={connectionPhaseTone}
|
||||
/>
|
||||
<div className="device-workspace__side">
|
||||
<K1AcquisitionPipeline
|
||||
<K1ConnectionPipelines
|
||||
controller={controller}
|
||||
desiredConnectionMode={effectiveDesiredConnectionMode}
|
||||
onDesiredConnectionModeChange={updateDesiredConnectionMode}
|
||||
operationalPanelsVisible={operationalPanelsVisible}
|
||||
openSpatialScene={host.openSpatialScene}
|
||||
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
|
||||
sourceLabel={sourceLabel}
|
||||
/>
|
||||
<K1Diagnostics controller={controller} sourceLabel={sourceLabel} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
isXgridsActiveStreamRecovery,
|
||||
type XgridsActiveStreamRecovery,
|
||||
type XgridsK1State,
|
||||
} from "./api";
|
||||
|
||||
export interface ActiveStreamRecoveryLineage {
|
||||
snapshotRuntimeId: string;
|
||||
acquisitionId: string;
|
||||
acquisitionStateRevision: number;
|
||||
recoveryGeneration: number;
|
||||
runtimeProducerGeneration: number;
|
||||
recovery: XgridsActiveStreamRecovery;
|
||||
}
|
||||
|
||||
export type ActiveStreamForceFinishAuthority = ActiveStreamRecoveryLineage;
|
||||
|
||||
export type ActiveStreamRecoveryPresentationAuthority = ActiveStreamRecoveryLineage;
|
||||
|
||||
export type ActiveStreamRecoveryVisibleState =
|
||||
| "reconnecting"
|
||||
| "blocked"
|
||||
| "standby"
|
||||
| "fault";
|
||||
|
||||
export interface ActiveStreamRecoveryPresentation {
|
||||
state: ActiveStreamRecoveryVisibleState;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
statusLabel: string;
|
||||
tone: "neutral" | "warning" | "danger";
|
||||
detail: string;
|
||||
progressLabel: string | null;
|
||||
showSpinner: boolean;
|
||||
forceFinishAvailable: boolean;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one exact active-stream lineage from the public runtime snapshot.
|
||||
*
|
||||
* A recovery-shaped object alone is not authority. The browser also requires
|
||||
* the current runtime id, the same acquisition id and the exact producer
|
||||
* generation on both sides of the projection. This keeps a late recovery
|
||||
* update from an older producer out of both presentation and mutation gates.
|
||||
*/
|
||||
export function exactActiveStreamRecoveryLineage(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): ActiveStreamRecoveryLineage | null {
|
||||
const recovery = state?.connection_recovery;
|
||||
const acquisition = state?.acquisition;
|
||||
const snapshotRuntimeId = state?.snapshot_runtime_id?.trim() || null;
|
||||
const producerGeneration = state?.producer_generation;
|
||||
const acquisitionId = acquisition?.acquisition_id?.trim() || null;
|
||||
const recoveryAcquisitionId = recovery?.acquisition_id?.trim() || null;
|
||||
if (
|
||||
!snapshotRuntimeId
|
||||
|| !isXgridsActiveStreamRecovery(recovery)
|
||||
|| !acquisition
|
||||
|| !acquisitionId
|
||||
|| recoveryAcquisitionId !== acquisitionId
|
||||
|| !positiveInteger(acquisition.state_revision)
|
||||
|| !positiveInteger(recovery.generation)
|
||||
|| !positiveInteger(producerGeneration)
|
||||
|| recovery.runtime_producer_generation !== producerGeneration
|
||||
|| recovery.automatic_read_only_rebind !== true
|
||||
) return null;
|
||||
return {
|
||||
snapshotRuntimeId,
|
||||
acquisitionId,
|
||||
acquisitionStateRevision: acquisition.state_revision,
|
||||
recoveryGeneration: recovery.generation,
|
||||
runtimeProducerGeneration: producerGeneration,
|
||||
recovery,
|
||||
};
|
||||
}
|
||||
|
||||
/** Exact, current and backend-policy-admitted authority for local-only finish. */
|
||||
export function activeStreamForceFinishAuthority(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): ActiveStreamForceFinishAuthority | null {
|
||||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||||
if (
|
||||
!lineage
|
||||
|| !["reconnecting", "blocked"].includes(lineage.recovery.state)
|
||||
|| lineage.recovery.force_finish_allowed !== true
|
||||
|| state?.phase !== "reconnecting"
|
||||
|| state.source_mode !== "live"
|
||||
|| ![
|
||||
"starting",
|
||||
"awaiting_external_start",
|
||||
"acquiring",
|
||||
].includes(state.acquisition?.state ?? "")
|
||||
) return null;
|
||||
return lineage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact authority for retaining browser presentation while the backend owns a
|
||||
* read-only reconnect. This is deliberately narrower than the recovery card:
|
||||
* terminal/blocked recovery states and an inactive acquisition cannot retain
|
||||
* a prior spatial or camera transport.
|
||||
*/
|
||||
export function activeStreamRecoveryPresentationAuthority(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): ActiveStreamRecoveryPresentationAuthority | null {
|
||||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||||
if (
|
||||
!lineage
|
||||
|| lineage.recovery.state !== "reconnecting"
|
||||
|| state?.phase !== "reconnecting"
|
||||
|| state.source_mode !== "live"
|
||||
|| ![
|
||||
"starting",
|
||||
"awaiting_external_start",
|
||||
"acquiring",
|
||||
].includes(state.acquisition?.state ?? "")
|
||||
) return null;
|
||||
return lineage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the exact recovered lineage available to disposable browser receivers
|
||||
* after the recovery card has disappeared. Spatial admission can complete on
|
||||
* the first authoritative PCL before the acquisition-owned camera produces
|
||||
* its first playable frame. This authority carries only the no-write
|
||||
* presentation lease: it grants neither force-finish nor START/STOP policy.
|
||||
*/
|
||||
export function activeStreamRecoveredBrowserAuthority(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): ActiveStreamRecoveryPresentationAuthority | null {
|
||||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||||
if (
|
||||
!lineage
|
||||
|| lineage.recovery.state !== "recovered"
|
||||
|| lineage.recovery.camera_recovery !== "owned"
|
||||
|| state?.phase !== "live"
|
||||
|| state.source_mode !== "live"
|
||||
|| state.acquisition?.state !== "acquiring"
|
||||
) return null;
|
||||
return lineage;
|
||||
}
|
||||
|
||||
/**
|
||||
* While a validated recovery contract is active it owns the presentation
|
||||
* decision. Ordinary supervisor data flags may be stale across the network
|
||||
* gap, so only an exact reconnect lease can retain browser transports.
|
||||
*/
|
||||
export function activeStreamRecoveryOwnsPresentationDecision(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): boolean {
|
||||
const recovery = state?.connection_recovery;
|
||||
return Boolean(
|
||||
isXgridsActiveStreamRecovery(recovery)
|
||||
&& !["inactive", "recovered"].includes(recovery.state),
|
||||
);
|
||||
}
|
||||
|
||||
export function activeStreamForceFinishAuthorityMatches(
|
||||
expected: ActiveStreamForceFinishAuthority,
|
||||
state: XgridsK1State | null | undefined,
|
||||
): boolean {
|
||||
const current = activeStreamForceFinishAuthority(state);
|
||||
return Boolean(
|
||||
current
|
||||
&& current.snapshotRuntimeId === expected.snapshotRuntimeId
|
||||
&& current.acquisitionId === expected.acquisitionId
|
||||
&& current.acquisitionStateRevision === expected.acquisitionStateRevision
|
||||
&& current.recoveryGeneration === expected.recoveryGeneration
|
||||
&& current.runtimeProducerGeneration === expected.runtimeProducerGeneration,
|
||||
);
|
||||
}
|
||||
|
||||
export function formatActiveStreamRecoveryElapsed(
|
||||
elapsedMs: number | null,
|
||||
): string | null {
|
||||
if (!Number.isFinite(elapsedMs) || elapsedMs === null || elapsedMs < 0) return null;
|
||||
const elapsedSeconds = Math.floor(elapsedMs / 1_000);
|
||||
if (elapsedSeconds < 60) return `${elapsedSeconds} с`;
|
||||
const minutes = Math.floor(elapsedSeconds / 60);
|
||||
const seconds = elapsedSeconds % 60;
|
||||
return seconds > 0 ? `${minutes} мин ${seconds} с` : `${minutes} мин`;
|
||||
}
|
||||
|
||||
function recoveryProgressLabel(
|
||||
recovery: XgridsActiveStreamRecovery,
|
||||
): string | null {
|
||||
const elapsed = formatActiveStreamRecoveryElapsed(recovery.elapsed_ms);
|
||||
const attempt = recovery.attempt > 0
|
||||
? `Попытка ${recovery.attempt}`
|
||||
: "Подготовка проверки";
|
||||
return elapsed ? `${attempt} · ${elapsed}` : attempt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Present only an exact current lineage. `recovered` deliberately returns
|
||||
* null so the ordinary confirmed live UI resumes without a transitional card.
|
||||
*/
|
||||
export function activeStreamRecoveryPresentation(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): ActiveStreamRecoveryPresentation | null {
|
||||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||||
if (!lineage) return null;
|
||||
const recovery = lineage.recovery;
|
||||
if (recovery.state === "reconnecting") {
|
||||
return {
|
||||
state: "reconnecting",
|
||||
eyebrow: "СВЯЗЬ · АКТИВНЫЙ ПРИЁМ",
|
||||
title: "Восстанавливаем соединение",
|
||||
statusLabel: "Восстановление связи",
|
||||
tone: "neutral",
|
||||
detail: "Проверка прежнего активного контура только для чтения. START, STOP и настройки сети не отправляются.",
|
||||
progressLabel: recoveryProgressLabel(recovery),
|
||||
showSpinner: true,
|
||||
forceFinishAvailable: activeStreamForceFinishAuthority(state) !== null,
|
||||
};
|
||||
}
|
||||
if (recovery.state === "blocked") {
|
||||
return {
|
||||
state: "blocked",
|
||||
eyebrow: "СВЯЗЬ · ТРЕБУЕТСЯ ДЕЙСТВИЕ",
|
||||
title: recovery.camera_recovery === "blocked"
|
||||
? "Видеопоток не восстановлен"
|
||||
: "Связь не восстановлена",
|
||||
statusLabel: "Восстановление остановлено",
|
||||
tone: "warning",
|
||||
detail: recovery.camera_recovery === "blocked"
|
||||
? "Связь с K1 проверена, но камера не возобновила передачу. Можно завершить только локальный приём."
|
||||
: "Автоматическая проверка остановлена. Можно завершить только локальный приём; команда устройству не отправится.",
|
||||
progressLabel: recoveryProgressLabel(recovery),
|
||||
showSpinner: false,
|
||||
forceFinishAvailable: activeStreamForceFinishAuthority(state) !== null,
|
||||
};
|
||||
}
|
||||
if (recovery.state === "standby") {
|
||||
return {
|
||||
state: "standby",
|
||||
eyebrow: "СВЯЗЬ · СОСТОЯНИЕ ПРОВЕРЕНО",
|
||||
title: "Устройство перешло в ожидание",
|
||||
statusLabel: "Приём завершён",
|
||||
tone: "neutral",
|
||||
detail: "K1 сообщил, что активное сканирование уже завершено. Локальный приём закрывается без команды STOP.",
|
||||
progressLabel: recoveryProgressLabel(recovery),
|
||||
showSpinner: false,
|
||||
forceFinishAvailable: false,
|
||||
};
|
||||
}
|
||||
if (recovery.state === "fault") {
|
||||
return {
|
||||
state: "fault",
|
||||
eyebrow: "СВЯЗЬ · СОСТОЯНИЕ ПРОВЕРЕНО",
|
||||
title: "K1 сообщил об ошибке",
|
||||
statusLabel: "Восстановление невозможно",
|
||||
tone: "danger",
|
||||
detail: "Безопасная проверка обнаружила ошибку устройства. Автоматических команд и повторов нет.",
|
||||
progressLabel: recoveryProgressLabel(recovery),
|
||||
showSpinner: false,
|
||||
forceFinishAvailable: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only an exact, still-active background reconnect may hide the generic red
|
||||
* error banner. A failed explicit local finish is operator-facing evidence and
|
||||
* must remain visible even while the last accepted snapshot says reconnecting.
|
||||
*/
|
||||
export function suppressGenericErrorDuringActiveStreamRecovery(
|
||||
state: XgridsK1State | null | undefined,
|
||||
errorAction?: string | null,
|
||||
): boolean {
|
||||
if (errorAction === "force-finish") return false;
|
||||
return activeStreamRecoveryPresentationAuthority(state) !== null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
GlassSurface,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { ActiveStreamRecoveryPresentation } from "../activeStreamRecovery";
|
||||
|
||||
export type ActiveStreamRecoverySurfaceVariant = "panel" | "compact";
|
||||
|
||||
export interface ActiveStreamRecoverySurfaceProps {
|
||||
presentation: ActiveStreamRecoveryPresentation | null;
|
||||
forceFinishing: boolean;
|
||||
actionBusy: boolean;
|
||||
onForceFinish: () => void;
|
||||
variant?: ActiveStreamRecoverySurfaceVariant;
|
||||
}
|
||||
|
||||
interface ActiveStreamRecoverySurfaceCopy {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
statusLabel: string;
|
||||
detail: string;
|
||||
showSpinner: boolean;
|
||||
forceFinishAvailable: boolean;
|
||||
}
|
||||
|
||||
function surfaceCopy({
|
||||
presentation,
|
||||
forceFinishing,
|
||||
}: Pick<
|
||||
ActiveStreamRecoverySurfaceProps,
|
||||
"presentation" | "forceFinishing"
|
||||
>): ActiveStreamRecoverySurfaceCopy {
|
||||
return {
|
||||
eyebrow: forceFinishing
|
||||
? "СВЯЗЬ · ЛОКАЛЬНОЕ ЗАВЕРШЕНИЕ"
|
||||
: presentation?.eyebrow ?? "СВЯЗЬ · АКТИВНЫЙ ПРИЁМ",
|
||||
title: forceFinishing
|
||||
? "Завершение локального приёма"
|
||||
: presentation?.title ?? "Восстанавливаем соединение",
|
||||
statusLabel: forceFinishing
|
||||
? "Локальное завершение"
|
||||
: presentation?.statusLabel ?? "Восстановление связи",
|
||||
detail: forceFinishing
|
||||
? "Закрываем только локальный приём и сохранение. Команда STOP устройству не отправляется."
|
||||
: presentation?.detail ?? "Проверка состояния активного приёма.",
|
||||
showSpinner: forceFinishing || presentation?.showSpinner === true,
|
||||
forceFinishAvailable:
|
||||
!forceFinishing && presentation?.forceFinishAvailable === true,
|
||||
};
|
||||
}
|
||||
|
||||
function RecoveryState({
|
||||
presentation,
|
||||
copy,
|
||||
}: {
|
||||
presentation: ActiveStreamRecoveryPresentation | null;
|
||||
copy: ActiveStreamRecoverySurfaceCopy;
|
||||
}) {
|
||||
const stateClassName = copy.showSpinner
|
||||
? "active-stream-recovery__state"
|
||||
: "active-stream-recovery__state active-stream-recovery__state--static";
|
||||
return (
|
||||
<div className={stateClassName}>
|
||||
{copy.showSpinner ? <ActivityIndicator size="compact" /> : null}
|
||||
<div className="active-stream-recovery__copy">
|
||||
<strong>{copy.title}</strong>
|
||||
<span>{copy.detail}</span>
|
||||
{presentation?.progressLabel ? (
|
||||
<small>{presentation.progressLabel}</small>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecoveryAction({
|
||||
visible,
|
||||
actionBusy,
|
||||
compact,
|
||||
onForceFinish,
|
||||
}: {
|
||||
visible: boolean;
|
||||
actionBusy: boolean;
|
||||
compact: boolean;
|
||||
onForceFinish: () => void;
|
||||
}) {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className="active-stream-recovery__actions">
|
||||
<Button
|
||||
size={compact ? "compact" : undefined}
|
||||
variant="secondary"
|
||||
disabled={actionBusy}
|
||||
onClick={onForceFinish}
|
||||
>
|
||||
Прервать соединение
|
||||
</Button>
|
||||
<p>
|
||||
Завершит только локальный front/back-приём и сохранение. START, STOP,
|
||||
Bluetooth и настройки устройства не отправляются.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One shared recovery owner for the connection and spatial workspaces.
|
||||
*
|
||||
* The surface never chooses a mutation by itself: its sole callback is the
|
||||
* explicitly fenced local force-finish action supplied by the K1 controller.
|
||||
*/
|
||||
export function ActiveStreamRecoverySurface({
|
||||
presentation,
|
||||
forceFinishing,
|
||||
actionBusy,
|
||||
onForceFinish,
|
||||
variant = "panel",
|
||||
}: ActiveStreamRecoverySurfaceProps) {
|
||||
const copy = surfaceCopy({ presentation, forceFinishing });
|
||||
const tone = forceFinishing ? "neutral" : presentation?.tone ?? "neutral";
|
||||
const content = (
|
||||
<>
|
||||
<RecoveryState presentation={presentation} copy={copy} />
|
||||
<RecoveryAction
|
||||
visible={copy.forceFinishAvailable}
|
||||
actionBusy={actionBusy}
|
||||
compact={variant === "compact"}
|
||||
onForceFinish={onForceFinish}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<section
|
||||
className="xgrids-k1-spatial-controls xgrids-k1-spatial-controls--recovery"
|
||||
aria-label="Восстановление активной сессии XGRIDS K1"
|
||||
aria-live="polite"
|
||||
aria-busy={copy.showSpinner}
|
||||
data-recovery-state={
|
||||
forceFinishing ? "force-finishing" : presentation?.state ?? "reconnecting"
|
||||
}
|
||||
>
|
||||
<div className="active-stream-recovery__compact-heading">
|
||||
<span>{copy.eyebrow}</span>
|
||||
<StatusBadge tone={tone}>{copy.statusLabel}</StatusBadge>
|
||||
</div>
|
||||
<div className="active-stream-recovery active-stream-recovery--compact">
|
||||
{content}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassSurface className="session-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">{copy.eyebrow}</span>
|
||||
<h2>{copy.title}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={tone}>{copy.statusLabel}</StatusBadge>
|
||||
</header>
|
||||
<div className="active-stream-recovery" aria-live="polite">
|
||||
{content}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
Checker,
|
||||
GlassSurface,
|
||||
@@ -12,6 +13,11 @@ import {
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { profileSelectionForConnectionMode } from "../compatibility";
|
||||
import {
|
||||
activeStreamForceFinishAuthority,
|
||||
activeStreamRecoveryPresentation,
|
||||
exactActiveStreamRecoveryLineage,
|
||||
} from "../activeStreamRecovery";
|
||||
import {
|
||||
SUPPORTED_GNSS_MODE,
|
||||
SUPPORTED_MOUNT_TYPE,
|
||||
@@ -22,48 +28,63 @@ import {
|
||||
} from "../configuration";
|
||||
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
|
||||
import {
|
||||
canIssueCanonicalStop,
|
||||
connectionPolicyAllows,
|
||||
isConfirmedLiveState,
|
||||
isSoftwareCommandedAcquisition,
|
||||
isReleasedTerminalAcquisitionFailure,
|
||||
currentAppliedConnectionTopology,
|
||||
isSourceRuntimeBusy,
|
||||
isVendorWriteCapable,
|
||||
isTerminalAcquisitionState,
|
||||
recoverableAcquisition,
|
||||
requiresCanonicalStopAfterTerminalLocalFailure,
|
||||
sourceStatusLabel,
|
||||
} from "../lifecycle";
|
||||
import { normalizeProjectName, validateProjectName } from "../projectName";
|
||||
import { connectionPolicyOperatorGuidance } from "../presentation";
|
||||
import {
|
||||
normalizeProjectName,
|
||||
projectNameAfterConnectionModeSelection,
|
||||
shouldHydratePreparedProject,
|
||||
validateProjectName,
|
||||
} from "../projectName";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
import type { OperatorPresenceConfirmation } from "../api";
|
||||
import {
|
||||
activeStopTarget,
|
||||
operatorActionPhysicalAcceptance,
|
||||
preparationTarget,
|
||||
preparedStartTarget,
|
||||
} from "../physicalCommandConfirmation";
|
||||
import { ActiveStreamRecoverySurface } from "./ActiveStreamRecoverySurface";
|
||||
|
||||
type SessionIntent = "live" | "replay";
|
||||
|
||||
const sessionItems = [
|
||||
{ value: "live", label: "Реальное устройство" },
|
||||
{ value: "live", label: "Прямой приём" },
|
||||
{ value: "replay", label: "Повтор записи" },
|
||||
] satisfies Array<{ value: SessionIntent; label: string }>;
|
||||
|
||||
const PHYSICAL_ACCEPTANCE = {
|
||||
operator_present: true,
|
||||
owner_controlled_device: true,
|
||||
lixelgo_closed: true,
|
||||
battery_storage_confirmed: true,
|
||||
expected_physical_state_confirmed: true,
|
||||
} satisfies OperatorPresenceConfirmation;
|
||||
|
||||
export function K1AcquisitionPipeline({
|
||||
controller,
|
||||
desiredConnectionMode = "bridge",
|
||||
openSpatialScene,
|
||||
activateAutomaticSpatialSource,
|
||||
}: {
|
||||
controller: XgridsK1Controller;
|
||||
desiredConnectionMode?: "bridge" | "quick-connect" | "direct-connect";
|
||||
openSpatialScene: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
pendingAction,
|
||||
physicalStopIntentSpent,
|
||||
physicalStopInFlight,
|
||||
closeApplicationControlSession,
|
||||
startCanonicalAcquisition,
|
||||
prepareCanonicalAcquisition,
|
||||
startPreparedAcquisition,
|
||||
startReplay,
|
||||
stop,
|
||||
stopLocalReceiver,
|
||||
forceFinishActiveStreamLocally,
|
||||
abort,
|
||||
} = controller;
|
||||
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||
@@ -75,13 +96,59 @@ export function K1AcquisitionPipeline({
|
||||
const [mountType, setMountType] = useState<MountType>(SUPPORTED_MOUNT_TYPE);
|
||||
const [gnssMode, setGnssMode] = useState<GnssMode>(SUPPORTED_GNSS_MODE);
|
||||
const hydratedAcquisitionId = useRef<string | null>(null);
|
||||
const previousDesiredConnectionMode = useRef(desiredConnectionMode);
|
||||
|
||||
const activeAcquisition = recoverableAcquisition(state);
|
||||
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
|
||||
const projectNameValidation = validateProjectName(projectName);
|
||||
const vendorWriteCapable = isVendorWriteCapable(state);
|
||||
const control = state?.application_control_session;
|
||||
const controlPhase = control?.state ?? "idle";
|
||||
const appliedTopology = currentAppliedConnectionTopology(state);
|
||||
const connectionMode = desiredConnectionMode;
|
||||
const backendDesiredConnectionMode = state?.desired_connection_mode
|
||||
?? desiredConnectionMode;
|
||||
const configuredConnectionMode = state?.configured_connection_mode
|
||||
?? state?.connection_mode
|
||||
?? null;
|
||||
const activeConnectionMode = state?.active_connection_mode
|
||||
?? (appliedTopology?.status === "active" ? appliedTopology.connectionMode : null);
|
||||
const desiredSelectionCommitted = backendDesiredConnectionMode
|
||||
=== desiredConnectionMode;
|
||||
const desiredModeMatchesActive = desiredSelectionCommitted
|
||||
&& activeConnectionMode === desiredConnectionMode;
|
||||
const modeSwitchRequired = Boolean(
|
||||
!desiredSelectionCommitted
|
||||
|| (activeConnectionMode && !desiredModeMatchesActive)
|
||||
|| (configuredConnectionMode && configuredConnectionMode !== desiredConnectionMode),
|
||||
);
|
||||
const connectionConfigured = Boolean(
|
||||
appliedTopology?.status === "active"
|
||||
&& desiredModeMatchesActive
|
||||
&& state?.connection_lifecycle?.ready_to_start === true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousDesiredConnectionMode.current === desiredConnectionMode) return;
|
||||
previousDesiredConnectionMode.current = desiredConnectionMode;
|
||||
const projectNameAfterSelection = projectNameAfterConnectionModeSelection(
|
||||
preparedAcquisition?.project_name,
|
||||
);
|
||||
// A prepared acquisition is immutable backend state, not a draft owned by
|
||||
// this selector. Preserve its project while the operator previews another
|
||||
// mode so selecting the active mode again can resume START immediately.
|
||||
if (preparedAcquisition) {
|
||||
setProjectName(projectNameAfterSelection);
|
||||
setProjectNameTouched(false);
|
||||
return;
|
||||
}
|
||||
// Draft project fields belong to the previously selected transport. The
|
||||
// dropdown sends no physical command; Connect performs the later bounded
|
||||
// mode transaction, while START remains fenced in the meantime.
|
||||
setProjectName(projectNameAfterSelection);
|
||||
setProjectNameTouched(false);
|
||||
setMountType(SUPPORTED_MOUNT_TYPE);
|
||||
setGnssMode(SUPPORTED_GNSS_MODE);
|
||||
}, [desiredConnectionMode, preparedAcquisition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.source_mode === "live" || state?.source_mode === "replay") {
|
||||
@@ -93,11 +160,35 @@ export function K1AcquisitionPipeline({
|
||||
|
||||
useEffect(() => {
|
||||
const acquisitionId = preparedAcquisition?.acquisition_id ?? null;
|
||||
if (!acquisitionId || hydratedAcquisitionId.current === acquisitionId) return;
|
||||
if (!shouldHydratePreparedProject({
|
||||
acquisitionId,
|
||||
hydratedAcquisitionId: hydratedAcquisitionId.current,
|
||||
modeSwitchRequired,
|
||||
})) return;
|
||||
hydratedAcquisitionId.current = acquisitionId;
|
||||
setProjectName(preparedAcquisition?.project_name ?? "");
|
||||
setProjectNameTouched(false);
|
||||
}, [preparedAcquisition?.acquisition_id, preparedAcquisition?.project_name]);
|
||||
}, [
|
||||
modeSwitchRequired,
|
||||
preparedAcquisition?.acquisition_id,
|
||||
preparedAcquisition?.project_name,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const acquisition = state?.acquisition;
|
||||
if (
|
||||
hydratedAcquisitionId.current === null
|
||||
|| !acquisition
|
||||
|| acquisition.acquisition_id !== hydratedAcquisitionId.current
|
||||
|| !isTerminalAcquisitionState(acquisition.state)
|
||||
|| state?.source_mode !== "idle"
|
||||
) return;
|
||||
hydratedAcquisitionId.current = null;
|
||||
setProjectName("");
|
||||
setProjectNameTouched(false);
|
||||
setMountType(SUPPORTED_MOUNT_TYPE);
|
||||
setGnssMode(SUPPORTED_GNSS_MODE);
|
||||
}, [state?.acquisition, state?.source_mode]);
|
||||
|
||||
const isBusy = pendingAction !== null;
|
||||
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
|
||||
@@ -108,10 +199,50 @@ export function K1AcquisitionPipeline({
|
||||
: activeAcquisition
|
||||
? "live"
|
||||
: sessionIntent;
|
||||
const sourceLabel = sourceStatusLabel(state);
|
||||
const relevantAcquisitionFailed = state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
|
||||
const gracefulStopTarget = activeStopTarget(state);
|
||||
const terminalPhysicalStopObserved =
|
||||
requiresCanonicalStopAfterTerminalLocalFailure(state);
|
||||
const physicalStopExecutable = Boolean(
|
||||
gracefulStopTarget
|
||||
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
|
||||
);
|
||||
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
|
||||
const localReceiverStopExecutable = Boolean(
|
||||
connectionPolicyAllows(state, "stop-local-receiver")
|
||||
&& preparedAcquisition === null,
|
||||
);
|
||||
const terminalPhysicalStopPending = terminalPhysicalStopObserved
|
||||
&& physicalStopInFlight;
|
||||
const recoveredPhysicalStop = terminalPhysicalStopObserved
|
||||
&& physicalStopExecutable
|
||||
&& !physicalStopInFlight;
|
||||
const terminalLocalRecovery = terminalPhysicalStopObserved
|
||||
&& !physicalStopPresented
|
||||
&& localReceiverStopExecutable;
|
||||
const terminalReadOnlyRecovery = terminalPhysicalStopObserved
|
||||
&& !physicalStopPresented
|
||||
&& !localReceiverStopExecutable;
|
||||
const terminalLocalCapturePending = Boolean(
|
||||
state?.acquisition?.cleanup_pending === true
|
||||
|| state?.source_mode === "live",
|
||||
);
|
||||
const sourceLabel = terminalPhysicalStopPending
|
||||
? "Команда отправлена"
|
||||
: recoveredPhysicalStop
|
||||
? "Требуется остановка"
|
||||
: terminalLocalRecovery
|
||||
? "Локальное завершение доступно"
|
||||
: terminalReadOnlyRecovery
|
||||
? "Действия заблокированы"
|
||||
: sourceStatusLabel(state);
|
||||
const releasedAcquisitionFailure = isReleasedTerminalAcquisitionFailure(state);
|
||||
const relevantAcquisitionFailed = state?.source_mode !== "replay"
|
||||
&& state?.acquisition?.state === "failed"
|
||||
&& !releasedAcquisitionFailure;
|
||||
const sourceTone: StatusTone =
|
||||
state?.phase === "error" || relevantAcquisitionFailed
|
||||
terminalPhysicalStopObserved
|
||||
? "warning"
|
||||
: (state?.phase === "error" && !releasedAcquisitionFailure) || relevantAcquisitionFailed
|
||||
? "danger"
|
||||
: isConfirmedLiveState(state) || state?.source_mode === "replay"
|
||||
? "success"
|
||||
@@ -122,42 +253,105 @@ export function K1AcquisitionPipeline({
|
||||
() => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })),
|
||||
[sessionLocked],
|
||||
);
|
||||
const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
|
||||
const activeRecoveryForceFinishAuthority = activeStreamForceFinishAuthority(state);
|
||||
const activeRecoveryLineage = exactActiveStreamRecoveryLineage(state);
|
||||
const recoveredActiveSession = Boolean(
|
||||
activeRecoveryLineage?.recovery.state === "recovered"
|
||||
&& state?.phase === "live"
|
||||
&& state.source_mode === "live"
|
||||
&& activeAcquisition?.state === "acquiring"
|
||||
&& activeRecoveryLineage.acquisitionId === activeAcquisition.acquisition_id,
|
||||
);
|
||||
const recoveredActiveSessionLabel = activeAcquisition?.project_name?.trim()
|
||||
|| activeAcquisition?.acquisition_id
|
||||
|| "текущая сессия";
|
||||
const localForceFinishPending = pendingAction === "force-finish";
|
||||
|
||||
if (activeRecoveryPresentation || localForceFinishPending) {
|
||||
return (
|
||||
<ActiveStreamRecoverySurface
|
||||
presentation={activeRecoveryPresentation}
|
||||
forceFinishing={localForceFinishPending}
|
||||
actionBusy={pendingAction !== null}
|
||||
onForceFinish={() => {
|
||||
if (!activeRecoveryForceFinishAuthority) return;
|
||||
void forceFinishActiveStreamLocally();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const preparedCanonicalLaunch =
|
||||
preparedAcquisition?.control_mode === "plugin-commanded";
|
||||
const launchBlockedByAcquisition =
|
||||
activeAcquisition !== null && !preparedCanonicalLaunch;
|
||||
const controlRetryBlocked =
|
||||
controlPhase === "failed" && control?.can_open !== true;
|
||||
const finalStartTarget = preparedStartTarget(state);
|
||||
const draftPreparationTarget = preparationTarget(
|
||||
state,
|
||||
projectNameValidation.value,
|
||||
);
|
||||
const physicalStartAllowed = connectionPolicyAllows(state, "start-acquisition");
|
||||
const physicalStopGuidance = gracefulStopTarget
|
||||
&& !physicalStopPresented
|
||||
&& !terminalPhysicalStopObserved
|
||||
? connectionPolicyOperatorGuidance(state, "stop-acquisition")
|
||||
: null;
|
||||
const physicalStopGuidanceCopy = terminalPhysicalStopPending
|
||||
? "Команда остановки устройства уже отправлена. Ждём новое подтверждённое состояние; повторная команда не отправляется."
|
||||
: terminalLocalRecovery
|
||||
? physicalStopIntentSpent
|
||||
? "Команда завершилась без нового подтверждённого результата. Повторная команда устройству не отправляется; завершите только локальный приём."
|
||||
: "Управляющая команда устройству сейчас недоступна. Завершите только разрешённый сервером локальный приём или выполните read-only восстановление."
|
||||
: terminalReadOnlyRecovery
|
||||
? "Управляющие действия сейчас не разрешены. Дождитесь нового подтверждённого состояния или выполните read-only восстановление."
|
||||
: physicalStopGuidance
|
||||
? `${physicalStopGuidance.reason} ${physicalStopGuidance.nextAction}`
|
||||
: gracefulStopTarget && !physicalStopPresented && physicalStopIntentSpent
|
||||
? "Команда завершилась без нового подтверждённого результата. Повторная команда устройству не отправляется; завершите только локальный приём."
|
||||
: gracefulStopTarget && !physicalStopPresented
|
||||
? "Команда устройству недоступна в текущем подтверждённом состоянии. Завершите только локальный приём или выполните read-only восстановление."
|
||||
: null;
|
||||
|
||||
const startLive = async () => {
|
||||
const submitFinalStart = async (
|
||||
physicalAcceptance = operatorActionPhysicalAcceptance(),
|
||||
) => {
|
||||
await runAutomaticSpatialSourceStart(
|
||||
() => startPreparedAcquisition(physicalAcceptance),
|
||||
activateAutomaticSpatialSource,
|
||||
openSpatialScene,
|
||||
);
|
||||
};
|
||||
|
||||
const requestLivePreparation = async () => {
|
||||
setProjectNameTouched(true);
|
||||
if (
|
||||
!state?.k1_ip ||
|
||||
!connectionConfigured ||
|
||||
!connectionMode ||
|
||||
sourceRuntimeBusy ||
|
||||
launchBlockedByAcquisition ||
|
||||
controlRetryBlocked ||
|
||||
projectNameValidation.error
|
||||
) return;
|
||||
const timezoneName = Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC";
|
||||
await runAutomaticSpatialSourceStart(
|
||||
() => startCanonicalAcquisition({
|
||||
control: {
|
||||
...PHYSICAL_ACCEPTANCE,
|
||||
timezone_name: timezoneName,
|
||||
},
|
||||
if (finalStartTarget) {
|
||||
if (!physicalStartAllowed) return;
|
||||
await submitFinalStart();
|
||||
return;
|
||||
}
|
||||
if (!draftPreparationTarget) return;
|
||||
const physicalAcceptance = operatorActionPhysicalAcceptance();
|
||||
const prepared = await prepareCanonicalAcquisition({
|
||||
acquisition: {
|
||||
project_name: projectNameValidation.value,
|
||||
mount_type: SUPPORTED_MOUNT_TYPE,
|
||||
gnss_mode: SUPPORTED_GNSS_MODE,
|
||||
compatibility_attestation: profileSelectionForConnectionMode(
|
||||
state.connection_mode ?? "bridge",
|
||||
),
|
||||
compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
|
||||
},
|
||||
physicalAcceptance: PHYSICAL_ACCEPTANCE,
|
||||
}),
|
||||
activateAutomaticSpatialSource,
|
||||
openSpatialScene,
|
||||
);
|
||||
physicalAcceptance,
|
||||
});
|
||||
if (!prepared) return;
|
||||
await submitFinalStart(physicalAcceptance);
|
||||
};
|
||||
|
||||
const submitReplay = async () => {
|
||||
@@ -177,8 +371,8 @@ export function K1AcquisitionPipeline({
|
||||
<GlassSurface className="session-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">{effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРОЕКТ И ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span>
|
||||
<h2>{effectiveSessionIntent === "live" ? "Назовите проект и запустите приём" : "Повторите запись"}</h2>
|
||||
<span className="section-eyebrow">{terminalPhysicalStopPending ? "ВОССТАНОВЛЕНИЕ · КОМАНДА ОТПРАВЛЕНА" : recoveredPhysicalStop ? "ВОССТАНОВЛЕНИЕ · ОСТАНОВКА" : terminalLocalRecovery ? "ВОССТАНОВЛЕНИЕ · ЛОКАЛЬНЫЙ КОНТУР" : terminalReadOnlyRecovery ? "ВОССТАНОВЛЕНИЕ · ТОЛЬКО ЧТЕНИЕ" : recoveredActiveSession ? "СВЯЗЬ ВОССТАНОВЛЕНА · АКТИВНЫЙ ПРИЁМ" : effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРОЕКТ И ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span>
|
||||
<h2>{terminalPhysicalStopPending ? "Ожидание подтверждения устройства" : recoveredPhysicalStop ? "Сканирование продолжается" : terminalLocalRecovery ? "Завершите локальный приём" : terminalReadOnlyRecovery ? "Ожидайте подтверждённое состояние" : recoveredActiveSession ? "Связь восстановлена · приём продолжается" : effectiveSessionIntent === "live" ? "Назовите проект и запустите приём" : "Повторите запись"}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={sourceTone}>{sourceLabel}</StatusBadge>
|
||||
</header>
|
||||
@@ -188,7 +382,55 @@ export function K1AcquisitionPipeline({
|
||||
items={selectableSessionItems}
|
||||
onChange={(intent) => { if (!sessionLocked) setSessionIntent(intent); }}
|
||||
/>
|
||||
{effectiveSessionIntent === "live" ? (
|
||||
{terminalPhysicalStopObserved ? (
|
||||
<div className="session-form">
|
||||
<div className="connection-summary">
|
||||
{terminalPhysicalStopPending ? (
|
||||
<>
|
||||
<span>{terminalLocalCapturePending ? "Локальный приём ещё требует завершения" : "Локальная запись завершена"}</span>
|
||||
<strong>Команда остановки устройства уже отправлена</strong>
|
||||
<small>
|
||||
Ждём новое подтверждённое состояние K1. Повторная команда устройству не отправляется.
|
||||
</small>
|
||||
</>
|
||||
) : recoveredPhysicalStop ? (
|
||||
<>
|
||||
<span>{terminalLocalCapturePending ? "Локальный приём ещё требует завершения" : "Локальная запись завершена"}</span>
|
||||
<strong>Сканирование подтверждено; требуется явный STOP</strong>
|
||||
<small>
|
||||
Нажмите «Остановить сканирование» ниже или в пространственной сцене. Новый проект, START и настройка сети останутся заблокированы до подтверждённого READY.
|
||||
</small>
|
||||
</>
|
||||
) : terminalLocalRecovery ? (
|
||||
<>
|
||||
<span>Команды устройству заблокированы</span>
|
||||
<strong>Доступно локальное завершение приёма</strong>
|
||||
<small>
|
||||
Повторная команда K1 не отправляется. Завершите локальный приём или выполните read-only восстановление.
|
||||
</small>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Управляющие действия заблокированы</span>
|
||||
<strong>Доступно только read-only восстановление</strong>
|
||||
<small>
|
||||
Дождитесь нового подтверждённого состояния; локальные и управляющие команды сейчас не разрешены.
|
||||
</small>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : recoveredActiveSession ? (
|
||||
<div className="session-form">
|
||||
<div className="connection-summary">
|
||||
<span>Исходная сессия · {recoveredActiveSessionLabel}</span>
|
||||
<strong>Продолжаем тот же приём без нового START</strong>
|
||||
<small>
|
||||
Автоматическое восстановление не отправляло START, STOP, Bluetooth или настройки сети. Явная остановка ниже доступна только при текущем подтверждённом праве на STOP.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
) : effectiveSessionIntent === "live" ? (
|
||||
<div className="session-form">
|
||||
<div className="scan-configuration-grid">
|
||||
<div className="configuration-field">
|
||||
@@ -216,7 +458,6 @@ export function K1AcquisitionPipeline({
|
||||
</div>
|
||||
<TextField
|
||||
label="Название проекта"
|
||||
hint="Имя войдёт в единственный канонический START"
|
||||
value={projectName}
|
||||
onChange={(event) => {
|
||||
setProjectName(event.target.value);
|
||||
@@ -228,40 +469,44 @@ export function K1AcquisitionPipeline({
|
||||
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
|
||||
description={projectNameTouched && projectNameValidation.error
|
||||
? projectNameValidation.error
|
||||
: "Отдельной команды сохранения имени на K1 нет: оно отправляется только при START."}
|
||||
: undefined}
|
||||
placeholder="Например, TEST001"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
aria-busy={pendingAction === "live"}
|
||||
icon={pendingAction === "live"
|
||||
? <ActivityIndicator size="compact" />
|
||||
: <Icon name="activity" />}
|
||||
disabled={
|
||||
isBusy ||
|
||||
!state?.k1_ip ||
|
||||
!connectionConfigured ||
|
||||
projectNameValidation.error !== null ||
|
||||
sourceRuntimeBusy ||
|
||||
launchBlockedByAcquisition ||
|
||||
controlRetryBlocked
|
||||
controlRetryBlocked ||
|
||||
modeSwitchRequired ||
|
||||
Boolean(finalStartTarget && !physicalStartAllowed)
|
||||
}
|
||||
onClick={() => void startLive()}
|
||||
onClick={() => void requestLivePreparation()}
|
||||
>
|
||||
{pendingAction === "live"
|
||||
? controlPhase === "connecting"
|
||||
? "Синхронизация с K1…"
|
||||
? "Синхронизация…"
|
||||
: controlPhase === "workspace-requested"
|
||||
? "Входим в рабочее пространство…"
|
||||
? "Переход в рабочее пространство…"
|
||||
: controlPhase === "project-requested"
|
||||
? "Готовим проект и локальный приём…"
|
||||
? "Подготовка проекта и локального приёма…"
|
||||
: controlPhase === "start-requested" || controlPhase === "initializing"
|
||||
? "Калибровка оборудования…"
|
||||
: "Запускаем K1 и локальный приём…"
|
||||
? "Запуск приёма…"
|
||||
: "Подготовка проекта и локального приёма…"
|
||||
: finalStartTarget
|
||||
? "Запустить приём"
|
||||
: preparedCanonicalLaunch
|
||||
? "Продолжить запуск сканирования и приёма"
|
||||
: "Запустить сканирование и локальный приём"}
|
||||
? "Продолжить запуск"
|
||||
: "Запустить приём"}
|
||||
</Button>
|
||||
<p className="start-confirmation-note">
|
||||
Нажатие запуска — явное операторское действие для выбранного K1. Автоматических повторов START нет.
|
||||
</p>
|
||||
{control?.control_socket_open && !activeAcquisition && !isBusy ? (
|
||||
{control?.control_socket_open && !activeAcquisition && !recoveredPhysicalStop && !isBusy ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isBusy}
|
||||
@@ -270,56 +515,63 @@ export function K1AcquisitionPipeline({
|
||||
Отменить запуск до START
|
||||
</Button>
|
||||
) : null}
|
||||
<p className="live-instruction">
|
||||
{controlPhase === "failed"
|
||||
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
|
||||
: controlPhase === "connecting"
|
||||
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
|
||||
: controlPhase === "workspace-requested"
|
||||
? "После подтверждённых операций 1–6 выполняется вход в рабочее пространство."
|
||||
: controlPhase === "project-requested"
|
||||
? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется на K1."
|
||||
: controlPhase === "start-requested" || controlPhase === "initializing"
|
||||
? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет."
|
||||
: controlPhase === "scanning"
|
||||
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
|
||||
: "Одна кнопка выражает намерение запустить сканирование. Совместимость подтверждается живым DeviceInfo; этапы идут строго по записанному порядку и только после ответов K1."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-form session-form--replay">
|
||||
<TextField label="Путь к записи" hint="Локальный файл исходных данных" value={replayPath} onChange={(event) => setReplayPath(event.target.value)} spellCheck={false} placeholder="sessions/.../capture.tsv" />
|
||||
<TextField label="Путь к записи" hint="Локальный файл записи" value={replayPath} onChange={(event) => setReplayPath(event.target.value)} spellCheck={false} placeholder="sessions/.../capture.tsv" />
|
||||
<TextField label="Скорость повтора" hint="Множитель" type="number" min="0.1" step="0.1" value={replaySpeed} onChange={(event) => setReplaySpeed(event.target.value)} />
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">После последнего кадра начать запись заново.</span>
|
||||
<Checker checked={replayLoop} label="Повторять по кругу" onChange={setReplayLoop} />
|
||||
</div>
|
||||
<Button variant="primary" icon={<Icon name="video" />} disabled={isBusy || sessionLocked || replayPath.trim().length === 0} onClick={() => void submitReplay()}>
|
||||
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
|
||||
{pendingAction === "replay" ? "Запуск повтора…" : "Запустить повтор записи"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="session-footer">
|
||||
<p>
|
||||
{state?.source_mode === "replay"
|
||||
{physicalStopGuidanceCopy
|
||||
? physicalStopGuidanceCopy
|
||||
: recoveredPhysicalStop && physicalStopExecutable
|
||||
? terminalLocalCapturePending
|
||||
? "Локальный приём ещё требует завершения. Эта кнопка отправит ровно один явный STOP и дождётся подтверждённого результата."
|
||||
: "Локальная запись уже остановлена. Эта кнопка отправит ровно один явный STOP и дождётся READY."
|
||||
: state?.source_mode === "replay"
|
||||
? "Остановка завершит фактически запущенный повтор записи."
|
||||
: activeAcquisition || state?.source_mode === "live"
|
||||
? vendorWriteCapable && activeAcquisition?.control_mode === "plugin-commanded"
|
||||
? "Остановка отправит профилированную команду K1 и дождётся завершения локального сохранения."
|
||||
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
|
||||
? physicalStopPresented
|
||||
? "Остановка отправит профилированную команду и дождётся завершения локального сохранения."
|
||||
: localReceiverStopExecutable
|
||||
? "Остановка завершает только локальный приём и сохранение. Состояние сканирования остаётся неизвестным."
|
||||
: "Действие остановки сейчас не разрешено. Дождитесь нового подтверждённого состояния или выполните read-only восстановление."
|
||||
: "Активного источника сейчас нет."}
|
||||
</p>
|
||||
{physicalStopPresented || localReceiverStopExecutable ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)}
|
||||
onClick={() => void stop(
|
||||
isSoftwareCommandedAcquisition(state) ? PHYSICAL_ACCEPTANCE : undefined,
|
||||
)}
|
||||
disabled={
|
||||
isBusy
|
||||
|| (physicalStopPresented && !physicalStopExecutable)
|
||||
|| (!sourceRuntimeBusy && preparedAcquisition !== null)
|
||||
}
|
||||
onClick={() => {
|
||||
if (physicalStopExecutable) {
|
||||
void stop(operatorActionPhysicalAcceptance());
|
||||
return;
|
||||
}
|
||||
if (localReceiverStopExecutable) {
|
||||
void stopLocalReceiver();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pendingAction === "stop"
|
||||
? state?.source_mode === "replay" ? "Останавливаем повтор…" : "Останавливаем локальный приём…"
|
||||
: state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Остановить локальный приём"}
|
||||
{physicalStopInFlight
|
||||
? "Остановка устройства…"
|
||||
: pendingAction === "stop"
|
||||
? physicalStopPresented ? "Остановка устройства…" : state?.source_mode === "replay" ? "Остановка повтора…" : "Завершение локального приёма…"
|
||||
: physicalStopPresented ? recoveredPhysicalStop ? "Остановить сканирование" : "Остановить устройство и запись" : state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Завершить локальный приём"}
|
||||
</Button>
|
||||
) : null}
|
||||
{activeAcquisition ? (
|
||||
<Button variant="ghost" disabled={isBusy} onClick={() => void abort()}>
|
||||
{pendingAction === "abort" ? "Прерываем локальную операцию…" : preparedAcquisition ? "Отменить подготовку" : "Аварийно завершить локальный приём"}
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
formatNumber,
|
||||
pipelineLatency,
|
||||
} from "../presentation";
|
||||
import { isConfirmedLiveState } from "../lifecycle";
|
||||
import {
|
||||
activeConnectionEndpointLabel,
|
||||
backendConnectionTopology,
|
||||
isConfirmedLiveState,
|
||||
} from "../lifecycle";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -33,6 +37,18 @@ export function K1Diagnostics({ controller, sourceLabel }: {
|
||||
const { state, backendStatus, eventStatus, latencyHistory } = controller;
|
||||
const streamActive = isConfirmedLiveState(state) || state?.source_mode === "replay";
|
||||
const latency = pipelineLatency(streamActive ? state?.metrics : undefined);
|
||||
const activeEndpoint = activeConnectionEndpointLabel(state);
|
||||
const topology = backendConnectionTopology(state);
|
||||
const unverifiedEndpoint = topology?.status !== "active" ? topology?.endpoint : null;
|
||||
const endpointLabel = activeEndpoint
|
||||
? "Адрес подключения"
|
||||
: topology?.source === "durable"
|
||||
? "Адрес конфигурации"
|
||||
: topology?.source === "last-known"
|
||||
? "Адрес конфигурации"
|
||||
: topology?.source === "applied"
|
||||
? "Адрес конфигурации"
|
||||
: "Адрес подключения";
|
||||
return (
|
||||
<div className="diagnostics-grid">
|
||||
<GlassSurface className="status-panel" padding="lg">
|
||||
@@ -43,7 +59,20 @@ export function K1Diagnostics({ controller, sourceLabel }: {
|
||||
<dl className="detail-list">
|
||||
<DetailRow label="Канал событий"><span className="inline-state" data-state={eventStatus}>{eventStatusLabel(eventStatus)}</span></DetailRow>
|
||||
<DetailRow label="Источник">{sourceLabel}</DetailRow>
|
||||
<DetailRow label="Адрес устройства"><code>{state?.k1_ip || "Не получен"}</code></DetailRow>
|
||||
<DetailRow label={endpointLabel}>
|
||||
{activeEndpoint
|
||||
? <code>{activeEndpoint}</code>
|
||||
: unverifiedEndpoint
|
||||
? (
|
||||
<span>
|
||||
<code>{unverifiedEndpoint}</code>
|
||||
{topology?.status === "configured-unverified"
|
||||
? " · подключение ещё не подтверждено"
|
||||
: " · связь не подтверждена"}
|
||||
</span>
|
||||
)
|
||||
: <span>Не получен</span>}
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</GlassSurface>
|
||||
<GlassSurface className="latency-panel" padding="lg">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { isConfirmedLiveState } from "../lifecycle";
|
||||
import { hasAuthoritativeData, isConfirmedLiveState } from "../lifecycle";
|
||||
import { finiteMetric, formatNumber, pipelineLatency } from "../presentation";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
import { MetricCard } from "./MetricCard";
|
||||
|
||||
export function K1Metrics({ controller }: { controller: XgridsK1Controller }) {
|
||||
const { state } = controller;
|
||||
const streamActive = isConfirmedLiveState(state) || state?.source_mode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const streamAuthoritative = state?.source_mode === "replay" || Boolean(
|
||||
isConfirmedLiveState(state) && hasAuthoritativeData(state),
|
||||
);
|
||||
const metrics = streamAuthoritative ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
@@ -35,7 +37,7 @@ export function K1Metrics({ controller }: { controller: XgridsK1Controller }) {
|
||||
<MetricCard
|
||||
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
|
||||
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Исходные данные при этом сохраняются"
|
||||
detail="Данные потока при этом сохраняются"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Button } from "@nodedc/ui-react";
|
||||
|
||||
import type { XgridsConnectionAttempt } from "../api";
|
||||
import { hostFailureDiagnosticPresentation } from "../hostDiagnosticPresentation";
|
||||
|
||||
const connectionAttemptStageLabels: Record<string, string> = {
|
||||
accepted: "Запрос принят",
|
||||
"scan-selection-admitted": "Результат выбран",
|
||||
"host-wifi-profile-preflight": "Подготовка профиля Wi‑Fi",
|
||||
"device-ap-activation": "Подготовка локальной сети",
|
||||
"ble-provisioning-write": "Передаются настройки сети",
|
||||
"ble-write-dispatched": "Настройки переданы",
|
||||
"status-observing": "Ожидание ответа",
|
||||
"device-topology-applied": "Целевая сеть подтверждена",
|
||||
"host-wifi-association": "Настройка связи с сетью",
|
||||
"control-endpoint-admission": "Подготовка управляющего канала",
|
||||
connected: "Связь подтверждена",
|
||||
"network-configured": "Сеть настроена",
|
||||
};
|
||||
|
||||
function attemptStageLabel(attempt: XgridsConnectionAttempt): string {
|
||||
const normalized = attempt.stage.replace(/-failed$/, "");
|
||||
return connectionAttemptStageLabels[normalized] ?? "Подключение остановлено";
|
||||
}
|
||||
|
||||
function attemptSideEffectLabel(value: string): string {
|
||||
if (value === "none") return "Команда не отправлялась";
|
||||
if (value === "applied") return "Целевая сеть подтверждена";
|
||||
if (value === "confirmed") return "Передача команды подтверждена";
|
||||
return "Результат команды не подтверждён";
|
||||
}
|
||||
|
||||
export function attemptNetworkPhaseLabel(
|
||||
value: XgridsConnectionAttempt["phase"],
|
||||
): string {
|
||||
const phase = String(value);
|
||||
if (phase === "network_applied") return "Настройки сети применены";
|
||||
if (phase === "network_outcome_unknown") {
|
||||
return "Результат применения настроек сети не подтверждён";
|
||||
}
|
||||
return "Настройки сети не применены";
|
||||
}
|
||||
|
||||
function attemptControlStateLabel(
|
||||
value: XgridsConnectionAttempt["control_state"],
|
||||
): string {
|
||||
if (value === "ready") return "Управляющее подключение подтверждено";
|
||||
if (value === "control_not_ready") return "Управляющее подключение не подтверждено";
|
||||
return "Состояние управляющего подключения неизвестно";
|
||||
}
|
||||
|
||||
export function attemptNextActionLabel(
|
||||
value: XgridsConnectionAttempt["safe_next_action"],
|
||||
): string {
|
||||
switch (value) {
|
||||
case "wait-for-current-attempt":
|
||||
return "Дождаться завершения текущей попытки";
|
||||
case "continue-with-control-verification":
|
||||
return "Продолжить текущее подключение";
|
||||
case "verify-control-read-only":
|
||||
return "Проверить управление без изменения сети";
|
||||
case "start-acquisition":
|
||||
return "Готово к запуску приёма";
|
||||
case "stop-local-receiver":
|
||||
return "Завершить только локальный приём";
|
||||
case "retire-unavailable-physical-target":
|
||||
return "Исключить недоступный прежний K1 и выбрать другой";
|
||||
case "scan-select-connect":
|
||||
return "Выполнить новый поиск и выбрать результат";
|
||||
case "manual-recovery-required":
|
||||
return "Требуется ручное восстановление";
|
||||
}
|
||||
}
|
||||
|
||||
const publicConnectionErrorLabels: Readonly<Record<string, string>> = {
|
||||
"network-provision-discovery-generation-conflict":
|
||||
"Результат Bluetooth-поиска устарел до отправки. Настройки устройства не изменялись; выполните новый поиск.",
|
||||
"connection-mode-draft-revision-conflict":
|
||||
"Способ подключения изменился до запуска операции. Настройки устройства не изменялись; повторите явное действие.",
|
||||
"connection-mode-draft-mismatch":
|
||||
"Выбранный способ подключения ещё не подтверждён локальным контуром. Настройки устройства не изменялись.",
|
||||
"physical-command-reconciliation-required":
|
||||
"Сначала завершите отдельную проверку физического состояния K1 без изменений устройства. Новая команда не отправлялась.",
|
||||
"physical-device-already-active":
|
||||
"K1 всё ещё подтверждён в активном сканировании. Сначала выполните явную остановку; новая сетевая команда не отправлялась.",
|
||||
};
|
||||
|
||||
function publicConnectionErrorLabel(
|
||||
attempt: XgridsConnectionAttempt | null | undefined,
|
||||
structured: ReturnType<typeof hostFailureDiagnosticPresentation>,
|
||||
): string {
|
||||
const publicCode = attempt?.public_error_code?.trim();
|
||||
if (publicCode && publicConnectionErrorLabels[publicCode]) {
|
||||
return publicConnectionErrorLabels[publicCode];
|
||||
}
|
||||
return structured
|
||||
? "Системный контур безопасно остановил операцию. Автоматического повтора не было."
|
||||
: "Подключение не завершено. Автоматического повтора не было.";
|
||||
}
|
||||
|
||||
export function K1OperatorError({
|
||||
diagnostic,
|
||||
attempt,
|
||||
title = "Локальная операция завершилась ошибкой",
|
||||
recoveryActions,
|
||||
compact = false,
|
||||
showDefaultActions = true,
|
||||
onRefresh,
|
||||
onClear,
|
||||
}: {
|
||||
/** Kept for call-site compatibility; unreviewed exception text is never rendered. */
|
||||
message?: string;
|
||||
diagnostic?: unknown;
|
||||
attempt?: XgridsConnectionAttempt | null;
|
||||
title?: string;
|
||||
recoveryActions?: ReactNode;
|
||||
compact?: boolean;
|
||||
showDefaultActions?: boolean;
|
||||
onRefresh: () => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const structured = hostFailureDiagnosticPresentation(diagnostic);
|
||||
const [diagnosticCopied, setDiagnosticCopied] = useState(false);
|
||||
const copyDiagnosticBundle = async () => {
|
||||
if (!attempt?.diagnostic_bundle || !navigator.clipboard) return;
|
||||
await navigator.clipboard.writeText(
|
||||
JSON.stringify(attempt.diagnostic_bundle, null, 2),
|
||||
);
|
||||
setDiagnosticCopied(true);
|
||||
};
|
||||
const hasDetails = Boolean(structured || attempt);
|
||||
return (
|
||||
<aside
|
||||
className={`error-banner${compact ? " error-banner--compact" : ""}`}
|
||||
role="alert"
|
||||
>
|
||||
<span className="error-banner__dot" aria-hidden="true" />
|
||||
<div className="error-banner__copy">
|
||||
<strong>{title}</strong>
|
||||
<p>{publicConnectionErrorLabel(attempt, structured)}</p>
|
||||
{recoveryActions ? (
|
||||
<div className="error-banner__recovery-actions">
|
||||
{recoveryActions}
|
||||
</div>
|
||||
) : null}
|
||||
{hasDetails ? (
|
||||
<details className="error-banner__details">
|
||||
<summary>Подробности и диагностика</summary>
|
||||
{structured ? (
|
||||
<dl className="error-banner__diagnostic">
|
||||
<div>
|
||||
<dt>Причина</dt>
|
||||
<dd>{structured.codeLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Системный контур</dt>
|
||||
<dd>{structured.domainLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Влияние</dt>
|
||||
<dd>{structured.impactLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Что сделать</dt>
|
||||
<dd>{structured.operatorActionLabel}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
{attempt ? (
|
||||
<dl
|
||||
className="error-banner__diagnostic"
|
||||
aria-label="Диагностика подключения"
|
||||
>
|
||||
<div>
|
||||
<dt>Попытка</dt>
|
||||
<dd><code>{attempt.attempt_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Остановлено на шаге</dt>
|
||||
<dd>{attemptStageLabel(attempt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Что изменилось</dt>
|
||||
<dd>{attemptSideEffectLabel(attempt.side_effect_status)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Сеть</dt>
|
||||
<dd>{attemptNetworkPhaseLabel(attempt.phase)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Управление</dt>
|
||||
<dd>{attemptControlStateLabel(attempt.control_state)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Безопасное действие</dt>
|
||||
<dd>{attemptNextActionLabel(attempt.safe_next_action)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
{attempt?.diagnostic_bundle || showDefaultActions ? (
|
||||
<div className="error-banner__actions">
|
||||
{attempt?.diagnostic_bundle ? (
|
||||
<Button size="compact" variant="secondary" onClick={() => void copyDiagnosticBundle()}>
|
||||
{diagnosticCopied ? "Диагностика скопирована" : "Скопировать диагностику"}
|
||||
</Button>
|
||||
) : null}
|
||||
{showDefaultActions ? (
|
||||
<>
|
||||
<Button size="compact" variant="secondary" onClick={onRefresh}>
|
||||
Проверить состояние
|
||||
</Button>
|
||||
<Button size="compact" variant="ghost" onClick={onClear}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,22 @@
|
||||
import { Button } from "@nodedc/ui-react";
|
||||
import { ActivityIndicator, Button } from "@nodedc/ui-react";
|
||||
|
||||
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
|
||||
import type {
|
||||
AcquisitionState,
|
||||
OperatorPresenceConfirmation,
|
||||
XgridsAcquisition,
|
||||
XgridsK1State,
|
||||
} from "../api";
|
||||
import {
|
||||
activeStreamForceFinishAuthority,
|
||||
activeStreamRecoveryPresentation,
|
||||
} from "../activeStreamRecovery";
|
||||
import {
|
||||
canIssueCanonicalStop,
|
||||
connectionPolicyAllows,
|
||||
hasAuthoritativeData,
|
||||
hasControlAuthority,
|
||||
isSoftwareCommandedAcquisition,
|
||||
requiresCanonicalStopAfterTerminalLocalFailure,
|
||||
shouldRenderSpatialControls,
|
||||
} from "../lifecycle";
|
||||
import {
|
||||
@@ -15,7 +24,15 @@ import {
|
||||
formatNumber,
|
||||
spatialActionFailure,
|
||||
} from "../presentation";
|
||||
import { useXgridsK1Controller } from "../runtimeContext";
|
||||
import {
|
||||
activeStopTarget,
|
||||
operatorActionPhysicalAcceptance,
|
||||
} from "../physicalCommandConfirmation";
|
||||
import {
|
||||
useXgridsK1Controller,
|
||||
type XgridsK1Controller,
|
||||
} from "../runtimeContext";
|
||||
import { ActiveStreamRecoverySurface } from "./ActiveStreamRecoverySurface";
|
||||
|
||||
interface PhasePresentation {
|
||||
label: string;
|
||||
@@ -23,22 +40,38 @@ interface PhasePresentation {
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
const PHYSICAL_ACCEPTANCE = {
|
||||
operator_present: true,
|
||||
owner_controlled_device: true,
|
||||
lixelgo_closed: true,
|
||||
battery_storage_confirmed: true,
|
||||
expected_physical_state_confirmed: true,
|
||||
} satisfies OperatorPresenceConfirmation;
|
||||
export interface K1SpatialAuthorityState {
|
||||
controlAuthoritative: boolean;
|
||||
dataAuthoritative: boolean;
|
||||
softwareCommanded: boolean;
|
||||
authorityFailure: string | null;
|
||||
}
|
||||
|
||||
function phasePresentation(
|
||||
export function k1SpatialAuthorityState(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): K1SpatialAuthorityState {
|
||||
const controlAuthoritative = hasControlAuthority(state);
|
||||
const dataAuthoritative = hasAuthoritativeData(state);
|
||||
return {
|
||||
controlAuthoritative,
|
||||
dataAuthoritative,
|
||||
softwareCommanded: controlAuthoritative && isSoftwareCommandedAcquisition(state),
|
||||
authorityFailure: state?.acquisition?.state === "acquiring" && !dataAuthoritative
|
||||
? controlAuthoritative
|
||||
? "Поток данных K1 не подтверждён supervisor-ом. Телеметрия скрыта до восстановления data authority."
|
||||
: "Управляющая сессия K1 потеряна. Локальное завершение доступно, но команды устройству запрещены."
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function k1SpatialPhasePresentation(
|
||||
acquisition: XgridsAcquisition,
|
||||
softwareCommanded: boolean,
|
||||
): PhasePresentation {
|
||||
const presentations: Record<AcquisitionState, PhasePresentation> = {
|
||||
preparing: {
|
||||
label: "Подготовка локального приёма",
|
||||
detail: "Проверяем контур и создаём сессию записи.",
|
||||
detail: "Проверка контура и создание сессии записи.",
|
||||
busy: true,
|
||||
},
|
||||
prepared: {
|
||||
@@ -49,16 +82,20 @@ function phasePresentation(
|
||||
busy: false,
|
||||
},
|
||||
awaiting_external_start: {
|
||||
label: "Ожидание запуска на устройстве",
|
||||
detail: "Запустите сканирование физической кнопкой K1.",
|
||||
label: softwareCommanded
|
||||
? "K1 калибруется и готовит облако точек"
|
||||
: "Ожидание запуска на устройстве",
|
||||
detail: softwareCommanded
|
||||
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
|
||||
: "Запустите сканирование физической кнопкой K1.",
|
||||
busy: true,
|
||||
},
|
||||
starting: {
|
||||
label: softwareCommanded
|
||||
? "Калибровка оборудования"
|
||||
? "K1 калибруется и готовит облако точек"
|
||||
: "Подготовка локального приёмника",
|
||||
detail: softwareCommanded
|
||||
? "Статическая инициализация после запуска — не перемещайте устройство."
|
||||
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
|
||||
: "Mission Core запускает запись до физического старта K1.",
|
||||
busy: true,
|
||||
},
|
||||
@@ -77,14 +114,14 @@ function phasePresentation(
|
||||
busy: true,
|
||||
},
|
||||
stopping: {
|
||||
label: softwareCommanded ? "Останавливаем K1 и запись" : "Останавливаем локальный приём",
|
||||
label: softwareCommanded ? "Остановка K1 и записи" : "Остановка локального приёма",
|
||||
detail: softwareCommanded
|
||||
? "Команда отправлена; ожидаем подтверждённое состояние устройства."
|
||||
: "Физическое состояние K1 остаётся неизвестным.",
|
||||
busy: true,
|
||||
},
|
||||
finalizing: {
|
||||
label: "Сохраняем локальную запись",
|
||||
label: "Сохранение локальной записи",
|
||||
detail: "Не закрывайте Mission Core до завершения финализации.",
|
||||
busy: true,
|
||||
},
|
||||
@@ -106,43 +143,146 @@ function formatDuration(seconds: number): string {
|
||||
: `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
||||
const controller = useXgridsK1Controller();
|
||||
const { state, pendingAction, stop } = controller;
|
||||
export function runSpatialActiveStreamForceFinish(
|
||||
controller: Pick<
|
||||
XgridsK1Controller,
|
||||
"state" | "forceFinishActiveStreamLocally"
|
||||
>,
|
||||
): Promise<boolean> {
|
||||
if (!activeStreamForceFinishAuthority(controller.state)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return controller.forceFinishActiveStreamLocally();
|
||||
}
|
||||
|
||||
export function K1SpatialControlsView({
|
||||
controller,
|
||||
}: {
|
||||
controller: XgridsK1Controller;
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
pendingAction,
|
||||
physicalStopIntentSpent,
|
||||
physicalStopInFlight,
|
||||
stop,
|
||||
stopLocalReceiver,
|
||||
forceFinishActiveStreamLocally,
|
||||
} = controller;
|
||||
const acquisition = state?.acquisition;
|
||||
const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
|
||||
const localForceFinishPending = pendingAction === "force-finish";
|
||||
|
||||
if (activeRecoveryPresentation || localForceFinishPending) {
|
||||
return (
|
||||
<ActiveStreamRecoverySurface
|
||||
presentation={activeRecoveryPresentation}
|
||||
forceFinishing={localForceFinishPending}
|
||||
actionBusy={pendingAction !== null}
|
||||
variant="compact"
|
||||
onForceFinish={() => {
|
||||
void runSpatialActiveStreamForceFinish({
|
||||
state,
|
||||
forceFinishActiveStreamLocally,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const physicalStopTarget = activeStopTarget(state);
|
||||
const localReceiverStopAllowed = connectionPolicyAllows(state, "stop-local-receiver");
|
||||
const physicalStopExecutable = Boolean(
|
||||
physicalStopTarget
|
||||
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
|
||||
);
|
||||
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
|
||||
const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
|
||||
acquisition?.state ?? "",
|
||||
);
|
||||
const cleanupPending = acquisition?.cleanup_pending === true;
|
||||
|
||||
if (!acquisition || !shouldRenderSpatialControls(state)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
controlAuthoritative,
|
||||
dataAuthoritative,
|
||||
authorityFailure,
|
||||
} = k1SpatialAuthorityState(state);
|
||||
const softwareCommanded = isSoftwareCommandedAcquisition(state);
|
||||
const phase = phasePresentation(acquisition, softwareCommanded);
|
||||
const telemetry = deviceTelemetry(state.metrics);
|
||||
const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
|
||||
acquisition.state,
|
||||
);
|
||||
const stopDisabled = pendingAction !== null || stopping;
|
||||
const dataPlaneState = state?.connection_supervisor?.observed.data_plane.state;
|
||||
const terminalPhysicalStopRequired = requiresCanonicalStopAfterTerminalLocalFailure(state);
|
||||
const phase = physicalStopInFlight
|
||||
? {
|
||||
label: "Команда остановки устройства отправлена",
|
||||
detail: "Ждём подтверждённое состояние K1; повторная команда не отправляется.",
|
||||
busy: true,
|
||||
}
|
||||
: terminalPhysicalStopRequired && physicalStopExecutable
|
||||
? {
|
||||
label: "Локальный приём остановился · K1 продолжает работу",
|
||||
detail: "Остановите устройство явной командой; новый START заблокирован.",
|
||||
busy: false,
|
||||
}
|
||||
: terminalPhysicalStopRequired && localReceiverStopAllowed
|
||||
? {
|
||||
label: "Состояние K1 требует безопасного восстановления",
|
||||
detail: "Команда устройству не отправляется. Доступно разрешённое сервером локальное завершение или read-only восстановление.",
|
||||
busy: false,
|
||||
}
|
||||
: terminalPhysicalStopRequired
|
||||
? {
|
||||
label: "Управляющие действия заблокированы",
|
||||
detail: "Дождитесь подтверждённого состояния или выполните read-only восстановление.",
|
||||
busy: false,
|
||||
}
|
||||
: acquisition.state === "acquiring"
|
||||
&& !dataAuthoritative
|
||||
? {
|
||||
label: !controlAuthoritative
|
||||
? "Управляющая сессия K1 потеряна"
|
||||
: dataPlaneState === "lost"
|
||||
? "Связь с потоком K1 потеряна"
|
||||
: dataPlaneState === "stalled"
|
||||
? "Поток K1 нестабилен"
|
||||
: "Ожидание подтверждённого потока K1",
|
||||
detail: !controlAuthoritative
|
||||
? "Состояние acquisition сохранено как последнее известное; команды устройству не отправляются."
|
||||
: "Управляющая сессия подтверждена, но живые данные пока не получили авторитетный статус.",
|
||||
busy: false,
|
||||
}
|
||||
: k1SpatialPhasePresentation(acquisition, softwareCommanded);
|
||||
const telemetry = deviceTelemetry(dataAuthoritative ? state.metrics : undefined);
|
||||
const stopDisabled = pendingAction !== null
|
||||
|| stopping;
|
||||
const controlFailure =
|
||||
state.application_control_session?.state === "failed"
|
||||
? state.application_control_session.failure?.message ||
|
||||
"Канонический диалог остановлен; автоматический повтор запрещён."
|
||||
: null;
|
||||
const actionFailure = spatialActionFailure(
|
||||
const runtimeActionFailure = spatialActionFailure(
|
||||
controller.error ??
|
||||
controlFailure ??
|
||||
(cleanupPending
|
||||
? "Локальный поток или архив ещё не завершён. Повторите остановку."
|
||||
? physicalStopInFlight
|
||||
? "Локальный поток или архив ещё не завершён. Команда устройству уже отправлена; дождитесь подтверждённого состояния."
|
||||
: localReceiverStopAllowed
|
||||
? "Локальный поток или архив ещё не завершён. Завершите только разрешённый сервером локальный приём."
|
||||
: "Локальный поток или архив ещё не завершён. Дождитесь подтверждённого состояния или выполните read-only восстановление."
|
||||
: null),
|
||||
);
|
||||
|
||||
const actionFailure = runtimeActionFailure ?? spatialActionFailure(authorityFailure);
|
||||
return (
|
||||
<section
|
||||
className="xgrids-k1-spatial-controls"
|
||||
aria-label="Управление сессией XGRIDS K1"
|
||||
aria-busy={phase.busy}
|
||||
data-busy={phase.busy ? "true" : undefined}
|
||||
>
|
||||
<div className="xgrids-k1-spatial-controls__phase">
|
||||
{phase.busy ? <span className="xgrids-k1-spatial-controls__spinner" aria-hidden="true" /> : null}
|
||||
{phase.busy ? <ActivityIndicator size="compact" /> : null}
|
||||
<span>
|
||||
<strong>{phase.label}</strong>
|
||||
<small>{phase.detail}</small>
|
||||
@@ -165,20 +305,47 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
||||
<small>{actionFailure.detail}</small>
|
||||
</div>
|
||||
) : null}
|
||||
{physicalStopPresented ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={stopDisabled}
|
||||
onClick={() => void stop(softwareCommanded ? PHYSICAL_ACCEPTANCE : undefined)}
|
||||
disabled={stopDisabled || !physicalStopExecutable}
|
||||
onClick={() => {
|
||||
if (physicalStopExecutable) {
|
||||
void stop(operatorActionPhysicalAcceptance());
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pendingAction === "stop"
|
||||
? softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
|
||||
<span className="xgrids-k1-spatial-controls__action-label">
|
||||
{physicalStopInFlight
|
||||
? "Остановка устройства…"
|
||||
: pendingAction === "stop"
|
||||
? terminalPhysicalStopRequired ? "Остановка K1…" : softwareCommanded ? "Остановка устройства…" : "Остановка приёма…"
|
||||
: stopping
|
||||
? acquisition.state === "finalizing" ? "Сохраняем запись…" : "Остановка выполняется…"
|
||||
: actionFailure
|
||||
? "Повторить остановку"
|
||||
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
|
||||
? acquisition.state === "finalizing" ? "Сохранение записи…" : "Остановка выполняется…"
|
||||
: terminalPhysicalStopRequired ? "Остановить K1" : "Остановить устройство и запись"}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{!physicalStopPresented && localReceiverStopAllowed && !stopping ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => void stopLocalReceiver()}
|
||||
>
|
||||
<span className="xgrids-k1-spatial-controls__action-label xgrids-k1-spatial-controls__action-label--local">
|
||||
{pendingAction === "stop"
|
||||
? "Завершение локального приёма…"
|
||||
: "Завершить локальный приём"}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
||||
const controller = useXgridsK1Controller();
|
||||
return <K1SpatialControlsView controller={controller} />;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user