32 Commits
Author SHA1 Message Date
DCCONSTRUCTIONS c7843a3c7e chore(k1): checkpoint connection recovery work 2026-08-21 08:12:22 +03:00
DCCONSTRUCTIONS a3138f3d71 fix(k1): retire terminal control before read-only recovery 2026-08-20 14:37:00 +03:00
DCCONSTRUCTIONS d01be34cac fix(k1): expose exact supervisor recovery verify 2026-08-20 14:04:55 +03:00
DCCONSTRUCTIONS ac4a00c67d fix(k1): cease reset zero-dispatch checkpoint 2026-08-20 13:16:56 +03:00
DCCONSTRUCTIONS aee60f12b9 fix(k1): admit live rerun only after real data 2026-08-20 12:54:55 +03:00
DCCONSTRUCTIONS 321cde70e8 fix(k1): settle same-process standby checkpoint 2026-08-14 21:32:26 +03:00
DCCONSTRUCTIONS 986ea69610 fix(k1): allow explicit reset after terminal start fault 2026-08-14 21:01:52 +03:00
DCCONSTRUCTIONS 72a2f37c03 fix(k1): use neutral process copy 2026-08-14 20:03:13 +03:00
DCCONSTRUCTIONS 4a24275ddc fix(k1): close verified stop checkpoint gap 2026-08-14 19:46:51 +03:00
DCCONSTRUCTIONS 17a07355cf fix(k1): keep reconnect feedback in place 2026-08-14 19:21:11 +03:00
DCCONSTRUCTIONS cd8a1045c4 fix(k1): keep live viewer on stream edge 2026-08-14 18:20:25 +03:00
DCCONSTRUCTIONS 3235ba7b1c fix(k1): fence start endpoint handoff 2026-08-14 17:40:07 +03:00
DCCONSTRUCTIONS 9e6b5b403a fix(k1): refresh stale endpoint before start 2026-08-14 16:27:03 +03:00
DCCONSTRUCTIONS 9ef115d3b4 fix(k1): admit start after reopened standby 2026-08-14 15:50:03 +03:00
DCCONSTRUCTIONS 606cbbfae2 fix(k1): settle reopened standby after reset 2026-08-14 15:16:30 +03:00
DCCONSTRUCTIONS 0ca7316a24 wip(k1): checkpoint connection recovery rewrite
Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
2026-08-14 14:57:50 +03:00
DCCONSTRUCTIONS aff331082f fix(k1): restore canonical local connection lifecycle 2026-08-06 13:52:46 +03:00
DCCONSTRUCTIONS 52da9b75b7 fix(k1): harden BLE discovery and bridge recovery 2026-08-06 12:43:00 +03:00
DCCONSTRUCTIONS be50144ca8 docs(m4): record E47 semantic shadow evidence 2026-08-06 11:26:55 +03:00
DCCONSTRUCTIONS 81ae4425cb feat(lab): visualize semantic SLAM shadow 2026-08-06 11:26:46 +03:00
DCCONSTRUCTIONS 8eaa3ab497 feat(perception): add diagnostic semantic SLAM replay 2026-08-06 11:26:37 +03:00
DCCONSTRUCTIONS b7a51e26e6 feat(lab): add bounded local SLAM surface 2026-08-06 09:41:25 +03:00
DCCONSTRUCTIONS b8fb4ebcba fix(lab): align spatial replay controls 2026-08-06 09:14:20 +03:00
DCCONSTRUCTIONS e38f2fb1da fix(lab): stabilize recorded spatial playback 2026-08-06 08:51:10 +03:00
DCCONSTRUCTIONS 9df9f58ab8 feat(lab): add recorded realtime spatial playback 2026-08-05 23:17:48 +03:00
DCCONSTRUCTIONS aacc6dc43b fix(m4): retain compact obstacle evidence 2026-08-05 22:16:33 +03:00
DCCONSTRUCTIONS 13ed096f80 docs(m4): seal rolling map correction evidence 2026-08-05 21:12:59 +03:00
DCCONSTRUCTIONS 31313c3c96 feat(lab): separate current increment from rolling map 2026-08-05 21:12:54 +03:00
DCCONSTRUCTIONS 7aa3ce55c0 fix(perception): reconstruct rolling occupancy from K1 increments 2026-08-05 21:12:47 +03:00
DCCONSTRUCTIONS c70ad345ea fix(perception): stabilize replay body frame 2026-08-05 20:04:08 +03:00
DCCONSTRUCTIONS de19229895 feat(lab): visualize dual evidence replay 2026-08-05 19:18:14 +03:00
DCCONSTRUCTIONS b0d0bc8d7f feat(perception): add dual evidence replay threat 2026-08-05 19:18:01 +03:00
236 changed files with 173080 additions and 3414 deletions
+3 -2
View File
@@ -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
+6 -4
View File
@@ -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.
+9 -10
View File
@@ -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,15 +146,17 @@ export function ObservationTimeline({
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
</small>
</div>
<button
type="button"
className="observation-timeline__follow"
data-active={!buffered && active ? "true" : undefined}
disabled={buffered ? !onJumpToEnd : true}
onClick={onJumpToEnd}
>
{buffered ? "К КОНЦУ" : "ЭФИР"}
</button>
{showJumpToEnd ? (
<button
type="button"
className="observation-timeline__follow"
data-active={!buffered && active ? "true" : undefined}
disabled={buffered ? !onJumpToEnd : true}
onClick={onJumpToEnd}
>
{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?.();
setRetryNonce((nonce) => nonce + 1);
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>
);
}
@@ -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>;
}
+8
View File
@@ -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";
+2 -2
View File
@@ -190,8 +190,8 @@ export const workspaces: WorkspaceDefinition[] = [
{
id: "local-device",
root: "fleet",
label: "Локальное устройство",
title: "Локальное устройство",
label: "Подключение",
title: "Подключение",
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
icon: "network",
+1
View File
@@ -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 identity = `S${item.routeTrackId}${
item.worldTrackId === null ? "" : `→W${item.worldTrackId}`
}`;
const label = `${identity} · ${item.displayCategory} · ${stateLabel(
const boxes = useMemo<readonly RecordedEvidenceBox[]>(() => (
frame?.objects.map((item) => {
const identity = `S${item.routeTrackId}${
item.worldTrackId === null ? "" : `→W${item.worldTrackId}`
}`;
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);
}
};
const observer = new ResizeObserver(render);
observer.observe(host);
render();
return () => observer.disconnect();
}, [frame, overlay.imageHeight, overlay.imageWidth]);
)} · ${Math.round(item.score * 100)}%`,
tone: stateTone(item.motionState),
dashed: !item.cameraEvidenceCurrent,
};
}) ?? []
), [frame]);
return (
<div className="e46c-video-scene" ref={hostRef}>
<RecordedFmp4Player
source={source}
playback={playback}
interactive
prepare
onPlaybackChange={onPlaybackChange}
/>
<canvas
ref={canvasRef}
role="img"
aria-label={
frame
? `E46C video frame ${frame.frameIndex}: ${frame.objects.length} route objects`
: "E46C recorded video overlay"
}
/>
</div>
<RecordedEvidenceVideoScene
source={source}
playback={playback}
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}
/>
);
}
@@ -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"
}
}
+10
View File
@@ -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"
}
}
+38
View File
@@ -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": [
+8 -1
View File
@@ -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
}
}
+2 -2
View File
@@ -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",
+58 -7
View File
@@ -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
+3 -4
View File
@@ -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
```
+115 -13
View File
@@ -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.
+309
View File
@@ -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"]}
]
}
+406
View File
@@ -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__,
}
+15 -1
View File
@@ -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
+10 -4
View File
@@ -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
controller={controller}
openSpatialScene={host.openSpatialScene}
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
/>
<K1Diagnostics controller={controller} sourceLabel={sourceLabel} />
</div>
</div>
<K1ConnectionPipelines
controller={controller}
desiredConnectionMode={effectiveDesiredConnectionMode}
onDesiredConnectionModeChange={updateDesiredConnectionMode}
operationalPanelsVisible={operationalPanelsVisible}
openSpatialScene={host.openSpatialScene}
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
sourceLabel={sourceLabel}
/>
</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,
},
acquisition: {
project_name: projectNameValidation.value,
mount_type: SUPPORTED_MOUNT_TYPE,
gnss_mode: SUPPORTED_GNSS_MODE,
compatibility_attestation: profileSelectionForConnectionMode(
state.connection_mode ?? "bridge",
),
},
physicalAcceptance: PHYSICAL_ACCEPTANCE,
}),
activateAutomaticSpatialSource,
openSpatialScene,
);
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(connectionMode),
},
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 и локальный приём…"
: preparedCanonicalLaunch
? "Продолжить запуск сканирования и приёма"
: "Запустить сканирование и локальный приём"}
? "Запуск приёма…"
: "Подготовка проекта и локального приёма…"
: 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>
<Button
variant="secondary"
disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)}
onClick={() => void stop(
isSoftwareCommandedAcquisition(state) ? PHYSICAL_ACCEPTANCE : undefined,
)}
>
{pendingAction === "stop"
? state?.source_mode === "replay" ? "Останавливаем повтор…" : "Останавливаем локальный приём…"
: state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Остановить локальный приём"}
</Button>
{physicalStopPresented || localReceiverStopExecutable ? (
<Button
variant="secondary"
disabled={
isBusy
|| (physicalStopPresented && !physicalStopExecutable)
|| (!sourceRuntimeBusy && preparedAcquisition !== null)
}
onClick={() => {
if (physicalStopExecutable) {
void stop(operatorActionPhysicalAcceptance());
return;
}
if (localReceiverStopExecutable) {
void stopLocalReceiver();
}
}}
>
{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}
<Button
size="compact"
variant="secondary"
disabled={stopDisabled}
onClick={() => void stop(softwareCommanded ? PHYSICAL_ACCEPTANCE : undefined)}
>
{pendingAction === "stop"
? softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
: stopping
? acquisition.state === "finalizing" ? "Сохраняем запись…" : "Остановка выполняется…"
: actionFailure
? "Повторить остановку"
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
</Button>
{physicalStopPresented ? (
<Button
size="compact"
variant="secondary"
disabled={stopDisabled || !physicalStopExecutable}
onClick={() => {
if (physicalStopExecutable) {
void stop(operatorActionPhysicalAcceptance());
}
}}
>
<span className="xgrids-k1-spatial-controls__action-label">
{physicalStopInFlight
? "Остановка устройства…"
: pendingAction === "stop"
? terminalPhysicalStopRequired ? "Остановка K1…" : softwareCommanded ? "Остановка устройства…" : "Остановка приёма…"
: stopping
? 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