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.
This commit is contained in:
DCCONSTRUCTIONS
2026-08-14 14:57:50 +03:00
parent aff331082f
commit 0ca7316a24
157 changed files with 152962 additions and 4036 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.
+7 -8
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,7 +776,7 @@ 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">
@@ -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 (
@@ -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;
@@ -813,6 +867,7 @@ export function RerunViewport({
followLive = false,
liveActivitySequence = null,
liveStreamId = null,
liveRecoveryAuthorityIdentity = null,
autoplayWhenReady = false,
presentationGate = "ready",
expectedTimelineStartSeconds,
@@ -845,7 +900,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);
@@ -871,14 +933,22 @@ export function RerunViewport({
recordedArtifact !== null,
);
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]);
}, [followLive, liveRecoveryAuthorityIdentity, liveStreamId, sourceUrl]);
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 +957,7 @@ export function RerunViewport({
onPlaybackControllerChange?.(null);
return;
}
const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource);
let resolvedSource: string;
try {
@@ -912,18 +983,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 +1030,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 +1117,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 +1136,7 @@ export function RerunViewport({
) => {
if (!followLive || disposed) return false;
if (emitErrorEvent) {
postLiveViewerDiagnostic({
diagnosticLifecycle.post({
eventCode: "live_receiver_error",
failureStage,
streamId: liveStreamIdRef.current,
@@ -1018,10 +1146,20 @@ export function RerunViewport({
recoveryAttempt: liveRecoveryRef.current.attempts || null,
});
}
const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current);
const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current, {
activeAuthorityIdentity: liveRecoveryAuthorityRef.current,
expectedAuthorityIdentity: liveRecoveryAuthorityIdentity,
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 +1175,7 @@ export function RerunViewport({
);
return true;
}
postLiveViewerDiagnostic({
diagnosticLifecycle.post({
eventCode: "live_receiver_restart_requested",
failureStage,
streamId: liveStreamIdRef.current,
@@ -1052,7 +1190,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 +1218,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 +1230,7 @@ export function RerunViewport({
}
if (observed.signal !== "stalled") return;
postLiveViewerDiagnostic({
diagnosticLifecycle.post({
eventCode: "live_receiver_stalled",
failureStage: "receiver-stalled",
streamId: liveStreamIdRef.current,
@@ -1093,12 +1239,30 @@ export function RerunViewport({
stalledForMs: Math.round(observed.stalledForMs),
recoveryAttempt: Math.min(
liveRecoveryRef.current.attempts + 1,
LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
liveRecoveryAuthorityIdentity
? 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 +1277,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 +1311,7 @@ export function RerunViewport({
} catch {
// The viewer may already have closed all auxiliary channels.
}
}, () => {
try {
if (viewer.ready) viewer.close(resolvedSource);
} catch {
@@ -1162,7 +1324,7 @@ export function RerunViewport({
// startup failure.
}
host.replaceChildren();
};
});
if (isRecordedSource && recordedArtifact) {
recordedOpenWatchdog = createRecordedOpenWatchdog({
byteLength: recordedArtifact.byteLength,
@@ -1188,8 +1350,17 @@ export function RerunViewport({
) return;
recordingOpened = true;
if (!isRecordedSource) {
clearLiveRecordingOpenTimer();
clearLiveRecordingDiscoveryTimer();
diagnosticLifecycle.markAdmitted();
if (liveRecoveryRef.current.awaitingRecovery) {
diagnosticLifecycle.post({
eventCode: "live_receiver_recovered",
streamId: liveStreamIdRef.current,
backendActivitySequence: liveActivitySequenceRef.current,
viewerRangeMaxNs: latestLiveRangeMaxNs,
recoveryAttempt: liveRecoveryRef.current.attempts,
});
}
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
}
if (
recordedBlueprintUrl &&
@@ -1386,7 +1557,7 @@ 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({
diagnosticLifecycle.post({
eventCode: "live_receiver_active_store_admitted",
streamId: liveStreamIdRef.current,
backendActivitySequence: liveActivitySequenceRef.current,
@@ -1489,16 +1660,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 +1694,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);
@@ -1537,6 +1713,7 @@ export function RerunViewport({
followLive,
initialPlaybackStartSeconds,
liveStreamId,
liveRecoveryAuthorityIdentity,
onPlaybackChange,
onPlaybackControllerChange,
onSelectionChange,
@@ -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(
@@ -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",
@@ -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,
@@ -490,12 +491,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}
liveActivitySequence={metrics?.publishedFrameCount}
liveStreamId={state?.spatialSource?.id}
liveRecoveryAuthorityIdentity={!recordedSource && streamActive ? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource) : null}
autoplayWhenReady={recordedSource}
presentationGate={recordedSessionGate}
expectedTimelineStartSeconds={recordedSource
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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",
);
});
@@ -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",
);
});
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,9 @@ import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let claimExclusiveLiveViewer;
let createRecordedOpenWatchdog;
let createReentrantViewerDisposer;
let recordedOpenWatchdogTimeoutMs;
let rerunViewerInitialSource;
let resolveRecordedViewerSourceUrl;
@@ -17,7 +19,9 @@ before(async () => {
server: { middlewareMode: true },
});
({
claimExclusiveLiveViewer,
createRecordedOpenWatchdog,
createReentrantViewerDisposer,
recordedOpenWatchdogTimeoutMs,
rerunViewerInitialSource,
resolveRecordedViewerSourceUrl,
@@ -118,6 +122,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),
@@ -129,7 +172,7 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
assert.match(
source,
/recordingOpened = true;[\s\S]*clearLiveRecordingOpenTimer\(\);[\s\S]*clearLiveRecordingDiscoveryTimer\(\);/,
/recordingOpened = true;[\s\S]*diagnosticLifecycle\.markAdmitted\(\);/,
);
assert.match(
source,
@@ -145,12 +188,31 @@ 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,
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
);
});
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);
@@ -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());
+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 16, waits for their correlated device
File diff suppressed because it is too large Load Diff
@@ -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,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"]}
]
}
+393
View File
@@ -0,0 +1,393 @@
# 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 only:
- the connection-mode selector;
- Step 01 **Подключение** with the explicit Bluetooth search action.
Historical K1 identity never adds a reconnect choice to cold entry. If local
session ownership or an older connection scenario exists, one explicit
`reset_scenario` CAS first closes only that local scenario. Only after the reset
is accepted does clean Step 01 expose **Найти по Bluetooth** as a separate
click; reset never starts 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 the old browser/backend presentation and source owners, and
leaves Scan as a separate click. 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 prior
UUID, result count, **Повторить поиск**, reconnect error, selected device,
credentials or recovery card; it returns to **Найти по 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 connection always requires an explicit search
and **Выбрать**. 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 | 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 backend restart | 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 and its explicit Scan action are
visible, while Step 02 is absent and no discovery starts automatically. 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 cold entry
contains no historical reconnect prompt; after one reset CAS and zero Scan,
a separate clean **Найти по Bluetooth** action remains clean 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.
@@ -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 0102 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,31 +1,264 @@
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,
sourceStatusLabel,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import { phaseLabel, phaseTone } from "./presentation";
import { useXgridsK1Controller } from "./runtimeContext";
import {
useXgridsK1Controller,
type XgridsK1Controller,
} from "./runtimeContext";
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;
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,
): boolean {
return Boolean(
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 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 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"
@@ -34,56 +267,88 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
: "neutral";
const connectionPhaseLabel = sourceRuntimeBusy || preparedAcquisition
? sourceLabel
: phaseLabel(state?.phase);
: activeRecoveryPresentation
? activeRecoveryPresentation.title
: physicalStopRecoverySettling
? "Завершение остановки"
: recoveredPhysicalScanning
? "Сканирование продолжается"
: physicalRecoveryRequired
? "Требуется действие"
: 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 = 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 = physicalStopRecoverySettling
? "Команда остановки уже принята. Завершение выполняется без повторной команды."
: activeRecoveryPresentation
? activeRecoveryPresentation.detail
: recoveredPhysicalScanning
? "Локальная запись остановлена, но сканирование ещё продолжается."
: physicalRecoveryRequired
? physicalRecoveryDetail
?? "Безопасное восстановление прежнего K1 сейчас недоступно."
: !sourceRuntimeBusy && connectionTopology?.status === "configured-unverified"
? "Начните новое подключение."
: !sourceRuntimeBusy && connectionTopology?.status === "active"
? "Готово к новой сессии."
: "Ожидается состояние локального контура.";
const operationalPanelsVisible = shouldRenderK1OperationalPanels(state);
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/WiFi 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 physicalStartGuidance = finalStartTarget && !physicalStartAllowed
? connectionPolicyOperatorGuidance(state, "start-acquisition")
: null;
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 () => {
const 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 prepared = await prepareCanonicalAcquisition({
acquisition: {
project_name: projectNameValidation.value,
mount_type: SUPPORTED_MOUNT_TYPE,
gnss_mode: SUPPORTED_GNSS_MODE,
compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
},
});
if (!prepared) return;
await submitFinalStart();
};
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">
@@ -228,40 +470,53 @@ export function K1AcquisitionPipeline({
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
description={projectNameTouched && projectNameValidation.error
? projectNameValidation.error
: "Отдельной команды сохранения имени на K1 нет: оно отправляется только при START."}
: "Имя отправляется только при START; отдельной команды сохранения нет."}
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 нет.
{modeSwitchRequired
? `Выбран другой способ связи. Сначала установите подключение через ${desiredConnectionMode === "bridge" ? "Bridge" : desiredConnectionMode === "quick-connect" ? "Quick Connect" : "Direct Connect"}.`
: !connectionConfigured
? "Сначала завершите подключение в выбранном режиме. START не используется для установки связи."
: physicalStartGuidance
? `${physicalStartGuidance.reason} ${physicalStartGuidance.nextAction}`
: "Одно нажатие выполняет каноническую подготовку и один START после подтверждённого READY. Автоматических повторов команд нет."}
</p>
{control?.control_socket_open && !activeAcquisition && !isBusy ? (
{control?.control_socket_open && !activeAcquisition && !recoveredPhysicalStop && !isBusy ? (
<Button
variant="ghost"
disabled={isBusy}
@@ -272,23 +527,23 @@ export function K1AcquisitionPipeline({
) : null}
<p className="live-instruction">
{controlPhase === "failed"
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручное действие"}`
: controlPhase === "connecting"
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ."
: controlPhase === "workspace-requested"
? "После подтверждённых операций 1–6 выполняется вход в рабочее пространство."
: controlPhase === "project-requested"
? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется на K1."
? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется."
: 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>
@@ -301,25 +556,47 @@ export function K1AcquisitionPipeline({
)}
<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,15 +40,31 @@ 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 {
@@ -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,
},
@@ -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} />;
}
@@ -12,17 +12,17 @@ export const connectionModeOptions: Array<SelectOption<ConnectionMode>> = [
{
value: "bridge",
label: "Общая сеть · Bridge",
description: "Mission Core передаёт K1 реквизиты существующей общей сети.",
description: "Передача реквизитов существующей общей сети.",
},
{
value: "quick-connect",
label: "Точка доступа K1 · Quick Connect",
description: "Лабораторный режим: Mission Core включает AP K1 и подключает только заранее подготовленный хост. Для обычной работы используйте Bridge.",
label: "Локальная сеть · Quick Connect",
description: "Связь через отдельную локальную сеть. Для обычной работы используйте Bridge.",
},
{
value: "direct-connect",
label: "Хотспот контроллера · Direct Connect",
description: "Mission Core передаёт K1 реквизиты хотспота управляющего устройства.",
description: "Передача реквизитов хотспота контроллера.",
},
];
@@ -0,0 +1,72 @@
import { ApiError, type XgridsK1State } from "./api";
export interface ExactApplicationControlCas {
expected_session_generation: number;
expected_state_revision: number;
}
export interface ExactAcquisitionControlCas {
expected_control_session_generation: number;
expected_control_state_revision: number;
}
interface ControlSessionVersion {
sessionGeneration: number;
stateRevision: number;
}
function exactControlSessionVersion(
state: XgridsK1State | null | undefined,
actionLabel: string,
): ControlSessionVersion {
const session = state?.application_control_session;
const sessionGeneration = session?.session_generation;
const stateRevision = session?.state_revision;
if (
!Number.isSafeInteger(sessionGeneration)
|| (sessionGeneration ?? -1) < 0
|| !Number.isSafeInteger(stateRevision)
|| (stateRevision ?? -1) < 0
) {
throw new ApiError(
`Команда ${actionLabel} не отправлена: последнее принятое состояние не содержит целые session_generation и state_revision управляющей сессии. Обновите состояние K1 и повторите отдельным действием.`,
);
}
return {
sessionGeneration: sessionGeneration as number,
stateRevision: stateRevision as number,
};
}
export function exactApplicationControlCas(
latestAcceptedState: XgridsK1State | null | undefined,
actionLabel: string,
): ExactApplicationControlCas {
const version = exactControlSessionVersion(latestAcceptedState, actionLabel);
return {
expected_session_generation: version.sessionGeneration,
expected_state_revision: version.stateRevision,
};
}
export function exactAcquisitionControlCas(
latestAcceptedState: XgridsK1State | null | undefined,
actionLabel: string,
): ExactAcquisitionControlCas {
const version = exactControlSessionVersion(latestAcceptedState, actionLabel);
return {
expected_control_session_generation: version.sessionGeneration,
expected_control_state_revision: version.stateRevision,
};
}
export function acquisitionMutationUsesControlSession(
latestAcceptedState: XgridsK1State | null | undefined,
): boolean {
const session = latestAcceptedState?.application_control_session;
return Boolean(
session
&& session.mode === "interactive-canonical"
&& !["idle", "closed", "completed"].includes(session.state),
);
}
@@ -0,0 +1,132 @@
import {
isXgridsHostFailureDiagnostic,
type XgridsHostDiagnosticCode,
type XgridsHostDiagnosticAction,
type XgridsHostDiagnosticDomain,
type XgridsHostDiagnosticImpact,
type XgridsHostFailureDiagnostic,
type XgridsOperation,
} from "./api";
const CODE_LABELS: Record<XgridsHostDiagnosticCode, string> = {
"host.bluetooth.permission-denied":
"macOS не разрешила Mission Core использовать Bluetooth.",
"host.bluetooth.adapter-powered-off":
"Bluetooth на этом Mac выключен.",
"host.bluetooth.adapter-unavailable":
"Системный Bluetooth-адаптер сейчас недоступен.",
"host.bluetooth.runtime-unavailable":
"Локальный Bluetooth runtime не готов к новой операции.",
"host.bluetooth.operation-timeout":
"Bluetooth-операция не завершилась за ограниченное время.",
"host.wifi.permission-denied":
"macOS не разрешила Mission Core читать состояние Wi‑Fi.",
"host.wifi.adapter-powered-off":
"WiFi на этом Mac выключен.",
"host.wifi.interface-unavailable":
"Системный Wi‑Fi-интерфейс сейчас недоступен.",
"host.wifi.ssid-unavailable":
"macOS не сообщила имя текущей Wi‑Fi-сети.",
"host.wifi.operation-timeout":
"Операция с Wi‑Fi не завершилась за ограниченное время.",
"host.wifi.association-failed":
"Mac не подтвердил подключение к ожидаемой Wi‑Fi-сети.",
"host.keychain.interaction-required":
"Связка ключей требует явного подтверждения оператора.",
"host.keychain.permission-denied":
"macOS запретила чтение профиля подключения.",
"host.keychain.unavailable":
"Профиль подключения сейчас недоступен в связке ключей.",
"host.route.unavailable":
"Прямой локальный маршрут к адресу подключения не найден.",
"host.tcp.connection-refused":
"Управляющий TCP endpoint отклонил соединение.",
"host.tcp.connection-timeout":
"Управляющий TCP endpoint не ответил за ограниченное время.",
"host.tcp.endpoint-unavailable":
"Управляющий TCP endpoint недоступен из текущей сети.",
"host.mqtt.connection-timeout":
"Управляющий MQTT-канал не открылся за ограниченное время.",
"host.mqtt.connection-refused":
"Управляющий MQTT-канал отклонил соединение.",
"host.mqtt.transport-unavailable":
"Транспорт управляющего MQTT-канала недоступен.",
"host.filesystem.permission-denied":
"Mission Core не может записать обязательные данные операции в локальное хранилище.",
"host.filesystem.ledger-unavailable":
"Журнал безопасного результата операции недоступен или не подтверждён.",
};
const DOMAIN_LABELS: Record<XgridsHostDiagnosticDomain, string> = {
corebluetooth: "Bluetooth macOS",
corewlan: "WiFi macOS",
keychain: "Связка ключей macOS",
route: "Локальный сетевой маршрут",
tcp: "Управляющий TCP endpoint",
mqtt: "Управляющий канал MQTT",
filesystem: "Локальное хранилище Mission Core",
};
const IMPACT_LABELS: Record<XgridsHostDiagnosticImpact, string> = {
discovery: "Поиск Bluetooth сейчас недоступен.",
"host-network": "Сетевой путь между этим компьютером и локальным контуром недоступен.",
control: "Управляющая связь не установлена; команды не повторяются автоматически.",
"durable-safety": "Надёжная фиксация результата операции недоступна; новая команда заблокирована.",
};
const ACTION_LABELS: Record<XgridsHostDiagnosticAction, string> = {
"grant-bluetooth-permission":
"Разрешите Mission Core доступ к Bluetooth в системных настройках macOS, затем повторите действие вручную.",
"power-on-bluetooth":
"Включите Bluetooth на этом Mac и запустите новый поиск вручную.",
"restore-bluetooth-adapter":
"Восстановите доступность Bluetooth-адаптера macOS и перезапустите локальный сервис перед новой попыткой.",
"grant-wifi-permission":
"Разрешите Mission Core доступ к данным Wi‑Fi в системных настройках macOS, затем повторите действие вручную.",
"power-on-wifi":
"Включите Wi‑Fi на этом Mac и заново выберите требуемый способ подключения.",
"restore-wifi-interface":
"Восстановите системный Wi‑Fi-интерфейс macOS перед новой попыткой подключения.",
"unlock-or-authorize-keychain":
"Разблокируйте связку ключей macOS и подтвердите доступ Mission Core к профилю подключения.",
"review-keychain-access":
"Разрешите Mission Core чтение профиля подключения в связке ключей macOS.",
"join-expected-network":
"Установите связь этого Mac с ожидаемой локальной сетью и повторите действие.",
"inspect-host-route":
"Восстановите прямой локальный маршрут к адресу подключения.",
"verify-broker-endpoint":
"Восстановите доступность управляющего endpoint из текущей сети; команда автоматически не повторяется.",
"inspect-local-storage":
"Освободите место и восстановите доступ к локальному хранилищу Mission Core до следующей операции.",
"restart-local-service":
"Перезапустите канонический локальный сервис Mission Core и после загрузки обновите состояние.",
"explicit-retry":
"После устранения причины повторите действие отдельным нажатием; автоматического повтора нет.",
};
export interface HostFailureDiagnosticPresentation {
codeLabel: string;
domainLabel: string;
impactLabel: string;
operatorActionLabel: string;
}
export function hostFailureDiagnosticPresentation(
value: unknown,
): HostFailureDiagnosticPresentation | null {
if (!isXgridsHostFailureDiagnostic(value)) return null;
return {
codeLabel: CODE_LABELS[value.code],
domainLabel: DOMAIN_LABELS[value.domain],
impactLabel: IMPACT_LABELS[value.impact],
operatorActionLabel: ACTION_LABELS[value.operator_action],
};
}
export function operationHostFailureDiagnostic(
operation: XgridsOperation | null | undefined,
): XgridsHostFailureDiagnostic | null {
const diagnostic = operation?.error?.host_diagnostic;
return isXgridsHostFailureDiagnostic(diagnostic) ? diagnostic : null;
}
File diff suppressed because it is too large Load Diff
@@ -17,12 +17,28 @@ export const xgridsK1Actions = Object.freeze({
xgridsK1Manifest,
"calibration.device-snapshot.read",
),
connectionModeSelect: requirePluginAction(
xgridsK1Manifest,
"connection.mode.select",
),
connectionReconfigurePrepare: requirePluginAction(
xgridsK1Manifest,
"connection.reconfigure.prepare",
),
networkProvision: requirePluginAction(xgridsK1Manifest, "network.provision"),
connectionVerify: requirePluginAction(xgridsK1Manifest, "connection.verify"),
configuredEndpointProbe: requirePluginAction(
xgridsK1Manifest,
"connection.endpoint-probe",
),
acquisitionPrepare: requirePluginAction(xgridsK1Manifest, "acquisition.prepare"),
acquisitionStart: requirePluginAction(xgridsK1Manifest, "acquisition.start"),
acquisitionStop: requirePluginAction(xgridsK1Manifest, "acquisition.stop"),
acquisitionAbort: requirePluginAction(xgridsK1Manifest, "acquisition.abort"),
acquisitionForceFinishLocal: requirePluginAction(
xgridsK1Manifest,
"acquisition.force-finish-local",
),
acquisitionStateRead: requirePluginAction(xgridsK1Manifest, "acquisition.state.read"),
compatibilityStreamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"),
streamStartReplay: requirePluginAction(xgridsK1Manifest, "stream.start-replay"),
@@ -54,4 +70,16 @@ export const xgridsK1Actions = Object.freeze({
xgridsK1Manifest,
"application-control.session.close",
),
physicalCommandReconcile: requirePluginAction(
xgridsK1Manifest,
"physical-command.reconcile",
),
physicalCommandRetireUnavailable: requirePluginAction(
xgridsK1Manifest,
"physical-command.retire-unavailable",
),
physicalCommandReopenRetiredReconciliation: requirePluginAction(
xgridsK1Manifest,
"physical-command.reopen-retired-reconciliation",
),
});
@@ -48,7 +48,6 @@ const runtimeMessageReplacements: Array<[RegExp, string]> = [
],
[/Foxglove/gi, "локальный мост визуализации"],
[/MacBook/gi, "компьютер"],
[/\bK1\b/g, "устройство"],
];
export function localizeRuntimeMessage(message: string | null | undefined): string | null {
@@ -3,15 +3,28 @@ import type {
ObservationSourceAvailability,
ObservationSourceDelivery,
ObservationSourceDescriptor,
ObservationSourcePresentationLease,
ObservationSourceProvider,
} from "@mission-core/plugin-sdk";
import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
import {
activeStreamRecoveredBrowserAuthority,
activeStreamRecoveryOwnsPresentationDecision,
activeStreamRecoveryPresentationAuthority,
type ActiveStreamRecoveryPresentationAuthority,
} from "./activeStreamRecovery";
import {
confirmedRuntimeSourceMode,
effectiveAcquisition,
hasAuthoritativeData,
hasControlAuthority,
} from "./lifecycle";
import { xgridsK1Manifest } from "./manifest";
import type {
XgridsCameraPreviewDelivery,
XgridsK1State,
XgridsSensorCatalogStream,
} from "./api";
import { isXgridsActiveStreamRecovery } from "./api";
function providerFor(
state: XgridsK1State,
@@ -22,30 +35,72 @@ function providerFor(
pluginVersion: xgridsK1Manifest.metadata.version,
modelId: state.device_ref?.model_id || activeModel.id,
compatibilityProfileId:
state.device_session?.compatibility_profile_id ?? state.compatibility?.profile_id ?? null,
(state.connection_supervisor?.observed.device_identity.state === "verified"
? state.connection_supervisor.observed.device_identity.compatibility_profile_id
: null)
?? state.device_session?.compatibility_profile_id
?? state.compatibility?.profile_id
?? null,
};
}
function bindingFor(state: XgridsK1State) {
function bindingFor(
state: XgridsK1State,
recoveryAuthority: ActiveStreamRecoveryPresentationAuthority | null,
) {
const acquisition = effectiveAcquisition(state);
const controlAuthoritative = hasControlAuthority(state);
const recoveryAuthoritative = recoveryAuthority !== null;
return {
deviceId: state.device_ref?.device_id ?? null,
deviceSessionId: state.device_session?.device_session_id ?? null,
// A legacy snapshot may retain a selected device and session long after
// the control topology has disappeared. Do not publish those values as a
// live host binding until the supervisor has re-attested the topology.
deviceId: recoveryAuthoritative
? acquisition?.device_id?.trim() || null
: controlAuthoritative ? state.device_ref?.device_id ?? null : null,
deviceSessionId: recoveryAuthoritative
? acquisition?.device_session_id?.trim() || null
: controlAuthoritative ? state.device_session?.device_session_id ?? null : null,
acquisitionId: acquisition?.acquisition_id ?? null,
};
}
function recoveryPresentationLease(
authority: ActiveStreamRecoveryPresentationAuthority,
): ObservationSourcePresentationLease {
return {
kind: "active-stream-recovery",
runtimeId: authority.snapshotRuntimeId,
acquisitionId: authority.acquisitionId,
acquisitionStateRevision: authority.acquisitionStateRevision,
producerGeneration: authority.runtimeProducerGeneration,
recoveryGeneration: authority.recoveryGeneration,
};
}
function catalogDeclares(state: XgridsK1State, streamId: string): boolean {
return Boolean(state.sensor_catalog?.streams?.some((stream) => stream.stream_id === streamId));
}
function spatialAvailability(state: XgridsK1State): ObservationSourceAvailability {
function spatialAvailability(
state: XgridsK1State,
recoveryAuthoritative: boolean,
recoveryOwnsPresentation: boolean,
): ObservationSourceAvailability {
const mode = confirmedRuntimeSourceMode(state);
if (mode !== "idle" && state.rerun_grpc_url?.trim()) return "streaming";
if (state.rerun_grpc_url?.trim()) return "available";
if (state.device_session?.connectivity === "degraded") return "degraded";
if (state.device_session?.connectivity === "connected") return "available";
return catalogDeclares(state, "spatial.point-cloud.live") ? "declared" : "unavailable";
if (mode === "replay" && state.rerun_grpc_url?.trim()) return "streaming";
const declared = catalogDeclares(state, "spatial.point-cloud.live");
if (recoveryAuthoritative && state.rerun_grpc_url?.trim()) return "connecting";
if (recoveryOwnsPresentation) return declared ? "degraded" : "unavailable";
if (!hasControlAuthority(state)) return declared ? "unverified" : "unavailable";
if (mode === "live" && state.rerun_grpc_url?.trim() && hasAuthoritativeData(state)) {
return "streaming";
}
if (["stalled", "lost"].includes(
state.connection_supervisor?.observed.data_plane.state ?? "idle",
)) return "degraded";
if (state.rerun_grpc_url?.trim() || declared) return "available";
return "unavailable";
}
function catalogAvailability(value: string | null | undefined): ObservationSourceAvailability {
@@ -167,15 +222,42 @@ function browserDelivery(
return { id, kind: value.kind, url, mediaType };
}
function sameBrowserDelivery(
left: ObservationSourceDelivery | null,
right: ObservationSourceDelivery | null,
): boolean {
return Boolean(
left
&& right
&& left.kind === "mse-fmp4-websocket"
&& right.kind === "mse-fmp4-websocket"
&& left.id === right.id
&& left.url === right.url
&& left.mediaType === right.mediaType,
);
}
function cameraAvailability(
state: XgridsK1State,
stream: XgridsSensorCatalogStream,
selected: boolean,
delivery: ObservationSourceDelivery | null,
attested: boolean,
recoverySelected: boolean,
exactCurrentEpochReady: boolean,
): ObservationSourceAvailability {
if (recoverySelected) {
return exactCurrentEpochReady
? "streaming"
: state.camera_preview?.phase?.trim().toLowerCase() === "degraded"
? "degraded"
: "connecting";
}
if (!attested) return "unverified";
if (state.device_session?.connectivity === "degraded") return "degraded";
if (!hasControlAuthority(state)) return "degraded";
if (["stalled", "lost"].includes(
state.connection_supervisor?.observed.data_plane.state ?? "idle",
)) return "degraded";
const base = catalogAvailability(stream.availability);
if (!selected) return base === "streaming" || base === "connecting" ? "available" : base;
@@ -188,12 +270,82 @@ function cameraAvailability(
return "connecting";
}
function exactCurrentCameraEpochReady(state: XgridsK1State): boolean {
const recovery = state.connection_recovery;
const previewGeneration = state.camera_preview?.generation;
if (
!isXgridsActiveStreamRecovery(recovery)
|| recovery.camera_media_state !== "ready"
|| recovery.camera_media_ready !== true
|| !Number.isInteger(previewGeneration)
|| (previewGeneration ?? 0) < 1
) return false;
const epoch = recovery.camera_epoch;
return Boolean(
epoch
&& epoch.generation === previewGeneration
&& epoch.init_committed === true
&& epoch.first_media_committed === true
&& epoch.committed_media_segment_count > 0,
);
}
function recoveryCameraTupleIsExact(
state: XgridsK1State,
authority: ActiveStreamRecoveryPresentationAuthority | null,
provider: ObservationSourceProvider,
): boolean {
const acquisition = state.acquisition;
const deviceId = acquisition?.device_id?.trim();
const deviceSessionId = acquisition?.device_session_id?.trim();
const compatibilityProfileId = acquisition?.compatibility_profile_id?.trim();
return Boolean(
authority
&& authority.recovery.camera_recovery === "owned"
&& acquisition
&& acquisition.acquisition_id.trim() === authority.acquisitionId
&& deviceId
&& deviceSessionId
&& compatibilityProfileId
&& state.device_ref?.device_id?.trim() === deviceId
&& state.device_session?.device_session_id?.trim() === deviceSessionId
&& state.device_session?.device_id?.trim() === deviceId
&& state.device_session?.compatibility_profile_id?.trim() === compatibilityProfileId
&& provider.compatibilityProfileId?.trim() === compatibilityProfileId
);
}
function cameraRecoveryPhaseRetainable(state: XgridsK1State): boolean {
return [
"active",
"buffering",
"connecting",
"degraded",
"ready",
"reconnecting",
"streaming",
].includes(state.camera_preview?.phase?.trim().toLowerCase() ?? "");
}
export function xgridsK1ObservationSources(
state: XgridsK1State,
activeModel: DeviceModelDefinition,
): ObservationSourceDescriptor[] {
const provider = providerFor(state, activeModel);
const binding = bindingFor(state);
const recoveryAuthority = activeStreamRecoveryPresentationAuthority(state);
const recoveredBrowserAuthority = activeStreamRecoveredBrowserAuthority(state);
const browserLineageAuthority = recoveryAuthority ?? recoveredBrowserAuthority;
const recoveryOwnsPresentation = activeStreamRecoveryOwnsPresentationDecision(state);
const recoveryAuthoritative = recoveryAuthority !== null;
const presentationLease = browserLineageAuthority
? recoveryPresentationLease(browserLineageAuthority)
: null;
const binding = bindingFor(state, browserLineageAuthority);
const replayAuthoritative = state.source_mode === "replay";
const dataAuthoritative = hasAuthoritativeData(state) && !recoveryOwnsPresentation;
const spatialPreviewUrl = replayAuthoritative || dataAuthoritative || recoveryAuthoritative
? state.rerun_grpc_url?.trim() || null
: null;
const clockId = binding.acquisitionId ?? binding.deviceSessionId ?? binding.deviceId ?? null;
const descriptorId = (sourceId: string) =>
`${provider.pluginId}:${provider.modelId}:${sourceId}`;
@@ -205,12 +357,22 @@ export function xgridsK1ObservationSources(
description: "Облако точек, поза и траектория в общей 3D-сцене",
modality: "point-cloud",
role: "primary",
availability: spatialAvailability(state),
availability: spatialAvailability(
state,
recoveryAuthoritative,
recoveryOwnsPresentation,
),
transport: "rerun-grpc",
endpointLabel: state.rerun_grpc_url?.trim() ? "Rerun gRPC" : "MQTT → Rerun",
previewUrl: state.rerun_grpc_url?.trim() || null,
previewUrl: spatialPreviewUrl,
delivery: null,
activation: null,
presentationLease: (
recoveryAuthoritative
|| (recoveredBrowserAuthority !== null && dataAuthoritative)
) && spatialPreviewUrl
? presentationLease
: null,
provider,
binding,
capabilities: {
@@ -234,9 +396,27 @@ export function xgridsK1ObservationSources(
const sourceId = stream.source_id?.trim();
if (sourceId) sourceIdCounts.set(sourceId, (sourceIdCounts.get(sourceId) ?? 0) + 1);
}
const attested = Boolean(provider.compatibilityProfileId && binding.deviceSessionId);
const supervisor = state.connection_supervisor;
const verifiedControl = state.application_control_session?.verified_control;
const attested = Boolean(
hasControlAuthority(state)
&& provider.compatibilityProfileId
&& binding.deviceSessionId
&& verifiedControl
&& supervisor?.observed.control_plane.session_id === verifiedControl.control_session_id
&& supervisor.observed.device_identity.logical_device_id
=== verifiedControl.logical_device_id
&& supervisor.observed.device_identity.compatibility_profile_id
=== verifiedControl.compatibility_profile_id,
);
const sessionScope = binding.deviceSessionId ?? binding.deviceId ?? "unbound";
const activeSourceId = state.camera_preview?.active_source_id?.trim() ?? null;
const exactCameraMediaReady = exactCurrentCameraEpochReady(state);
const browserLineageCameraTupleExact = recoveryCameraTupleIsExact(
state,
browserLineageAuthority,
provider,
);
const cameras = cameraRows.flatMap<ObservationSourceDescriptor>((stream) => {
const sourceId = stream.source_id?.trim();
@@ -249,19 +429,58 @@ export function xgridsK1ObservationSources(
const activationValid = Boolean(
groupId && Number.isInteger(maxActive) && (maxActive ?? 0) > 0,
);
const selected = Boolean(
attested && activationValid && rawActivation?.selected === true && activeSourceId === sourceId,
const normallySelected = Boolean(
!recoveryOwnsPresentation
&& attested
&& activationValid
&& rawActivation?.selected === true
&& activeSourceId === sourceId
&& exactCameraMediaReady,
);
const streamDelivery = browserDelivery(stream.delivery);
const previewDelivery = browserDelivery(state.camera_preview?.delivery);
const candidateDelivery = stream.delivery ?? state.camera_preview?.delivery;
const retainedDelivery = browserDelivery(candidateDelivery);
const deliveryConsistent = !stream.delivery || !state.camera_preview?.delivery
|| sameBrowserDelivery(streamDelivery, previewDelivery);
const streamRecoveryAvailable = [
"available",
"connecting",
"degraded",
"streaming",
].includes(catalogAvailability(stream.availability));
const browserLineageSelected = Boolean(
browserLineageCameraTupleExact
&& activationValid
&& maxActive === 1
&& rawActivation?.selected === true
&& activeSourceId === sourceId
&& cameraRecoveryPhaseRetainable(state)
&& streamRecoveryAvailable
&& retainedDelivery
&& deliveryConsistent
);
const recoverySelected = recoveryAuthority !== null && browserLineageSelected;
const recoveredBrowserSelected = Boolean(
recoveredBrowserAuthority
&& browserLineageSelected
);
const selected = normallySelected || recoverySelected || recoveredBrowserSelected;
const activation = activationValid
? {
groupId: `${provider.pluginId}:${sessionScope}:${groupId}`,
maxActive: maxActive as number,
selected,
controllable: Boolean(attested && rawActivation?.controllable),
controllable: Boolean(
!recoveryOwnsPresentation && attested && rawActivation?.controllable,
),
}
: null;
const candidateDelivery = stream.delivery ?? state.camera_preview?.delivery;
const delivery = selected ? browserDelivery(candidateDelivery) : null;
const delivery = selected && (
dataAuthoritative || recoverySelected || recoveredBrowserSelected
)
? retainedDelivery
: null;
const label = stream.label?.trim() || sourceId;
return [{
@@ -272,12 +491,23 @@ export function xgridsK1ObservationSources(
description: "Видеоканал, опубликованный активным device-плагином",
modality: "video",
role: "auxiliary",
availability: cameraAvailability(state, stream, selected, delivery, attested),
availability: cameraAvailability(
state,
stream,
selected,
delivery,
attested,
recoverySelected || recoveredBrowserSelected,
exactCameraMediaReady,
),
transport: delivery ? "websocket" : "other",
endpointLabel: safeEndpointLabel(stream.endpoint_label) ?? "Локальный video adapter",
previewUrl: null,
delivery,
activation,
presentationLease: (recoverySelected || recoveredBrowserSelected) && delivery
? presentationLease
: null,
provider,
binding,
capabilities: {
@@ -6,6 +6,15 @@ export interface OperatorIntentToken extends RuntimeGenerationToken {
readonly intentGeneration: number;
}
export function isSnapshotRuntimeCurrent(
expectedSnapshotRuntimeId: string,
currentSnapshotRuntimeId: string | null | undefined,
): boolean {
const expected = expectedSnapshotRuntimeId.trim();
const current = currentSnapshotRuntimeId?.trim() ?? "";
return Boolean(expected && current && expected === current);
}
/**
* Invalidates asynchronous UI work across both plugin activation changes and
* successive explicit operator intents.
@@ -0,0 +1,430 @@
import type {
OperatorPresenceConfirmation,
XgridsAcquisition,
XgridsApplicationControlSession,
XgridsConnectionMode,
XgridsK1State,
} from "./api";
import { currentAppliedConnectionTopology, isSoftwareCommandedAcquisition } from "./lifecycle";
export interface PhysicalConfirmationChecks {
operatorPresent: boolean;
ownerControlledDevice: boolean;
lixelgoClosed: boolean;
batteryStorageConfirmed: boolean;
expectedPhysicalStateConfirmed: boolean;
}
type CompletedPhysicalConfirmationChecks = {
[Key in keyof PhysicalConfirmationChecks]: true;
};
export type K1PhysicalConfirmationKind = "prepare" | "start" | "stop";
/**
* Semantic state that authorises one physical command confirmation.
*
* Timestamps, the polling snapshot revision and ConnectionSupervisor.revision
* are deliberately absent: the latter is an observation counter and advances
* even when a probe confirms the same semantic route. A read-only refresh must
* not invalidate an operator confirmation. Every field below, however, changes
* the identity, route, CAS authority, acquisition or runtime state of the
* command and therefore closes an already-open modal.
*/
export interface K1PhysicalCommandFence {
kind: K1PhysicalConfirmationKind;
commandDeviceId: string;
commandProjectName: string;
acquisitionId: string;
runtimeId: string | null;
runtimePhase: string | null;
runtimeSourceMode: string | null;
selectedDeviceId: string | null;
deviceRefId: string | null;
deviceSessionId: string | null;
deviceSessionDeviceId: string | null;
deviceSessionConnectivity: string | null;
connectionIntentId: string | null;
requestedConnectionMode: XgridsConnectionMode | null;
expectedDeviceId: string | null;
deviceNetworkState: string | null;
deviceNetworkIntentId: string | null;
transportRef: string | null;
connectionMode: XgridsConnectionMode | null;
targetIpv4: string | null;
targetPort: number | null;
hostPathEpoch: number | null;
hostPathAvailable: boolean | null;
deviceIdentityState: string | null;
deviceIdentityId: string | null;
controlPlaneState: string | null;
controlPlaneSessionId: string | null;
dataPlaneState: string | null;
dataPlaneSessionId: string | null;
leaseState: string | null;
leaseGeneration: number | null;
controlAllowed: boolean | null;
acquisitionStartAllowed: boolean | null;
dataIngestAuthoritative: boolean | null;
controlSessionGeneration: number | null;
controlStateRevision: number | null;
controlState: string | null;
controlSocketOpen: boolean | null;
verifiedControlSessionId: string | null;
controlProofRevision: number | null;
controlProofFresh: boolean | null;
deviceReportedState: string | null;
deviceProjectBound: boolean | null;
deviceInitReady: boolean | null;
acquisitionState: string | null;
acquisitionStateRevision: number | null;
acquisitionDeviceId: string | null;
acquisitionDeviceSessionId: string | null;
acquisitionControlMode: string | null;
}
export interface K1PhysicalCommandTarget {
deviceId: string;
connection: string;
projectName: string;
acquisitionId: string;
deviceState: string;
fence: K1PhysicalCommandFence;
}
export interface K1PhysicalCommandCheckpoint {
readonly kind: K1PhysicalConfirmationKind;
readonly target: Readonly<Omit<K1PhysicalCommandTarget, "fence">>;
readonly fence: Readonly<K1PhysicalCommandFence>;
readonly fenceKey: string;
}
export interface K1PhysicalCommandConfirmationPayload {
readonly physicalAcceptance: Readonly<OperatorPresenceConfirmation>;
readonly checkpoint: K1PhysicalCommandCheckpoint;
}
export function emptyPhysicalConfirmationChecks(): PhysicalConfirmationChecks {
return {
operatorPresent: false,
ownerControlledDevice: false,
lixelgoClosed: false,
batteryStorageConfirmed: false,
expectedPhysicalStateConfirmed: false,
};
}
export function physicalConfirmationComplete(
checks: PhysicalConfirmationChecks,
): checks is CompletedPhysicalConfirmationChecks {
return (
checks.operatorPresent
&& checks.ownerControlledDevice
&& checks.lixelgoClosed
&& checks.batteryStorageConfirmed
&& checks.expectedPhysicalStateConfirmed
);
}
export function operatorPresenceConfirmation(
checks: PhysicalConfirmationChecks,
): OperatorPresenceConfirmation | null {
if (!physicalConfirmationComplete(checks)) return null;
return {
operator_present: checks.operatorPresent,
owner_controlled_device: checks.ownerControlledDevice,
lixelgo_closed: checks.lixelgoClosed,
battery_storage_confirmed: checks.batteryStorageConfirmed,
expected_physical_state_confirmed: checks.expectedPhysicalStateConfirmed,
};
}
/**
* One deliberate click on the local K1 START/STOP action is the operator's
* physical acceptance. The backend still validates the exact control CAS,
* live DeviceInfo/status binding and command ledger before a vendor write;
* this helper only removes the redundant five-checkbox modal.
*/
export function operatorActionPhysicalAcceptance(): OperatorPresenceConfirmation {
return {
operator_present: true,
owner_controlled_device: true,
lixelgo_closed: true,
battery_storage_confirmed: true,
expected_physical_state_confirmed: true,
};
}
function recordValue(
record: Record<string, unknown> | null | undefined,
key: string,
): unknown {
return record?.[key];
}
function trimmed(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function integer(value: unknown): number | null {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
? value
: null;
}
function boolean(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
function commandFence(
kind: K1PhysicalConfirmationKind,
state: XgridsK1State | null | undefined,
target: Omit<K1PhysicalCommandTarget, "fence">,
): K1PhysicalCommandFence {
const supervisor = state?.connection_supervisor;
const deviceNetwork = supervisor?.observed.device_network;
const hostPath = supervisor?.observed.host_path;
const deviceIdentity = supervisor?.observed.device_identity;
const controlPlane = supervisor?.observed.control_plane;
const dataPlane = supervisor?.observed.data_plane;
const control = state?.application_control_session;
const verifiedControl = control?.verified_control;
const acquisition = state?.acquisition;
return {
kind,
commandDeviceId: target.deviceId,
commandProjectName: target.projectName,
acquisitionId: target.acquisitionId,
runtimeId: trimmed(state?.snapshot_runtime_id),
runtimePhase: trimmed(state?.phase),
runtimeSourceMode: trimmed(state?.source_mode),
selectedDeviceId: trimmed(state?.selected_device_id),
deviceRefId: trimmed(state?.device_ref?.device_id),
deviceSessionId: trimmed(state?.device_session?.device_session_id),
deviceSessionDeviceId: trimmed(state?.device_session?.device_id),
deviceSessionConnectivity: trimmed(state?.device_session?.connectivity),
connectionIntentId: trimmed(supervisor?.intent?.intent_id),
requestedConnectionMode: supervisor?.intent?.requested_mode ?? null,
expectedDeviceId: trimmed(supervisor?.intent?.expected_device_id),
deviceNetworkState: trimmed(deviceNetwork?.state),
deviceNetworkIntentId: trimmed(deviceNetwork?.intent_id),
transportRef: trimmed(deviceNetwork?.transport_ref),
connectionMode: deviceNetwork?.connection_mode ?? null,
targetIpv4: trimmed(deviceNetwork?.target?.ipv4),
targetPort: integer(deviceNetwork?.target?.port),
hostPathEpoch: integer(hostPath?.epoch),
hostPathAvailable: boolean(hostPath?.available),
deviceIdentityState: trimmed(deviceIdentity?.state),
deviceIdentityId: trimmed(deviceIdentity?.logical_device_id),
controlPlaneState: trimmed(controlPlane?.state),
controlPlaneSessionId: trimmed(controlPlane?.session_id),
dataPlaneState: trimmed(dataPlane?.state),
dataPlaneSessionId: trimmed(dataPlane?.session_id),
leaseState: trimmed(supervisor?.lease.state),
leaseGeneration: integer(supervisor?.lease.generation),
controlAllowed: boolean(supervisor?.authority.control_allowed),
acquisitionStartAllowed: boolean(supervisor?.authority.acquisition_start_allowed),
dataIngestAuthoritative: boolean(supervisor?.authority.data_ingest_authoritative),
controlSessionGeneration: integer(control?.session_generation),
controlStateRevision: integer(control?.state_revision),
controlState: trimmed(control?.state),
controlSocketOpen: boolean(control?.control_socket_open),
verifiedControlSessionId: trimmed(verifiedControl?.control_session_id),
controlProofRevision: integer(verifiedControl?.control_proof_revision),
controlProofFresh: boolean(verifiedControl?.control_proof_fresh),
deviceReportedState: trimmed(recordValue(control?.transport, "latest_device_session_state")),
deviceProjectBound: boolean(recordValue(control?.transport, "latest_device_project_bound")),
deviceInitReady: boolean(recordValue(control?.transport, "latest_device_init_ready")),
acquisitionState: trimmed(acquisition?.state),
acquisitionStateRevision: integer(acquisition?.state_revision),
acquisitionDeviceId: trimmed(acquisition?.device_id),
acquisitionDeviceSessionId: trimmed(acquisition?.device_session_id),
acquisitionControlMode: trimmed(acquisition?.control_mode),
};
}
export function physicalCommandFenceKey(
kind: K1PhysicalConfirmationKind,
target: K1PhysicalCommandTarget,
): string {
// Both values are included. This makes a mismatched component kind fail
// closed even if a caller accidentally supplies a target built for another
// physical command.
return JSON.stringify([
kind,
target.deviceId,
target.connection,
target.projectName,
target.acquisitionId,
target.deviceState,
target.fence,
]);
}
export function createPhysicalCommandCheckpoint(
kind: K1PhysicalConfirmationKind,
target: K1PhysicalCommandTarget,
): K1PhysicalCommandCheckpoint {
const fence = Object.freeze({ ...target.fence });
const targetSnapshot = Object.freeze({
deviceId: target.deviceId,
connection: target.connection,
projectName: target.projectName,
acquisitionId: target.acquisitionId,
deviceState: target.deviceState,
});
return Object.freeze({
kind,
target: targetSnapshot,
fence,
fenceKey: physicalCommandFenceKey(kind, target),
});
}
export function physicalCommandCheckpointMatches(
checkpoint: K1PhysicalCommandCheckpoint,
kind: K1PhysicalConfirmationKind,
target: K1PhysicalCommandTarget,
): boolean {
return checkpoint.kind === kind
&& checkpoint.fence.kind === kind
&& target.fence.kind === kind
&& checkpoint.fenceKey === physicalCommandFenceKey(kind, target);
}
function targetWithFence(
kind: K1PhysicalConfirmationKind,
state: XgridsK1State | null | undefined,
target: Omit<K1PhysicalCommandTarget, "fence">,
): K1PhysicalCommandTarget {
return {
...target,
fence: commandFence(kind, state, target),
};
}
function exactReadyState(
control: XgridsApplicationControlSession,
): string | null {
const deviceState = trimmed(recordValue(control.transport, "latest_device_session_state"));
const projectBound = recordValue(control.transport, "latest_device_project_bound");
const initReady = recordValue(control.transport, "latest_device_init_ready");
if (deviceState !== "ready" || projectBound !== true || initReady !== false) return null;
return "READY · проект привязан · инициализация не запущена";
}
function exactScanningState(
control: XgridsApplicationControlSession | null | undefined,
): string {
const deviceState = trimmed(recordValue(control?.transport, "latest_device_session_state"));
const projectBound = recordValue(control?.transport, "latest_device_project_bound");
const initReady = recordValue(control?.transport, "latest_device_init_ready");
if (deviceState === "scanning" && projectBound === true && initReady === true) {
return "SCANNING · проект привязан · инициализация завершена";
}
return deviceState
? `${deviceState.toUpperCase()} · последнее подтверждённое состояние K1`
: "Состояние K1 не подтверждено текущим управляющим каналом";
}
function exactConnection(
control: XgridsApplicationControlSession | null | undefined,
): string | null {
const verified = control?.verified_control;
if (!verified) return null;
return `${verified.connection_mode} · ${verified.target_ipv4}:${verified.target_port}`;
}
function acquisitionProject(acquisition: XgridsAcquisition): string {
return trimmed(acquisition.project_name) ?? "Проект без опубликованного имени";
}
export function preparedStartTarget(
state: XgridsK1State | null | undefined,
): K1PhysicalCommandTarget | null {
const acquisition = state?.acquisition;
const control = state?.application_control_session;
const verified = control?.verified_control;
const topology = currentAppliedConnectionTopology(state);
const supervisor = state?.connection_supervisor;
const deviceNetwork = supervisor?.observed.device_network;
const hostPath = supervisor?.observed.host_path;
const readyState = control ? exactReadyState(control) : null;
if (
!acquisition
|| acquisition.state !== "prepared"
|| acquisition.control_mode !== "plugin-commanded"
|| !control
|| control.state !== "project-ready"
|| control.can_start !== true
|| !verified
|| verified.control_proof_fresh !== true
|| verified.logical_device_id !== acquisition.device_id
|| verified.compatibility_profile_id !== acquisition.compatibility_profile_id
|| supervisor?.authority.acquisition_start_allowed !== true
|| verified.intent_id !== supervisor.intent?.intent_id
|| verified.host_path_epoch !== hostPath?.epoch
|| verified.transport_ref !== deviceNetwork?.transport_ref
|| verified.connection_mode !== deviceNetwork?.connection_mode
|| verified.target_ipv4 !== deviceNetwork?.target?.ipv4
|| verified.target_port !== deviceNetwork?.target?.port
|| topology?.status !== "active"
|| topology.connectionMode !== verified.connection_mode
|| topology.endpoint !== verified.target_ipv4
|| state?.connection_lifecycle?.ready_to_start !== true
|| !readyState
) {
return null;
}
return targetWithFence("start", state, {
deviceId: verified.logical_device_id,
connection: `${verified.connection_mode} · ${verified.target_ipv4}:${verified.target_port}`,
projectName: acquisitionProject(acquisition),
acquisitionId: acquisition.acquisition_id,
deviceState: readyState,
});
}
export function preparationTarget(
state: XgridsK1State | null | undefined,
projectName: string,
): K1PhysicalCommandTarget | null {
const topology = currentAppliedConnectionTopology(state);
const supervisor = state?.connection_supervisor;
const deviceNetwork = supervisor?.observed.device_network;
if (
!topology
|| !supervisor
|| topology.status === "configured-offline"
|| !deviceNetwork?.transport_ref
|| !deviceNetwork.target
) {
return null;
}
const logicalDeviceId = supervisor.observed.device_identity.logical_device_id
?? supervisor.intent?.expected_device_id
?? deviceNetwork.transport_ref;
return targetWithFence("prepare", state, {
deviceId: logicalDeviceId,
connection: `${topology.connectionMode} · ${deviceNetwork.target.ipv4}:${deviceNetwork.target.port}`,
projectName: projectName.trim(),
acquisitionId: "Будет создана подготовительным этапом; START пока недоступен",
deviceState: "Подготовка не начата · физический START не разрешён",
});
}
export function activeStopTarget(
state: XgridsK1State | null | undefined,
): K1PhysicalCommandTarget | null {
const acquisition = state?.acquisition;
if (!acquisition || !isSoftwareCommandedAcquisition(state)) return null;
const control = state?.application_control_session;
return targetWithFence("stop", state, {
deviceId: acquisition.device_id,
connection: exactConnection(control) ?? "Текущий управляющий канал не подтверждён",
projectName: acquisitionProject(acquisition),
acquisitionId: acquisition.acquisition_id,
deviceState: exactScanningState(control),
});
}
+239 -1
View File
@@ -1,7 +1,245 @@
import type { StatusTone } from "@nodedc/ui-react";
import type { BackendStatus } from "@mission-core/plugin-sdk";
import type { XgridsK1Metrics } from "./api";
import type {
XgridsConnectionPolicyAction,
XgridsK1Metrics,
XgridsK1State,
} from "./api";
import {
connectionPolicyAllows,
connectionPolicyDecision,
} from "./lifecycle";
const connectionPolicyReasonCopy: Record<string, string> = {
"connection-supervisor-closed": "Контур связи K1 закрыт.",
"supervisor-action-not-allowed": "Текущая связь с K1 не подтверждает право на эту физическую команду.",
"network-provision-operation-active": "Предыдущая сетевая операция K1 ещё не завершена.",
"acquisition-active": "Сетевой режим K1 нельзя менять во время активного приёма.",
"acquisition-cleanup-pending": "Локальный приём K1 ещё завершает очистку ресурсов.",
"local-runtime-active": "Локальный исполнительный контур K1 ещё активен.",
"control-session-not-admissible-for-network-change": "Текущая управляющая сессия K1 ещё не допускает смену сети.",
"network-mutation-reconciliation-required": "Результат предыдущей сетевой записи K1 не подтверждён.",
"network-mutation-ledger-corrupt": "Журнал сетевых изменений K1 повреждён.",
"fresh-ble-candidate-required": "Для этого действия нужен K1 из нового Bluetooth-поиска.",
"retained-recovery-context-unavailable": "Сохранённый Bluetooth-контекст текущего K1 больше недоступен.",
"fresh-candidate-supersedes-retained-recovery": "K1 снова виден в свежем поиске; используйте новый найденный экземпляр.",
"reconciliation-target-not-observed": "В свежем Bluetooth-поиске не найден K1, связанный с незавершённой записью.",
"reconciliation-target-not-retained": "Текущий серверный Bluetooth-контекст относится не к тому K1, для которого не завершена сетевая операция.",
"current-device-context-unavailable": "Текущий K1 не подтверждён в оперативном контексте этого процесса.",
"durable-recovery-target-unavailable": "В сохранённом серверном состоянии нет одной точной пары K1 и режима для проверки после перезапуска.",
"fresh-candidate-supersedes-durable-recovery": "Нужный K1 снова найден свежим Bluetooth-поиском; сервер требует проверить именно свежий экземпляр.",
"retained-recovery-supersedes-durable-recovery": "Сервер хранит более свежий контекст текущего K1.",
"configured-endpoint-unavailable": "Нет подтверждённого сохранённого адреса K1 для безопасной проверки.",
"configured-endpoint-probe-lifecycle-busy": "Контур подключения K1 занят другой операцией.",
"configured-endpoint-changed-during-probe": "Адрес K1 изменился во время проверки; результат отброшен.",
"configured-endpoint-probe-failed": "Маршрут и управляющий endpoint K1 не удалось проверить.",
"host-path-unavailable": "На этом компьютере не подтверждён сетевой путь до K1.",
"host-route-not-direct": "Маршрут до K1 проходит не через ожидаемую локальную сеть.",
"endpoint-not-reachable": "Управляющий endpoint K1 сейчас недоступен.",
"device-identity-unverified": "Идентичность подключённого K1 ещё не подтверждена.",
"device-identity-stale": "Подтверждение идентичности K1 устарело.",
"device-identity-mismatch": "Подключённое устройство не совпало с выбранным K1.",
"device-identity-pin-store-corrupt": "Хранилище привязки устройства повреждено.",
"network-provisioning-idempotency-unavailable": "Журнал сетевых намерений K1 недоступен.",
"network-provisioning-idempotency-corrupt": "Журнал сетевых намерений K1 повреждён.",
"network-provisioning-idempotency-invalid": "Журнал сетевых намерений K1 не прошёл проверку.",
"network-provisioning-idempotency-operation-mismatch": "Текущая сетевая операция не совпала с сохранённым намерением.",
"semantic-topology-store-corrupt": "Сохранённая топология K1 повреждена.",
"control-plane-not-healthy": "Управляющий канал K1 не подтверждён.",
"connection-lease-not-reachable": "Текущая сессия связи K1 больше не подтверждена.",
"data-plane-stalled": "Поток данных K1 перестал обновляться.",
"data-plane-lost": "Поток данных K1 потерян.",
"physical-command-reconciliation-required": "Результат предыдущей физической команды K1 не подтверждён.",
"physical-device-already-active": "Последнее подтверждённое состояние K1 — активное сканирование.",
"physical-command-recovery-target-unavailable": "Журнал не содержит точную Bluetooth-цель для восстановления K1.",
"physical-command-recovery-target-not-observed": "Исходный K1 пока не найден в свежем Bluetooth-поиске.",
"physical-command-recovery-target-not-retained": "Сохранённый Bluetooth-контекст относится не к исходному K1.",
"physical-command-recovery-target-mismatch": "Выбрано другое устройство или другой способ связи, чем в незавершённой физической сессии.",
"physical-command-ledger-corrupt": "Журнал физических команд K1 повреждён.",
"physical-command-ledger-unavailable": "Журнал физических команд K1 недоступен.",
"physical-control-authority-unavailable": "Управляющая связь не позволяет безопасно отправить физический STOP.",
"ble-runtime-restart-required": "BLE-контур требует контролируемого перезапуска.",
"ble-runtime-cleanup-pending": "BLE-контур завершает предыдущую операцию.",
"ble-runtime-busy": "BLE-контур занят другой операцией.",
"k1-lifecycle-process-lease-network-owned": "Сетевой переход K1 ещё владеет исполнительным контуром.",
"k1-lifecycle-process-lease-control-owned": "Управляющая сессия K1 ещё владеет исполнительным контуром.",
"local-acquisition-receiver-not-active": "Активного локального приёмника сейчас нет.",
"physical-stop-is-authoritative": "Доступна подтверждённая физическая остановка K1; локальная очистка не должна её подменять.",
"action-not-implemented": "Это действие не реализовано и не может быть выполнено.",
"connection-reconfiguration-lifecycle-busy": "Другое действие подключения ещё не завершено.",
"connection-reconfiguration-bridge-only": "Это действие доступно только для подключения Bridge.",
"connection-reconfiguration-current-device-unavailable": "Нет точной привязки текущего устройства для изменения сети.",
"connection-reconfiguration-active": "Сначала завершите или отмените текущий выбор устройства или сети.",
"connection-reconfiguration-required-device-not-observed": "Исходное устройство не найдено в текущем Bluetooth-поиске.",
"connection-reconfiguration-not-active": "Активного изменения устройства или сети уже нет.",
"connection-reconfiguration-process-lease-busy": "Другой локальный процесс ещё управляет подключением устройства.",
"connection-reconfiguration-acquisition-changed": "Состояние приёма изменилось во время подготовки подключения.",
"connection-reconfiguration-revision-conflict": "Выбор устройства или сети уже изменился в другой вкладке.",
"connection-reconfiguration-binding-conflict": "Активное подключение изменилось до выполнения действия.",
"connection-reconfiguration-fresh-scan-required": "Для этого действия нужен новый Bluetooth-поиск.",
"connection-reconfiguration-discovery-conflict": "Результаты Bluetooth-поиска относятся к предыдущему действию.",
"connection-reconfiguration-target-mismatch": "Изменение сети разрешено только для исходного устройства.",
"acquisition-start-operation-active": "Запуск приёма ещё не завершён.",
"control-session-state-unsafe": "Управляющий диалог ещё не достиг безопасного состояния ожидания.",
};
const connectionPolicyNextActionCopy: Record<string, string> = {
"wait-for-operation": "Дождитесь завершения текущей операции и обновите состояние.",
"diagnose-network-ledger": "Не отправляйте новые команды и проверьте журнал сетевой операции.",
"diagnose-physical-command-ledger": "Не повторяйте команду; сначала проверьте журнал физических команд.",
"restart-ble-runtime": "Контролируемо перезапустите локальный BLE-контур и обновите состояние.",
"scan-ble": "Выполните свежий поиск Bluetooth-устройств.",
"observe-fresh-device-network": "Дождитесь автоматического восстановления связи с выбранным K1.",
"observe-current-device-network": "Дождитесь автоматического восстановления связи с тем же K1.",
"observe-configured-device-network": "Дождитесь автоматического восстановления сохранённого подключения K1.",
"recover-current-device-network": "Нажмите «Подключиться заново».",
"inspect-host-network": "Проверьте активную локальную сеть и маршрут этого компьютера.",
"probe-endpoint": "Дождитесь обновления подключения K1.",
"verify-control-device-info": "Подключитесь заново к выбранному K1.",
"select-connection-intent": "Выберите способ подключения и заново подтвердите текущий K1.",
"start-acquisition": "Повторно откройте финальное подтверждение физического START.",
"stop-acquisition": "Остановите K1 через подтверждённую физическую остановку.",
"stop-local-receiver": "Завершите локальный приём; физическое состояние K1 проверьте вручную.",
"retire-unavailable-physical-target": "Явно исключите недоступный прежний K1 перед новым выбором.",
"manual-recovery-required": "Автоматически безопасного продолжения нет; проверьте состояние K1 вручную.",
"cancel-reconfiguration": "Отмените текущий выбор и вернитесь к обычному восстановлению подключения.",
};
export interface ConnectionPolicyOperatorGuidance {
reason: string;
nextAction: string;
}
const connectionModeSelectionReasonCopy: Record<string, string> = {
"connection-mode-selection-physical-state-unsafe":
"Предыдущая физическая команда K1 осталась без подтверждённого результата. Поэтому способ подключения пока нельзя изменить.",
"connection-mode-selection-control-state-unsafe":
"Текущий управляющий процесс K1 ещё не завершён. После его завершения способ подключения снова станет доступен.",
"connection-mode-selection-lifecycle-busy":
"Текущее действие подключения ещё завершается. После него способ подключения снова станет доступен.",
"connection-reconfiguration-active":
"Сначала завершите или отмените текущий выбор устройства или сети.",
};
const physicalRetirementReasonCopy: Record<string, string> = {
"physical-command-retirement-operation-conflict":
"Сейчас завершается другая операция с физическим состоянием устройства. Дождитесь её завершения и обновите состояние.",
"physical-command-retirement-state-unsafe":
"Состояние предыдущей физической команды изменилось. Обновите состояние перед новым выбором устройства.",
"physical-command-retirement-not-required":
"Предыдущая физическая команда уже разрешена или больше не удерживает выбор устройства. Обновите состояние и продолжите обычное подключение.",
"physical-command-target-retired":
"Предыдущее устройство уже выведено из текущего контура. Можно сразу выполнить новый явный Bluetooth-поиск.",
"network-provision-operation-active":
"Сейчас завершается подключение устройства к сети. Дождитесь результата перед выбором другого устройства.",
"connection-reconfiguration-active":
"Сначала завершите или отмените текущее изменение устройства или сети.",
"control-local-retirement-pending":
"Управляющая сессия ещё освобождает локальные ресурсы. Дождитесь завершения и обновите состояние.",
"acquisition-active":
"Сканирование ещё активно. Сначала остановите его и дождитесь подтверждённого завершения.",
"acquisition-cleanup-pending":
"Локальный приём ещё освобождает ресурсы после остановки. Дождитесь завершения.",
"acquisition-start-operation-active":
"Запуск сканирования ещё не завершён. Дождитесь его результата перед сменой устройства.",
"acquisition-stop-operation-active":
"Остановка сканирования ещё не завершена. Дождитесь её результата перед сменой устройства.",
"local-runtime-active":
"Локальный поток устройства ещё активен или завершается. Дождитесь перехода в состояние ожидания.",
"control-session-state-unsafe":
"Управляющая сессия устройства ещё не завершена. Дождитесь её закрытия и обновите состояние.",
"ble-runtime-busy":
"Bluetooth занят другой операцией устройства. Дождитесь её завершения; новый поиск автоматически не запустится.",
"ble-runtime-cleanup-pending":
"Bluetooth ещё завершает предыдущую операцию. Дождитесь освобождения соединения и обновите состояние.",
"ble-runtime-restart-required":
"Локальный Bluetooth-контур требует контролируемого перезапуска. Команды устройству не отправлялись.",
"k1-lifecycle-process-lease-active":
"Другой локальный процесс ещё завершает действие с устройством. Дождитесь его завершения и обновите состояние.",
"physical-command-ledger-corrupt":
"Журнал физической команды повреждён. Не выбирайте другое устройство до проверки журнала.",
};
const physicalReopenReasonCopy: Record<string, string> = {
...physicalRetirementReasonCopy,
"physical-command-reconciliation-reopen-not-required":
"Это устройство больше не требует возврата из предыдущего выбора. Обновите состояние и продолжите обычное подключение.",
"physical-command-reconciliation-reopen-target-not-observed":
"Предыдущее устройство не найдено в последнем Bluetooth-поиске. Обновите поиск после проверки питания K1.",
"physical-command-reconciliation-reopen-target-not-connectable":
"Предыдущее устройство найдено, но сейчас не принимает Bluetooth-подключение. Проверьте питание K1 и повторите явный поиск.",
"physical-command-reconciliation-reopen-candidate-ambiguous":
"Последний Bluetooth-поиск не подтвердил один точный экземпляр предыдущего K1. Повторите поиск рядом только с нужным устройством.",
"physical-command-reconciliation-reopen-operation-conflict":
"Другая операция уже меняет локальное состояние предыдущего устройства. Дождитесь её завершения и обновите поиск.",
"device-calibration-read-active":
"Сейчас читается калибровка устройства. Дождитесь завершения проверки перед повторным использованием K1.",
};
/** Human explanation for a backend-disabled topology selector. */
export function connectionModeSelectionGuidance(
state: XgridsK1State | null | undefined,
): string | null {
const selection = state?.connection_lifecycle?.mode_selection;
if (!selection) return null;
if (selection?.allowed === true) return null;
const reasonCode = selection?.reason_codes.find((code) => code.trim().length > 0);
return reasonCode
? connectionModeSelectionReasonCopy[reasonCode]
?? "Способ подключения пока недоступен, потому что состояние K1 не позволяет безопасно изменить его."
: "Способ подключения пока недоступен, потому что состояние K1 не позволяет безопасно изменить его."
}
/** Human explanation for a currently unavailable local-only device escape. */
export function physicalRetirementGuidance(
state: XgridsK1State | null | undefined,
): string | null {
const retirement = (
state?.physical_command
?? state?.application_control_session?.physical_command
?? null
)?.operator_retirement;
if (!retirement || retirement.allowed === true) return null;
const reasonCode = retirement.reason_codes.find((code) => code.trim().length > 0);
return reasonCode
? physicalRetirementReasonCopy[reasonCode]
?? "Выбор другого устройства пока небезопасен. Обновите состояние после завершения текущей операции."
: "Выбор другого устройства пока небезопасен. Обновите состояние после завершения текущей операции.";
}
/** Human explanation for a backend-disabled exact retired-device reopen. */
export function physicalReopenGuidance(
state: XgridsK1State | null | undefined,
): string | null {
const reopen = state?.physical_command?.operator_reconciliation_reopen;
if (!reopen || reopen.allowed === true) return null;
const reasonCode = reopen.reason_codes.find((code) => code.trim().length > 0);
return reasonCode
? physicalReopenReasonCopy[reasonCode]
?? "Повторная проверка предыдущего K1 сейчас небезопасна. Дождитесь завершения текущей операции и обновите поиск."
: "Повторная проверка предыдущего K1 сейчас небезопасна. Обновите поиск после завершения текущей операции.";
}
export function connectionPolicyOperatorGuidance(
state: XgridsK1State | null | undefined,
action: XgridsConnectionPolicyAction,
): ConnectionPolicyOperatorGuidance | null {
if (connectionPolicyAllows(state, action)) return null;
const decision = connectionPolicyDecision(state, action);
const reasonCode = decision?.reason_codes.find((code) => code.trim().length > 0);
const recommendedAction = state?.connection_policy?.recommended_action?.trim();
return {
reason: reasonCode
? connectionPolicyReasonCopy[reasonCode]
?? "Система временно запретила действие до восстановления подтверждённого состояния."
: "Подтверждённая политика действия ещё не получена.",
nextAction: recommendedAction
? connectionPolicyNextActionCopy[recommendedAction]
?? "Обновите состояние подключения и следуйте рекомендованному безопасному действию."
: "Обновите состояние подключения перед новым действием.",
};
}
const phaseLabels: Record<string, string> = {
idle: "Ожидание",
@@ -30,3 +30,24 @@ export function validateProjectName(input: string): ProjectNameValidation {
}
return { value, error: null };
}
export function projectNameAfterConnectionModeSelection(
preparedProjectName: string | null | undefined,
): string {
return preparedProjectName ?? "";
}
export function shouldHydratePreparedProject({
acquisitionId,
hydratedAcquisitionId,
modeSwitchRequired,
}: {
acquisitionId: string | null;
hydratedAcquisitionId: string | null;
modeSwitchRequired: boolean;
}): boolean {
return Boolean(
acquisitionId
&& !(hydratedAcquisitionId === acquisitionId && modeSwitchRequired),
);
}
@@ -8,11 +8,19 @@ import {
type MissionRuntimeState,
} from "@mission-core/plugin-sdk";
import {
activeConnectionEndpointLabel,
canonicalDeviceConnectivity,
confirmedRuntimeSourceMode,
effectiveAcquisition,
hasAuthoritativeData,
hasControlAuthority,
normalizeRuntimePhase,
spatialSourceId,
} from "./lifecycle";
import {
activeStreamRecoveryOwnsPresentationDecision,
activeStreamRecoveryPresentationAuthority,
} from "./activeStreamRecovery";
import { localizeRuntimeMessage } from "./messages";
import { xgridsK1Manifest } from "./manifest";
import { deviceTelemetry, finiteMetric, pipelineLatency } from "./presentation";
@@ -23,13 +31,21 @@ export type XgridsK1Controller = ReturnType<typeof useXgridsK1Runtime>;
const XgridsK1RuntimeContext = createContext<XgridsK1Controller | null>(null);
function normalizeState(
controller: XgridsK1Controller,
export function normalizeXgridsK1MissionState(
controller: Pick<XgridsK1Controller, "state">,
activeModel: DeviceModelDefinition,
): MissionRuntimeState | null {
const state = controller.state;
if (!state) return null;
const metrics = state.metrics;
const controlAuthoritative = hasControlAuthority(state);
const recoveryPresentationAuthority = activeStreamRecoveryPresentationAuthority(state);
const recoveryOwnsPresentation = activeStreamRecoveryOwnsPresentationDecision(state);
const dataAuthoritative = hasAuthoritativeData(state) && !recoveryOwnsPresentation;
const recoveryPresentationAuthoritative = recoveryPresentationAuthority !== null;
const replayAuthoritative = state.source_mode === "replay";
const metrics = replayAuthoritative || dataAuthoritative
? state.metrics
: undefined;
const telemetry = deviceTelemetry(metrics);
const deviceRef = state.device_ref;
const deviceSession = state.device_session;
@@ -43,13 +59,13 @@ function normalizeState(
return {
phase: normalizeRuntimePhase(state),
message: localizeRuntimeMessage(state.message),
activeDevice: deviceRef
activeDevice: deviceRef && controlAuthoritative
? {
pluginId: xgridsK1Manifest.metadata.id,
modelId: deviceRef.model_id || activeModel.id,
displayName: activeModel.displayName,
instanceId: deviceRef.device_id,
endpointLabel: state.k1_ip,
endpointLabel: activeConnectionEndpointLabel(state),
}
: null,
deviceSession: deviceSession
@@ -57,7 +73,7 @@ function normalizeState(
sessionId: deviceSession.device_session_id,
deviceId: deviceSession.device_id,
compatibilityProfileId: deviceSession.compatibility_profile_id,
connectivity: deviceSession.connectivity,
connectivity: canonicalDeviceConnectivity(state),
}
: null,
acquisition: acquisition
@@ -80,7 +96,9 @@ function normalizeState(
stageCode: operation.stage_code,
messageCode: operation.message_code,
})),
spatialSource: sourceUrl && resolvedSpatialSourceId
spatialSource: sourceUrl
&& resolvedSpatialSourceId
&& (replayAuthoritative || dataAuthoritative || recoveryPresentationAuthoritative)
? {
id: resolvedSpatialSourceId,
url: sourceUrl,
@@ -102,7 +120,9 @@ function normalizeState(
range: null,
},
viewerSettings: state.viewer_settings,
sourceMode: confirmedRuntimeSourceMode(state),
sourceMode: recoveryPresentationAuthoritative
? "live"
: confirmedRuntimeSourceMode(state),
metrics: {
publishedFrameCount: (
Number.isSafeInteger(metrics?.pcl_frames) &&
@@ -140,10 +160,13 @@ export function XgridsK1RuntimeProvider({
const inheritedRuntime = useMissionRuntime();
const controller = useXgridsK1Runtime(active);
const missionRuntime: MissionRuntimeController = {
state: activeModel ? normalizeState(controller, activeModel) : null,
state: activeModel
? normalizeXgridsK1MissionState(controller, activeModel)
: null,
backendStatus: controller.backendStatus,
pendingAction: controller.pendingAction,
refresh: controller.refresh,
refresh: () => controller.refresh().then(() => undefined),
resetConnectionScenario: controller.resetConnectionScenario,
updateViewerSettings: controller.updateViewerSettings,
setObservationSourceActive: controller.setObservationSourceActive,
};
@@ -10,6 +10,66 @@ function deviceSessionScope(state: XgridsK1State): string | null {
return typeof sessionId === "string" && sessionId.trim() ? sessionId : null;
}
interface RuntimeSnapshotStamp {
startedAtMonotonicNs: bigint | null;
startedAtEpochMs: number | null;
runtimeId: string;
revision: number;
}
function monotonicNanoseconds(value: string | null | undefined): bigint | null {
if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value)) return null;
try {
return BigInt(value);
} catch {
return null;
}
}
function runtimeSnapshotStamp(state: XgridsK1State): RuntimeSnapshotStamp | null {
const startedAt = state.snapshot_runtime_started_at_utc;
const startedAtMonotonicNs = monotonicNanoseconds(
state.snapshot_runtime_started_monotonic_ns,
);
const runtimeId = state.snapshot_runtime_id;
const revision = monotonicInteger(state.snapshot_revision);
if (
typeof runtimeId !== "string"
|| !runtimeId.trim()
|| revision === null
) {
return null;
}
const parsedEpochMs = typeof startedAt === "string" ? Date.parse(startedAt) : Number.NaN;
const startedAtEpochMs = Number.isFinite(parsedEpochMs) ? parsedEpochMs : null;
if (startedAtMonotonicNs === null && startedAtEpochMs === null) return null;
return { startedAtMonotonicNs, startedAtEpochMs, runtimeId, revision };
}
function stampedSnapshotIsAtLeastAsNew(
current: RuntimeSnapshotStamp,
incoming: RuntimeSnapshotStamp,
): boolean {
if (incoming.runtimeId === current.runtimeId) {
return incoming.revision >= current.revision;
}
if (incoming.startedAtMonotonicNs !== null || current.startedAtMonotonicNs !== null) {
if (incoming.startedAtMonotonicNs === null) return false;
if (current.startedAtMonotonicNs === null) return true;
return incoming.startedAtMonotonicNs > current.startedAtMonotonicNs;
}
if (
incoming.startedAtEpochMs !== null
&& current.startedAtEpochMs !== null
&& incoming.startedAtEpochMs !== current.startedAtEpochMs
) {
return incoming.startedAtEpochMs > current.startedAtEpochMs;
}
// Legacy UTC-only process identities with equal timestamps cannot be
// ordered safely. Keep the already accepted authority.
return false;
}
function cameraSnapshotIsAtLeastAsNew(
current: XgridsCameraPreviewState,
incoming: XgridsCameraPreviewState,
@@ -43,6 +103,17 @@ export function selectMonotonicXgridsState(
current: XgridsK1State | null,
incoming: XgridsK1State,
): XgridsK1State {
if (!current) return incoming;
const currentStamp = runtimeSnapshotStamp(current);
const incomingStamp = runtimeSnapshotStamp(incoming);
if (currentStamp || incomingStamp) {
if (!currentStamp) return incoming;
if (!incomingStamp) return current;
return stampedSnapshotIsAtLeastAsNew(currentStamp, incomingStamp)
? incoming
: current;
}
if (current && deviceSessionScope(current) !== deviceSessionScope(incoming)) {
return incoming;
}
+587 -34
View File
@@ -1,9 +1,27 @@
/* All selectors below are scoped to the XGRIDS frontend contribution. */
.xgrids-k1-plugin {
container: xgrids-k1 / inline-size;
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
*,
*::before,
*::after {
box-sizing: border-box;
}
> * {
min-width: 0;
max-width: 100%;
}
.device-workspace__grid {
display: grid;
min-width: 0;
grid-template-columns: minmax(23rem, 0.78fr) minmax(34rem, 1.22fr);
max-width: 100%;
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 0.85rem;
}
@@ -11,6 +29,7 @@
.device-workspace__side {
display: grid;
min-width: 0;
max-width: 100%;
gap: 0.85rem;
}
@@ -18,9 +37,114 @@
.status-panel,
.latency-panel,
.session-panel {
min-width: 0;
max-width: 100%;
background: var(--station-panel);
}
/* Every layout hop between the plugin root and the canonical controls must be
shrinkable. A single auto min-size in this chain lets topology/status text
establish a wider intrinsic track and paint the provisioning job over the
acquisition job even though the outer grid itself uses minmax(0, 1fr). */
.workspace-lead,
.metrics-grid,
.error-banner,
.wizard-list,
.wizard-step,
.field-stack,
.session-form,
.scan-configuration-grid,
.device-list,
.device-row,
.diagnostics-grid,
.detail-list {
min-width: 0;
max-width: 100%;
}
.metrics-grid > *,
.device-workspace__grid > *,
.device-workspace__side > *,
.scan-configuration-grid > *,
.diagnostics-grid > * {
min-width: 0;
max-width: 100%;
}
.error-banner > div {
min-width: 0;
}
.error-banner__copy {
display: grid;
gap: 0.2rem;
}
.error-banner--compact {
align-items: start;
padding-block: 0.7rem;
}
.error-banner__recovery-actions {
display: grid;
gap: 0.45rem;
margin-top: 0.55rem;
}
.error-banner__details {
margin-top: 0.35rem;
color: var(--nodedc-text-secondary);
font-size: 0.66rem;
}
.error-banner__details summary {
width: fit-content;
color: var(--nodedc-text-tertiary);
cursor: pointer;
}
.workspace-lead__status,
.workspace-lead__status > span,
.panel-heading > div,
.panel-heading h2,
.wizard-step__content,
.wizard-step__content > header,
.wizard-step__content > header h3,
.connection-summary span,
.nodedc-field__description,
.empty-device-list,
.retained-recovery-target small,
.session-footer p {
min-width: 0;
}
.workspace-lead__status > span,
.panel-heading h2,
.wizard-step__content > header h3,
.connection-summary span,
.nodedc-field__description,
.empty-device-list,
.retained-recovery-target small,
.session-footer p {
overflow-wrap: anywhere;
}
.workspace-lead p,
.error-banner p,
.step-copy,
.safety-note,
.live-instruction {
overflow-wrap: anywhere;
}
.connection-panel {
container: k1-connection-panel / inline-size;
}
.session-panel {
container: k1-session-panel / inline-size;
}
.error-banner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
@@ -58,12 +182,54 @@
line-height: 1.45;
}
.error-banner__diagnostic {
display: grid;
min-width: 0;
max-width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
margin: 0.45rem 0 0;
}
.error-banner__diagnostic > div {
min-width: 0;
}
.error-banner__diagnostic dt,
.error-banner__diagnostic dd {
margin: 0;
overflow-wrap: anywhere;
}
.error-banner__diagnostic dt {
color: var(--nodedc-text-tertiary);
font-size: 0.58rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.error-banner__diagnostic dd {
margin-top: 0.12rem;
color: var(--nodedc-text-secondary);
font-size: 0.66rem;
line-height: 1.4;
}
.error-banner__actions {
display: flex;
min-width: 0;
max-width: 100%;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
}
.error-banner__actions > .nodedc-button {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
}
.wizard-list {
display: grid;
margin-top: 1.4rem;
@@ -71,6 +237,8 @@
.configuration-anchor {
display: grid;
min-width: 0;
max-width: 100%;
gap: 0.55rem;
margin-top: 1.2rem;
border-radius: 0.95rem;
@@ -81,6 +249,35 @@
.configuration-anchor .nodedc-select-anchor,
.configuration-field .nodedc-select-anchor {
width: 100%;
min-width: 0;
max-width: 100%;
}
.connection-topology-summary {
display: grid;
min-width: 0;
max-width: 100%;
gap: 0.42rem;
}
.connection-topology-summary .connection-summary {
min-width: 0;
max-width: 100%;
margin: 0;
}
.connection-summary__value {
display: flex;
min-width: 0;
max-width: 100%;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
.connection-summary__value strong {
min-width: 0;
max-width: 100%;
}
.wizard-step {
@@ -219,19 +416,26 @@
}
.device-row__identity strong {
overflow: hidden;
min-width: 0;
max-width: 100%;
overflow: visible;
overflow-wrap: anywhere;
font-size: 0.69rem;
text-overflow: ellipsis;
white-space: nowrap;
text-overflow: clip;
white-space: normal;
}
.device-row code,
.detail-row code {
overflow: hidden;
display: block;
min-width: 0;
max-width: 100%;
overflow: visible;
overflow-wrap: anywhere;
color: var(--nodedc-text-muted);
font-size: 0.58rem;
text-overflow: ellipsis;
white-space: nowrap;
text-overflow: clip;
white-space: normal;
}
.device-row__signal {
@@ -242,7 +446,7 @@
background: var(--nodedc-text-muted);
}
.device-row[data-compatible="true"] .device-row__signal {
.device-row[data-likely-k1="true"] .device-row__signal {
background: rgb(var(--nodedc-success-rgb));
}
@@ -260,14 +464,81 @@
line-height: 1.45;
}
.retained-recovery-target {
display: grid;
min-width: 0;
gap: 0.42rem;
margin-top: 0.62rem;
border-radius: 0.95rem;
background: rgb(255 255 255 / 0.025);
padding: 0.78rem;
}
.retained-recovery-target > div {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.55rem;
}
.retained-recovery-target span,
.retained-recovery-target small {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
line-height: 1.45;
}
.retained-recovery-target code {
min-width: 0;
overflow-wrap: anywhere;
color: var(--nodedc-text-secondary);
font-size: 0.58rem;
}
.field-stack,
.session-form {
display: grid;
gap: 0.85rem;
}
.password-field-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 0.55rem;
}
.connection-recovery-choice {
display: grid;
min-width: 0;
max-width: 100%;
gap: 0.65rem;
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.95rem;
background: rgb(255 255 255 / 0.025);
padding: 0.85rem;
}
.connection-recovery-choice > strong {
color: var(--nodedc-text-primary);
font-size: 0.72rem;
line-height: 1.4;
}
.connection-recovery-choice > .safety-note {
margin: 0;
}
.connection-recovery-choice--retirement {
border-color: rgb(var(--nodedc-danger-rgb) / 0.24);
}
.connection-summary {
display: flex;
min-width: 0;
max-width: 100%;
align-items: center;
justify-content: space-between;
gap: 1rem;
@@ -283,6 +554,8 @@
}
.connection-summary strong {
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -296,6 +569,61 @@
margin-top: 1rem;
}
.active-stream-recovery {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
.active-stream-recovery__state {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 0.75rem;
border-radius: 0.85rem;
background: rgb(255 255 255 / 0.03);
padding: 0.85rem;
}
.active-stream-recovery__state--static {
grid-template-columns: minmax(0, 1fr);
}
.active-stream-recovery__state > .nodedc-activity-indicator {
margin-top: 0.12rem;
}
.active-stream-recovery__copy {
display: grid;
min-width: 0;
gap: 0.28rem;
}
.active-stream-recovery__copy strong {
color: var(--nodedc-text-primary);
font-size: 0.72rem;
line-height: 1.4;
}
.active-stream-recovery__copy span,
.active-stream-recovery__copy small,
.active-stream-recovery__actions p {
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
line-height: 1.5;
overflow-wrap: anywhere;
}
.active-stream-recovery__copy small {
color: var(--nodedc-text-secondary);
}
.active-stream-recovery__actions {
display: grid;
gap: 0.55rem;
}
.scan-configuration-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -375,11 +703,12 @@
}
.detail-row dd {
overflow: hidden;
overflow: visible;
overflow-wrap: anywhere;
color: var(--nodedc-text-secondary);
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
text-overflow: clip;
white-space: normal;
}
.inline-state {
@@ -449,25 +778,106 @@
}
}
@media (max-width: 1480px) {
/* Keep both jobs usable before admitting the split composition: the
provisioning column retains 32 rem and the acquisition column 38 rem.
Below their combined working width the panels stack instead of squeezing
and letting intrinsic text paint into the neighbouring surface. */
@container xgrids-k1 (min-width: 78rem) {
.xgrids-k1-plugin .device-workspace__grid {
grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr);
grid-template-columns: minmax(32rem, 0.8fr) minmax(38rem, 1.2fr);
}
}
@media (max-width: 1280px) {
.xgrids-k1-plugin .device-workspace__grid {
grid-template-columns: 1fr;
@container k1-connection-panel (max-width: 48rem) {
.xgrids-k1-plugin .panel-heading,
.xgrids-k1-plugin .wizard-step__content > header {
min-width: 0;
align-items: flex-start;
flex-wrap: wrap;
}
.xgrids-k1-plugin .panel-heading > div,
.xgrids-k1-plugin .wizard-step__content > header h3 {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
}
.xgrids-k1-plugin .panel-heading > .nodedc-status,
.xgrids-k1-plugin .wizard-step__content > header > .nodedc-status,
.xgrids-k1-plugin .connection-summary__value > .nodedc-status,
.xgrids-k1-plugin .retained-recovery-target .nodedc-status {
max-width: 100%;
flex: 0 1 auto;
line-height: 1.35;
text-align: left;
white-space: normal;
}
.xgrids-k1-plugin .connection-summary,
.xgrids-k1-plugin .connection-summary--topology,
.xgrids-k1-plugin .connection-summary__value {
align-items: stretch;
flex-direction: column;
}
.xgrids-k1-plugin .connection-summary__value {
justify-content: flex-start;
}
.xgrids-k1-plugin .connection-summary strong {
overflow-wrap: anywhere;
text-overflow: clip;
white-space: normal;
}
.xgrids-k1-plugin .device-row__action {
align-items: stretch;
flex-direction: column;
}
.xgrids-k1-plugin .device-row__name {
flex-wrap: wrap;
}
.xgrids-k1-plugin .device-row__name small {
flex: 1 1 100%;
}
.xgrids-k1-plugin .retained-recovery-target > div {
align-items: flex-start;
flex-direction: column;
}
}
@media (max-width: 1040px) {
@container xgrids-k1 (max-width: 65rem) {
.xgrids-k1-plugin .diagnostics-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
@container k1-session-panel (max-width: 48rem) {
.xgrids-k1-plugin .panel-heading {
min-width: 0;
align-items: flex-start;
flex-wrap: wrap;
}
.xgrids-k1-plugin .panel-heading > div {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
}
.xgrids-k1-plugin .panel-heading > .nodedc-status {
max-width: 100%;
flex: 0 1 auto;
line-height: 1.35;
text-align: left;
white-space: normal;
}
.xgrids-k1-plugin .scan-configuration-grid {
grid-template-columns: 1fr;
}
@@ -480,12 +890,33 @@
grid-column: auto;
}
.xgrids-k1-plugin .session-footer,
.xgrids-k1-plugin .error-banner {
.xgrids-k1-plugin .session-footer {
align-items: stretch;
grid-template-columns: 1fr;
flex-direction: column;
}
}
@container xgrids-k1 (max-width: 48rem) {
.xgrids-k1-plugin .workspace-lead,
.xgrids-k1-plugin .panel-heading,
.xgrids-k1-plugin .wizard-step__content > header {
min-width: 0;
align-items: flex-start;
flex-wrap: wrap;
}
.xgrids-k1-plugin .workspace-lead > div,
.xgrids-k1-plugin .workspace-lead__status,
.xgrids-k1-plugin .panel-heading > div,
.xgrids-k1-plugin .wizard-step__content > header h3 {
min-width: 0;
max-width: 100%;
}
.xgrids-k1-plugin .workspace-lead__status {
align-items: flex-start;
text-align: left;
}
.xgrids-k1-plugin .error-banner {
grid-template-columns: auto minmax(0, 1fr);
@@ -493,12 +924,57 @@
.xgrids-k1-plugin .error-banner__actions {
grid-column: 2;
min-width: 0;
flex-wrap: wrap;
justify-content: flex-end;
}
.xgrids-k1-plugin .device-row__action {
.xgrids-k1-plugin .error-banner__diagnostic {
grid-template-columns: 1fr;
gap: 0.4rem;
}
.xgrids-k1-plugin .retained-recovery-target > div {
align-items: flex-start;
flex-direction: column;
}
}
@container xgrids-k1 (max-width: 32rem) {
.xgrids-k1-plugin .wizard-step {
grid-template-columns: 1.75rem minmax(0, 1fr);
gap: 0.55rem;
}
.xgrids-k1-plugin .wizard-step__rail span {
width: 1.75rem;
height: 1.75rem;
}
.xgrids-k1-plugin .detail-row {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.xgrids-k1-plugin .detail-row dd {
overflow-wrap: anywhere;
text-align: left;
white-space: normal;
}
.xgrids-k1-plugin .error-banner {
grid-template-columns: 1fr;
}
.xgrids-k1-plugin .error-banner__dot {
display: none;
}
.xgrids-k1-plugin .error-banner__actions {
grid-column: 1;
align-items: stretch;
flex-direction: column;
justify-content: flex-start;
}
}
@@ -517,6 +993,57 @@
backdrop-filter: blur(20px);
}
.xgrids-k1-spatial-controls--recovery {
display: grid;
min-width: min(46rem, 100%);
grid-template-columns: minmax(0, 1fr);
gap: 0.45rem;
padding: 0.65rem 0.75rem;
}
.active-stream-recovery__compact-heading {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.active-stream-recovery__compact-heading > span:first-child {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.5rem;
font-weight: 650;
letter-spacing: 0.14em;
text-overflow: ellipsis;
white-space: nowrap;
}
.active-stream-recovery--compact {
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.65rem;
margin-top: 0;
}
.active-stream-recovery--compact .active-stream-recovery__state {
min-width: 0;
background: transparent;
padding: 0;
}
.active-stream-recovery--compact .active-stream-recovery__actions {
max-width: 15rem;
grid-template-columns: auto;
gap: 0.25rem;
}
.active-stream-recovery--compact .active-stream-recovery__actions p {
font-size: 0.5rem;
line-height: 1.35;
}
.xgrids-k1-spatial-controls__phase {
display: flex;
min-width: 11rem;
@@ -547,16 +1074,6 @@
font-size: 0.53rem;
}
.xgrids-k1-spatial-controls__spinner {
width: 0.82rem;
height: 0.82rem;
flex: 0 0 0.82rem;
border: 1px solid rgb(255 255 255 / 0.16);
border-top-color: var(--nodedc-text-primary);
border-radius: 50%;
animation: xgrids-k1-spin 900ms linear infinite;
}
.xgrids-k1-spatial-controls__telemetry {
display: flex;
flex: 0 1 auto;
@@ -598,8 +1115,32 @@
white-space: nowrap;
}
@keyframes xgrids-k1-spin {
to { transform: rotate(360deg); }
.xgrids-k1-spatial-controls__action-label {
display: block;
width: 9.75rem;
font-size: 0.66rem;
line-height: 1.08;
text-align: center;
white-space: normal;
}
.xgrids-k1-spatial-controls__action-label--local {
width: 8.75rem;
}
.connection-action-progress {
display: flex;
min-height: 2.75rem;
width: 100%;
align-items: center;
justify-content: center;
gap: 0.65rem;
color: var(--nodedc-text-secondary);
}
.connection-action-progress strong {
font-size: 0.78rem;
font-weight: 600;
}
@media (max-width: 960px) {
@@ -612,4 +1153,16 @@
.xgrids-k1-spatial-controls__error small {
display: none;
}
.active-stream-recovery--compact {
grid-template-columns: minmax(0, 1fr);
}
.active-stream-recovery--compact .active-stream-recovery__actions {
max-width: none;
}
.active-stream-recovery--compact .active-stream-recovery__actions p {
display: none;
}
}
File diff suppressed because it is too large Load Diff
+406 -172
View File
@@ -1,6 +1,7 @@
import AppKit
import CoreWLAN
import CryptoKit
import Foundation
import LocalAuthentication
import Security
private let keychainService = "NODEDC Mission Core Host Wi-Fi Profiles"
@@ -14,6 +15,8 @@ private struct HostWifiRequest: Decodable {
let password: String?
let scanTimeoutSeconds: Double?
let credentialSourceID: String?
let interfaceName: String?
let continuityKeyHex: String?
enum CodingKeys: String, CodingKey {
case action
@@ -22,6 +25,8 @@ private struct HostWifiRequest: Decodable {
case password
case scanTimeoutSeconds = "scan_timeout_seconds"
case credentialSourceID = "credential_source_id"
case interfaceName = "interface_name"
case continuityKeyHex = "continuity_key_hex"
}
}
@@ -60,6 +65,9 @@ private struct HostWifiResponse: Encodable {
let scanAttemptCount: Int?
let scanElapsedMilliseconds: Int?
let credentialSource: String?
let wifiInterface: Bool?
let associationIdentity: String?
let associationEvidence: String?
let reasonCode: String?
enum CodingKeys: String, CodingKey {
@@ -73,6 +81,9 @@ private struct HostWifiResponse: Encodable {
case scanAttemptCount = "scan_attempt_count"
case scanElapsedMilliseconds = "scan_elapsed_ms"
case credentialSource = "credential_source"
case wifiInterface = "wifi_interface"
case associationIdentity = "association_identity"
case associationEvidence = "association_evidence"
case reasonCode = "reason_code"
}
}
@@ -88,6 +99,9 @@ private func emit(
scanAttemptCount: Int? = nil,
scanElapsedMilliseconds: Int? = nil,
credentialSource: String? = nil,
wifiInterface: Bool? = nil,
associationIdentity: String? = nil,
associationEvidence: String? = nil,
reasonCode: String? = nil,
exitCode: Int32
) -> Never {
@@ -102,6 +116,9 @@ private func emit(
scanAttemptCount: scanAttemptCount,
scanElapsedMilliseconds: scanElapsedMilliseconds,
credentialSource: credentialSource,
wifiInterface: wifiInterface,
associationIdentity: associationIdentity,
associationEvidence: associationEvidence,
reasonCode: reasonCode
)
if let data = try? JSONEncoder().encode(response) {
@@ -148,10 +165,75 @@ private func materialKeychainQuery(sourceID: String) -> [String: Any] {
return keychainQuery(service: credentialMaterialKeychainService, account: sourceID)
}
private func loadProfile(profileID: String) throws -> StoredProfile {
private func nonInteractiveAuthenticationContext() -> LAContext {
let context = LAContext()
context.interactionNotAllowed = true
return context
}
private func keychainItemExists(service: String, account: String) throws -> Bool {
var query = keychainQuery(service: service, account: account)
query[kSecReturnAttributes as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
// Preflight is deliberately non-interactive. Authorization prompts belong
// only to an explicit enrollment/migration step, never to a K1 network
// mutation that has already been admitted by the browser.
query[kSecUseAuthenticationContext as String] = nonInteractiveAuthenticationContext()
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess {
return true
}
if status == errSecItemNotFound {
return false
}
throw NSError(domain: "HostWifiKeychainMetadata", code: Int(status))
}
private func keychainReasonCode(_ error: Error, missing: String) -> String {
let status = OSStatus((error as NSError).code)
switch status {
case errSecItemNotFound:
return missing
case errSecInteractionNotAllowed:
return "keychain-authorization-required"
case errSecAuthFailed:
return "keychain-authorization-denied"
case errSecUserCanceled:
return "keychain-authorization-cancelled"
default:
return "keychain-access-failed"
}
}
private func coreWLANReasonCode(_ error: Error) -> String {
let nsError = error as NSError
guard nsError.domain == CWErrorDomain else {
return "corewlan-error"
}
// Stable CWErr values from Apple's CoreWLANTypes contract. Export only a
// reviewed failure class; NSError descriptions may contain host details.
switch nsError.code {
case -3930: // kCWOperationNotPermittedErr
return "corewlan-authorization-denied"
case -3905, -3925: // kCWTimeoutErr, kCWSupplicantTimeoutErr
return "host-wifi-operation-timeout"
default:
return "corewlan-error"
}
}
private func loadProfile(
profileID: String,
interactionAllowed: Bool = true
) throws -> StoredProfile {
var query = profileKeychainQuery(profileID: profileID)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
if !interactionAllowed {
query[kSecUseAuthenticationContext as String] = nonInteractiveAuthenticationContext()
}
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
@@ -191,10 +273,16 @@ private func storeProfile(profileID: String, profile: StoredProfile) throws {
}
}
private func loadCredentialMaterial(sourceID: String) throws -> StoredCredentialMaterial {
private func loadCredentialMaterial(
sourceID: String,
interactionAllowed: Bool = true
) throws -> StoredCredentialMaterial {
var query = materialKeychainQuery(sourceID: sourceID)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
if !interactionAllowed {
query[kSecUseAuthenticationContext as String] = nonInteractiveAuthenticationContext()
}
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
@@ -237,25 +325,6 @@ private func storeCredentialMaterial(
}
}
private func loadSystemWiFiProfile(ssid: String, ssidData: Data) -> StoredProfile? {
var password: NSString?
let status = CWKeychainFindWiFiPassword(
CWKeychainDomain.user,
ssidData,
&password
)
guard status == errSecSuccess, let password else {
return nil
}
let profile = StoredProfile(
schemaVersion: 1,
ssid: ssid,
password: password as String,
credentialSource: "system-wifi-keychain"
)
return profileIsValid(profile) ? profile : nil
}
private struct TargetedScanResult {
let network: CWNetwork?
let attemptCount: Int
@@ -298,26 +367,57 @@ private func scanForExpectedNetwork(
}
}
private func promptForDevicePassword(ssid: String) -> String? {
let application = NSApplication.shared
application.setActivationPolicy(.accessory)
let passwordField = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 360, height: 24))
passwordField.placeholderString = "Пароль точки доступа K1"
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = "Первое подключение к \(ssid)"
alert.informativeText = "macOS не нашла локальный профиль этой точки доступа. Если credential вам неизвестен, нажмите «Отмена» и выполните авторизованный импорт device-профиля LixelGO. Введённое значение будет сохранено только в Keychain этого Mac и не попадёт в браузер, API, журнал или evidence Mission Core."
alert.accessoryView = passwordField
alert.addButton(withTitle: "Подключиться")
alert.addButton(withTitle: "Отмена")
application.activate(ignoringOtherApps: true)
guard alert.runModal() == .alertFirstButtonReturn else {
private func decodeContinuityKey(_ value: String) -> Data? {
let bytes = Array(value.utf8)
guard bytes.count == 64 else {
return nil
}
return passwordField.stringValue
func nibble(_ byte: UInt8) -> UInt8? {
switch byte {
case 48 ... 57:
return byte - 48
case 97 ... 102:
return byte - 87
default:
return nil
}
}
var decoded = Data(capacity: 32)
for offset in stride(from: 0, to: bytes.count, by: 2) {
guard let high = nibble(bytes[offset]), let low = nibble(bytes[offset + 1]) else {
return nil
}
decoded.append((high << 4) | low)
}
return decoded
}
private func appendLengthPrefixed(_ value: String, to material: inout Data) {
let data = Data(value.utf8)
var length = UInt32(data.count).bigEndian
withUnsafeBytes(of: &length) { bytes in
material.append(contentsOf: bytes)
}
material.append(data)
}
private func associationIdentity(
continuityKey: Data,
interfaceName: String,
bssid: String
) -> String {
// BSSID is the association identity. SSID visibility is permission- and
// timing-dependent on macOS, so folding it into this token would rotate a
// healthy binding when the same AP alternates between `ssid+bssid` and
// `bssid-only` evidence.
var material = Data("mission-core/host-wifi-association/v2".utf8)
appendLengthPrefixed(interfaceName, to: &material)
appendLengthPrefixed(bssid.lowercased(), to: &material)
let digest = HMAC<SHA256>.authenticationCode(
for: material,
using: SymmetricKey(data: continuityKey)
)
return digest.map { String(format: "%02x", $0) }.joined()
}
private let input = FileHandle.standardInput.readDataToEndOfFile()
@@ -335,6 +435,75 @@ do {
emit(ok: false, reasonCode: "profile-id-invalid", exitCode: 1)
}
if request.action == "inspect-association" {
guard let interfaceName = request.interfaceName,
(1 ... 32).contains(interfaceName.count),
interfaceName.allSatisfy({
$0.isASCII && ($0.isLetter || $0.isNumber || ".-_".contains($0))
}),
let continuityKeyHex = request.continuityKeyHex,
let continuityKey = decodeContinuityKey(continuityKeyHex)
else {
emit(ok: false, reasonCode: "association-inspection-invalid", exitCode: 1)
}
guard let interface = CWWiFiClient.shared().interface(withName: interfaceName) else {
emit(
ok: true,
adapter: "CoreWLAN",
wifiInterface: false,
associationIdentity: associationIdentity(
continuityKey: continuityKey,
interfaceName: interfaceName,
bssid: "not-wifi-interface"
),
associationEvidence: "not-wifi",
exitCode: 0
)
}
guard interface.powerOn(), interface.serviceActive() else {
emit(
ok: true,
adapter: "CoreWLAN",
wifiInterface: true,
associationEvidence: "unavailable",
reasonCode: "wifi-interface-inactive",
exitCode: 0
)
}
let currentSSID = interface.ssid()?.trimmingCharacters(in: .whitespacesAndNewlines)
let currentBSSID = interface.bssid()?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let currentBSSID, !currentBSSID.isEmpty else {
// SSID alone is not an exact association identity: two APs may use
// the same network name. Returning no digest forces the Python
// caller to rotate its fail-closed continuity token.
emit(
ok: true,
adapter: "CoreWLAN",
wifiInterface: true,
associationEvidence: "unavailable",
reasonCode: "association-identity-unavailable",
exitCode: 0
)
}
emit(
ok: true,
adapter: "CoreWLAN",
wifiInterface: true,
associationIdentity: associationIdentity(
continuityKey: continuityKey,
interfaceName: interfaceName,
bssid: currentBSSID
),
associationEvidence: (
currentSSID == nil || currentSSID?.isEmpty == true
? "bssid-only"
: "ssid+bssid"
),
exitCode: 0
)
}
if request.action == "store-profile" {
guard let ssid = request.ssid, let password = request.password else {
emit(ok: false, reasonCode: "credential-missing", exitCode: 1)
@@ -364,20 +533,27 @@ do {
if request.action == "check-credential-material" {
do {
_ = try loadCredentialMaterial(sourceID: request.profileID)
let available = try keychainItemExists(
service: credentialMaterialKeychainService,
account: request.profileID
)
emit(
ok: true,
adapter: "macOS Keychain",
profileAvailable: true,
credentialSource: "exact-firmware-profile",
profileAvailable: available,
credentialSource: available ? "exact-firmware-profile" : nil,
exitCode: 0
)
} catch {
emit(
ok: true,
ok: false,
adapter: "macOS Keychain",
profileAvailable: false,
exitCode: 0
reasonCode: keychainReasonCode(
error,
missing: "credential-source-unavailable"
),
exitCode: 1
)
}
}
@@ -395,55 +571,77 @@ do {
emit(ok: false, reasonCode: "credential-source-invalid", exitCode: 1)
}
let material: StoredCredentialMaterial
do {
material = try loadCredentialMaterial(sourceID: sourceID)
let profileAvailable = try keychainItemExists(
service: keychainService,
account: request.profileID
)
if profileAvailable {
let profile = try loadProfile(
profileID: request.profileID,
interactionAllowed: false
)
guard profile.ssid == ssid else {
emit(
ok: false,
adapter: "macOS Keychain",
profileAvailable: false,
profileEnrolled: false,
reasonCode: "profile-ssid-mismatch",
exitCode: 1
)
}
guard profile.credentialSource == "exact-firmware-profile" else {
emit(
ok: false,
adapter: "macOS Keychain",
profileAvailable: false,
profileEnrolled: false,
reasonCode: "profile-credential-source-mismatch",
exitCode: 1
)
}
emit(
ok: true,
adapter: "macOS Keychain",
profileAvailable: true,
profileEnrolled: false,
credentialSource: "exact-firmware-profile",
exitCode: 0
)
}
} catch {
emit(
ok: true,
ok: false,
adapter: "macOS Keychain",
profileAvailable: false,
profileEnrolled: false,
reasonCode: "credential-source-unavailable",
exitCode: 0
reasonCode: keychainReasonCode(error, missing: "profile-unavailable"),
exitCode: 1
)
}
let material: StoredCredentialMaterial
do {
material = try loadCredentialMaterial(
sourceID: sourceID,
interactionAllowed: false
)
} catch {
emit(
ok: false,
adapter: "macOS Keychain",
profileAvailable: false,
profileEnrolled: false,
reasonCode: keychainReasonCode(
error,
missing: "credential-source-unavailable"
),
exitCode: 1
)
}
do {
let existing = try loadProfile(profileID: request.profileID)
guard existing.ssid == ssid else {
emit(
ok: false,
adapter: "macOS Keychain",
profileAvailable: false,
profileEnrolled: false,
reasonCode: "profile-ssid-mismatch",
exitCode: 1
)
}
if existing.password == material.password,
existing.credentialSource != "exact-firmware-profile" {
try storeProfile(
profileID: request.profileID,
profile: StoredProfile(
schemaVersion: 1,
ssid: ssid,
password: existing.password,
credentialSource: "exact-firmware-profile"
)
)
}
emit(
ok: true,
adapter: "macOS Keychain",
profileAvailable: true,
profileEnrolled: false,
credentialSource: existing.password == material.password
? "exact-firmware-profile"
: (existing.credentialSource ?? "mission-core-keychain"),
exitCode: 0
)
} catch {
try storeProfile(
profileID: request.profileID,
profile: StoredProfile(
@@ -461,39 +659,82 @@ do {
credentialSource: "exact-firmware-profile",
exitCode: 0
)
}
}
if request.action == "check-profile" {
let profile: StoredProfile
do {
profile = try loadProfile(profileID: request.profileID)
} catch {
emit(
ok: true,
adapter: "macOS Keychain",
profileAvailable: false,
exitCode: 0
)
}
if let expectedSSID = request.ssid, profile.ssid != expectedSSID {
emit(
ok: false,
adapter: "macOS Keychain",
profileAvailable: false,
reasonCode: "profile-ssid-mismatch",
profileEnrolled: false,
reasonCode: keychainReasonCode(error, missing: "profile-unavailable"),
exitCode: 1
)
}
emit(
ok: true,
adapter: "macOS Keychain",
profileAvailable: true,
exitCode: 0
)
}
guard request.action == "associate" || request.action == "scan-profile" else {
if request.action == "check-profile" {
guard let expectedSSID = request.ssid,
let expectedSSIDData = expectedSSID.data(using: .utf8),
(1 ... 32).contains(expectedSSIDData.count)
else {
emit(ok: false, reasonCode: "ssid-invalid", exitCode: 1)
}
do {
let available = try keychainItemExists(
service: keychainService,
account: request.profileID
)
if !available {
emit(
ok: true,
adapter: "macOS Keychain",
profileAvailable: false,
exitCode: 0
)
}
let profile = try loadProfile(
profileID: request.profileID,
interactionAllowed: false
)
guard profile.ssid == expectedSSID else {
emit(
ok: false,
adapter: "macOS Keychain",
profileAvailable: false,
reasonCode: "profile-ssid-mismatch",
exitCode: 1
)
}
guard profile.credentialSource == "exact-firmware-profile" else {
emit(
ok: false,
adapter: "macOS Keychain",
profileAvailable: false,
reasonCode: "profile-credential-source-mismatch",
exitCode: 1
)
}
emit(
ok: true,
adapter: "macOS Keychain",
profileAvailable: true,
credentialSource: "exact-firmware-profile",
exitCode: 0
)
} catch {
emit(
ok: false,
adapter: "macOS Keychain",
profileAvailable: false,
reasonCode: keychainReasonCode(error, missing: "profile-unavailable"),
exitCode: 1
)
}
}
guard request.action == "associate"
|| request.action == "associate-ephemeral"
|| request.action == "scan-profile"
else {
emit(ok: false, reasonCode: "action-unsupported", exitCode: 1)
}
guard let interface = CWWiFiClient.shared().interface() else {
@@ -528,7 +769,7 @@ do {
)
}
guard request.action == "associate" else {
guard request.action == "associate" || request.action == "associate-ephemeral" else {
emit(ok: false, reasonCode: "action-unsupported", exitCode: 1)
}
guard let expectedSSID = request.ssid,
@@ -554,83 +795,76 @@ do {
)
}
var profileEnrolled = false
var profileNeedsStore = false
let credentialSource: String
let profile: StoredProfile
do {
profile = try loadProfile(profileID: request.profileID)
credentialSource = profile.credentialSource ?? "mission-core-keychain"
} catch {
if let systemProfile = loadSystemWiFiProfile(
ssid: expectedSSID,
ssidData: expectedSSIDData
) {
profile = systemProfile
credentialSource = "system-wifi-keychain"
profileNeedsStore = true
} else {
guard let password = promptForDevicePassword(ssid: expectedSSID) else {
emit(
ok: false,
scanAttemptCount: scan.attemptCount,
scanElapsedMilliseconds: scan.elapsedMilliseconds,
reasonCode: "credential-entry-cancelled",
exitCode: 1
)
}
guard let passwordData = password.data(using: .utf8),
(1 ... 64).contains(passwordData.count)
else {
emit(
ok: false,
scanAttemptCount: scan.attemptCount,
scanElapsedMilliseconds: scan.elapsedMilliseconds,
reasonCode: "credential-invalid",
exitCode: 1
)
}
profile = StoredProfile(
schemaVersion: 1,
ssid: expectedSSID,
password: password,
credentialSource: "native-secure-prompt"
let associationPassword: String
if request.action == "associate-ephemeral" {
guard let password = request.password,
let passwordData = password.data(using: .utf8),
(1 ... 64).contains(passwordData.count)
else {
emit(
ok: false,
scanAttemptCount: scan.attemptCount,
scanElapsedMilliseconds: scan.elapsedMilliseconds,
reasonCode: "credential-missing",
exitCode: 1
)
credentialSource = "native-secure-prompt"
profileNeedsStore = true
}
}
guard profile.ssid == expectedSSID else {
emit(
ok: false,
scanAttemptCount: scan.attemptCount,
scanElapsedMilliseconds: scan.elapsedMilliseconds,
reasonCode: "profile-ssid-mismatch",
exitCode: 1
)
credentialSource = "operation-memory"
associationPassword = password
} else {
let profile: StoredProfile
do {
// The AP write has already happened. A prepared-host Quick action must
// never trigger a Keychain authorization sheet at this stage.
profile = try loadProfile(
profileID: request.profileID,
interactionAllowed: false
)
guard profile.credentialSource == "exact-firmware-profile" else {
emit(
ok: false,
scanAttemptCount: scan.attemptCount,
scanElapsedMilliseconds: scan.elapsedMilliseconds,
reasonCode: "profile-credential-source-mismatch",
exitCode: 1
)
}
credentialSource = "exact-firmware-profile"
} catch {
emit(
ok: false,
scanAttemptCount: scan.attemptCount,
scanElapsedMilliseconds: scan.elapsedMilliseconds,
reasonCode: keychainReasonCode(error, missing: "profile-unavailable"),
exitCode: 1
)
}
guard profile.ssid == expectedSSID else {
emit(
ok: false,
scanAttemptCount: scan.attemptCount,
scanElapsedMilliseconds: scan.elapsedMilliseconds,
reasonCode: "profile-ssid-mismatch",
exitCode: 1
)
}
associationPassword = profile.password
}
if interface.ssid() == profile.ssid {
if profileNeedsStore {
try storeProfile(profileID: request.profileID, profile: profile)
profileEnrolled = true
}
if interface.ssid() == expectedSSID {
emit(
ok: true,
adapter: "CoreWLAN",
alreadyAssociated: true,
profileEnrolled: profileEnrolled,
profileEnrolled: false,
scanAttemptCount: scan.attemptCount,
scanElapsedMilliseconds: scan.elapsedMilliseconds,
credentialSource: credentialSource,
exitCode: 0
)
}
try interface.associate(to: network, password: profile.password)
if profileNeedsStore {
try storeProfile(profileID: request.profileID, profile: profile)
profileEnrolled = true
}
try interface.associate(to: network, password: associationPassword)
// CoreWLAN's synchronous association call throws on failure. Reading the
// current SSID again would require Location authorization on recent macOS
// versions and could turn a successful association into a false negative.
@@ -638,12 +872,12 @@ do {
ok: true,
adapter: "CoreWLAN",
alreadyAssociated: false,
profileEnrolled: profileEnrolled,
profileEnrolled: false,
scanAttemptCount: scan.attemptCount,
scanElapsedMilliseconds: scan.elapsedMilliseconds,
credentialSource: credentialSource,
exitCode: 0
)
} catch {
emit(ok: false, reasonCode: "corewlan-error", exitCode: 1)
emit(ok: false, reasonCode: coreWLANReasonCode(error), exitCode: 1)
}
+9 -2
View File
@@ -3,7 +3,7 @@
"kind": "DevicePlugin",
"metadata": {
"id": "nodedc.device.xgrids-lixelkity-k1",
"version": "0.6.0",
"version": "0.7.5",
"displayName": "XGRIDS K1 Integration"
},
"spec": {
@@ -37,11 +37,15 @@
{ "id": "sensor.catalog.read", "mutating": false, "secretFields": [] },
{ "id": "calibration.device-snapshot.read", "mutating": false, "secretFields": [] },
{ "id": "network.provision", "mutating": true, "secretFields": ["password"] },
{ "id": "connection.mode.select", "mutating": true, "secretFields": [] },
{ "id": "connection.reconfigure.prepare", "mutating": true, "secretFields": [] },
{ "id": "connection.verify", "mutating": false, "secretFields": [] },
{ "id": "connection.endpoint-probe", "mutating": false, "secretFields": [] },
{ "id": "acquisition.prepare", "mutating": true, "secretFields": [] },
{ "id": "acquisition.start", "mutating": true, "secretFields": [] },
{ "id": "acquisition.stop", "mutating": true, "secretFields": [] },
{ "id": "acquisition.abort", "mutating": true, "secretFields": [] },
{ "id": "acquisition.force-finish-local", "mutating": true, "secretFields": [] },
{ "id": "acquisition.state.read", "mutating": false, "secretFields": [] },
{ "id": "stream.start-live", "mutating": true, "secretFields": [] },
{ "id": "stream.start-replay", "mutating": true, "secretFields": [] },
@@ -54,7 +58,10 @@
{ "id": "application-control.shadow-disarm", "mutating": true, "secretFields": [] },
{ "id": "application-control.session.open", "mutating": true, "secretFields": [] },
{ "id": "application-control.workspace.enter", "mutating": true, "secretFields": [] },
{ "id": "application-control.session.close", "mutating": true, "secretFields": [] }
{ "id": "application-control.session.close", "mutating": true, "secretFields": [] },
{ "id": "physical-command.reconcile", "mutating": true, "secretFields": [] },
{ "id": "physical-command.retire-unavailable", "mutating": true, "secretFields": [] },
{ "id": "physical-command.reopen-retired-reconciliation", "mutating": true, "secretFields": [] }
],
"models": [
{
+1 -1
View File
@@ -25,7 +25,7 @@ WHEEL_NAME = "nodedc_mission_core-0.1.0-py3-none-any.whl"
RUNNER_NAME = RUNNER.name
PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
EXPECTED_BASELINE_SHA256 = "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
EXPECTED_WHEEL_SHA256 = "ac0ee30446130d3e309cd01e875bec81171a30e057d11d8558a12bb8aec9bf26"
EXPECTED_WHEEL_SHA256 = "9a60efa68eadf2267fffe3dbb89fb58d5e69e672fa3cdf7529474c9f47416acb"
PAYLOAD_FILES = (
RUNNER_NAME,
WHEEL_NAME,
+13 -1
View File
@@ -14,7 +14,12 @@ def utc_now_iso() -> str:
def write_json_atomic(path: Path, payload: Any) -> None:
"""Write JSON without exposing a partially written artifact."""
"""Write JSON without exposing or acknowledging a partial commit.
Flushing the temporary file protects its contents, but a crash can still
lose the directory entry created by ``replace``. The parent directory is
therefore flushed after the atomic rename as the second durability edge.
"""
path = path.expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
serialized = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
@@ -34,6 +39,13 @@ def write_json_atomic(path: Path, payload: Any) -> None:
stream.flush()
os.fsync(stream.fileno())
Path(temp_name).replace(path)
directory_flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
directory_flags |= getattr(os, "O_DIRECTORY", 0)
directory_descriptor = os.open(path.parent, directory_flags)
try:
os.fsync(directory_descriptor)
finally:
os.close(directory_descriptor)
finally:
if temp_name is not None:
Path(temp_name).unlink(missing_ok=True)
@@ -142,7 +142,10 @@ def build_l34a_assisted_yolox_error_audit(
},
"limitations": [
"the review was seeded from the same frozen candidate and is not independent truth",
"precision, recall and F1 are assisted diagnostic alignment metrics, not acceptance metrics",
(
"precision, recall and F1 are assisted diagnostic alignment metrics, "
"not acceptance metrics"
),
"custom labels are retained as proposed ontology terms and were not adjudicated",
"the result is source-scoped to 32 RAVNOVES00 right-camera frames",
],
+61 -4
View File
@@ -10,7 +10,7 @@ import threading
import time
import zlib
from collections import deque
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from hashlib import sha256
from typing import Any, Final, Literal
@@ -33,7 +33,7 @@ LiveIngressModality = Literal[
LIVE_INGRESS_SCHEMA: Final = "missioncore.live-perception-ingress/v1"
LIVE_INGRESS_WIRE_SCHEMA: Final = "missioncore.live-perception-wire/v1"
LIVE_RESULT_WIRE_SCHEMA: Final = "missioncore.live-perception-result-wire/v1"
LIVE_RESULT_WIRE_SCHEMA: Final = "missioncore.live-perception-result-wire/v2"
LIVE_RESULT_MAGIC: Final = b"MCPR"
LIVE_RESULT_MAX_HEADER_BYTES: Final = 256 * 1024
LIVE_RESULT_MAX_PAYLOAD_BYTES: Final = 2 * 1024 * 1024
@@ -41,6 +41,8 @@ LIVE_RESULT_MAX_PAYLOAD_BYTES: Final = 2 * 1024 * 1024
@dataclass(frozen=True, slots=True)
class LivePerceptionResultFrame:
session_id: str
session_generation: int
frame_index: int
source_frame_index: int
session_seconds: float
@@ -53,6 +55,8 @@ class LivePerceptionResultFrame:
def encode_live_perception_result(
*,
session_id: str,
session_generation: int,
frame_index: int,
source_frame_index: int,
session_seconds: float,
@@ -65,7 +69,11 @@ def encode_live_perception_result(
"""Encode one bounded, non-authoritative worker-to-viewer result frame."""
if (
frame_index < 0
not session_id
or len(session_id) > 160
or isinstance(session_generation, bool)
or session_generation < 1
or frame_index < 0
or source_frame_index < 0
or captured_at_epoch_ns < 0
or not math.isfinite(session_seconds)
@@ -91,6 +99,8 @@ def encode_live_perception_result(
raise ValueError("live perception result payload exceeds the bound")
header = {
"schema_version": LIVE_RESULT_WIRE_SCHEMA,
"session_id": session_id,
"session_generation": session_generation,
"frame_index": frame_index,
"source_frame_index": source_frame_index,
"session_seconds": session_seconds,
@@ -192,11 +202,19 @@ def decode_live_perception_result(encoded: bytes) -> LivePerceptionResultFrame:
mask = np.frombuffer(raw_mask, dtype=np.uint8).reshape((600, 800)).copy()
normalized_objects = tuple(_normalize_live_result_object(value) for value in objects)
frame_index = header.get("frame_index")
session_id = header.get("session_id")
session_generation = header.get("session_generation")
source_frame_index = header.get("source_frame_index")
session_seconds = header.get("session_seconds")
captured_at_epoch_ns = header.get("captured_at_epoch_ns")
if (
not isinstance(frame_index, int)
not isinstance(session_id, str)
or not session_id
or len(session_id) > 160
or not isinstance(session_generation, int)
or isinstance(session_generation, bool)
or session_generation < 1
or not isinstance(frame_index, int)
or isinstance(frame_index, bool)
or frame_index < 0
or not isinstance(source_frame_index, int)
@@ -212,6 +230,8 @@ def decode_live_perception_result(encoded: bytes) -> LivePerceptionResultFrame:
):
raise ValueError("live perception result time identity is invalid")
return LivePerceptionResultFrame(
session_id=session_id,
session_generation=session_generation,
frame_index=frame_index,
source_frame_index=source_frame_index,
session_seconds=float(session_seconds),
@@ -297,6 +317,7 @@ class LiveIngressEvent:
ingress_sequence: int
session_id: str
session_generation: int
modality: LiveIngressModality
source_id: str
source_sequence: int
@@ -310,6 +331,7 @@ class LiveIngressEvent:
"schema_version": LIVE_INGRESS_WIRE_SCHEMA,
"ingress_sequence": self.ingress_sequence,
"session_id": self.session_id,
"session_generation": self.session_generation,
"modality": self.modality,
"source_id": self.source_id,
"source_sequence": self.source_sequence,
@@ -382,9 +404,13 @@ class LivePerceptionIngress:
}
self._ingress_sequence = 0
self._session_id: str | None = None
self._session_generation = 0
self._active = False
self._closed = False
self._consumer_id: str | None = None
self._results_accepted = 0
self._results_rejected_stale = 0
self._results_rejected_receiver = 0
def begin_session(self, session_id: str) -> None:
if not session_id or len(session_id) > 160:
@@ -402,6 +428,7 @@ class LivePerceptionIngress:
for queue in self._queues.values():
queue.items.clear()
self._session_id = session_id
self._session_generation += 1
self._active = True
self._publish_locked(
modality="control",
@@ -470,6 +497,31 @@ class LivePerceptionIngress:
self._consumer_id = None
self._condition.notify_all()
def admit_result(
self,
*,
session_id: str,
session_generation: int,
receiver: Callable[[], bool],
) -> bool:
"""Atomically reject a late worker result before viewer admission."""
with self._condition:
if (
self._closed
or not self._active
or self._session_id != session_id
or self._session_generation != session_generation
):
self._results_rejected_stale += 1
return False
accepted = receiver()
if accepted:
self._results_accepted += 1
else:
self._results_rejected_receiver += 1
return accepted
def take_next(
self,
consumer_id: str,
@@ -509,7 +561,11 @@ class LivePerceptionIngress:
"mode": "shadow-diagnostic-only",
"active": self._active,
"session_id": self._session_id,
"session_generation": self._session_generation,
"consumer_connected": self._consumer_id is not None,
"results_accepted": self._results_accepted,
"results_rejected_stale": self._results_rejected_stale,
"results_rejected_receiver": self._results_rejected_receiver,
"commands_enabled": False,
"navigation_or_safety_accepted": False,
"closed": self._closed,
@@ -548,6 +604,7 @@ class LivePerceptionIngress:
event = LiveIngressEvent(
ingress_sequence=self._ingress_sequence,
session_id=session_id,
session_generation=self._session_generation,
modality=modality,
source_id=source_id,
source_sequence=source_sequence,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,265 @@
from __future__ import annotations
import fcntl
import os
import stat
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from k1link.sessions.store import resolve_missioncore_data_dir
APPLICATION_CONTROL_LOCK_FILENAME = ".application-control.lock"
class ApplicationControlProcessLeaseError(RuntimeError):
"""The process-wide K1 control fence cannot be trusted."""
reason_code = "application-control-process-lease-error"
class ApplicationControlProcessLeaseUnavailable(ApplicationControlProcessLeaseError):
"""Another Mission Core process owns the canonical K1 control dialogue."""
reason_code = "application-control-process-lease-unavailable"
ApplicationControlProcessLeaseReleaseState = Literal["owned", "released", "ambiguous"]
ApplicationControlProcessLeaseReleaseDisposition = Literal[
"released",
"already-released",
]
class ApplicationControlProcessLeaseReleaseAmbiguous(ApplicationControlProcessLeaseError):
"""Neither strict unlock nor close proved the OS fence disposition."""
reason_code = "application-control-process-lease-release-ambiguous"
@dataclass(frozen=True, slots=True)
class ApplicationControlProcessLeaseReleaseOutcome:
"""Terminal release result, including non-retryable syscall diagnostics."""
disposition: ApplicationControlProcessLeaseReleaseDisposition
unlock_error_code: str | None = None
close_error_code: str | None = None
@dataclass(slots=True)
class ApplicationControlProcessLease:
"""Non-persistent ownership of the one canonical K1 control dialogue.
The lock file is deliberately stable while ownership lives only in the OS
lock attached to ``_descriptor``. A process crash normally releases
control admission without manufacturing any K1 command or durable recovery
claim. A target-owning child may deliberately inherit a duplicate of that
exact description; after a parent crash the kernel then preserves the fence
until the child itself exits.
"""
path: Path
_descriptor: int
_identity: tuple[int, int]
_released: bool = False
_release_ambiguous: bool = False
@property
def release_state(self) -> ApplicationControlProcessLeaseReleaseState:
if self._release_ambiguous:
return "ambiguous"
return "released" if self._released else "owned"
@classmethod
def acquire(cls, repository_root: Path) -> ApplicationControlProcessLease:
data_dir = resolve_missioncore_data_dir(repository_root)
lock_dir = data_dir / "xgrids-k1"
_ensure_private_directory(data_dir, parents=True)
_ensure_private_directory(lock_dir, parents=False)
path = lock_dir / APPLICATION_CONTROL_LOCK_FILENAME
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags, 0o600)
except OSError as exc:
raise ApplicationControlProcessLeaseError(
"application control process lock cannot be opened safely"
) from exc
locked = False
try:
opened = os.fstat(descriptor)
_validate_private_lock_file(opened)
try:
current = path.lstat()
except OSError as exc:
raise ApplicationControlProcessLeaseError(
"application control process lock identity is unavailable"
) from exc
_validate_private_lock_file(current)
identity = (opened.st_dev, opened.st_ino)
if identity != (current.st_dev, current.st_ino):
raise ApplicationControlProcessLeaseError(
"application control process lock changed while opening"
)
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
locked = True
except BlockingIOError as exc:
raise ApplicationControlProcessLeaseUnavailable(
"another Mission Core process owns K1 application control"
) from exc
except OSError as exc:
raise ApplicationControlProcessLeaseError(
"application control process lock cannot be acquired safely"
) from exc
try:
locked_path = path.lstat()
except OSError as exc:
raise ApplicationControlProcessLeaseError(
"application control process lock disappeared after acquisition"
) from exc
_validate_private_lock_file(locked_path)
if identity != (locked_path.st_dev, locked_path.st_ino):
raise ApplicationControlProcessLeaseError(
"application control process lock changed during acquisition"
)
return cls(path=path, _descriptor=descriptor, _identity=identity)
except BaseException:
if locked:
_unlock_descriptor(descriptor)
os.close(descriptor)
raise
def release(self) -> ApplicationControlProcessLeaseReleaseOutcome:
if self._released:
return ApplicationControlProcessLeaseReleaseOutcome(
disposition="already-released"
)
if self._release_ambiguous:
raise ApplicationControlProcessLeaseReleaseAmbiguous(
"application control process lock release remains ambiguous"
)
unlock_error: OSError | None = None
close_error: OSError | None = None
try:
fcntl.flock(self._descriptor, fcntl.LOCK_UN)
except OSError as exc:
unlock_error = exc
try:
os.close(self._descriptor)
except OSError as exc:
close_error = exc
if unlock_error is not None and close_error is not None:
# There is no portable ownership answer after two failing syscalls.
# Never retry either syscall on this descriptor: quarantine the
# process-local object and require process restart.
self._release_ambiguous = True
error = ApplicationControlProcessLeaseReleaseAmbiguous(
"application control process lock release outcome is ambiguous"
)
error.add_note(
"explicit unlock failed: "
f"{type(unlock_error).__name__}: {unlock_error}"
)
error.add_note(
"descriptor close failed: "
f"{type(close_error).__name__}: {close_error}"
)
raise error from close_error
# Either strict LOCK_UN or close proved that the flock can no longer
# be reused through this object. A diagnostic on the other syscall is
# terminal, not a retry instruction.
self._released = True
return ApplicationControlProcessLeaseReleaseOutcome(
disposition="released",
unlock_error_code=_os_error_code(unlock_error),
close_error_code=_os_error_code(close_error),
)
def duplicate_descriptor_for_child(self) -> int:
"""Duplicate the live flock description for one target-owning child.
``flock`` ownership follows the open file description across ``dup``
and ``exec``. A camera adapter that inherits this duplicate therefore
keeps the K1 lifecycle fence after an abrupt parent-process exit. The
caller must pass the returned descriptor through ``pass_fds`` and
close its parent-side duplicate immediately after spawning.
Normal shutdown must still terminate and reap the child before calling
:meth:`release`: an explicit ``LOCK_UN`` on any duplicate unlocks the
shared description for every process.
"""
if self._released or self._release_ambiguous:
raise ApplicationControlProcessLeaseError(
"released or quarantined application control lease cannot be inherited"
)
try:
descriptor = os.dup(self._descriptor)
except OSError as exc:
raise ApplicationControlProcessLeaseError(
"application control process lock cannot be duplicated safely"
) from exc
try:
metadata = os.fstat(descriptor)
_validate_private_lock_file(metadata)
if (metadata.st_dev, metadata.st_ino) != self._identity:
raise ApplicationControlProcessLeaseError(
"duplicated application control process lock changed identity"
)
return descriptor
except BaseException:
os.close(descriptor)
raise
def __enter__(self) -> ApplicationControlProcessLease:
return self
def __exit__(self, *_: object) -> None:
self.release()
def _ensure_private_directory(path: Path, *, parents: bool) -> None:
try:
metadata = path.lstat()
except FileNotFoundError:
try:
path.mkdir(mode=0o700, parents=parents, exist_ok=False)
except FileExistsError:
metadata = path.lstat()
else:
metadata = path.lstat()
except OSError as exc:
raise ApplicationControlProcessLeaseError(
"application control process lock directory is unavailable"
) from exc
if not stat.S_ISDIR(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o700:
raise ApplicationControlProcessLeaseError(
"application control process lock directory is not private"
)
def _validate_private_lock_file(metadata: os.stat_result) -> None:
if (
not stat.S_ISREG(metadata.st_mode)
or stat.S_IMODE(metadata.st_mode) != 0o600
or metadata.st_nlink != 1
):
raise ApplicationControlProcessLeaseError(
"application control process lock is not a private regular file"
)
def _os_error_code(error: OSError | None) -> str | None:
if error is None:
return None
return f"{type(error).__name__}:{error.errno if error.errno is not None else 'unknown'}"
def _unlock_descriptor(descriptor: int) -> None:
try:
fcntl.flock(descriptor, fcntl.LOCK_UN)
except OSError:
return
@@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from importlib.metadata import version
from time import monotonic
@@ -11,7 +11,19 @@ from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError, BleakError
from k1link.artifacts import utc_now_iso
from k1link.device_plugins.xgrids_k1.ble.scanner import discovered_device_selection
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
BleOperationProgress,
run_ble_operation_session,
)
from k1link.device_plugins.xgrids_k1.ble.scanner import (
CapturedDiscoveredDevice,
captured_device_handle,
connected_device_capture,
demote_connected_device_handle_after_gatt_failure,
discovered_device_selection,
mark_captured_device_gatt_validated,
retrieve_connected_device_capture,
)
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
AP_FALLBACK_IPV4,
SERVICE_UUID,
@@ -23,6 +35,7 @@ from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
WifiStatus,
WriteMode,
_annotate_ble_operation_error,
_optional_int_attribute,
parse_wifi_status,
)
@@ -30,6 +43,7 @@ PROFILE_ID = "xgrids-k1-fw3-quick-connect-ap-v1"
FRAME_LENGTH = 100
COMMAND_OFFSET = 99
ENABLE_AP_COMMAND = 1
BLE_AP_ENABLE_HARD_TIMEOUT_GRACE_SECONDS = 25.0
ApActivationOutcome = Literal[
"already_active",
@@ -56,7 +70,7 @@ class ApActivationResult(TypedDict):
write_performed: bool
write_mode: ResolvedWriteMode | None
write_without_response_advertised: bool
max_write_without_response_size: int
max_write_without_response_size: int | None
frame_length: int
baseline_status: WifiStatus
observations: list[StatusObservation]
@@ -100,11 +114,16 @@ def _outcome(
@asynccontextmanager
async def device_ap_activation_session(
async def _device_ap_activation_session_impl(
device_macos_uuid: str,
timeout_seconds: float = 15.0,
poll_interval_seconds: float = 0.5,
write_mode: WriteMode = "auto",
*,
captured_device: CapturedDiscoveredDevice | None,
recovery_device_session_id: str | None,
on_write_dispatch: Callable[[WifiStatus, ResolvedWriteMode], None] | None,
progress: BleOperationProgress,
) -> AsyncIterator[ApActivationResult]:
"""Keep BLE connected around one reviewed Quick Connect AP-enable write.
@@ -130,6 +149,12 @@ async def device_ap_activation_session(
operation_stage: BleOperationStage = "resolution"
device_write_attempted = False
device_write_confirmed = False
resolved_write_mode_for_error: ResolvedWriteMode | None = None
write_characteristic_properties: tuple[str, ...] | None = None
max_without_response: int | None = None
active_captured_device: CapturedDiscoveredDevice | None = None
gatt_baseline_validated = False
progress.operation_stage = operation_stage
try:
try:
@@ -137,9 +162,44 @@ async def device_ap_activation_session(
# Keep the explicit scan and AP activation in one CoreBluetooth
# lifecycle. Re-looking up the UUID here lost a physically present
# K1 during acceptance, while the retained BLEDevice connected.
selection = discovered_device_selection(device_macos_uuid)
device = selection.device
if device is None and not selection.from_fresh_scan:
if captured_device is not None:
device = (
captured_device_handle(captured_device)
if captured_device.macos_uuid.casefold()
== device_macos_uuid.casefold()
else None
)
if device is not None:
active_captured_device = captured_device
selection_from_fresh_scan = False
elif recovery_device_session_id is not None:
# Retrieval is part of the same serialized ap-enable
# lease as connect, baseline read and the one command.
active_captured_device = connected_device_capture(
device_macos_uuid,
device_session_id=recovery_device_session_id,
)
if active_captured_device is None:
active_captured_device = await retrieve_connected_device_capture(
device_macos_uuid,
device_session_id=recovery_device_session_id,
)
device = (
captured_device_handle(active_captured_device)
if active_captured_device is not None
else None
)
selection_from_fresh_scan = False
else:
selection = discovered_device_selection(device_macos_uuid)
device = selection.device
selection_from_fresh_scan = selection.from_fresh_scan
if (
device is None
and captured_device is None
and recovery_device_session_id is None
and not selection_from_fresh_scan
):
# Preserve a bounded fallback for non-UI callers that did not
# establish a fresh explicit scan lease.
device = await BleakScanner.find_device_by_address(
@@ -157,21 +217,34 @@ async def device_ap_activation_session(
operation_stage=operation_stage,
device_write_attempted=device_write_attempted,
device_write_confirmed=device_write_confirmed,
resolved_write_mode=resolved_write_mode_for_error,
write_characteristic_properties=write_characteristic_properties,
max_write_without_response_size=max_without_response,
frame_length=len(frame),
)
raise
async with AsyncExitStack() as client_stack:
operation_stage = "connect"
progress.operation_stage = operation_stage
try:
client = await client_stack.enter_async_context(
BleakClient(device, timeout=timeout_seconds, pair=False)
)
except Exception as exc:
if active_captured_device is not None:
demote_connected_device_handle_after_gatt_failure(
active_captured_device
)
_annotate_ble_operation_error(
exc,
operation_stage=operation_stage,
device_write_attempted=device_write_attempted,
device_write_confirmed=device_write_confirmed,
resolved_write_mode=resolved_write_mode_for_error,
write_characteristic_properties=write_characteristic_properties,
max_write_without_response_size=max_without_response,
frame_length=len(frame),
)
raise
@@ -179,6 +252,7 @@ async def device_ap_activation_session(
async with asyncio.timeout(timeout_seconds + 10.0):
device_name = client.name
operation_stage = "gatt-contract"
progress.operation_stage = operation_stage
service = client.services.get_service(SERVICE_UUID)
write_characteristic = client.services.get_characteristic(
WRITE_CHARACTERISTIC_UUID
@@ -210,7 +284,11 @@ async def device_ap_activation_session(
raise ValueError("Reviewed K1 status characteristic is not readable")
properties = set(write_characteristic.properties)
max_without_response = write_characteristic.max_write_without_response_size
write_characteristic_properties = tuple(sorted(properties))
max_without_response = _optional_int_attribute(
write_characteristic,
"max_write_without_response_size",
)
resolved_write_mode: ResolvedWriteMode
if write_mode == "auto":
if "write-without-response" in properties:
@@ -226,17 +304,32 @@ async def device_ap_activation_session(
)
resolved_write_mode = "with_response"
else:
resolved_write_mode = "without_response"
resolved_write_mode_for_error = resolved_write_mode
if resolved_write_mode == "without_response":
if max_without_response is None:
raise ValueError(
"Negotiated write-without-response size is unavailable"
)
if len(frame) > max_without_response:
raise ValueError(
"AP activation frame exceeds the negotiated "
"write-without-response size"
)
resolved_write_mode = "without_response"
operation_stage = "baseline-read"
progress.operation_stage = operation_stage
baseline = parse_wifi_status(
bytes(await client.read_gatt_char(status_characteristic))
)
if active_captured_device is not None and not (
mark_captured_device_gatt_validated(active_captured_device)
):
raise RuntimeError(
"Exact BLE recovery handle changed before AP validation"
)
gatt_baseline_validated = True
# WIFI_AP is a control-mode status, not proof that the radio is
# still beaconing. A physical run found the exact SSID shortly
# after AP-enable, then found no beacon while 7f02 continued to
@@ -246,17 +339,26 @@ async def device_ap_activation_session(
# a stale-ready status. There is still no automatic retry.
operation_stage = "gatt-write"
# Persist the write barrier before handing the frame to
# CoreBluetooth. The callback is deliberately synchronous
# and secret-free; failure here prevents the device write.
if on_write_dispatch is not None:
on_write_dispatch(baseline, resolved_write_mode)
device_write_attempted = True
progress.operation_stage = operation_stage
progress.device_write_attempted = True
await client.write_gatt_char(
write_characteristic,
frame,
response=resolved_write_mode == "with_response",
)
device_write_confirmed = resolved_write_mode == "with_response"
progress.device_write_confirmed = device_write_confirmed
write_completed = monotonic()
deadline = write_completed + timeout_seconds
operation_stage = "status-poll"
progress.operation_stage = operation_stage
while monotonic() < deadline:
try:
status = parse_wifi_status(
@@ -306,11 +408,19 @@ async def device_ap_activation_session(
"outcome": _outcome(baseline, observations, disconnected),
}
except Exception as exc:
if active_captured_device is not None and not gatt_baseline_validated:
demote_connected_device_handle_after_gatt_failure(
active_captured_device
)
_annotate_ble_operation_error(
exc,
operation_stage=operation_stage,
device_write_attempted=device_write_attempted,
device_write_confirmed=device_write_confirmed,
resolved_write_mode=resolved_write_mode_for_error,
write_characteristic_properties=write_characteristic_properties,
max_write_without_response_size=max_without_response,
frame_length=len(frame),
)
raise
@@ -321,14 +431,62 @@ async def device_ap_activation_session(
# annotated as BLE failures when they are thrown back through yield.
yield result
finally:
# The runtime arbiter enforces a hard deadline by cancellation. Even
# when that bypasses the normal exception annotator, a captured
# recovery object that never passed baseline GATT validation must be
# discarded.
if active_captured_device is not None and not gatt_baseline_validated:
demote_connected_device_handle_after_gatt_failure(active_captured_device)
frame[:] = b"\x00" * len(frame)
@asynccontextmanager
async def device_ap_activation_session(
device_macos_uuid: str,
timeout_seconds: float = 15.0,
poll_interval_seconds: float = 0.5,
write_mode: WriteMode = "auto",
*,
captured_device: CapturedDiscoveredDevice | None = None,
recovery_device_session_id: str | None = None,
on_write_dispatch: Callable[[WifiStatus, ResolvedWriteMode], None] | None = None,
) -> AsyncIterator[ApActivationResult]:
"""Own one process BLE lease through AP-ready and host Wi-Fi handoff."""
if captured_device is not None and recovery_device_session_id is not None:
raise ValueError(
"captured_device and recovery_device_session_id are mutually exclusive"
)
if recovery_device_session_id == "":
raise ValueError("recovery_device_session_id must not be empty")
progress = BleOperationProgress(operation_stage="resolution")
async with run_ble_operation_session(
"ap-enable",
hard_setup_timeout_seconds=(timeout_seconds + BLE_AP_ENABLE_HARD_TIMEOUT_GRACE_SECONDS),
operation=lambda operation_progress: _device_ap_activation_session_impl(
device_macos_uuid,
timeout_seconds=timeout_seconds,
poll_interval_seconds=poll_interval_seconds,
write_mode=write_mode,
captured_device=captured_device,
recovery_device_session_id=recovery_device_session_id,
on_write_dispatch=on_write_dispatch,
progress=operation_progress,
),
progress=progress,
) as result:
yield result
async def activate_device_ap_once(
device_macos_uuid: str,
timeout_seconds: float = 15.0,
poll_interval_seconds: float = 0.5,
write_mode: WriteMode = "auto",
*,
captured_device: CapturedDiscoveredDevice | None = None,
recovery_device_session_id: str | None = None,
on_write_dispatch: Callable[[WifiStatus, ResolvedWriteMode], None] | None = None,
) -> ApActivationResult:
"""Run one AP activation and release BLE immediately after its result.
@@ -341,5 +499,8 @@ async def activate_device_ap_once(
timeout_seconds=timeout_seconds,
poll_interval_seconds=poll_interval_seconds,
write_mode=write_mode,
captured_device=captured_device,
recovery_device_session_id=recovery_device_session_id,
on_write_dispatch=on_write_dispatch,
) as result:
return result
+60 -45
View File
@@ -8,6 +8,10 @@ from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError
from k1link.artifacts import utc_now_iso
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
BleOperationProgress,
run_ble_operation,
)
class DescriptorRecord(TypedDict):
@@ -48,57 +52,68 @@ async def dump_metadata(device_macos_uuid: str, timeout_seconds: float) -> GattD
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
started_at = utc_now_iso()
async with asyncio.timeout(timeout_seconds):
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
async def perform(progress: BleOperationProgress) -> GattDumpResult:
started_at = utc_now_iso()
async with asyncio.timeout(timeout_seconds):
progress.operation_stage = "gatt-metadata-discovery"
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
)
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
services: list[ServiceRecord] = []
for service in client.services:
characteristics: list[CharacteristicRecord] = []
for characteristic in service.characteristics:
descriptors: list[DescriptorRecord] = []
for descriptor in characteristic.descriptors:
descriptors.append(
progress.operation_stage = "gatt-metadata-connect"
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
progress.operation_stage = "gatt-metadata-enumeration"
services: list[ServiceRecord] = []
for service in client.services:
characteristics: list[CharacteristicRecord] = []
for characteristic in service.characteristics:
descriptors: list[DescriptorRecord] = []
for descriptor in characteristic.descriptors:
descriptors.append(
{
"uuid": descriptor.uuid,
"handle": descriptor.handle,
"description": descriptor.description,
}
)
characteristics.append(
{
"uuid": descriptor.uuid,
"handle": descriptor.handle,
"description": descriptor.description,
"uuid": characteristic.uuid,
"handle": characteristic.handle,
"description": characteristic.description,
"properties": sorted(characteristic.properties),
"descriptors": descriptors,
}
)
characteristics.append(
services.append(
{
"uuid": characteristic.uuid,
"handle": characteristic.handle,
"description": characteristic.description,
"properties": sorted(characteristic.properties),
"descriptors": descriptors,
"uuid": service.uuid,
"handle": service.handle,
"description": service.description,
"characteristics": characteristics,
}
)
services.append(
{
"uuid": service.uuid,
"handle": service.handle,
"description": service.description,
"characteristics": characteristics,
}
)
return {
"schema_version": 1,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": client.name,
"metadata_only": True,
"services": services,
}
progress.operation_stage = "gatt-metadata-complete"
return {
"schema_version": 1,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": client.name,
"metadata_only": True,
"services": services,
}
return await run_ble_operation(
"status-read",
hard_timeout_seconds=timeout_seconds + 5.0,
operation=perform,
)
@@ -8,6 +8,10 @@ from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError
from k1link.artifacts import utc_now_iso
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
BleOperationProgress,
run_ble_operation,
)
class CharacteristicReadResult(TypedDict):
@@ -33,36 +37,47 @@ async def read_characteristic_once(
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
started_at = utc_now_iso()
async with asyncio.timeout(timeout_seconds):
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
async def perform(progress: BleOperationProgress) -> CharacteristicReadResult:
started_at = utc_now_iso()
async with asyncio.timeout(timeout_seconds):
progress.operation_stage = "gatt-characteristic-discovery"
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
)
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
characteristic = client.services.get_characteristic(characteristic_uuid)
if characteristic is None:
raise ValueError(f"Characteristic not found: {characteristic_uuid}")
if "read" not in characteristic.properties:
raise ValueError(f"Characteristic is not readable: {characteristic_uuid}")
value = bytes(await client.read_gatt_char(characteristic))
progress.operation_stage = "gatt-characteristic-connect"
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
characteristic = client.services.get_characteristic(characteristic_uuid)
if characteristic is None:
raise ValueError(f"Characteristic not found: {characteristic_uuid}")
if "read" not in characteristic.properties:
raise ValueError(f"Characteristic is not readable: {characteristic_uuid}")
progress.operation_stage = "gatt-characteristic-read"
value = bytes(await client.read_gatt_char(characteristic))
return {
"schema_version": 1,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": client.name,
"characteristic_uuid": characteristic.uuid,
"operation": "single_gatt_read_no_pair_no_write",
"value_length": len(value),
"value_hex": value.hex(),
}
progress.operation_stage = "gatt-characteristic-complete"
return {
"schema_version": 1,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": client.name,
"characteristic_uuid": characteristic.uuid,
"operation": "single_gatt_read_no_pair_no_write",
"value_length": len(value),
"value_hex": value.hex(),
}
return await run_ble_operation(
"status-read",
hard_timeout_seconds=timeout_seconds + 5.0,
operation=perform,
)
@@ -0,0 +1,745 @@
from __future__ import annotations
import asyncio
import threading
from collections.abc import AsyncIterator, Callable, Coroutine, Iterator
from contextlib import AbstractAsyncContextManager, asynccontextmanager, contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, TypedDict
from k1link.device_plugins.xgrids_k1.application_control_process_lease import (
APPLICATION_CONTROL_LOCK_FILENAME,
ApplicationControlProcessLease,
)
from k1link.sessions.store import resolve_missioncore_data_dir
BleOperationKind = Literal["scan", "status-read", "wifi-provision", "ap-enable"]
BleRuntimeIdleCallbackDisposition = Literal["released", "deferred", "poisoned"]
class BleRuntimeBusy(RuntimeError):
"""Another process-owned CoreBluetooth operation still owns the adapter."""
def __init__(
self,
*,
active_operation_kind: BleOperationKind,
cleanup_pending: bool,
) -> None:
message = (
"предыдущая BLE-операция ещё завершает безопасную очистку"
if cleanup_pending
else "другая BLE-операция уже выполняется в этом процессе"
)
super().__init__(message)
self.reason_code = "ble-runtime-cleanup-pending" if cleanup_pending else "ble-runtime-busy"
self.active_operation_kind = active_operation_kind
self.cleanup_pending = cleanup_pending
class BleRuntimeOwnerLoopConflict(RuntimeError):
"""The process BLE runtime is still owned by another live event loop."""
def __init__(self) -> None:
super().__init__("BLE runtime уже привязан к другому активному event loop")
self.reason_code = "ble-runtime-owner-loop-conflict"
class BleRuntimePoisoned(RuntimeError):
"""A closed owner loop abandoned an operation whose cleanup is unproved."""
def __init__(self) -> None:
super().__init__(
"BLE runtime требует перезапуска: предыдущая очистка CoreBluetooth не подтверждена"
)
self.reason_code = "ble-runtime-restart-required"
class BleRuntimeProcessLeaseNotConfigured(RuntimeError):
"""Low-level BLE was entered before its canonical OS lock was configured."""
def __init__(self) -> None:
super().__init__("BLE runtime process lease repository root is not configured")
self.reason_code = "ble-runtime-process-lease-not-configured"
class BleRuntimeProcessLeaseConfigurationConflict(RuntimeError):
"""The canonical OS lock target changed while BLE ownership was live."""
def __init__(self) -> None:
super().__init__("BLE runtime process lease configuration cannot change while active")
self.reason_code = "ble-runtime-process-lease-configuration-conflict"
class BleRuntimeProcessLeaseBorrowInvalid(RuntimeError):
"""An explicit higher-level OS lease borrow is stale or mismatched."""
def __init__(self) -> None:
super().__init__("BLE runtime process lease borrow is not active for this lock target")
self.reason_code = "ble-runtime-process-lease-borrow-invalid"
@dataclass(slots=True)
class BleOperationProgress:
"""Non-secret facts copied onto a hard-deadline exception."""
operation_stage: str = "pending"
device_write_attempted: bool = False
device_write_confirmed: bool = False
owner_epoch: int = 0
class BleOperationHardTimeout(TimeoutError):
"""A hard caller deadline elapsed while cleanup continues in the background."""
def __init__(
self,
operation_kind: BleOperationKind,
progress: BleOperationProgress,
) -> None:
super().__init__("BLE-операция не завершилась в отведённое время")
self.reason_code = {
"scan": "ble-discovery-timeout",
"status-read": "ble-status-read-timeout",
"wifi-provision": "ble-provisioning-timeout",
"ap-enable": "ble-ap-enable-timeout",
}[operation_kind]
self.operation_kind = operation_kind
self.operation_stage = progress.operation_stage
self.device_write_attempted = progress.device_write_attempted
self.device_write_confirmed = progress.device_write_confirmed
class BleRuntimeSnapshot(TypedDict):
owner_epoch: int
owner_loop_bound: bool
active_operation_kind: BleOperationKind | None
cleanup_pending: bool
poisoned: bool
@dataclass(slots=True)
class BleRuntimeProcessLeaseBorrowToken:
"""An opaque, context-local proof that a higher layer owns the OS lease."""
repository_root: Path
lock_path: Path
configuration_epoch: int
_lease: ApplicationControlProcessLease
_active: bool = True
@dataclass(slots=True)
class _ActiveLease:
token: int
operation_kind: BleOperationKind
owner_epoch: int
cleanup_pending: bool = False
task: asyncio.Task[Any] | None = None
owned_process_lease: ApplicationControlProcessLease | None = None
borrowed_process_lease: ApplicationControlProcessLease | None = None
class _ProcessBleRuntimeArbiter:
"""One fail-fast process lease shared by every supported BLE entrypoint."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._owner_loop: asyncio.AbstractEventLoop | None = None
self._owner_epoch = 0
self._next_token = 0
self._active: _ActiveLease | None = None
self._poisoned = False
self._idle_callbacks: list[Callable[[], None]] = []
self._process_lease_repository_root: Path | None = None
self._process_lease_lock_path: Path | None = None
self._process_lease_configuration_epoch = 0
def configure_process_lease(self, repository_root: Path) -> int:
"""Pin every low-level BLE entrypoint to one canonical OS lock file."""
resolved_root = repository_root.expanduser().resolve()
lock_path = _process_lease_path(resolved_root)
with self._lock:
if (
self._process_lease_repository_root == resolved_root
and self._process_lease_lock_path == lock_path
):
return self._process_lease_configuration_epoch
if self._active is not None or self._poisoned or self._idle_callbacks:
raise BleRuntimeProcessLeaseConfigurationConflict()
self._process_lease_configuration_epoch += 1
self._process_lease_repository_root = resolved_root
self._process_lease_lock_path = lock_path
return self._process_lease_configuration_epoch
def create_process_lease_borrow(
self,
lease: ApplicationControlProcessLease,
) -> BleRuntimeProcessLeaseBorrowToken:
"""Validate an already-held higher-level lease before context borrowing."""
with self._lock:
repository_root = self._process_lease_repository_root
lock_path = self._process_lease_lock_path
if repository_root is None or lock_path is None:
raise BleRuntimeProcessLeaseNotConfigured()
if lease.path != lock_path or lease.release_state != "owned":
raise BleRuntimeProcessLeaseBorrowInvalid()
return BleRuntimeProcessLeaseBorrowToken(
repository_root=repository_root,
lock_path=lock_path,
configuration_epoch=self._process_lease_configuration_epoch,
_lease=lease,
)
def deactivate_process_lease_borrow(
self,
borrow: BleRuntimeProcessLeaseBorrowToken,
) -> None:
with self._lock:
borrow._active = False
def bind_owner_loop(self, loop: asyncio.AbstractEventLoop) -> int:
if loop.is_closed():
raise BleRuntimeOwnerLoopConflict()
with self._lock:
current = self._owner_loop
if current is loop:
# Facade dispatch binds every action so state/STOP/close remain
# available after an unproved BLE teardown. Only the next BLE
# lease acquisition is rejected while this loop stays owner.
return self._owner_epoch
if self._poisoned:
raise BleRuntimePoisoned()
if current is not None and not current.is_closed():
raise BleRuntimeOwnerLoopConflict()
if self._active is not None:
# Its done callback cannot be trusted after the owning loop has
# closed. Never admit a replacement native CoreBluetooth task.
self._poisoned = True
raise BleRuntimePoisoned()
self._owner_epoch += 1
self._owner_loop = loop
return self._owner_epoch
def owner_epoch_for_loop(self, loop: asyncio.AbstractEventLoop) -> int | None:
with self._lock:
if self._poisoned or self._owner_loop is not loop or loop.is_closed():
return None
return self._owner_epoch
def invalidate_owner_loop(
self,
expected_loop: asyncio.AbstractEventLoop | None,
) -> int:
with self._lock:
if expected_loop is not None and self._owner_loop is not expected_loop:
return self._owner_epoch
self._owner_epoch += 1
self._owner_loop = None
if self._active is not None:
self._poisoned = True
return self._owner_epoch
def acquire(
self,
operation_kind: BleOperationKind,
*,
owner_epoch: int,
process_lease_borrow: BleRuntimeProcessLeaseBorrowToken | None,
) -> int:
with self._lock:
if self._poisoned:
raise BleRuntimePoisoned()
if self._owner_epoch != owner_epoch:
raise BleRuntimeOwnerLoopConflict()
if self._active is not None:
raise BleRuntimeBusy(
active_operation_kind=self._active.operation_kind,
cleanup_pending=self._active.cleanup_pending,
)
repository_root = self._process_lease_repository_root
lock_path = self._process_lease_lock_path
if repository_root is None or lock_path is None:
raise BleRuntimeProcessLeaseNotConfigured()
owned_process_lease: ApplicationControlProcessLease | None = None
borrowed_process_lease: ApplicationControlProcessLease | None = None
if process_lease_borrow is None:
# Detect an environment-driven data-root change before opening
# or creating a lock at a different path than configuration.
if _process_lease_path(repository_root) != lock_path:
raise BleRuntimeProcessLeaseConfigurationConflict()
owned_process_lease = ApplicationControlProcessLease.acquire(repository_root)
if owned_process_lease.path != lock_path:
owned_process_lease.release()
raise BleRuntimeProcessLeaseConfigurationConflict()
else:
if (
not process_lease_borrow._active
or process_lease_borrow.configuration_epoch
!= self._process_lease_configuration_epoch
or process_lease_borrow.repository_root != repository_root
or process_lease_borrow.lock_path != lock_path
or process_lease_borrow._lease.path != lock_path
or process_lease_borrow._lease.release_state != "owned"
):
raise BleRuntimeProcessLeaseBorrowInvalid()
borrowed_process_lease = process_lease_borrow._lease
self._next_token += 1
token = self._next_token
self._active = _ActiveLease(
token=token,
operation_kind=operation_kind,
owner_epoch=owner_epoch,
owned_process_lease=owned_process_lease,
borrowed_process_lease=borrowed_process_lease,
)
return token
def attach_task(self, token: int, task: asyncio.Task[Any]) -> None:
with self._lock:
if self._active is None or self._active.token != token:
raise RuntimeError("BLE runtime lease was invalidated before task attachment")
self._active.task = task
def mark_cleanup_pending(self, token: int) -> None:
with self._lock:
if self._active is not None and self._active.token == token:
self._active.cleanup_pending = True
def release(self, token: int) -> None:
"""Finish one lease and drain callbacks before admitting another lease.
The completed task remains represented by ``_active`` while callbacks
run. Consequently a concurrent local BLE acquisition still fails
closed until the external lifecycle barriers retained by those
callbacks have actually been released. Callbacks must be bounded and
must not call back into this arbiter.
"""
while True:
with self._lock:
active = self._active
if active is None or active.token != token or self._poisoned:
return
callbacks = tuple(self._idle_callbacks)
self._idle_callbacks.clear()
if not callbacks:
process_lease = active.owned_process_lease
if process_lease is not None:
try:
# This is a non-blocking unlock/close. Keep the
# arbiter lock held so neither a local acquisition
# nor a newly registered external release can race
# the final OS ownership transition.
process_lease.release()
except Exception:
active.cleanup_pending = True
self._poisoned = True
return
self._active = None
return
for index, callback in enumerate(callbacks):
try:
callback()
except Exception:
# An external release that cannot be proven complete is a
# process-wide safety failure. Keep this lease and every
# unexecuted callback retained until process restart; never
# retry a callback whose partial effects are unknowable.
with self._lock:
current = self._active
if current is not None and current.token == token:
current.cleanup_pending = True
self._poisoned = True
self._idle_callbacks[0:0] = callbacks[index:]
return
def poison_cleanup_failure(self, token: int) -> None:
"""Retain ownership when native session teardown is not proven clean."""
with self._lock:
if self._active is not None and self._active.token == token:
self._active.cleanup_pending = True
self._poisoned = True
def defer_until_idle(
self,
callback: Callable[[], None],
) -> BleRuntimeIdleCallbackDisposition:
"""Run a bounded callback now or at the next proven-idle transition.
Registration and the idle decision are serialized with acquisition and
release. When a BLE operation is active, its lease stays active while
the callback runs, so another local operation cannot enter the native
runtime between teardown and release of an external process lease.
Poisoned runtimes retain callbacks without ever invoking them: only a
process restart may safely release the corresponding OS ownership.
The callback must be quick, non-blocking, and must not call this arbiter.
"""
with self._lock:
if self._poisoned:
self._idle_callbacks.append(callback)
return "poisoned"
if self._active is not None:
self._idle_callbacks.append(callback)
return "deferred"
try:
callback()
except Exception:
# There is no caller-independent way to know whether an
# external release partially succeeded. Preserve the callback
# and prohibit a later BLE admission until process restart.
self._poisoned = True
self._idle_callbacks.append(callback)
raise
return "released"
def snapshot(self) -> BleRuntimeSnapshot:
with self._lock:
active = self._active
return {
"owner_epoch": self._owner_epoch,
"owner_loop_bound": (
self._owner_loop is not None and not self._owner_loop.is_closed()
),
"active_operation_kind": (active.operation_kind if active is not None else None),
"cleanup_pending": bool(active is not None and active.cleanup_pending),
"poisoned": self._poisoned,
}
def reset_for_tests(self) -> None:
"""Forget singleton state without letting an old callback release a new lease."""
with self._lock:
owned_process_lease = (
self._active.owned_process_lease if self._active is not None else None
)
self._owner_epoch += 1
self._next_token += 1
self._owner_loop = None
self._active = None
self._poisoned = False
self._idle_callbacks.clear()
self._process_lease_configuration_epoch += 1
self._process_lease_repository_root = None
self._process_lease_lock_path = None
if owned_process_lease is not None:
# Test reset deliberately models process exit for synthetic poison.
owned_process_lease.release()
_BLE_PROCESS_ARBITER = _ProcessBleRuntimeArbiter()
_BLE_PROCESS_LEASE_BORROW_CONTEXT: ContextVar[
BleRuntimeProcessLeaseBorrowToken | None
] = ContextVar("ble_runtime_process_lease_borrow", default=None)
def _process_lease_path(repository_root: Path) -> Path:
return (
resolve_missioncore_data_dir(repository_root)
/ "xgrids-k1"
/ APPLICATION_CONTROL_LOCK_FILENAME
)
def _annotate_cancellation(
exc: asyncio.CancelledError,
progress: BleOperationProgress,
) -> None:
"""Preserve side-effect facts when caller cancellation crosses BLE I/O."""
exc.operation_stage = progress.operation_stage # type: ignore[attr-defined]
exc.device_write_attempted = progress.device_write_attempted # type: ignore[attr-defined]
exc.device_write_confirmed = progress.device_write_confirmed # type: ignore[attr-defined]
def bind_ble_runtime_owner_loop(
loop: asyncio.AbstractEventLoop | None = None,
) -> int:
"""Bind process CoreBluetooth ownership to the current persistent loop."""
return _BLE_PROCESS_ARBITER.bind_owner_loop(loop or asyncio.get_running_loop())
def ble_runtime_owner_epoch_for_current_loop() -> int | None:
"""Return the owner epoch only when called on the bound live loop."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return None
return _BLE_PROCESS_ARBITER.owner_epoch_for_loop(loop)
def invalidate_ble_runtime_owner_loop(
loop: asyncio.AbstractEventLoop | None = None,
) -> int:
"""Invalidate process loop ownership without releasing an active BLE lease."""
return _BLE_PROCESS_ARBITER.invalidate_owner_loop(loop)
def ble_runtime_snapshot() -> BleRuntimeSnapshot:
"""Expose a non-secret process snapshot for diagnostics and focused tests."""
return _BLE_PROCESS_ARBITER.snapshot()
def reset_ble_runtime_arbiter_for_tests() -> None:
"""Reset process-global state between synthetic tests only."""
_BLE_PROCESS_ARBITER.reset_for_tests()
_BLE_PROCESS_LEASE_BORROW_CONTEXT.set(None)
def configure_ble_runtime_process_lease(repository_root: Path) -> int:
"""Configure the canonical OS lifecycle lock used by all BLE entrypoints."""
return _BLE_PROCESS_ARBITER.configure_process_lease(repository_root)
@contextmanager
def borrow_ble_runtime_process_lease(
lease: ApplicationControlProcessLease,
) -> Iterator[BleRuntimeProcessLeaseBorrowToken]:
"""Borrow a higher-level lifecycle lease without a second flock attempt.
The caller must keep ``lease`` owned until every BLE operation admitted in
this context has reached proven idle. A copied context cannot admit a new
operation after this manager exits because the opaque token is invalidated.
"""
borrow = _BLE_PROCESS_ARBITER.create_process_lease_borrow(lease)
context_token = _BLE_PROCESS_LEASE_BORROW_CONTEXT.set(borrow)
try:
yield borrow
finally:
_BLE_PROCESS_ARBITER.deactivate_process_lease_borrow(borrow)
_BLE_PROCESS_LEASE_BORROW_CONTEXT.reset(context_token)
def defer_until_ble_runtime_idle(
callback: Callable[[], None],
) -> BleRuntimeIdleCallbackDisposition:
"""Release an external barrier only after native BLE ownership is idle.
``"released"`` means the callback ran synchronously because no BLE lease
existed. ``"deferred"`` means it is owned by the active lease and will run
exactly once after native task completion. ``"poisoned"`` means it is
intentionally retained without execution until process restart.
"""
return _BLE_PROCESS_ARBITER.defer_until_idle(callback)
async def wait_for_ble_runtime_idle(timeout_seconds: float = 1.0) -> bool:
"""Wait without blocking the owner loop until the active lease is released."""
if timeout_seconds < 0:
raise ValueError("timeout_seconds must be non-negative")
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_seconds
while ble_runtime_snapshot()["active_operation_kind"] is not None:
if loop.time() >= deadline:
return False
await asyncio.sleep(min(0.01, max(0.0, deadline - loop.time())))
return True
async def run_ble_operation[T](
operation_kind: BleOperationKind,
*,
hard_timeout_seconds: float,
operation: Callable[[BleOperationProgress], Coroutine[Any, Any, T]],
progress: BleOperationProgress | None = None,
) -> T:
"""Run one BLE task behind a process lease and a non-blocking hard deadline."""
if hard_timeout_seconds <= 0:
raise ValueError("hard_timeout_seconds must be positive")
loop = asyncio.get_running_loop()
owner_epoch = bind_ble_runtime_owner_loop(loop)
operation_progress = progress or BleOperationProgress()
operation_progress.owner_epoch = owner_epoch
token = _BLE_PROCESS_ARBITER.acquire(
operation_kind,
owner_epoch=owner_epoch,
process_lease_borrow=_BLE_PROCESS_LEASE_BORROW_CONTEXT.get(),
)
try:
task: asyncio.Task[T] = loop.create_task(operation(operation_progress))
except BaseException:
_BLE_PROCESS_ARBITER.release(token)
raise
_BLE_PROCESS_ARBITER.attach_task(token, task)
cleanup_requested = False
def release_after_completion(completed: asyncio.Future[T]) -> None:
cleanup_failed = False
try:
completed_exception = None if completed.cancelled() else completed.exception()
cleanup_failed = cleanup_requested and completed_exception is not None
except BaseException:
# The caller observes the original result/exception. This callback
# only consumes detached cleanup outcomes and releases ownership.
cleanup_failed = cleanup_requested
finally:
if cleanup_failed:
_BLE_PROCESS_ARBITER.poison_cleanup_failure(token)
else:
_BLE_PROCESS_ARBITER.release(token)
task.add_done_callback(release_after_completion)
try:
completed, _ = await asyncio.wait({task}, timeout=hard_timeout_seconds)
except asyncio.CancelledError as exc:
_annotate_cancellation(exc, operation_progress)
if not task.done():
_BLE_PROCESS_ARBITER.mark_cleanup_pending(token)
cleanup_requested = True
task.cancel()
raise
if completed:
return task.result()
_BLE_PROCESS_ARBITER.mark_cleanup_pending(token)
cleanup_requested = True
task.cancel()
raise BleOperationHardTimeout(operation_kind, operation_progress)
@asynccontextmanager
async def run_ble_operation_session[T](
operation_kind: BleOperationKind,
*,
hard_setup_timeout_seconds: float,
hard_cleanup_timeout_seconds: float = 5.0,
operation: Callable[
[BleOperationProgress],
AbstractAsyncContextManager[T],
],
progress: BleOperationProgress | None = None,
) -> AsyncIterator[T]:
"""Hold one process BLE lease across setup, caller work, and cleanup.
Only setup is subject to the hard deadline. Once the inner session yields,
its native BLE client remains alive until the caller leaves this context.
Cancellation never frees the process lease early: a detached native cleanup
continues to own the lease until its task has actually completed.
"""
if hard_setup_timeout_seconds <= 0:
raise ValueError("hard_setup_timeout_seconds must be positive")
if hard_cleanup_timeout_seconds <= 0:
raise ValueError("hard_cleanup_timeout_seconds must be positive")
loop = asyncio.get_running_loop()
owner_epoch = bind_ble_runtime_owner_loop(loop)
operation_progress = progress or BleOperationProgress()
operation_progress.owner_epoch = owner_epoch
token = _BLE_PROCESS_ARBITER.acquire(
operation_kind,
owner_epoch=owner_epoch,
process_lease_borrow=_BLE_PROCESS_LEASE_BORROW_CONTEXT.get(),
)
ready: asyncio.Future[T] = loop.create_future()
release_requested = asyncio.Event()
setup_cleanup_requested = False
async def session_task() -> None:
async with operation(operation_progress) as value:
if not ready.done():
ready.set_result(value)
await release_requested.wait()
try:
task = loop.create_task(session_task())
except BaseException:
_BLE_PROCESS_ARBITER.release(token)
raise
_BLE_PROCESS_ARBITER.attach_task(token, task)
def release_after_completion(completed: asyncio.Future[None]) -> None:
cleanup_failed = False
try:
completed_exception = None if completed.cancelled() else completed.exception()
cleanup_failed = bool(
(
ready.done()
and not ready.cancelled()
and release_requested.is_set()
and (completed.cancelled() or completed_exception is not None)
)
or (setup_cleanup_requested and completed_exception is not None)
)
except BaseException:
# The active caller observes setup/cleanup failures directly. This
# callback only consumes detached outcomes before releasing ownership.
cleanup_failed = setup_cleanup_requested or bool(
ready.done() and not ready.cancelled() and release_requested.is_set()
)
finally:
if cleanup_failed:
_BLE_PROCESS_ARBITER.poison_cleanup_failure(token)
else:
_BLE_PROCESS_ARBITER.release(token)
task.add_done_callback(release_after_completion)
setup_waiters: set[asyncio.Future[Any]] = {ready, task}
try:
completed, _ = await asyncio.wait(
setup_waiters,
timeout=hard_setup_timeout_seconds,
return_when=asyncio.FIRST_COMPLETED,
)
except asyncio.CancelledError as exc:
_annotate_cancellation(exc, operation_progress)
if not task.done():
_BLE_PROCESS_ARBITER.mark_cleanup_pending(token)
setup_cleanup_requested = True
task.cancel()
raise
if ready not in completed:
if task in completed:
# Setup failed before the inner session became available.
task.result()
raise RuntimeError("BLE session ended before setup completed")
_BLE_PROCESS_ARBITER.mark_cleanup_pending(token)
setup_cleanup_requested = True
task.cancel()
raise BleOperationHardTimeout(operation_kind, operation_progress)
try:
try:
yield ready.result()
except asyncio.CancelledError as exc:
_annotate_cancellation(exc, operation_progress)
raise
finally:
# From this point the setup deadline no longer applies. The caller may
# perform a bounded host-side handoff while the same BLE client remains
# connected. On exit, ownership is retained until __aexit__ really ends.
# A wedged native disconnect must not hold the HTTP caller forever:
# detach it after the cleanup deadline while the task and process lease
# remain quarantined until CoreBluetooth actually acknowledges cleanup.
_BLE_PROCESS_ARBITER.mark_cleanup_pending(token)
release_requested.set()
completed, _ = await asyncio.wait(
{task},
timeout=hard_cleanup_timeout_seconds,
)
if completed:
task.result()
# Do not cancel a disconnect already in progress. A second cancellation
# can make an otherwise responsive ``__aexit__`` finish as cancelled
# without proving that CoreBluetooth acknowledged the native teardown.
# The detached task therefore keeps the process lease until its natural
# completion callback releases it.
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,8 @@ from __future__ import annotations
import asyncio
import ipaddress
import math
from collections.abc import Callable
from importlib.metadata import version
from time import monotonic
from typing import Literal, TypedDict
@@ -10,8 +12,20 @@ from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError, BleakError, BleakGATTProtocolError
from k1link.artifacts import utc_now_iso
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
BleOperationProgress,
run_ble_operation,
)
from k1link.device_plugins.xgrids_k1.ble.scanner import (
CapturedDiscoveredDevice,
captured_device_handle,
connected_device_capture,
demote_connected_device_handle_after_gatt_failure,
discover_known_device_capture_for_status_read,
discovered_device_selection,
mark_captured_device_gatt_validated,
retrieve_connected_device_capture,
retrieve_known_device_capture_for_status_read,
)
PROFILE_ID = "xgrids-k1-fw3-wifi-v1"
@@ -22,6 +36,8 @@ FRAME_LENGTH = 99
SSID_SLOT_LENGTH = 32
PASSWORD_SLOT_LENGTH = 64
AP_FALLBACK_IPV4 = "192.168.56.1"
BLE_STATUS_HARD_TIMEOUT_GRACE_SECONDS = 5.0
BLE_PROVISION_HARD_TIMEOUT_GRACE_SECONDS = 25.0
ProvisioningOutcome = Literal[
"lan_address_observed",
"status_changed",
@@ -38,11 +54,28 @@ BleOperationStage = Literal[
"gatt-write",
"status-poll",
]
StatusReadOperationStage = Literal[
"resolution",
"exact-uuid-scan",
"connect",
"gatt-contract",
"status-read",
]
_STATUS_READ_OPERATION_STAGES: frozenset[str] = frozenset(
{
"resolution",
"exact-uuid-scan",
"connect",
"gatt-contract",
"status-read",
}
)
class WifiStatus(TypedDict):
value_length: int
mode: str | None
network_name: str | None
ipv4: str | None
status_code: int
reserved: int | None
@@ -70,7 +103,7 @@ class WifiProvisioningResult(TypedDict):
operation: str
write_mode: ResolvedWriteMode
write_without_response_advertised: bool
max_write_without_response_size: int
max_write_without_response_size: int | None
frame_length: int
baseline_status: WifiStatus
observations: list[StatusObservation]
@@ -86,6 +119,10 @@ class WifiStatusReadResult(TypedDict):
device_macos_uuid: str
device_name: str
service_uuid: str
write_characteristic_uuid: str
write_characteristic_properties: list[str]
max_write_without_response_size: int | None
mtu_size: int | None
status_characteristic_uuid: str
operation: Literal["single_reviewed_wifi_status_read"]
write_performed: Literal[False]
@@ -98,17 +135,61 @@ def _annotate_ble_operation_error(
operation_stage: BleOperationStage,
device_write_attempted: bool,
device_write_confirmed: bool,
resolved_write_mode: ResolvedWriteMode | None,
write_characteristic_properties: tuple[str, ...] | None,
max_write_without_response_size: int | None,
frame_length: int,
) -> None:
"""Attach non-secret transport facts while preserving the exception type."""
exc.operation_stage = operation_stage # type: ignore[attr-defined]
exc.device_write_attempted = device_write_attempted # type: ignore[attr-defined]
exc.device_write_confirmed = device_write_confirmed # type: ignore[attr-defined]
exc.resolved_write_mode = resolved_write_mode # type: ignore[attr-defined]
exc.write_characteristic_properties = ( # type: ignore[attr-defined]
write_characteristic_properties
)
exc.max_write_without_response_size = ( # type: ignore[attr-defined]
max_write_without_response_size
)
exc.frame_length = frame_length # type: ignore[attr-defined]
if isinstance(exc, BleakGATTProtocolError):
exc.att_error_code = int(exc.code) # type: ignore[attr-defined]
exc.att_error_name = exc.code.name # type: ignore[attr-defined]
def _annotate_status_read_error(exc: Exception, operation_stage: str) -> None:
"""Attach only one sanitized read-only stage to a transport failure."""
try:
existing_stage = getattr(exc, "operation_stage", None)
except Exception:
existing_stage = None
if existing_stage in _STATUS_READ_OPERATION_STAGES:
return
sanitized_stage = (
operation_stage
if operation_stage in _STATUS_READ_OPERATION_STAGES
else "resolution"
)
try:
exc.operation_stage = sanitized_stage # type: ignore[attr-defined]
except Exception:
# A third-party exception may forbid dynamic attributes. Preserve its
# original type and traceback rather than replacing the BLE failure.
return
def _optional_int_attribute(source: object, name: str) -> int | None:
"""Read optional backend metadata without making diagnostics operationally required."""
try:
value = getattr(source, name, None)
except Exception:
return None
return value if isinstance(value, int) and not isinstance(value, bool) else None
def build_wifi_provisioning_frame(ssid: str, password: str) -> bytearray:
"""Build the deterministic 99-byte frame used by LixelGO for K1 Wi-Fi setup."""
ssid_bytes = ssid.encode("utf-8")
@@ -133,17 +214,40 @@ def build_wifi_provisioning_frame(ssid: str, password: str) -> bytearray:
def parse_wifi_status(value: bytes) -> WifiStatus:
"""Parse the non-secret status frame returned by the K1 read characteristic."""
"""Parse the status frame returned by the K1 read characteristic.
The first 32-byte text slot is not a mode enum in station mode. Physical
K1 FW 3.0.2 evidence shows that it contains the joined Wi-Fi network name
(for example a lab SSID), while AP mode uses the control literal
``WIFI_AP``. Keep ``mode`` as the normalized semantic family so callers do
not have to mistake an operator network name for a protocol enum, and
expose ``network_name`` only for exact, in-process target comparison.
"""
if len(value) < 51:
raise ValueError("K1 Wi-Fi status must contain at least 51 bytes")
mode_length = value[0]
if mode_length > SSID_SLOT_LENGTH:
raise ValueError("K1 Wi-Fi status mode length is invalid")
text_length = value[0]
if text_length > SSID_SLOT_LENGTH:
raise ValueError("K1 Wi-Fi status text length is invalid")
try:
mode = value[1 : 1 + mode_length].decode("utf-8") if mode_length else None
status_text = (
value[1 : 1 + text_length].decode("utf-8") if text_length else None
)
except UnicodeDecodeError as exc:
raise ValueError("K1 Wi-Fi status mode is not valid UTF-8") from exc
raise ValueError("K1 Wi-Fi status text is not valid UTF-8") from exc
if status_text == "WIFI_AP":
mode = "WIFI_AP"
network_name = None
elif status_text:
# Older synthetic fixtures and possible legacy firmware may still
# report the literal WIFI_CLIENT. It identifies the station family
# but supplies no exact network discriminator.
mode = "WIFI_CLIENT"
network_name = None if status_text == "WIFI_CLIENT" else status_text
else:
mode = None
network_name = None
address_length = value[33]
address_start = 34
@@ -163,6 +267,7 @@ def parse_wifi_status(value: bytes) -> WifiStatus:
return {
"value_length": len(value),
"mode": mode,
"network_name": network_name,
"ipv4": ipv4,
"status_code": value[50],
"reserved": value[51] if len(value) > 51 else None,
@@ -186,25 +291,95 @@ def _outcome(
return "no_status_change_before_timeout"
async def read_wifi_status_once(
async def _read_wifi_status_impl(
device_macos_uuid: str,
*,
timeout_seconds: float = 20.0,
rediscover: bool = False,
timeout_seconds: float,
exact_scan_timeout_seconds: float,
rediscover: bool,
captured_device: CapturedDiscoveredDevice | None,
recovery_device_session_id: str | None,
allow_known_device_retrieval: bool,
on_gatt_validated: Callable[[CapturedDiscoveredDevice], None] | None,
progress: BleOperationProgress,
) -> WifiStatusReadResult:
"""Read the K1's current DHCP status over BLE without writing a characteristic."""
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
async with asyncio.timeout(timeout_seconds + 5.0):
# A still-live explicit scan lease is authoritative even for a caller
# requesting recovery. Physical acceptance proved that immediately
# looking the same CoreBluetooth UUID up again can lose a present K1.
# ``rediscover`` therefore permits fallback only after that short lease
# has expired; it never discards a fresh retained BLEDevice.
selection = discovered_device_selection(device_macos_uuid)
device = selection.device
if device is None and not selection.from_fresh_scan:
active_captured_device: CapturedDiscoveredDevice | None = None
gatt_baseline_validated = False
try:
progress.operation_stage = "resolution"
if captured_device is not None:
# Explicit recovery is fail-closed: only the exact retrieved
# CoreBluetooth object may be used. Never replace it with a scan,
# UUID lookup, or automatic retry behind the operator's back.
device = (
captured_device_handle(captured_device)
if captured_device.macos_uuid.casefold()
== device_macos_uuid.casefold()
else None
)
if device is not None:
active_captured_device = captured_device
selection_from_fresh_scan = False
elif recovery_device_session_id is not None:
# Same-process recovery is resolved inside the status-read BLE
# lease. Retrieval, connect, reviewed GATT contract and 7f02 are
# therefore one serialized operation; no scan row or UUID lookup
# can race between those steps.
active_captured_device = connected_device_capture(
device_macos_uuid,
device_session_id=recovery_device_session_id,
)
if active_captured_device is None:
active_captured_device = await retrieve_connected_device_capture(
device_macos_uuid,
device_session_id=recovery_device_session_id,
)
device = (
captured_device_handle(active_captured_device)
if active_captured_device is not None
else None
)
selection_from_fresh_scan = False
elif allow_known_device_retrieval:
# Durable physical recovery deliberately bypasses public scan
# generations. ``rediscover`` means one primary, unfiltered
# advertisement wait for this exact CoreBluetooth UUID; it never
# attempts the potentially stale cached peripheral first and never
# falls back to it after a timeout or failed connect.
if rediscover:
progress.operation_stage = "exact-uuid-scan"
active_captured_device = (
await discover_known_device_capture_for_status_read(
device_macos_uuid,
timeout_seconds=exact_scan_timeout_seconds,
)
)
else:
active_captured_device = (
await retrieve_known_device_capture_for_status_read(
device_macos_uuid,
)
)
device = (
captured_device_handle(active_captured_device)
if active_captured_device is not None
else None
)
selection_from_fresh_scan = False
else:
# A still-live explicit scan lease is authoritative. Physical
# acceptance proved that immediately looking the same UUID up
# again can lose a present K1.
selection = discovered_device_selection(device_macos_uuid)
device = selection.device
selection_from_fresh_scan = selection.from_fresh_scan
if (
device is None
and captured_device is None
and recovery_device_session_id is None
and not selection_from_fresh_scan
and not allow_known_device_retrieval
):
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=timeout_seconds,
@@ -212,23 +387,61 @@ async def read_wifi_status_once(
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
"Exact BLE device is unavailable; run an explicit recovery or scan.",
)
progress.operation_stage = "connect"
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
progress.operation_stage = "gatt-contract"
service = client.services.get_service(SERVICE_UUID)
status_characteristic = client.services.get_characteristic(STATUS_CHARACTERISTIC_UUID)
write_characteristic = client.services.get_characteristic(
WRITE_CHARACTERISTIC_UUID
)
status_characteristic = client.services.get_characteristic(
STATUS_CHARACTERISTIC_UUID
)
if service is None:
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
if write_characteristic is None:
raise ValueError(
"Reviewed K1 write characteristic not found: "
f"{WRITE_CHARACTERISTIC_UUID}"
)
if status_characteristic is None:
raise ValueError(
f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}"
"Reviewed K1 status characteristic not found: "
f"{STATUS_CHARACTERISTIC_UUID}"
)
if write_characteristic.service_uuid != service.uuid:
raise ValueError(
"K1 write characteristic is attached to an unexpected service"
)
if status_characteristic.service_uuid != service.uuid:
raise ValueError("K1 status characteristic is attached to an unexpected service")
raise ValueError(
"K1 status characteristic is attached to an unexpected service"
)
if "read" not in status_characteristic.properties:
raise ValueError("Reviewed K1 status characteristic is not readable")
write_properties = sorted(
{str(item) for item in write_characteristic.properties}
)
max_without_response = _optional_int_attribute(
write_characteristic,
"max_write_without_response_size",
)
mtu_size = _optional_int_attribute(client, "mtu_size")
progress.operation_stage = "status-read"
value = bytes(await client.read_gatt_char(status_characteristic))
status = parse_wifi_status(value)
if active_captured_device is not None and not (
mark_captured_device_gatt_validated(active_captured_device)
):
raise RuntimeError(
"Exact BLE recovery handle changed before status validation"
)
gatt_baseline_validated = True
if active_captured_device is not None and on_gatt_validated is not None:
on_gatt_validated(active_captured_device)
return {
"schema_version": 1,
"profile_id": PROFILE_ID,
@@ -238,11 +451,325 @@ async def read_wifi_status_once(
"device_macos_uuid": device_macos_uuid,
"device_name": client.name or "",
"service_uuid": service.uuid,
"write_characteristic_uuid": write_characteristic.uuid,
"write_characteristic_properties": write_properties,
"max_write_without_response_size": max_without_response,
"mtu_size": mtu_size,
"status_characteristic_uuid": status_characteristic.uuid,
"operation": "single_reviewed_wifi_status_read",
"write_performed": False,
"status": parse_wifi_status(value),
"status": status,
}
except Exception as exc:
_annotate_status_read_error(exc, progress.operation_stage)
raise
finally:
# Connection/contract/read failure revokes only this transport lease;
# the process-scoped UUID/session token remains available for another
# explicit CoreBluetooth retrieval attempt.
if active_captured_device is not None and not gatt_baseline_validated:
demote_connected_device_handle_after_gatt_failure(active_captured_device)
async def read_wifi_status_once(
device_macos_uuid: str,
*,
timeout_seconds: float = 20.0,
exact_scan_timeout_seconds: float = 30.0,
rediscover: bool = False,
captured_device: CapturedDiscoveredDevice | None = None,
recovery_device_session_id: str | None = None,
allow_known_device_retrieval: bool = False,
on_gatt_validated: Callable[[CapturedDiscoveredDevice], None] | None = None,
) -> WifiStatusReadResult:
"""Read the K1's current DHCP status over BLE without writing a characteristic."""
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
if captured_device is not None and recovery_device_session_id is not None:
raise ValueError(
"captured_device and recovery_device_session_id are mutually exclusive"
)
if recovery_device_session_id == "":
raise ValueError("recovery_device_session_id must not be empty")
if recovery_device_session_id is not None and allow_known_device_retrieval:
raise ValueError(
"same-process recovery and durable known-device retrieval are mutually exclusive"
)
use_exact_uuid_scan = bool(
rediscover
and allow_known_device_retrieval
and captured_device is None
and recovery_device_session_id is None
)
if use_exact_uuid_scan and (
not math.isfinite(exact_scan_timeout_seconds)
or exact_scan_timeout_seconds <= 0
):
raise ValueError("exact_scan_timeout_seconds must be positive and finite")
exact_scan_budget = exact_scan_timeout_seconds if use_exact_uuid_scan else 0.0
return await run_ble_operation(
"status-read",
hard_timeout_seconds=(
exact_scan_budget
+ timeout_seconds
+ BLE_STATUS_HARD_TIMEOUT_GRACE_SECONDS
),
operation=lambda progress: _read_wifi_status_impl(
device_macos_uuid,
timeout_seconds=timeout_seconds,
exact_scan_timeout_seconds=exact_scan_timeout_seconds,
rediscover=rediscover,
captured_device=captured_device,
recovery_device_session_id=recovery_device_session_id,
allow_known_device_retrieval=allow_known_device_retrieval,
on_gatt_validated=on_gatt_validated,
progress=progress,
),
progress=BleOperationProgress(operation_stage="resolution"),
)
async def _provision_wifi_impl(
device_macos_uuid: str,
ssid: str,
password: str,
*,
timeout_seconds: float,
poll_interval_seconds: float,
write_mode: WriteMode,
captured_device: CapturedDiscoveredDevice | None,
recovery_device_session_id: str | None,
on_write_dispatch: Callable[[WifiStatus, ResolvedWriteMode], None] | None,
progress: BleOperationProgress,
) -> WifiProvisioningResult:
frame = build_wifi_provisioning_frame(ssid, password)
started_at = utc_now_iso()
observations: list[StatusObservation] = []
disconnected = False
operation_stage: BleOperationStage = "resolution"
device_write_attempted = False
device_write_confirmed = False
resolved_write_mode_for_error: ResolvedWriteMode | None = None
write_characteristic_properties: tuple[str, ...] | None = None
max_without_response: int | None = None
active_captured_device: CapturedDiscoveredDevice | None = None
gatt_baseline_validated = False
try:
progress.operation_stage = operation_stage
# The explicit UI scan and its selected network action are one
# CoreBluetooth lifecycle. A supplied capture is fail-closed: never
# replace an expired/mismatched object with a scan or UUID lookup.
if captured_device is not None:
device = (
captured_device_handle(captured_device)
if captured_device.macos_uuid.casefold()
== device_macos_uuid.casefold()
else None
)
if device is not None:
active_captured_device = captured_device
selection_from_fresh_scan = False
elif recovery_device_session_id is not None:
# Resolve an exact retained UUID/session token only after the
# wifi-provision arbiter lease has been admitted. This prevents a
# concurrent scan/status operation and removes the preflight-to-
# write lease gap.
active_captured_device = connected_device_capture(
device_macos_uuid,
device_session_id=recovery_device_session_id,
)
if active_captured_device is None:
active_captured_device = await retrieve_connected_device_capture(
device_macos_uuid,
device_session_id=recovery_device_session_id,
)
device = (
captured_device_handle(active_captured_device)
if active_captured_device is not None
else None
)
selection_from_fresh_scan = False
else:
selection = discovered_device_selection(device_macos_uuid)
device = selection.device
selection_from_fresh_scan = selection.from_fresh_scan
if (
device is None
and captured_device is None
and recovery_device_session_id is None
and not selection_from_fresh_scan
):
# Non-UI callers without a current explicit scan retain the
# bounded lookup fallback. A fresh scan missing this device is
# authoritative and must not be silently replaced here.
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
)
operation_stage = "connect"
progress.operation_stage = operation_stage
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
device_name = client.name
operation_stage = "gatt-contract"
progress.operation_stage = operation_stage
service = client.services.get_service(SERVICE_UUID)
write_characteristic = client.services.get_characteristic(WRITE_CHARACTERISTIC_UUID)
status_characteristic = client.services.get_characteristic(STATUS_CHARACTERISTIC_UUID)
if service is None:
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
if write_characteristic is None:
raise ValueError(
f"Reviewed K1 write characteristic not found: {WRITE_CHARACTERISTIC_UUID}"
)
if status_characteristic is None:
raise ValueError(
f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}"
)
if write_characteristic.service_uuid != service.uuid:
raise ValueError("K1 write characteristic is attached to an unexpected service")
if status_characteristic.service_uuid != service.uuid:
raise ValueError("K1 status characteristic is attached to an unexpected service")
if "read" not in status_characteristic.properties:
raise ValueError("Reviewed K1 status characteristic is not readable")
properties = set(write_characteristic.properties)
write_characteristic_properties = tuple(sorted(properties))
max_without_response = _optional_int_attribute(
write_characteristic,
"max_write_without_response_size",
)
resolved_write_mode: ResolvedWriteMode
if write_mode == "auto":
if "write-without-response" in properties:
resolved_write_mode = "without_response"
elif "write" in properties:
resolved_write_mode = "with_response"
else:
raise ValueError("Reviewed K1 characteristic is not writable")
elif write_mode == "with_response":
if "write" not in properties:
raise ValueError(
"Reviewed K1 characteristic does not advertise writes with response"
)
resolved_write_mode = "with_response"
else:
resolved_write_mode = "without_response"
resolved_write_mode_for_error = resolved_write_mode
if resolved_write_mode == "without_response":
if max_without_response is None:
raise ValueError("Negotiated write-without-response size is unavailable")
if len(frame) > max_without_response:
raise ValueError(
"Provisioning frame exceeds the negotiated write-without-response size"
)
operation_stage = "baseline-read"
progress.operation_stage = operation_stage
baseline_value = bytes(await client.read_gatt_char(status_characteristic))
baseline = parse_wifi_status(baseline_value)
if active_captured_device is not None and not (
mark_captured_device_gatt_validated(active_captured_device)
):
raise RuntimeError(
"Exact BLE recovery handle changed before provisioning validation"
)
gatt_baseline_validated = True
operation_stage = "gatt-write"
# This callback is the durable side-effect boundary. It must finish
# before CoreBluetooth receives the frame, so a process crash can
# only create a conservative false-positive fence, never an unsafe
# forgotten write. It receives no SSID, password, or frame bytes.
if on_write_dispatch is not None:
on_write_dispatch(baseline, resolved_write_mode)
device_write_attempted = True
progress.operation_stage = operation_stage
progress.device_write_attempted = True
await client.write_gatt_char(
write_characteristic,
frame,
response=resolved_write_mode == "with_response",
)
device_write_confirmed = resolved_write_mode == "with_response"
progress.device_write_confirmed = device_write_confirmed
write_completed = monotonic()
deadline = write_completed + timeout_seconds
operation_stage = "status-poll"
progress.operation_stage = operation_stage
while monotonic() < deadline:
try:
value = bytes(await client.read_gatt_char(status_characteristic))
except BleakError:
if not client.is_connected:
disconnected = True
break
raise
status = parse_wifi_status(value)
observation: StatusObservation = {
"observed_at_utc": utc_now_iso(),
"seconds_after_write": round(monotonic() - write_completed, 3),
"status": status,
}
if not observations or status != observations[-1]["status"]:
observations.append(observation)
if status["ipv4"] not in (None, AP_FALLBACK_IPV4):
break
await asyncio.sleep(poll_interval_seconds)
return {
"schema_version": 1,
"profile_id": PROFILE_ID,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": device_name,
"service_uuid": service.uuid,
"write_characteristic_uuid": write_characteristic.uuid,
"status_characteristic_uuid": status_characteristic.uuid,
"operation": "single_reviewed_wifi_provisioning_write",
"write_mode": resolved_write_mode,
"write_without_response_advertised": ("write-without-response" in properties),
"max_write_without_response_size": max_without_response,
"frame_length": len(frame),
"baseline_status": baseline,
"observations": observations,
"outcome": _outcome(baseline, observations, disconnected),
}
except Exception as exc:
if active_captured_device is not None and not gatt_baseline_validated:
demote_connected_device_handle_after_gatt_failure(active_captured_device)
_annotate_ble_operation_error(
exc,
operation_stage=operation_stage,
device_write_attempted=device_write_attempted,
device_write_confirmed=device_write_confirmed,
resolved_write_mode=resolved_write_mode_for_error,
write_characteristic_properties=write_characteristic_properties,
max_write_without_response_size=max_without_response,
frame_length=len(frame),
)
raise
finally:
# Hard-timeout cancellation may bypass ``except Exception``. The
# failed live transport lease must still be demoted when the exact
# captured object never completed the reviewed baseline read; the
# UUID/session recovery token itself remains available for a later
# explicit attempt.
if active_captured_device is not None and not gatt_baseline_validated:
demote_connected_device_handle_after_gatt_failure(active_captured_device)
frame[:] = b"\x00" * len(frame)
async def provision_wifi_once(
@@ -252,160 +779,40 @@ async def provision_wifi_once(
timeout_seconds: float = 45.0,
poll_interval_seconds: float = 1.0,
write_mode: WriteMode = "auto",
*,
captured_device: CapturedDiscoveredDevice | None = None,
recovery_device_session_id: str | None = None,
on_write_dispatch: Callable[[WifiStatus, ResolvedWriteMode], None] | None = None,
) -> WifiProvisioningResult:
"""Perform one reviewed provisioning write and poll the K1 status characteristic."""
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
if poll_interval_seconds <= 0:
raise ValueError("poll_interval_seconds must be positive")
if write_mode not in ("auto", "with_response", "without_response"):
raise ValueError(f"Unsupported write mode: {write_mode}")
frame = build_wifi_provisioning_frame(ssid, password)
started_at = utc_now_iso()
observations: list[StatusObservation] = []
disconnected = False
operation_stage: BleOperationStage = "resolution"
device_write_attempted = False
device_write_confirmed = False
try:
async with asyncio.timeout(timeout_seconds + 25.0):
# The explicit UI scan and its selected network action are one
# CoreBluetooth lifecycle. Physical acceptance proved that a
# second UUID lookup can fail moments after a successful scan, so
# use the exact retained handle while its short lease is fresh.
selection = discovered_device_selection(device_macos_uuid)
device = selection.device
if device is None and not selection.from_fresh_scan:
# Non-UI callers without a current explicit scan retain the
# bounded lookup fallback. A fresh scan missing this device is
# authoritative and must not be silently replaced here.
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
)
operation_stage = "connect"
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
device_name = client.name
operation_stage = "gatt-contract"
service = client.services.get_service(SERVICE_UUID)
write_characteristic = client.services.get_characteristic(WRITE_CHARACTERISTIC_UUID)
status_characteristic = client.services.get_characteristic(
STATUS_CHARACTERISTIC_UUID
)
if service is None:
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
if write_characteristic is None:
raise ValueError(
f"Reviewed K1 write characteristic not found: {WRITE_CHARACTERISTIC_UUID}"
)
if status_characteristic is None:
raise ValueError(
f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}"
)
if write_characteristic.service_uuid != service.uuid:
raise ValueError("K1 write characteristic is attached to an unexpected service")
if status_characteristic.service_uuid != service.uuid:
raise ValueError(
"K1 status characteristic is attached to an unexpected service"
)
if "read" not in status_characteristic.properties:
raise ValueError("Reviewed K1 status characteristic is not readable")
properties = set(write_characteristic.properties)
max_without_response = write_characteristic.max_write_without_response_size
resolved_write_mode: ResolvedWriteMode
if write_mode == "auto":
if "write-without-response" in properties:
resolved_write_mode = "without_response"
elif "write" in properties:
resolved_write_mode = "with_response"
else:
raise ValueError("Reviewed K1 characteristic is not writable")
elif write_mode == "with_response":
if "write" not in properties:
raise ValueError(
"Reviewed K1 characteristic does not advertise writes with response"
)
resolved_write_mode = "with_response"
else:
if len(frame) > max_without_response:
raise ValueError(
"Provisioning frame exceeds the negotiated write-without-response size"
)
resolved_write_mode = "without_response"
operation_stage = "baseline-read"
baseline_value = bytes(await client.read_gatt_char(status_characteristic))
baseline = parse_wifi_status(baseline_value)
operation_stage = "gatt-write"
device_write_attempted = True
await client.write_gatt_char(
write_characteristic,
frame,
response=resolved_write_mode == "with_response",
)
device_write_confirmed = resolved_write_mode == "with_response"
write_completed = monotonic()
deadline = write_completed + timeout_seconds
operation_stage = "status-poll"
while monotonic() < deadline:
try:
value = bytes(await client.read_gatt_char(status_characteristic))
except BleakError:
if not client.is_connected:
disconnected = True
break
raise
status = parse_wifi_status(value)
observation: StatusObservation = {
"observed_at_utc": utc_now_iso(),
"seconds_after_write": round(monotonic() - write_completed, 3),
"status": status,
}
if not observations or status != observations[-1]["status"]:
observations.append(observation)
if status["ipv4"] not in (None, AP_FALLBACK_IPV4):
break
await asyncio.sleep(poll_interval_seconds)
return {
"schema_version": 1,
"profile_id": PROFILE_ID,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": device_name,
"service_uuid": service.uuid,
"write_characteristic_uuid": write_characteristic.uuid,
"status_characteristic_uuid": status_characteristic.uuid,
"operation": "single_reviewed_wifi_provisioning_write",
"write_mode": resolved_write_mode,
"write_without_response_advertised": ("write-without-response" in properties),
"max_write_without_response_size": max_without_response,
"frame_length": len(frame),
"baseline_status": baseline,
"observations": observations,
"outcome": _outcome(baseline, observations, disconnected),
}
except Exception as exc:
_annotate_ble_operation_error(
exc,
operation_stage=operation_stage,
device_write_attempted=device_write_attempted,
device_write_confirmed=device_write_confirmed,
if captured_device is not None and recovery_device_session_id is not None:
raise ValueError(
"captured_device and recovery_device_session_id are mutually exclusive"
)
raise
finally:
frame[:] = b"\x00" * len(frame)
if recovery_device_session_id == "":
raise ValueError("recovery_device_session_id must not be empty")
progress = BleOperationProgress(operation_stage="resolution")
return await run_ble_operation(
"wifi-provision",
hard_timeout_seconds=(timeout_seconds + BLE_PROVISION_HARD_TIMEOUT_GRACE_SECONDS),
operation=lambda operation_progress: _provision_wifi_impl(
device_macos_uuid,
ssid,
password,
timeout_seconds=timeout_seconds,
poll_interval_seconds=poll_interval_seconds,
write_mode=write_mode,
captured_device=captured_device,
recovery_device_session_id=recovery_device_session_id,
on_write_dispatch=on_write_dispatch,
progress=operation_progress,
),
progress=progress,
)
File diff suppressed because it is too large Load Diff
+411 -31
View File
@@ -1,13 +1,21 @@
from __future__ import annotations
import asyncio
import fcntl
import http.client
import json
import os
import platform
import shutil
import stat
import subprocess
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, TypedDict
from typing import Annotated, Literal, TypedDict
from uuid import UUID
import typer
import uvicorn
@@ -35,8 +43,17 @@ from k1link.device_plugins.xgrids_k1.analyze import (
run_calibrated_overlay_experiment,
summarize_mqtt_streams,
)
from k1link.device_plugins.xgrids_k1.application_control_process_lease import (
ApplicationControlProcessLease,
ApplicationControlProcessLeaseError,
)
from k1link.device_plugins.xgrids_k1.archive import discover_legacy_viewer_sessions
from k1link.device_plugins.xgrids_k1.ble.gatt import dump_metadata
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
borrow_ble_runtime_process_lease,
configure_ble_runtime_process_lease,
defer_until_ble_runtime_idle,
)
from k1link.device_plugins.xgrids_k1.ble.scanner import scan
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
PROFILE_ID,
@@ -54,6 +71,10 @@ from k1link.device_plugins.xgrids_k1.mqtt import (
capture_mqtt,
)
from k1link.device_plugins.xgrids_k1.net.snapshot import snapshot
from k1link.device_plugins.xgrids_k1.physical_command_ledger import (
PhysicalCommandLedger,
active_operator_retirements,
)
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
ApplicationAuthorityLoadError,
MacOSKeychainApplicationAuthorityProvisioner,
@@ -93,6 +114,243 @@ app.add_typer(compute_app, name="compute")
app.add_typer(lab_app, name="lab")
app.add_typer(artifact_app, name="artifact")
_CANONICAL_MISSION_CORE_PORT = 8000
_MISSION_CORE_SERVE_LOCK_FILENAME = ".serve.lock"
class _MissionCoreServeLeaseError(RuntimeError):
"""The canonical backend singleton lock cannot be trusted."""
class _MissionCoreServeLeaseUnavailable(_MissionCoreServeLeaseError):
"""Another live process owns canonical backend startup or runtime."""
@dataclass(slots=True)
class _MissionCoreServeLease:
"""Stable OS-owned lease for the complete canonical Uvicorn lifetime."""
path: Path
_descriptor: int
_identity: tuple[int, int]
_released: bool = False
@classmethod
def acquire(cls, repository_root: Path) -> _MissionCoreServeLease:
runtime_dir = repository_root.expanduser().resolve() / ".runtime"
_ensure_serve_runtime_directory(runtime_dir)
lock_dir = runtime_dir / "mission-core"
_ensure_private_serve_lock_directory(lock_dir)
path = lock_dir / _MISSION_CORE_SERVE_LOCK_FILENAME
common_flags = os.O_RDWR | getattr(os, "O_CLOEXEC", 0)
common_flags |= getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, common_flags | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
try:
descriptor = os.open(path, common_flags)
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock cannot be opened safely"
) from exc
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock cannot be created safely"
) from exc
locked = False
try:
opened = os.fstat(descriptor)
_validate_private_serve_lock_file(opened)
try:
current = path.lstat()
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock identity is unavailable"
) from exc
_validate_private_serve_lock_file(current)
identity = (opened.st_dev, opened.st_ino)
if identity != (current.st_dev, current.st_ino):
raise _MissionCoreServeLeaseError(
"Mission Core serve lock changed while opening"
)
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
locked = True
except BlockingIOError as exc:
raise _MissionCoreServeLeaseUnavailable(
"another process owns Mission Core startup or runtime"
) from exc
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock cannot be acquired safely"
) from exc
try:
locked_path = path.lstat()
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock disappeared after acquisition"
) from exc
_validate_private_serve_lock_file(locked_path)
if identity != (locked_path.st_dev, locked_path.st_ino):
raise _MissionCoreServeLeaseError(
"Mission Core serve lock changed during acquisition"
)
return cls(path=path, _descriptor=descriptor, _identity=identity)
except BaseException:
if locked:
_unlock_serve_descriptor(descriptor)
os.close(descriptor)
raise
def release(self) -> None:
if self._released:
return
try:
_unlock_serve_descriptor(self._descriptor)
finally:
os.close(self._descriptor)
self._released = True
def __enter__(self) -> _MissionCoreServeLease:
return self
def __exit__(self, *_: object) -> None:
self.release()
def _ensure_serve_runtime_directory(path: Path) -> None:
try:
path.mkdir(mode=0o700, parents=False, exist_ok=False)
except FileExistsError:
pass
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core runtime directory is unavailable"
) from exc
try:
metadata = path.lstat()
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core runtime directory identity is unavailable"
) from exc
if not stat.S_ISDIR(metadata.st_mode):
raise _MissionCoreServeLeaseError(
"Mission Core runtime directory is not a regular directory"
)
def _ensure_private_serve_lock_directory(path: Path) -> None:
try:
path.mkdir(mode=0o700, parents=False, exist_ok=False)
except FileExistsError:
pass
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock directory is unavailable"
) from exc
try:
metadata = path.lstat()
except OSError as exc:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock directory identity is unavailable"
) from exc
if not stat.S_ISDIR(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o700:
raise _MissionCoreServeLeaseError(
"Mission Core serve lock directory is not private"
)
def _validate_private_serve_lock_file(metadata: os.stat_result) -> None:
if (
not stat.S_ISREG(metadata.st_mode)
or stat.S_IMODE(metadata.st_mode) != 0o600
or metadata.st_nlink != 1
):
raise _MissionCoreServeLeaseError(
"Mission Core serve lock is not a private regular file"
)
def _unlock_serve_descriptor(descriptor: int) -> None:
try:
fcntl.flock(descriptor, fcntl.LOCK_UN)
except OSError:
return
def _acquire_mission_core_serve_lease(repository_root: Path) -> _MissionCoreServeLease:
return _MissionCoreServeLease.acquire(repository_root)
def _configure_ble_process_lease() -> Path:
"""Pin every standalone CLI BLE call to Mission Core's global K1 lock."""
repository_root = Path(__file__).resolve().parents[4]
configure_ble_runtime_process_lease(repository_root)
return repository_root
class _CliWifiProvisioningBlocked(RuntimeError):
"""The standalone mutating BLE command lacks safe physical authority."""
def _canonical_corebluetooth_uuid(value: str) -> str:
try:
return str(UUID(str(value).strip())).upper()
except (AttributeError, ValueError) as exc:
raise _CliWifiProvisioningBlocked(
"BLE device target is not a canonical CoreBluetooth UUID"
) from exc
def _require_cli_wifi_target_not_retired(
repository_root: Path,
device: str,
) -> str:
canonical_device = _canonical_corebluetooth_uuid(device)
snapshot = PhysicalCommandLedger(repository_root).snapshot()
if snapshot.status == "corrupt":
raise _CliWifiProvisioningBlocked(
"physical command audit is unavailable; Wi-Fi write is blocked"
)
record = snapshot.record
if record is not None:
for retirement in active_operator_retirements(record):
retired_device = _canonical_corebluetooth_uuid(
retirement.retired_transport_ref
)
if retired_device == canonical_device:
raise _CliWifiProvisioningBlocked(
"the selected BLE UUID belongs to an operator-retired K1"
)
return canonical_device
@contextmanager
def _cli_wifi_mutation_lease(device: str) -> Iterator[str]:
"""Fence one explicit CLI Wi-Fi write against retirement and Mission Core."""
repository_root = _configure_ble_process_lease()
lease = ApplicationControlProcessLease.acquire(repository_root)
try:
# This durable check is intentionally after acquiring the same global
# flock as the backend retirement action. Retirement either commits
# first and this write is denied, or this explicit write owns the fence
# through credential entry and the complete native BLE lifecycle.
canonical_device = _require_cli_wifi_target_not_retired(
repository_root,
device,
)
with borrow_ble_runtime_process_lease(lease):
yield canonical_device
finally:
# A hard CoreBluetooth timeout may return before its native cleanup
# task is terminal. Never expose the retirement boundary until the
# shared BLE arbiter proves idle; poison intentionally keeps it held
# until process restart.
defer_until_ble_runtime_idle(lease.release)
class ToolStatus(TypedDict):
name: str
@@ -837,25 +1095,133 @@ def publish_e26_lab(
def serve_console(
port: Annotated[
int,
typer.Option(min=1024, max=65535, help="Loopback HTTP port for the local console."),
typer.Option(
min=1024,
max=65535,
help="Canonical loopback HTTP port; only 8000 is accepted.",
),
] = 8000,
) -> None:
"""Serve the built Mission Core Control Station and loopback control API."""
frontend = Path(__file__).resolve().parents[4] / "apps" / "control-station" / "dist"
if not frontend.is_dir():
if port != _CANONICAL_MISSION_CORE_PORT:
console.print(
"[red]Frontend build is missing.[/red] Run npm install && npm run build "
"inside apps/control-station."
f"[red]Mission Core запускается только на каноническом порту "
f"{_CANONICAL_MISSION_CORE_PORT}.[/red]"
)
console.print("Другой локальный backend не создан.")
raise typer.Exit(code=2)
console.print(f"NODEDC MISSION CORE: http://127.0.0.1:{port}")
console.print("The credential endpoint is bound to this Mac only.")
uvicorn.run(
"k1link.web.app:app",
host="127.0.0.1",
port=port,
log_level="info",
access_log=True,
repository_root = Path(__file__).resolve().parents[4]
try:
lease = _acquire_mission_core_serve_lease(repository_root)
except _MissionCoreServeLeaseUnavailable:
local_server = _local_server_status(_CANONICAL_MISSION_CORE_PORT)
if local_server == "mission-core":
_print_existing_mission_core(_CANONICAL_MISSION_CORE_PORT)
return
if local_server == "free":
console.print(
"[yellow]Mission Core уже запускается или завершает работу.[/yellow]"
)
else:
console.print(
"[red]Запуск Mission Core уже выполняется, но канонический health endpoint "
"пока не подтверждён.[/red]"
)
console.print(
"Второй backend не создан. Повторите запуск после завершения текущего перехода."
)
raise typer.Exit(code=2) from None
except _MissionCoreServeLeaseError as exc:
console.print(
"[red]Не удалось безопасно получить блокировку канонического Mission Core.[/red]"
)
console.print(f"Второй backend не создан: {exc}")
raise typer.Exit(code=2) from exc
with lease:
local_server = _local_server_status(_CANONICAL_MISSION_CORE_PORT)
if local_server == "mission-core":
_print_existing_mission_core(_CANONICAL_MISSION_CORE_PORT)
return
if local_server == "occupied":
console.print(
f"[red]Порт 127.0.0.1:{_CANONICAL_MISSION_CORE_PORT} уже занят другим "
"или неготовым процессом.[/red]"
)
console.print(
"Mission Core не стал создавать второй backend. Остановите точный "
"процесс-владелец порта и повторите запуск."
)
raise typer.Exit(code=2)
frontend = repository_root / "apps" / "control-station" / "dist"
if not frontend.is_dir():
console.print(
"[red]Frontend build is missing.[/red] Run npm install && npm run build "
"inside apps/control-station."
)
raise typer.Exit(code=2)
console.print(
f"NODEDC MISSION CORE: http://127.0.0.1:{_CANONICAL_MISSION_CORE_PORT}"
)
console.print("The credential endpoint is bound to this Mac only.")
uvicorn.run(
"k1link.web.app:app",
host="127.0.0.1",
port=_CANONICAL_MISSION_CORE_PORT,
log_level="info",
access_log=True,
)
def _print_existing_mission_core(port: int) -> None:
console.print(
f"[green]NODEDC MISSION CORE уже запущен:[/green] http://127.0.0.1:{port}"
)
console.print("Используется единственный локальный backend; второй процесс не создан.")
def _local_server_status(port: int) -> Literal["free", "mission-core", "occupied"]:
"""Classify the loopback listener before starting the singleton backend.
A healthy Mission Core listener makes ``k1link serve`` idempotent. Any
other listener fails with a short operator-facing error instead of letting
Uvicorn start application resources and then emit an opaque ``Errno 48``.
The check is observational only and never kills or replaces a process.
"""
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=0.75)
try:
connection.request(
"GET",
"/api/health",
headers={"Accept": "application/json", "Connection": "close"},
)
response = connection.getresponse()
if response.status != 200:
return "occupied"
body = response.read(16_385)
if len(body) > 16_384:
return "occupied"
payload = json.loads(body.decode("utf-8"))
except ConnectionRefusedError:
return "free"
except (
OSError,
TimeoutError,
UnicodeDecodeError,
json.JSONDecodeError,
http.client.HTTPException,
):
return "occupied"
finally:
connection.close()
return (
"mission-core"
if isinstance(payload, dict)
and payload.get("service") == "mission-core-control-plane"
else "occupied"
)
@@ -871,6 +1237,7 @@ def ble_scan(
] = 30.0,
) -> None:
"""Discover BLE advertisements without connecting or changing device configuration."""
_configure_ble_process_lease()
try:
result = asyncio.run(scan(duration))
except (BleakError, OSError, ValueError) as exc:
@@ -908,6 +1275,7 @@ def ble_gatt_dump(
] = 45.0,
) -> None:
"""Enumerate GATT metadata only: no characteristic reads, subscriptions or writes."""
_configure_ble_process_lease()
console.print(
"Connecting for service discovery only; no characteristic values will be read or written."
)
@@ -963,28 +1331,40 @@ def ble_wifi_configure(
)
raise typer.Exit(code=2)
console.print(
"Two local macOS dialogs will request the Wi-Fi name and hidden password. "
"The password is never printed, logged, or written to the result file; "
"the K1 may echo the SSID in the ignored sensitive status result."
)
ssid = ""
password = ""
try:
ssid, password = prompt_wifi_credentials()
with _cli_wifi_mutation_lease(device) as canonical_device:
console.print(
"Two local macOS dialogs will request the Wi-Fi name and hidden password. "
"The password is never printed, logged, or written to the result file; "
"the K1 may echo the SSID in the ignored sensitive status result."
)
ssid, password = prompt_wifi_credentials()
console.print(
"Credentials accepted locally. Starting the single reviewed BLE write."
)
result = asyncio.run(
provision_wifi_once(
canonical_device,
ssid,
password,
timeout_seconds=timeout,
write_mode=write_mode,
)
)
except CredentialDialogError as exc:
console.print(f"[red]Credential entry failed:[/red] {exc}")
raise typer.Exit(code=2) from exc
console.print("Credentials accepted locally. Starting the single reviewed BLE write.")
try:
result = asyncio.run(
provision_wifi_once(
device,
ssid,
password,
timeout_seconds=timeout,
write_mode=write_mode,
)
except (
_CliWifiProvisioningBlocked,
ApplicationControlProcessLeaseError,
) as exc:
console.print(
"[red]Wi-Fi provisioning blocked before credential or device access:[/red] "
f"{exc}"
)
raise typer.Exit(code=2) from exc
except (BleakError, OSError, TimeoutError, ValueError) as exc:
console.print(f"[red]Wi-Fi provisioning failed:[/red] {type(exc).__name__}: {exc}")
console.print("No automatic retry was attempted.")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,641 @@
from __future__ import annotations
import fcntl
import hmac
import json
import os
import re
import stat
import tempfile
import threading
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import IO, Literal, cast
from k1link.sessions.store import resolve_missioncore_data_dir
DEVICE_IDENTITY_PIN_SCHEMA = "missioncore.xgrids-k1-device-identity-pins/v1"
DEVICE_IDENTITY_PIN_FILENAME = "device-identity-pins.json"
DEVICE_IDENTITY_PIN_LOCK_FILENAME = ".device-identity-pins.lock"
DEVICE_IDENTITY_PIN_MAX_BYTES = 64 * 1024
DEVICE_IDENTITY_PIN_MAX_COUNT = 256
DEVICE_IDENTITY_PIN_MAX_REVISION = (1 << 63) - 1
DeviceIdentityPinStoreStatus = Literal["empty", "available", "corrupt"]
_SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:+-]{0,159}$")
_MAX_VENDOR_DEVICE_ID_BYTES = 4 * 1024
class DeviceIdentityPinStoreError(RuntimeError):
"""Base error for the durable BLE-to-K1 identity binding."""
reason_code = "device-identity-pin-store-error"
class DeviceIdentityPinStoreCorrupt(DeviceIdentityPinStoreError):
"""The private identity store cannot be trusted and fails closed."""
reason_code = "device-identity-pin-store-corrupt"
class DeviceIdentityPinMismatch(DeviceIdentityPinStoreError):
"""Live DeviceInfo identity does not match the first-contact pin."""
reason_code = "device-identity-pin-mismatch"
def __init__(
self,
*,
transport_ref: str,
expected_vendor_device_id: str,
observed_vendor_device_id: str,
expected_compatibility_profile_id: str,
observed_compatibility_profile_id: str,
) -> None:
super().__init__(
"live K1 identity/profile does not match the durable BLE transport pin"
)
self.transport_ref = transport_ref
self.expected_vendor_device_id = expected_vendor_device_id
self.observed_vendor_device_id = observed_vendor_device_id
self.expected_compatibility_profile_id = expected_compatibility_profile_id
self.observed_compatibility_profile_id = observed_compatibility_profile_id
@dataclass(frozen=True, slots=True)
class DeviceIdentityPin:
"""Immutable first-contact binding for one CoreBluetooth transport."""
transport_ref: str
vendor_device_id: str
compatibility_profile_id: str
def as_dict(self) -> dict[str, str]:
return {
"transport_ref": self.transport_ref,
"vendor_device_id": self.vendor_device_id,
"compatibility_profile_id": self.compatibility_profile_id,
}
@dataclass(frozen=True, slots=True)
class DeviceIdentityPinDecision:
pin: DeviceIdentityPin
created: bool
revision: int
@dataclass(frozen=True, slots=True)
class DeviceIdentityPinStoreSnapshot:
status: DeviceIdentityPinStoreStatus
revision: int | None
pins: tuple[DeviceIdentityPin, ...]
reason_code: str | None
def for_transport(self, transport_ref: str) -> DeviceIdentityPin | None:
return next(
(pin for pin in self.pins if pin.transport_ref == transport_ref),
None,
)
def as_public_dict(self) -> dict[str, object]:
"""Expose store health without publishing durable device identifiers."""
return {
"schema_version": DEVICE_IDENTITY_PIN_SCHEMA,
"status": self.status,
"revision": self.revision,
"pin_count": len(self.pins),
"reason_code": self.reason_code,
}
class DeviceIdentityPinStore:
"""Private, atomic first-contact K1 identity pins.
A pin binds one exact BLE ``transport_ref`` to the logical vendor
``device_id`` proved by DeviceInfo and the reviewed compatibility profile.
An IP address is intentionally absent: TCP reachability can never substitute
for this identity. The stable flock serializes the complete
reload/check/publish transaction across Mission Core processes.
"""
def __init__(self, repository_root: Path) -> None:
data_dir = resolve_missioncore_data_dir(repository_root)
self.path = data_dir / "xgrids-k1" / DEVICE_IDENTITY_PIN_FILENAME
self._lock_path = data_dir / "xgrids-k1" / DEVICE_IDENTITY_PIN_LOCK_FILENAME
self._data_dir = data_dir
self._thread_lock = threading.RLock()
self._revision = 0
self._pins: dict[str, DeviceIdentityPin] = {}
self._corrupt = False
with self._thread_lock, self._process_lock_locked():
self._reload_locked()
def snapshot(self) -> DeviceIdentityPinStoreSnapshot:
with self._thread_lock, self._process_lock_locked():
self._reload_locked()
if self._corrupt:
return DeviceIdentityPinStoreSnapshot(
status="corrupt",
revision=None,
pins=(),
reason_code=DeviceIdentityPinStoreCorrupt.reason_code,
)
pins = tuple(self._pins[key] for key in sorted(self._pins))
if not pins:
return DeviceIdentityPinStoreSnapshot(
status="empty",
revision=None,
pins=(),
reason_code=None,
)
return DeviceIdentityPinStoreSnapshot(
status="available",
revision=self._revision,
pins=pins,
reason_code=None,
)
def pin_or_match(
self,
*,
transport_ref: str,
vendor_device_id: str,
compatibility_profile_id: str,
) -> DeviceIdentityPinDecision:
"""Create the first pin or verify an exact existing pin.
Matching an existing pin is read-only and does not bump the revision or
rewrite the file. Any identity or profile mismatch is a typed,
fail-closed error and preserves the original bytes.
"""
_validate_identifier(transport_ref, field_name="transport_ref")
_validate_vendor_device_id(vendor_device_id)
_validate_identifier(
compatibility_profile_id,
field_name="compatibility_profile_id",
)
with self._thread_lock, self._process_lock_locked():
self._reload_locked()
if self._corrupt:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin store is corrupt; live K1 identity was not adopted"
)
current = self._pins.get(transport_ref)
if current is not None:
vendor_matches = hmac.compare_digest(
current.vendor_device_id,
vendor_device_id,
)
profile_matches = hmac.compare_digest(
current.compatibility_profile_id,
compatibility_profile_id,
)
if not vendor_matches or not profile_matches:
raise DeviceIdentityPinMismatch(
transport_ref=transport_ref,
expected_vendor_device_id=current.vendor_device_id,
observed_vendor_device_id=vendor_device_id,
expected_compatibility_profile_id=(
current.compatibility_profile_id
),
observed_compatibility_profile_id=compatibility_profile_id,
)
return DeviceIdentityPinDecision(
pin=current,
created=False,
revision=self._revision,
)
if len(self._pins) >= DEVICE_IDENTITY_PIN_MAX_COUNT:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin store reached its bounded pin count"
)
if self._revision >= DEVICE_IDENTITY_PIN_MAX_REVISION:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin store revision is exhausted"
)
pin = DeviceIdentityPin(
transport_ref=transport_ref,
vendor_device_id=vendor_device_id,
compatibility_profile_id=compatibility_profile_id,
)
next_pins = dict(self._pins)
next_pins[transport_ref] = pin
revision = self._revision + 1
self._persist_locked(revision=revision, pins=next_pins)
return DeviceIdentityPinDecision(
pin=pin,
created=True,
revision=revision,
)
@contextmanager
def _process_lock_locked(self) -> Iterator[None]:
data_dir_created = _ensure_private_directory(self._data_dir, parents=True)
if data_dir_created:
_fsync_directory(self._data_dir.parent)
store_dir_created = _ensure_private_directory(self.path.parent, parents=False)
if store_dir_created:
_fsync_directory(self._data_dir)
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(self._lock_path, flags, 0o600)
except OSError as exc:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin lock cannot be opened safely"
) from exc
stream: IO[bytes] | None = None
try:
try:
_validate_private_open_file(
descriptor,
self._lock_path,
label="device identity pin lock",
require_empty=True,
)
except ValueError as exc:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin lock is not a stable private file"
) from exc
stream = os.fdopen(descriptor, "r+b", closefd=True)
descriptor = -1
fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
try:
try:
_validate_private_open_file(
stream.fileno(),
self._lock_path,
label="device identity pin lock",
require_empty=True,
)
except ValueError as exc:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin lock changed while being acquired"
) from exc
_fsync_directory(self.path.parent)
yield
finally:
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
finally:
if stream is not None:
stream.close()
elif descriptor >= 0:
os.close(descriptor)
def _persist_locked(
self,
*,
revision: int,
pins: Mapping[str, DeviceIdentityPin],
) -> None:
ordered = tuple(pins[key] for key in sorted(pins))
payload: dict[str, object] = {
"schema_version": DEVICE_IDENTITY_PIN_SCHEMA,
"revision": revision,
"pins": [pin.as_dict() for pin in ordered],
}
_write_private_json_atomic(
self.path,
payload,
data_dir=self._data_dir,
)
self._revision = revision
self._pins = dict(pins)
self._corrupt = False
def _reload_locked(self) -> None:
try:
payload = _read_private_json(self.path)
except FileNotFoundError:
self._revision = 0
self._pins = {}
self._corrupt = False
return
except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError):
self._revision = 0
self._pins = {}
self._corrupt = True
return
try:
revision, pins = _document_from_mapping(payload)
except (TypeError, ValueError):
self._revision = 0
self._pins = {}
self._corrupt = True
return
self._revision = revision
self._pins = {pin.transport_ref: pin for pin in pins}
self._corrupt = False
def _read_private_json(path: Path) -> object:
try:
initial = path.lstat()
except FileNotFoundError:
raise
except OSError as exc:
raise ValueError("device identity pin file cannot be inspected safely") from exc
_validate_private_metadata(
initial,
label="device identity pin file",
require_empty=False,
)
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
try:
descriptor = os.open(path, flags)
except OSError as exc:
raise ValueError("device identity pin file cannot be opened safely") from exc
try:
metadata = _validate_private_open_file(
descriptor,
path,
label="device identity pin file",
require_empty=False,
)
if (initial.st_dev, initial.st_ino) != (metadata.st_dev, metadata.st_ino):
raise ValueError("device identity pin file changed while opening")
if metadata.st_size > DEVICE_IDENTITY_PIN_MAX_BYTES:
raise ValueError("device identity pin file exceeds the bounded size")
chunks: list[bytes] = []
remaining = DEVICE_IDENTITY_PIN_MAX_BYTES + 1
while remaining > 0:
chunk = os.read(descriptor, min(remaining, 64 * 1024))
if not chunk:
break
chunks.append(chunk)
remaining -= len(chunk)
raw = b"".join(chunks)
if len(raw) > DEVICE_IDENTITY_PIN_MAX_BYTES:
raise ValueError("device identity pin file exceeds the bounded size")
finally:
os.close(descriptor)
return json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
def _write_private_json_atomic(
path: Path,
payload: Mapping[str, object],
*,
data_dir: Path,
) -> None:
serialized = (
json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
)
+ "\n"
).encode("utf-8")
if len(serialized) > DEVICE_IDENTITY_PIN_MAX_BYTES:
raise ValueError("device identity pin payload exceeds the bounded size")
_ensure_private_directory(data_dir, parents=True)
_ensure_private_directory(path.parent, parents=False)
previous_identity = _existing_private_file_identity(path)
descriptor, temp_name = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temp_path = Path(temp_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(serialized)
stream.flush()
os.fsync(stream.fileno())
_require_unchanged_existing_path(path, previous_identity)
os.replace(temp_path, path)
_fsync_directory(path.parent)
finally:
if descriptor >= 0:
os.close(descriptor)
temp_path.unlink(missing_ok=True)
def _existing_private_file_identity(path: Path) -> tuple[int, int] | None:
try:
metadata = path.lstat()
except FileNotFoundError:
return None
_validate_private_metadata(
metadata,
label="device identity pin file",
require_empty=False,
)
return metadata.st_dev, metadata.st_ino
def _require_unchanged_existing_path(
path: Path,
expected: tuple[int, int] | None,
) -> None:
try:
metadata = path.lstat()
except FileNotFoundError:
if expected is None:
return
raise ValueError("device identity pin file disappeared during publication") from None
_validate_private_metadata(
metadata,
label="device identity pin file",
require_empty=False,
)
observed = metadata.st_dev, metadata.st_ino
if expected is None or observed != expected:
raise ValueError("device identity pin file changed during publication")
def _validate_private_open_file(
descriptor: int,
path: Path,
*,
label: str,
require_empty: bool,
) -> os.stat_result:
metadata = os.fstat(descriptor)
_validate_private_metadata(metadata, label=label, require_empty=require_empty)
try:
path_metadata = path.lstat()
except OSError as exc:
raise ValueError(f"{label} path cannot be verified") from exc
if (path_metadata.st_dev, path_metadata.st_ino) != (
metadata.st_dev,
metadata.st_ino,
):
raise ValueError(f"{label} path does not reference the opened inode")
_validate_private_metadata(path_metadata, label=label, require_empty=False)
return metadata
def _validate_private_metadata(
metadata: os.stat_result,
*,
label: str,
require_empty: bool,
) -> None:
if not stat.S_ISREG(metadata.st_mode):
raise ValueError(f"{label} is not a regular file")
if stat.S_IMODE(metadata.st_mode) != 0o600:
raise ValueError(f"{label} is not private")
if metadata.st_nlink != 1:
raise ValueError(f"{label} has an unsafe hard link")
if require_empty and metadata.st_size != 0:
raise ValueError(f"{label} must remain empty")
def _ensure_private_directory(path: Path, *, parents: bool) -> bool:
try:
metadata = path.lstat()
except FileNotFoundError:
try:
path.mkdir(mode=0o700, parents=parents, exist_ok=False)
except FileExistsError:
metadata = path.lstat()
else:
path.chmod(0o700)
return True
except OSError as exc:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin directory is unavailable"
) from exc
if not stat.S_ISDIR(metadata.st_mode):
raise DeviceIdentityPinStoreCorrupt(
"device identity pin directory is not a regular private directory"
)
if stat.S_IMODE(metadata.st_mode) != 0o700:
raise DeviceIdentityPinStoreCorrupt(
"device identity pin directory permissions are not private"
)
return False
def _fsync_directory(path: Path) -> None:
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
descriptor = os.open(path, flags)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
document: dict[str, object] = {}
for key, value in pairs:
if key in document:
raise ValueError("device identity pin store contains duplicate fields")
document[key] = value
return document
def _document_from_mapping(value: object) -> tuple[int, tuple[DeviceIdentityPin, ...]]:
document = _exact_mapping(
value,
{"schema_version", "revision", "pins"},
label="device identity pin document",
)
if document["schema_version"] != DEVICE_IDENTITY_PIN_SCHEMA:
raise ValueError("unsupported device identity pin schema")
revision = _positive_revision(document["revision"])
raw_pins = document["pins"]
if not isinstance(raw_pins, list) or not 1 <= len(raw_pins) <= DEVICE_IDENTITY_PIN_MAX_COUNT:
raise ValueError("device identity pin list has an invalid bounded size")
pins: list[DeviceIdentityPin] = []
seen_transport_refs: set[str] = set()
for raw_pin in raw_pins:
pin_document = _exact_mapping(
raw_pin,
{"transport_ref", "vendor_device_id", "compatibility_profile_id"},
label="device identity pin",
)
transport_ref = _required_string(
pin_document["transport_ref"],
field_name="transport_ref",
)
vendor_device_id = _required_string(
pin_document["vendor_device_id"],
field_name="vendor_device_id",
)
compatibility_profile_id = _required_string(
pin_document["compatibility_profile_id"],
field_name="compatibility_profile_id",
)
_validate_identifier(transport_ref, field_name="transport_ref")
_validate_vendor_device_id(vendor_device_id)
_validate_identifier(
compatibility_profile_id,
field_name="compatibility_profile_id",
)
if transport_ref in seen_transport_refs:
raise ValueError("device identity pin transport_ref is duplicated")
seen_transport_refs.add(transport_ref)
pins.append(
DeviceIdentityPin(
transport_ref=transport_ref,
vendor_device_id=vendor_device_id,
compatibility_profile_id=compatibility_profile_id,
)
)
if pins != sorted(pins, key=lambda pin: pin.transport_ref):
raise ValueError("device identity pins are not in canonical order")
if revision != len(pins):
raise ValueError("device identity pin revision does not match immutable pin count")
return revision, tuple(pins)
def _exact_mapping(value: object, keys: set[str], *, label: str) -> Mapping[str, object]:
if not isinstance(value, dict) or set(value) != keys:
raise ValueError(f"{label} does not match the secret-free schema")
return cast(Mapping[str, object], value)
def _required_string(value: object, *, field_name: str) -> str:
if not isinstance(value, str) or not value:
raise ValueError(f"{field_name} must be a non-empty string")
return value
def _validate_identifier(value: str, *, field_name: str) -> None:
if _SAFE_IDENTIFIER.fullmatch(value) is None:
raise ValueError(f"{field_name} is outside the secret-free identifier schema")
def _validate_vendor_device_id(value: str) -> None:
try:
encoded = value.encode("ascii")
except UnicodeEncodeError as exc:
raise ValueError("vendor_device_id must use printable ASCII") from exc
if not encoded or len(encoded) > _MAX_VENDOR_DEVICE_ID_BYTES:
raise ValueError("vendor_device_id is outside the bounded identity schema")
if any(byte <= 0x20 or byte > 0x7E for byte in encoded):
raise ValueError("vendor_device_id must use printable ASCII without spaces")
def _positive_revision(value: object) -> int:
if (
isinstance(value, bool)
or not isinstance(value, int)
or value < 1
or value > DEVICE_IDENTITY_PIN_MAX_REVISION
):
raise ValueError("device identity pin revision must be a bounded positive integer")
return value
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,429 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Final, Literal
from bleak.exc import (
BleakBluetoothNotAvailableError,
BleakBluetoothNotAvailableReason,
)
HostDiagnosticBoundary = Literal[
"corebluetooth",
"corewlan",
"keychain",
"route",
"tcp",
"mqtt",
"filesystem",
]
HostDiagnosticDomain = HostDiagnosticBoundary
HostDiagnosticCode = Literal[
"host.bluetooth.permission-denied",
"host.bluetooth.adapter-powered-off",
"host.bluetooth.adapter-unavailable",
"host.bluetooth.runtime-unavailable",
"host.bluetooth.operation-timeout",
"host.wifi.permission-denied",
"host.wifi.adapter-powered-off",
"host.wifi.interface-unavailable",
"host.wifi.ssid-unavailable",
"host.wifi.operation-timeout",
"host.wifi.association-failed",
"host.keychain.interaction-required",
"host.keychain.permission-denied",
"host.keychain.unavailable",
"host.route.unavailable",
"host.tcp.connection-refused",
"host.tcp.connection-timeout",
"host.tcp.endpoint-unavailable",
"host.mqtt.connection-timeout",
"host.mqtt.connection-refused",
"host.mqtt.transport-unavailable",
"host.filesystem.permission-denied",
"host.filesystem.ledger-unavailable",
]
HostDiagnosticImpact = Literal[
"discovery",
"host-network",
"control",
"durable-safety",
]
HostDiagnosticAction = Literal[
"grant-bluetooth-permission",
"power-on-bluetooth",
"restore-bluetooth-adapter",
"grant-wifi-permission",
"power-on-wifi",
"restore-wifi-interface",
"unlock-or-authorize-keychain",
"review-keychain-access",
"join-expected-network",
"inspect-host-route",
"verify-broker-endpoint",
"inspect-local-storage",
"restart-local-service",
"explicit-retry",
]
@dataclass(frozen=True, slots=True)
class HostFailureDiagnostic:
"""Secret-free operator diagnostic for one host-side failure boundary."""
code: HostDiagnosticCode
domain: HostDiagnosticDomain
impact: HostDiagnosticImpact
operator_action: HostDiagnosticAction
def as_dict(self) -> dict[str, object]:
return {
"schema_version": "missioncore.host-failure-diagnostic/v1",
"code": self.code,
"domain": self.domain,
"impact": self.impact,
"operator_action": self.operator_action,
"automatic_retry": False,
"redacted": True,
}
@dataclass(frozen=True, slots=True)
class _DiagnosticSpec:
code: HostDiagnosticCode
domain: HostDiagnosticDomain
impact: HostDiagnosticImpact
operator_action: HostDiagnosticAction
def diagnostic(self) -> HostFailureDiagnostic:
return HostFailureDiagnostic(
code=self.code,
domain=self.domain,
impact=self.impact,
operator_action=self.operator_action,
)
_BLUETOOTH_PERMISSION = _DiagnosticSpec(
"host.bluetooth.permission-denied",
"corebluetooth",
"discovery",
"grant-bluetooth-permission",
)
_BLUETOOTH_POWERED_OFF = _DiagnosticSpec(
"host.bluetooth.adapter-powered-off",
"corebluetooth",
"discovery",
"power-on-bluetooth",
)
_BLUETOOTH_UNAVAILABLE = _DiagnosticSpec(
"host.bluetooth.adapter-unavailable",
"corebluetooth",
"discovery",
"restore-bluetooth-adapter",
)
_BLUETOOTH_RUNTIME_UNAVAILABLE = _DiagnosticSpec(
"host.bluetooth.runtime-unavailable",
"corebluetooth",
"discovery",
"restart-local-service",
)
_BLUETOOTH_TIMEOUT = _DiagnosticSpec(
"host.bluetooth.operation-timeout",
"corebluetooth",
"discovery",
"explicit-retry",
)
_WIFI_PERMISSION = _DiagnosticSpec(
"host.wifi.permission-denied",
"corewlan",
"host-network",
"grant-wifi-permission",
)
_WIFI_POWERED_OFF = _DiagnosticSpec(
"host.wifi.adapter-powered-off",
"corewlan",
"host-network",
"power-on-wifi",
)
_WIFI_UNAVAILABLE = _DiagnosticSpec(
"host.wifi.interface-unavailable",
"corewlan",
"host-network",
"restore-wifi-interface",
)
_WIFI_SSID_UNAVAILABLE = _DiagnosticSpec(
"host.wifi.ssid-unavailable",
"corewlan",
"host-network",
"join-expected-network",
)
_WIFI_TIMEOUT = _DiagnosticSpec(
"host.wifi.operation-timeout",
"corewlan",
"host-network",
"explicit-retry",
)
_WIFI_ASSOCIATION_FAILED = _DiagnosticSpec(
"host.wifi.association-failed",
"corewlan",
"host-network",
"join-expected-network",
)
_KEYCHAIN_INTERACTION_REQUIRED = _DiagnosticSpec(
"host.keychain.interaction-required",
"keychain",
"control",
"unlock-or-authorize-keychain",
)
_KEYCHAIN_PERMISSION = _DiagnosticSpec(
"host.keychain.permission-denied",
"keychain",
"control",
"review-keychain-access",
)
_KEYCHAIN_UNAVAILABLE = _DiagnosticSpec(
"host.keychain.unavailable",
"keychain",
"control",
"unlock-or-authorize-keychain",
)
_ROUTE_UNAVAILABLE = _DiagnosticSpec(
"host.route.unavailable",
"route",
"host-network",
"inspect-host-route",
)
_TCP_REFUSED = _DiagnosticSpec(
"host.tcp.connection-refused",
"tcp",
"control",
"verify-broker-endpoint",
)
_TCP_TIMEOUT = _DiagnosticSpec(
"host.tcp.connection-timeout",
"tcp",
"control",
"verify-broker-endpoint",
)
_TCP_UNAVAILABLE = _DiagnosticSpec(
"host.tcp.endpoint-unavailable",
"tcp",
"control",
"verify-broker-endpoint",
)
_MQTT_TIMEOUT = _DiagnosticSpec(
"host.mqtt.connection-timeout",
"mqtt",
"control",
"verify-broker-endpoint",
)
_MQTT_REFUSED = _DiagnosticSpec(
"host.mqtt.connection-refused",
"mqtt",
"control",
"verify-broker-endpoint",
)
_MQTT_UNAVAILABLE = _DiagnosticSpec(
"host.mqtt.transport-unavailable",
"mqtt",
"control",
"verify-broker-endpoint",
)
_FILESYSTEM_PERMISSION = _DiagnosticSpec(
"host.filesystem.permission-denied",
"filesystem",
"durable-safety",
"inspect-local-storage",
)
_LEDGER_UNAVAILABLE = _DiagnosticSpec(
"host.filesystem.ledger-unavailable",
"filesystem",
"durable-safety",
"inspect-local-storage",
)
_REASON_SPECS: Final[dict[str, _DiagnosticSpec]] = {
"ble-permission-denied": _BLUETOOTH_PERMISSION,
"ble-adapter-powered-off": _BLUETOOTH_POWERED_OFF,
"ble-adapter-unavailable": _BLUETOOTH_UNAVAILABLE,
"ble-runtime-owner-loop-conflict": _BLUETOOTH_RUNTIME_UNAVAILABLE,
"ble-runtime-restart-required": _BLUETOOTH_RUNTIME_UNAVAILABLE,
"connection-verify-runtime-loop-unavailable": _BLUETOOTH_RUNTIME_UNAVAILABLE,
"ble-discovery-timeout": _BLUETOOTH_TIMEOUT,
"ble-status-read-timeout": _BLUETOOTH_TIMEOUT,
"ble-provisioning-timeout": _BLUETOOTH_TIMEOUT,
"ble-ap-enable-timeout": _BLUETOOTH_TIMEOUT,
"corewlan-permission-denied": _WIFI_PERMISSION,
"corewlan-authorization-denied": _WIFI_PERMISSION,
"wifi-interface-inactive": _WIFI_POWERED_OFF,
"wifi-interface-unavailable": _WIFI_UNAVAILABLE,
"network-not-found": _WIFI_SSID_UNAVAILABLE,
"host-wifi-operation-timeout": _WIFI_TIMEOUT,
"corewlan-error": _WIFI_ASSOCIATION_FAILED,
"keychain-authorization-required": _KEYCHAIN_INTERACTION_REQUIRED,
"keychain-authorization-denied": _KEYCHAIN_PERMISSION,
"keychain-authorization-cancelled": _KEYCHAIN_PERMISSION,
"keychain-access-failed": _KEYCHAIN_UNAVAILABLE,
"application_authority_unavailable": _KEYCHAIN_UNAVAILABLE,
"host-route-unavailable": _ROUTE_UNAVAILABLE,
"host-path-unavailable": _ROUTE_UNAVAILABLE,
"host-route-interface-unavailable": _ROUTE_UNAVAILABLE,
"association-identity-unavailable": _ROUTE_UNAVAILABLE,
"host-path-observation-stale": _ROUTE_UNAVAILABLE,
"host-path-epoch-changed": _ROUTE_UNAVAILABLE,
"host-path-probe-error": _ROUTE_UNAVAILABLE,
"host-path-recheck-error": _ROUTE_UNAVAILABLE,
"connection-monitor-start-failed": _ROUTE_UNAVAILABLE,
"tcp-connection-refused": _TCP_REFUSED,
"tcp-connection-timeout": _TCP_TIMEOUT,
"tcp-endpoint-unreachable": _TCP_UNAVAILABLE,
"tcp-route-lost": _TCP_UNAVAILABLE,
"tcp-probe-error": _TCP_UNAVAILABLE,
"endpoint-unreachable": _TCP_UNAVAILABLE,
"endpoint-observation-stale": _TCP_UNAVAILABLE,
"quick_connect_endpoint_unreachable": _TCP_UNAVAILABLE,
"connection_lease_endpoint_unreachable_after_provision": _TCP_UNAVAILABLE,
"connection_lease_recovered_endpoint_unreachable": _TCP_UNAVAILABLE,
"mqtt_connection_timeout": _MQTT_TIMEOUT,
"mqtt_connect_call_failed": _MQTT_UNAVAILABLE,
"mqtt_connect_rejected": _MQTT_REFUSED,
"mqtt_broker_rejected_connection": _MQTT_REFUSED,
"mqtt_network_loop_failed": _MQTT_UNAVAILABLE,
"mqtt_connection_ended": _MQTT_UNAVAILABLE,
"mqtt_client_unavailable": _MQTT_UNAVAILABLE,
"mqtt_transport_failure": _MQTT_UNAVAILABLE,
"mqtt-control-loop-lost": _MQTT_UNAVAILABLE,
"control-proof-observation-stale": _MQTT_UNAVAILABLE,
"network-mutation-ledger-error": _LEDGER_UNAVAILABLE,
"network-mutation-ledger-corrupt": _LEDGER_UNAVAILABLE,
"network-provisioning-idempotency-error": _LEDGER_UNAVAILABLE,
"network-provisioning-idempotency-corrupt": _LEDGER_UNAVAILABLE,
"physical-command-ledger-error": _LEDGER_UNAVAILABLE,
"physical-command-ledger-corrupt": _LEDGER_UNAVAILABLE,
"semantic-topology-store-error": _LEDGER_UNAVAILABLE,
"semantic-topology-store-corrupt": _LEDGER_UNAVAILABLE,
"device-identity-pin-store-error": _LEDGER_UNAVAILABLE,
"device-identity-pin-store-corrupt": _LEDGER_UNAVAILABLE,
"application-control-process-lease-error": _LEDGER_UNAVAILABLE,
"application-control-process-lease-unavailable": _LEDGER_UNAVAILABLE,
}
def host_diagnostic_for_reason(reason_code: object) -> HostFailureDiagnostic | None:
"""Map only reviewed reason codes; never reflect arbitrary input."""
if not isinstance(reason_code, str):
return None
spec = _REASON_SPECS.get(reason_code)
return spec.diagnostic() if spec is not None else None
def host_diagnostic_for_exception(
exc: BaseException,
*,
boundary: HostDiagnosticBoundary | None = None,
) -> HostFailureDiagnostic | None:
"""Classify a host exception into a redacted diagnostic whitelist."""
explicit = host_diagnostic_for_reason(getattr(exc, "reason_code", None))
if explicit is not None:
return explicit
if isinstance(exc, ConnectionRefusedError):
if boundary == "tcp":
return _TCP_REFUSED.diagnostic()
if boundary == "mqtt":
return _MQTT_REFUSED.diagnostic()
return None
if isinstance(exc, PermissionError):
if boundary == "corebluetooth":
return _BLUETOOTH_PERMISSION.diagnostic()
if boundary == "corewlan":
return _WIFI_PERMISSION.diagnostic()
if boundary == "keychain":
return _KEYCHAIN_PERMISSION.diagnostic()
if boundary == "filesystem":
return _FILESYSTEM_PERMISSION.diagnostic()
return None
if isinstance(exc, TimeoutError):
if boundary == "corebluetooth":
return _BLUETOOTH_TIMEOUT.diagnostic()
if boundary == "corewlan":
return _WIFI_TIMEOUT.diagnostic()
if boundary == "tcp":
return _TCP_TIMEOUT.diagnostic()
if boundary == "mqtt":
return _MQTT_TIMEOUT.diagnostic()
return None
if boundary == "filesystem" and isinstance(exc, OSError):
return _LEDGER_UNAVAILABLE.diagnostic()
if boundary == "corebluetooth" and isinstance(
exc,
BleakBluetoothNotAvailableError,
):
spec = {
BleakBluetoothNotAvailableReason.POWERED_OFF: _BLUETOOTH_POWERED_OFF,
BleakBluetoothNotAvailableReason.DENIED_BY_USER: _BLUETOOTH_PERMISSION,
BleakBluetoothNotAvailableReason.DENIED_BY_SYSTEM: _BLUETOOTH_PERMISSION,
BleakBluetoothNotAvailableReason.DENIED_BY_UNKNOWN: _BLUETOOTH_PERMISSION,
BleakBluetoothNotAvailableReason.NO_BLUETOOTH: _BLUETOOTH_UNAVAILABLE,
BleakBluetoothNotAvailableReason.NO_BLE_CENTRAL_ROLE: _BLUETOOTH_UNAVAILABLE,
BleakBluetoothNotAvailableReason.UNKNOWN: _BLUETOOTH_UNAVAILABLE,
}[exc.reason]
return spec.diagnostic()
# Older CoreBluetooth surfaces still expose several failures only as a
# human string. Match a narrow, reviewed vocabulary and export none of it.
message = str(exc).casefold()
if boundary == "corebluetooth":
if any(
fragment in message
for fragment in (
"not authorized",
"permission denied",
"access denied",
"bluetooth permission",
)
):
return _BLUETOOTH_PERMISSION.diagnostic()
if any(
fragment in message
for fragment in ("powered off", "power off", "bluetooth is off", "poweredoff")
):
return _BLUETOOTH_POWERED_OFF.diagnostic()
if any(
fragment in message
for fragment in (
"adapter unavailable",
"bluetooth unavailable",
"no bluetooth adapter",
)
):
return _BLUETOOTH_UNAVAILABLE.diagnostic()
if boundary == "keychain" or "authorityloaderror" in type(exc).__name__.casefold():
return _KEYCHAIN_UNAVAILABLE.diagnostic()
return None
def host_diagnostics_for_reasons(*reason_codes: object) -> tuple[HostFailureDiagnostic, ...]:
diagnostics: list[HostFailureDiagnostic] = []
seen: set[str] = set()
for reason_code in reason_codes:
diagnostic = host_diagnostic_for_reason(reason_code)
if diagnostic is None or diagnostic.code in seen:
continue
seen.add(diagnostic.code)
diagnostics.append(diagnostic)
return tuple(diagnostics)
__all__ = [
"HostDiagnosticBoundary",
"HostDiagnosticCode",
"HostFailureDiagnostic",
"host_diagnostic_for_exception",
"host_diagnostic_for_reason",
"host_diagnostics_for_reasons",
]
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
from typing import Any
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Header, HTTPException, WebSocket, WebSocketDisconnect
from k1link.device_plugins.xgrids_k1.facade import (
ACTION_DISCOVERY_SCAN,
@@ -36,23 +36,41 @@ def build_xgrids_k1_legacy_router(runtime: DevicePluginRuntimeTransport) -> APIR
return await invoke_device_plugin_runtime(runtime, ACTION_STATE_READ, {})
@router.post("/api/ble/scan", deprecated=True)
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
async def scan_ble(
request: BleScanRequest,
expected_snapshot_runtime_id: str = Header(
...,
alias="X-Mission-Core-Snapshot-Runtime-Id",
),
) -> dict[str, Any]:
try:
return await invoke_device_plugin_runtime(
runtime,
ACTION_DISCOVERY_SCAN,
request.model_dump(),
{
**request.model_dump(),
"expected_snapshot_runtime_id": expected_snapshot_runtime_id,
},
)
except (PluginExecutionError, ValueError) as exc:
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
@router.post("/api/connect", deprecated=True)
async def connect(request: ConnectRequest) -> dict[str, Any]:
async def connect(
request: ConnectRequest,
expected_snapshot_runtime_id: str = Header(
...,
alias="X-Mission-Core-Snapshot-Runtime-Id",
),
) -> dict[str, Any]:
try:
return await invoke_device_plugin_runtime(
runtime,
ACTION_NETWORK_PROVISION,
request.model_dump(),
{
**request.model_dump(),
"expected_snapshot_runtime_id": expected_snapshot_runtime_id,
},
)
except (PluginExecutionError, ValueError) as exc:
raise HTTPException(
@@ -13,16 +13,42 @@ from uuid import uuid4
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from k1link.compute.live_perception import LivePerceptionIngress
from k1link.compute.live_perception import (
LivePerceptionIngress,
LivePerceptionResultFrame,
decode_live_perception_result,
)
TOKEN_BYTES = 32
TOKEN_FILE_NAME = "shadow-worker.token"
def build_live_perception_result_receiver(
ingress: LivePerceptionIngress,
publish_frame: Callable[[LivePerceptionResultFrame], bool],
) -> Callable[[bytes], bool]:
"""Bind worker results to the exact active ingress session atomically."""
def receive(encoded: bytes) -> bool:
frame = decode_live_perception_result(encoded)
return ingress.admit_result(
session_id=frame.session_id,
session_generation=frame.session_generation,
receiver=lambda: publish_frame(frame),
)
return receive
def ensure_live_shadow_token(repository_root: Path) -> tuple[Path, str]:
"""Load or create the private bearer used only through the SSH tunnel."""
token_root = repository_root.resolve() / ".runtime" / "live-perception"
configured_data_root = os.environ.get("MISSIONCORE_DATA_DIR", "").strip()
token_root = (
Path(configured_data_root).expanduser().resolve() / "live-perception"
if configured_data_root
else repository_root.resolve() / ".runtime" / "live-perception"
)
token_root.mkdir(mode=0o700, parents=True, exist_ok=True)
with suppress(OSError):
token_root.chmod(0o700)
@@ -59,9 +85,7 @@ def build_live_perception_shadow_router(
router = APIRouter(include_in_schema=False)
@router.websocket(
f"/api/v1/device-plugins/{plugin_id}/live-perception-shadow"
)
@router.websocket(f"/api/v1/device-plugins/{plugin_id}/live-perception-shadow")
async def live_perception_shadow(websocket: WebSocket) -> None:
authorization = websocket.headers.get("authorization", "")
supplied = authorization.removeprefix("Bearer ")
@@ -103,13 +127,19 @@ def build_live_perception_shadow_router(
)
return
try:
await asyncio.to_thread(result_receiver, result)
accepted = await asyncio.to_thread(result_receiver, result)
except (RuntimeError, ValueError):
await websocket.close(
code=1008,
reason="Shadow result contract is invalid",
)
return
if not accepted:
await websocket.close(
code=1008,
reason="Shadow result session is stale",
)
return
client_event = asyncio.create_task(websocket.receive())
if ingress_event in completed:
event = ingress_event.result()
@@ -8,10 +8,11 @@ import os
import re
import stat
import struct
import threading
import time
from collections.abc import Callable, Iterator
from contextlib import suppress
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import IO, Literal, TypedDict
@@ -29,6 +30,19 @@ REPORT_TOPICS: tuple[str, ...] = (
"DeviceStatus",
)
# A fresh subscription proves only broker transport. Recovery of the live
# scene requires one non-retained point-cloud report from that exact MQTT
# client to establish a candidate sequence fence. Pose, DeviceStatus and
# heartbeat remain useful transport/control evidence, but none proves that the
# visible acquisition cloud has resumed. Only the downstream post-publish
# observer can confirm that candidate.
RECOVERY_POINT_CLOUD_TOPICS = frozenset(
{
"RealtimePointcloud",
"lixel/application/report/lio_pcl",
}
)
DEFAULT_MAX_MESSAGE_BYTES = 64 * 1024 * 1024
MAX_CONFIGURABLE_MESSAGE_BYTES = 256 * 1024 * 1024
MAX_TOPIC_BYTES = 65_535
@@ -40,6 +54,8 @@ GROUP_COMMIT_MAX_BYTES = 4 * 1024 * 1024
GROUP_COMMIT_MAX_MESSAGES = 32
CAPTURE_CLOCK_FILENAME = "mqtt.timeline.json"
CAPTURE_CLOCK_ORIGIN_FILENAME = "mqtt.timeline.origin.json"
RECOVERY_GAPS_FILENAME = "mqtt.recovery.jsonl"
RECOVERY_GAP_SCHEMA_VERSION = 1
CAPTURE_CLOCK_ORIGIN_SCHEMA_VERSION = 1
CAPTURE_CLOCK_SEALED_PATTERN = re.compile(r"^mqtt\.timeline\.session-[a-f0-9]{64}\.json$")
CAPTURE_CLOCK_SCHEMA_VERSION = 1
@@ -61,14 +77,18 @@ StopReason = Literal[
"message_too_large",
"connection_failed",
"connection_lost",
"recovery_standby",
"subscription_failed",
"capture_error",
]
RecoveryDecision = Literal["retry", "resume", "standby", "fault", "blocked"]
RecoveryGapOutcome = Literal["recovered", "standby", "fault", "blocked", "interrupted"]
class ArtifactPaths(TypedDict):
raw: str
metadata_jsonl: str
recovery_gaps_jsonl: str
capture_clock_origin: str
capture_clock: str
summary: str
@@ -77,6 +97,7 @@ class ArtifactPaths(TypedDict):
class ArtifactHashes(TypedDict):
raw_sha256: str
metadata_jsonl_sha256: str
recovery_gaps_jsonl_sha256: str
capture_clock_origin_sha256: str
capture_clock_sha256: str
@@ -87,6 +108,17 @@ class RawFormat(TypedDict):
frame_layout: str
class RecoveryGapSummary(TypedDict):
gap_index: int
started_at_utc: str
started_monotonic_ns: int
ended_at_utc: str
ended_monotonic_ns: int
duration_seconds: float
recovery_attempt: int
outcome: RecoveryGapOutcome
class CaptureSummary(TypedDict):
schema_version: int
created_at_utc: str
@@ -99,6 +131,11 @@ class CaptureSummary(TypedDict):
subscription_qos: int
clean_session: bool
reconnect_enabled: bool
recovery_attempts: int
successful_recoveries: int
recovery_point_cloud_candidates: int
recovery_blocked: bool
recovery_gaps: list[RecoveryGapSummary]
publishing_enabled: bool
subscriptions: list[str]
requested_duration_seconds: float | None
@@ -195,6 +232,13 @@ class _CaptureState:
subscription_mid: int | None = None
stop_reason: StopReason = "capture_error"
error: str | None = None
connection_lost: bool = False
connection_lost_message: str | None = None
recovery_attempts: int = 0
successful_recoveries: int = 0
recovery_point_cloud_candidates: int = 0
recovery_blocked: bool = False
recovery_gaps: list[RecoveryGapSummary] = field(default_factory=list)
class _CaptureWriter:
@@ -202,6 +246,7 @@ class _CaptureWriter:
self.out_dir = out_dir.expanduser().resolve()
self.raw_path = self.out_dir / "mqtt.raw.k1mqtt"
self.metadata_path = self.out_dir / "mqtt.metadata.jsonl"
self.recovery_gaps_path = self.out_dir / RECOVERY_GAPS_FILENAME
self.capture_clock_origin_path = self.out_dir / CAPTURE_CLOCK_ORIGIN_FILENAME
self.capture_clock_path = self.out_dir / CAPTURE_CLOCK_FILENAME
self.summary_path = self.out_dir / "mqtt.summary.json"
@@ -212,6 +257,8 @@ class _CaptureWriter:
self.topic_counts: dict[str, int] = {}
self._raw: IO[bytes] | None = None
self._metadata: IO[str] | None = None
self._recovery_gaps: IO[str] | None = None
self._recovery_gaps_lock = threading.Lock()
self._pending_metadata: list[str] = []
self._pending_raw_bytes = 0
self._last_commit_monotonic = time.monotonic()
@@ -223,6 +270,7 @@ class _CaptureWriter:
artifact_paths = (
self.raw_path,
self.metadata_path,
self.recovery_gaps_path,
self.capture_clock_origin_path,
self.capture_clock_path,
self.summary_path,
@@ -236,6 +284,7 @@ class _CaptureWriter:
self._raw = _open_binary_exclusive(self.raw_path)
self._raw.write(RAW_MAGIC)
self._metadata = _open_text_exclusive(self.metadata_path)
self._recovery_gaps = _open_text_exclusive(self.recovery_gaps_path)
_fsync_directory(self.out_dir)
# This is the earliest durable point from which the capture can
# accept evidence. The camera is armed only after this writer is
@@ -364,9 +413,36 @@ class _CaptureWriter:
except OSError as exc:
if first_error is None:
first_error = exc
with self._recovery_gaps_lock:
recovery_stream = self._recovery_gaps
if recovery_stream is not None and not recovery_stream.closed:
try:
recovery_stream.flush()
os.fsync(recovery_stream.fileno())
except OSError as exc:
if first_error is None:
first_error = exc
finally:
try:
recovery_stream.close()
except OSError as exc:
if first_error is None:
first_error = exc
if first_error is not None:
raise first_error
def record_recovery_gap_event(self, record: dict[str, object]) -> None:
"""Append and fsync one recovery boundary without touching frame metadata."""
payload = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
with self._recovery_gaps_lock:
stream = self._recovery_gaps
if stream is None or stream.closed:
raise RuntimeError("recovery gap journal is not open")
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
def finalize_capture_clock(self) -> CaptureClockEnvelope:
"""Publish the exact capture envelope after every producer is sealed."""
@@ -748,6 +824,20 @@ def seal_capture_clock(capture_root: Path) -> CaptureClockEnvelope:
raise CaptureError(f"could not seal session capture clock: {exc}") from exc
def _recovery_backoff_seconds(attempt: int) -> float:
"""Return the canonical capped delay without evaluating an unbounded power."""
if attempt <= 1:
return 0.5
if attempt == 2:
return 1.0
if attempt == 3:
return 2.0
if attempt == 4:
return 4.0
return 5.0
def capture_mqtt(
host: str,
out_dir: Path,
@@ -759,9 +849,23 @@ def capture_mqtt(
on_ready: Callable[[], None] | None = None,
on_message_recorded: Callable[[CapturedMqttMessage], None] | None = None,
should_stop: Callable[[], bool] | None = None,
on_connection_lost: Callable[[str], None] | None = None,
consume_connection_recovery_request: Callable[[], str | None] | None = None,
recover_connection: Callable[[int], RecoveryDecision] | None = None,
on_recovery_point_cloud_candidate: Callable[[int, int], None] | None = None,
on_recovery_confirmer_ready: Callable[[Callable[[int], bool]], None] | None = None,
_client_factory: Callable[[], mqtt.Client] | None = None,
) -> CaptureSummary:
"""Capture the fixed K1 report subscriptions once, without publishing or reconnecting."""
"""Capture fixed K1 reports with an optional externally fenced reconnect.
The capture layer never decides that an endpoint is still the same K1. A
caller may supply ``recover_connection`` only when a higher-level owner can
revalidate the exact route, DeviceInfo identity and physical SCANNING
lineage. ``consume_connection_recovery_request`` lets that same owner wake
this existing capture before the MQTT keepalive notices a short host-path
outage. The callback returns the only admitted next step; this function
itself never publishes, scans BLE, changes Wi-Fi, or retries START/STOP.
"""
target_ipv4 = validate_private_ipv4(host)
if not 1 <= port <= 65535:
raise ValueError("port must be between 1 and 65535")
@@ -774,16 +878,18 @@ def capture_mqtt(
f"max_message_bytes must be between 1 and {MAX_CONFIGURABLE_MESSAGE_BYTES}"
)
client = (
_client_factory()
if _client_factory is not None
else mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
clean_session=True,
protocol=mqtt.MQTTv311,
reconnect_on_failure=False,
def make_client() -> mqtt.Client:
return (
_client_factory()
if _client_factory is not None
else mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
clean_session=True,
protocol=mqtt.MQTTv311,
reconnect_on_failure=False,
)
)
)
writer = _CaptureWriter(out_dir, max_message_bytes)
try:
writer.open()
@@ -798,12 +904,119 @@ def capture_mqtt(
operation_started = time.monotonic()
capture_started: float | None = None
failure: CaptureError | None = None
disconnect_expected = False
recovering = False
pending_recovery_attempt: int | None = None
pending_recovery_confirmation_attempt: int | None = None
active_recovery_gap: tuple[int, str, int] | None = None
recovery_lock = threading.Lock()
active_client: mqtt.Client | None = None
def fail(reason: StopReason, message: str) -> None:
if state.error is None:
state.stop_reason = reason
state.error = message
def finish_recovery_gap_locked(
outcome: RecoveryGapOutcome,
attempt: int,
) -> bool:
nonlocal active_recovery_gap, pending_recovery_confirmation_attempt
gap = active_recovery_gap
if gap is None:
return False
gap_index, started_at_utc, started_monotonic_ns = gap
ended_at_utc = utc_now_iso()
ended_monotonic_ns = time.monotonic_ns()
summary: RecoveryGapSummary = {
"gap_index": gap_index,
"started_at_utc": started_at_utc,
"started_monotonic_ns": started_monotonic_ns,
"ended_at_utc": ended_at_utc,
"ended_monotonic_ns": ended_monotonic_ns,
"duration_seconds": round(
max(ended_monotonic_ns - started_monotonic_ns, 0) / 1_000_000_000,
9,
),
"recovery_attempt": attempt,
"outcome": outcome,
}
try:
writer.record_recovery_gap_event(
{
"schema_version": RECOVERY_GAP_SCHEMA_VERSION,
"record_type": "recovery_gap_ended",
**summary,
}
)
except (OSError, RuntimeError, ValueError) as exc:
fail(
"capture_error",
f"recovery gap journal failed: {type(exc).__name__}: {exc}",
)
return False
state.recovery_gaps.append(summary)
if outcome == "recovered":
state.successful_recoveries += 1
active_recovery_gap = None
pending_recovery_confirmation_attempt = None
return True
def begin_recovery_gap() -> bool:
nonlocal active_recovery_gap, pending_recovery_confirmation_attempt
with recovery_lock:
# A raw candidate from a client that has already failed can no
# longer prove the still-live scene. Keep the original outage open
# and fence that late downstream callback.
pending_recovery_confirmation_attempt = None
if active_recovery_gap is not None:
return True
gap_index = len(state.recovery_gaps) + 1
started_at_utc = utc_now_iso()
started_monotonic_ns = time.monotonic_ns()
try:
writer.record_recovery_gap_event(
{
"schema_version": RECOVERY_GAP_SCHEMA_VERSION,
"record_type": "recovery_gap_started",
"gap_index": gap_index,
"started_at_utc": started_at_utc,
"started_monotonic_ns": started_monotonic_ns,
}
)
except (OSError, RuntimeError, ValueError) as exc:
fail(
"capture_error",
f"recovery gap journal failed: {type(exc).__name__}: {exc}",
)
return False
active_recovery_gap = (gap_index, started_at_utc, started_monotonic_ns)
return True
def confirm_recovery(attempt: int) -> bool:
"""Consume one exact post-publication proof for the current open gap."""
with recovery_lock:
if (
isinstance(attempt, bool)
or attempt < 1
or attempt != pending_recovery_confirmation_attempt
or active_recovery_gap is None
):
return False
return finish_recovery_gap_locked("recovered", attempt)
def fail_transport_handshake(reason: StopReason, message: str) -> None:
if recovering:
state.connected = False
state.subscribed = False
state.connection_lost = True
state.connection_lost_message = message
return
fail(reason, message)
def on_connect(
callback_client: mqtt.Client,
_userdata: object,
@@ -811,129 +1024,347 @@ def capture_mqtt(
reason_code: ReasonCode,
_properties: Properties | None,
) -> None:
if callback_client is not active_client:
return
if reason_code.is_failure:
fail("connection_failed", f"broker rejected connection: {reason_code}")
fail_transport_handshake(
"connection_failed",
f"broker rejected connection: {reason_code}",
)
return
state.connected = True
try:
result, mid = callback_client.subscribe([(topic, 0) for topic in REPORT_TOPICS])
except (OSError, RuntimeError, ValueError) as exc:
fail(
fail_transport_handshake(
"subscription_failed",
f"subscribe failed: {type(exc).__name__}: {exc}",
)
return
if result != mqtt.MQTT_ERR_SUCCESS or mid is None:
fail("subscription_failed", f"subscribe failed: {mqtt.error_string(result)}")
fail_transport_handshake(
"subscription_failed",
f"subscribe failed: {mqtt.error_string(result)}",
)
return
state.subscription_mid = mid
def on_subscribe(
_callback_client: mqtt.Client,
callback_client: mqtt.Client,
_userdata: object,
mid: int,
reason_codes: list[ReasonCode],
_properties: Properties | None,
) -> None:
if callback_client is not active_client:
return
if mid != state.subscription_mid:
fail("subscription_failed", f"unexpected SUBACK message id: {mid}")
fail_transport_handshake(
"subscription_failed",
f"unexpected SUBACK message id: {mid}",
)
return
if len(reason_codes) != len(REPORT_TOPICS) or any(
reason_code.is_failure for reason_code in reason_codes
):
fail("subscription_failed", "broker rejected one or more fixed subscriptions")
fail_transport_handshake(
"subscription_failed",
"broker rejected one or more fixed subscriptions",
)
return
state.subscribed = True
def on_message(
_callback_client: mqtt.Client,
callback_client: mqtt.Client,
_userdata: object,
message: mqtt.MQTTMessage,
) -> None:
if state.error is not None:
nonlocal pending_recovery_attempt, pending_recovery_confirmation_attempt, recovering
if callback_client is not active_client or state.error is not None:
return
try:
recorded = writer.record(message)
except MessageTooLargeError as exc:
fail("message_too_large", str(exc))
return
except (OSError, RuntimeError, ValueError) as exc:
fail("capture_error", f"artifact write failed: {type(exc).__name__}: {exc}")
return
recovery_candidate: tuple[int, int] | None = None
if (
pending_recovery_attempt is not None
and not recorded.retain
and recorded.topic in RECOVERY_POINT_CLOUD_TOPICS
):
recovered_attempt = pending_recovery_attempt
pending_recovery_attempt = None
recovering = False
with recovery_lock:
if active_recovery_gap is not None:
pending_recovery_confirmation_attempt = recovered_attempt
state.recovery_point_cloud_candidates += 1
recovery_candidate = (recovered_attempt, recorded.sequence)
# Arm the provisional sequence fence after the raw frame is durable but
# before the preview queue can publish it on another thread. This is
# deliberately not a recovery-success edge: only a later normalized,
# non-empty Rerun publication may consume the candidate.
if recovery_candidate is not None and on_recovery_point_cloud_candidate is not None:
try:
on_recovery_point_cloud_candidate(*recovery_candidate)
except (OSError, RuntimeError, ValueError) as exc:
fail(
"capture_error",
f"recovery candidate callback failed: {type(exc).__name__}: {exc}",
)
return
if on_message_recorded is not None:
try:
on_message_recorded(recorded)
except (OSError, RuntimeError, ValueError) as exc:
fail("capture_error", f"preview callback failed: {type(exc).__name__}: {exc}")
return
def on_disconnect(
_callback_client: mqtt.Client,
callback_client: mqtt.Client,
_userdata: object,
_flags: mqtt.DisconnectFlags,
reason_code: ReasonCode,
_properties: Properties | None,
) -> None:
if not state.stopping:
fail("connection_lost", f"broker connection ended: {reason_code}")
if callback_client is not active_client or state.stopping or disconnect_expected:
return
state.connected = False
state.subscribed = False
state.connection_lost = True
state.connection_lost_message = f"broker connection ended: {reason_code}"
client.on_connect = on_connect
client.on_subscribe = on_subscribe
client.on_message = on_message
client.on_disconnect = on_disconnect
def externally_stopped_or_elapsed() -> bool:
now = time.monotonic()
if should_stop is not None and should_stop():
state.stop_reason = "external_stop"
return True
if (
capture_started is not None
and duration_seconds is not None
and now - capture_started >= duration_seconds
):
state.stop_reason = "duration_elapsed"
return True
return False
def consume_owner_recovery_request() -> str | None:
if consume_connection_recovery_request is None:
return None
try:
reason = consume_connection_recovery_request()
except (OSError, RuntimeError, ValueError) as exc:
fail(
"capture_error",
"connection recovery request failed: "
f"{type(exc).__name__}: {exc}",
)
return None
if reason is None:
return None
if not isinstance(reason, str) or not reason.strip():
fail("capture_error", "connection recovery request returned an invalid reason")
return None
return reason.strip()
def wait_recovery_backoff(attempt: int) -> bool:
delay = _recovery_backoff_seconds(attempt)
deadline = time.monotonic() + delay
while time.monotonic() < deadline:
if externally_stopped_or_elapsed():
return False
time.sleep(min(0.1, max(deadline - time.monotonic(), 0.0)))
return True
if on_recovery_confirmer_ready is not None:
try:
on_recovery_confirmer_ready(confirm_recovery)
except (OSError, RuntimeError, ValueError) as exc:
fail(
"capture_error",
f"recovery confirmer registration failed: {type(exc).__name__}: {exc}",
)
connect_attempted = False
try:
connect_attempted = True
connect_result = client.connect(
target_ipv4,
port=port,
keepalive=KEEPALIVE_SECONDS,
)
if connect_result != mqtt.MQTT_ERR_SUCCESS:
fail("connection_failed", f"connect failed: {mqtt.error_string(connect_result)}")
while state.error is None:
now = time.monotonic()
if should_stop is not None and should_stop():
state.stop_reason = "external_stop"
if recovering and externally_stopped_or_elapsed():
break
if state.subscribed and capture_started is None:
capture_started = now
if on_ready is not None:
on_ready()
if (
capture_started is not None
and duration_seconds is not None
and now - capture_started >= duration_seconds
):
state.stop_reason = "duration_elapsed"
break
if capture_started is None and now - operation_started >= CONNECT_TIMEOUT_SECONDS:
fail("connection_failed", "timed out waiting for CONNACK/SUBACK")
break
loop_result = client.loop(timeout=LOOP_INTERVAL_SECONDS)
state.connected = False
state.subscribed = False
state.subscription_mid = None
state.connection_lost = False
state.connection_lost_message = None
client = make_client()
active_client = client
client.on_connect = on_connect
client.on_subscribe = on_subscribe
client.on_message = on_message
client.on_disconnect = on_disconnect
connect_attempted = False
try:
writer.maybe_commit()
except OSError as exc:
fail("capture_error", f"group commit failed: {type(exc).__name__}: {exc}")
connect_attempted = True
connect_result = client.connect(
target_ipv4,
port=port,
keepalive=KEEPALIVE_SECONDS,
)
if connect_result != mqtt.MQTT_ERR_SUCCESS:
message = f"connect failed: {mqtt.error_string(connect_result)}"
if recovering:
state.connection_lost = True
state.connection_lost_message = message
else:
fail("connection_failed", message)
connect_started = time.monotonic()
while state.error is None and not state.connection_lost:
now = time.monotonic()
if externally_stopped_or_elapsed():
break
if state.subscribed and capture_started is None:
capture_started = now
if on_ready is not None:
on_ready()
if not state.subscribed and now - connect_started >= CONNECT_TIMEOUT_SECONDS:
message = "timed out waiting for CONNACK/SUBACK"
if recovering:
state.connection_lost = True
state.connection_lost_message = message
else:
fail("connection_failed", message)
break
loop_result = client.loop(timeout=LOOP_INTERVAL_SECONDS)
owner_recovery_reason = consume_owner_recovery_request()
try:
writer.maybe_commit()
except OSError as exc:
fail(
"capture_error",
f"group commit failed: {type(exc).__name__}: {exc}",
)
break
if loop_result != mqtt.MQTT_ERR_SUCCESS and state.error is None:
state.connected = False
state.subscribed = False
state.connection_lost = True
state.connection_lost_message = (
f"MQTT network loop failed: {mqtt.error_string(loop_result)}"
)
if owner_recovery_reason is not None and not state.connection_lost:
state.connected = False
state.subscribed = False
state.connection_lost = True
state.connection_lost_message = (
"guarded recovery requested by the connection owner: "
f"{owner_recovery_reason}"
)
except KeyboardInterrupt:
state.stop_reason = "keyboard_interrupt"
except (OSError, RuntimeError, ValueError) as exc:
message = f"MQTT capture failed: {type(exc).__name__}: {exc}"
if recovering:
state.connection_lost = True
state.connection_lost_message = message
else:
fail("connection_failed", message)
finally:
disconnect_expected = True
active_client = None
if connect_attempted:
try:
client.disconnect()
except (OSError, RuntimeError, ValueError) as exc:
if state.error is None and not state.connection_lost:
fail(
"capture_error",
f"disconnect failed: {type(exc).__name__}: {exc}",
)
disconnect_expected = False
if (
state.stop_reason
in {
"external_stop",
"duration_elapsed",
"keyboard_interrupt",
}
or state.error is not None
):
break
if loop_result != mqtt.MQTT_ERR_SUCCESS and state.error is None:
if not state.connection_lost:
break
# A guarded ``resume`` authorizes exactly one fresh MQTT client.
# If that client never delivers a fresh point-cloud report, consume the
# authorization before inspecting the device again; a later
# READY/SCAN_OVER must be able to end recovery without another
# data-plane connect.
pending_recovery_attempt = None
if not begin_recovery_gap():
break
if recover_connection is None:
fail(
"connection_lost",
f"MQTT network loop failed: {mqtt.error_string(loop_result)}",
state.connection_lost_message or "MQTT connection was lost",
)
except KeyboardInterrupt:
state.stop_reason = "keyboard_interrupt"
except (OSError, RuntimeError, ValueError) as exc:
fail("connection_failed", f"MQTT capture failed: {type(exc).__name__}: {exc}")
break
if not recovering and on_connection_lost is not None:
on_connection_lost(state.connection_lost_message or "MQTT connection was lost")
recovering = True
while state.error is None:
if externally_stopped_or_elapsed():
break
state.recovery_attempts += 1
attempt = state.recovery_attempts
try:
decision = recover_connection(attempt)
except (OSError, RuntimeError, ValueError):
decision = "retry"
if decision == "resume":
pending_recovery_attempt = attempt
break
if decision == "standby":
state.stop_reason = "recovery_standby"
break
if decision == "fault":
fail(
"connection_lost",
"exact active-stream recovery rejected the remote state",
)
break
if decision == "blocked":
state.recovery_blocked = True
while not externally_stopped_or_elapsed():
time.sleep(LOOP_INTERVAL_SECONDS)
break
if decision != "retry":
fail("capture_error", "invalid active-stream recovery decision")
break
if not wait_recovery_backoff(attempt):
break
if pending_recovery_attempt is not None:
continue
break
finally:
state.stopping = True
if connect_attempted:
try:
client.disconnect()
except (OSError, RuntimeError, ValueError) as exc:
if state.error is None:
fail("capture_error", f"disconnect failed: {type(exc).__name__}: {exc}")
with recovery_lock:
if active_recovery_gap is not None:
if state.stop_reason == "recovery_standby":
gap_outcome: RecoveryGapOutcome = "standby"
elif state.recovery_blocked:
gap_outcome = "blocked"
elif state.error is not None:
gap_outcome = "fault"
else:
gap_outcome = "interrupted"
finish_recovery_gap_locked(gap_outcome, state.recovery_attempts)
try:
writer.close()
except OSError as exc:
@@ -958,6 +1389,7 @@ def capture_mqtt(
created_at_utc=created_at_utc,
capture_clock=capture_clock,
state=state,
reconnect_enabled=recover_connection is not None,
)
try:
_write_summary_exclusive(writer.summary_path, summary)
@@ -983,6 +1415,7 @@ def _build_summary(
created_at_utc: str,
capture_clock: CaptureClockEnvelope,
state: _CaptureState,
reconnect_enabled: bool,
) -> CaptureSummary:
capture_clock_origin = read_capture_clock_origin(writer.capture_clock_origin_path)
return {
@@ -996,7 +1429,12 @@ def _build_summary(
"mqtt_protocol": "3.1.1",
"subscription_qos": 0,
"clean_session": True,
"reconnect_enabled": False,
"reconnect_enabled": reconnect_enabled,
"recovery_attempts": state.recovery_attempts,
"successful_recoveries": state.successful_recoveries,
"recovery_point_cloud_candidates": state.recovery_point_cloud_candidates,
"recovery_blocked": state.recovery_blocked,
"recovery_gaps": list(state.recovery_gaps),
"publishing_enabled": False,
"subscriptions": list(REPORT_TOPICS),
"requested_duration_seconds": duration_seconds,
@@ -1021,6 +1459,7 @@ def _build_summary(
"artifacts": {
"raw": writer.raw_path.name,
"metadata_jsonl": writer.metadata_path.name,
"recovery_gaps_jsonl": writer.recovery_gaps_path.name,
"capture_clock_origin": writer.capture_clock_origin_path.name,
"capture_clock": writer.capture_clock_path.name,
"summary": writer.summary_path.name,
@@ -1028,6 +1467,7 @@ def _build_summary(
"artifact_hashes": {
"raw_sha256": _sha256_file(writer.raw_path),
"metadata_jsonl_sha256": _sha256_file(writer.metadata_path),
"recovery_gaps_jsonl_sha256": _sha256_file(writer.recovery_gaps_path),
"capture_clock_origin_sha256": capture_clock_origin.artifact_sha256,
"capture_clock_sha256": capture_clock.artifact_sha256,
},
@@ -0,0 +1,958 @@
from __future__ import annotations
import fcntl
import ipaddress
import json
import os
import re
import stat
import tempfile
import threading
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from pathlib import Path
from typing import IO, Literal, cast
from k1link.sessions.store import resolve_missioncore_data_dir
NETWORK_MUTATION_LEDGER_SCHEMA = "missioncore.xgrids-k1-network-mutation/v2"
NETWORK_MUTATION_LEDGER_LEGACY_SCHEMA = "missioncore.xgrids-k1-network-mutation/v1"
NETWORK_MUTATION_LEDGER_FILENAME = "network-mutation.json"
NETWORK_MUTATION_LEDGER_LOCK_FILENAME = ".network-mutation.lock"
NETWORK_MUTATION_LEDGER_MAX_BYTES = 64 * 1024
NetworkConnectionMode = Literal["bridge", "quick-connect", "direct-connect"]
NetworkMutationStage = Literal["prepared", "dispatching", "observing", "resolved"]
NetworkMutationWriteMode = Literal["with_response", "without_response"]
NetworkMutationResolution = Literal[
"not-dispatched",
"target-observed",
"interrupted",
"superseded",
]
NetworkMutationLedgerStatus = Literal["empty", "unresolved", "resolved", "corrupt"]
_CONNECTION_MODES = frozenset({"bridge", "quick-connect", "direct-connect"})
_STAGES = frozenset({"prepared", "dispatching", "observing", "resolved"})
_WRITE_MODES = frozenset({"with_response", "without_response"})
_RESOLUTIONS = frozenset(
{"not-dispatched", "target-observed", "interrupted", "superseded"}
)
_SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:+-]{0,159}$")
_SAFE_STATUS_MODE = re.compile(r"^[A-Z][A-Z0-9_-]{0,31}$")
class NetworkMutationLedgerError(RuntimeError):
"""Base error for the durable K1 network-mutation fence."""
reason_code = "network-mutation-ledger-error"
class NetworkMutationBlocked(NetworkMutationLedgerError):
"""A prior durable record prevents admission of another device write."""
reason_code = "network-mutation-reconciliation-required"
class NetworkMutationLedgerCorrupt(NetworkMutationBlocked):
"""The durable fence cannot be trusted and therefore fails closed."""
reason_code = "network-mutation-ledger-corrupt"
class NetworkMutationTransitionError(NetworkMutationLedgerError):
"""A caller attempted an invalid ledger state transition."""
reason_code = "network-mutation-ledger-transition-invalid"
@dataclass(frozen=True, slots=True)
class NetworkStatusEvidence:
"""The bounded, non-secret subset of one decoded K1 7f02 status."""
mode: str | None
ipv4: str | None
status_code: int
reserved: int | None
def __post_init__(self) -> None:
if self.mode is not None and _SAFE_STATUS_MODE.fullmatch(self.mode) is None:
raise ValueError("network status mode is outside the secret-free schema")
if self.ipv4 is not None:
try:
parsed = ipaddress.ip_address(self.ipv4)
except ValueError as exc:
raise ValueError("network status address must be an IPv4 address") from exc
if not isinstance(parsed, ipaddress.IPv4Address) or str(parsed) != self.ipv4:
raise ValueError("network status address must be canonical IPv4")
_validate_byte(self.status_code, field_name="status_code")
if self.reserved is not None:
_validate_byte(self.reserved, field_name="reserved")
def as_dict(self) -> dict[str, object]:
return {
"mode": self.mode,
"ipv4": self.ipv4,
"status_code": self.status_code,
"reserved": self.reserved,
}
@dataclass(frozen=True, slots=True)
class PreviousConnectionEvidence:
"""Last admitted topology retained as evidence, never as live reachability."""
transport_ref: str
mode: NetworkConnectionMode
ipv4: str | None
device_session_id: str | None
def __post_init__(self) -> None:
_validate_identifier(self.transport_ref, field_name="transport_ref")
_validate_connection_mode(self.mode)
if self.ipv4 is not None:
try:
parsed = ipaddress.ip_address(self.ipv4)
except ValueError as exc:
raise ValueError("previous connection address must be an IPv4 address") from exc
if not isinstance(parsed, ipaddress.IPv4Address) or str(parsed) != self.ipv4:
raise ValueError("previous connection address must be canonical IPv4")
if self.device_session_id is not None:
_validate_identifier(self.device_session_id, field_name="device_session_id")
def as_dict(self) -> dict[str, object]:
return {
"transport_ref": self.transport_ref,
"mode": self.mode,
"ipv4": self.ipv4,
"device_session_id": self.device_session_id,
}
@dataclass(frozen=True, slots=True)
class NetworkMutationRecord:
schema_version: str
revision: int
operation_id: str
transport_ref: str
intended_mode: NetworkConnectionMode
stage: NetworkMutationStage
write_mode: NetworkMutationWriteMode
baseline_status: NetworkStatusEvidence
previous_connection: PreviousConnectionEvidence | None
write_confirmed: bool | None
last_observation: NetworkStatusEvidence | None
resolution: NetworkMutationResolution | None
created_at_utc: str
updated_at_utc: str
@property
def unresolved(self) -> bool:
return self.stage != "resolved"
def as_dict(self) -> dict[str, object]:
return {
"schema_version": self.schema_version,
"revision": self.revision,
"operation_id": self.operation_id,
"transport_ref": self.transport_ref,
"intended_mode": self.intended_mode,
"stage": self.stage,
"write_mode": self.write_mode,
"baseline_status": self.baseline_status.as_dict(),
"previous_connection": (
self.previous_connection.as_dict() if self.previous_connection is not None else None
),
"write_confirmed": self.write_confirmed,
"last_observation": (
self.last_observation.as_dict() if self.last_observation is not None else None
),
"resolution": self.resolution,
"created_at_utc": self.created_at_utc,
"updated_at_utc": self.updated_at_utc,
}
@dataclass(frozen=True, slots=True)
class NetworkMutationLedgerSnapshot:
status: NetworkMutationLedgerStatus
record: NetworkMutationRecord | None
reason_code: str | None
@property
def mutation_allowed(self) -> bool:
return self.status in {"empty", "resolved"}
class NetworkMutationLedger:
"""One durable, secret-free fence around K1 network side effects.
The ledger is intentionally independent from the BLE helpers. Integration
writes ``dispatching`` durably immediately before entering
``write_gatt_char``. A process crash can therefore create a conservative
false-positive fence, but can never silently authorize a second write.
"""
def __init__(
self,
repository_root: Path,
*,
clock: Callable[[], datetime] | None = None,
) -> None:
data_dir = resolve_missioncore_data_dir(repository_root)
self.path = data_dir / "xgrids-k1" / NETWORK_MUTATION_LEDGER_FILENAME
self._process_lock_path = data_dir / "xgrids-k1" / NETWORK_MUTATION_LEDGER_LOCK_FILENAME
self._data_dir = data_dir
self._clock = clock or (lambda: datetime.now(UTC))
self._lock = threading.RLock()
self._record: NetworkMutationRecord | None = None
self._corrupt = False
with self._lock, self._process_lock_locked():
self._reload_locked()
def snapshot(self) -> NetworkMutationLedgerSnapshot:
with self._lock, self._process_lock_locked():
self._reload_locked()
if self._corrupt:
return NetworkMutationLedgerSnapshot(
status="corrupt",
record=None,
reason_code=NetworkMutationLedgerCorrupt.reason_code,
)
if self._record is None:
return NetworkMutationLedgerSnapshot(status="empty", record=None, reason_code=None)
return NetworkMutationLedgerSnapshot(
status="unresolved" if self._record.unresolved else "resolved",
record=self._record,
reason_code=(
NetworkMutationBlocked.reason_code if self._record.unresolved else None
),
)
def require_mutation_allowed(self) -> None:
with self._lock, self._process_lock_locked():
self._reload_locked()
self._require_mutation_allowed_locked()
def prepare(
self,
*,
operation_id: str,
transport_ref: str,
intended_mode: NetworkConnectionMode,
write_mode: NetworkMutationWriteMode,
baseline_status: NetworkStatusEvidence,
previous_connection: PreviousConnectionEvidence | None = None,
) -> NetworkMutationRecord:
"""Persist a pre-write record after the live baseline has been read."""
_validate_identifier(operation_id, field_name="operation_id")
_validate_identifier(transport_ref, field_name="transport_ref")
_validate_connection_mode(intended_mode)
_validate_write_mode(write_mode)
if not isinstance(baseline_status, NetworkStatusEvidence):
raise TypeError("baseline_status must be NetworkStatusEvidence")
if previous_connection is not None and not isinstance(
previous_connection, PreviousConnectionEvidence
):
raise TypeError("previous_connection must be PreviousConnectionEvidence")
with self._lock, self._process_lock_locked():
self._reload_locked()
self._require_mutation_allowed_locked()
previous_revision = self._record.revision if self._record is not None else 0
now = _nondecreasing_audit_timestamp(
self._clock(),
floor=(self._record.updated_at_utc if self._record is not None else None),
)
record = NetworkMutationRecord(
schema_version=NETWORK_MUTATION_LEDGER_SCHEMA,
revision=previous_revision + 1,
operation_id=operation_id,
transport_ref=transport_ref,
intended_mode=intended_mode,
stage="prepared",
write_mode=write_mode,
baseline_status=baseline_status,
previous_connection=previous_connection,
write_confirmed=None,
last_observation=None,
resolution=None,
created_at_utc=now,
updated_at_utc=now,
)
self._persist_locked(record)
return record
def mark_dispatching(
self,
operation_id: str,
*,
expected_revision: int,
) -> NetworkMutationRecord:
"""Durably cross the side-effect boundary before the BLE write call."""
_positive_int(expected_revision, field_name="expected_revision")
with self._lock, self._process_lock_locked():
current = self._current_operation_locked(operation_id)
self._require_expected_revision(current, expected_revision)
if current.stage != "prepared":
raise NetworkMutationTransitionError(
"network mutation may dispatch only from prepared"
)
return self._transition_locked(current, stage="dispatching")
def confirm_dispatching_after_uncertain_return(
self,
operation_id: str,
*,
expected_prepared: NetworkMutationRecord,
) -> NetworkMutationRecord:
"""Re-fsync one exact DISPATCHING commit after its return path failed.
Reading a replaced file is not sufficient proof that its directory
entry reached stable storage. This recovery primitive verifies the
complete immutable predecessor/operation tuple under the store lock,
then republishes the identical record through the normal file+parent
fsync path. It never advances revision or creates dispatch authority
for a merely PREPARED row.
"""
_validate_identifier(operation_id, field_name="operation_id")
if not isinstance(expected_prepared, NetworkMutationRecord):
raise TypeError("expected_prepared must be NetworkMutationRecord")
if (
expected_prepared.operation_id != operation_id
or expected_prepared.stage != "prepared"
or expected_prepared.write_confirmed is not None
or expected_prepared.last_observation is not None
or expected_prepared.resolution is not None
):
raise NetworkMutationTransitionError(
"dispatch confirmation requires the exact PREPARED predecessor"
)
with self._lock, self._process_lock_locked():
self._reload_locked()
if self._corrupt or self._record is None:
raise NetworkMutationLedgerCorrupt(
"uncertain network dispatch record cannot be trusted"
)
current = self._record
if not (
current.operation_id == expected_prepared.operation_id
and current.transport_ref == expected_prepared.transport_ref
and current.intended_mode == expected_prepared.intended_mode
and current.write_mode == expected_prepared.write_mode
and current.baseline_status == expected_prepared.baseline_status
and current.previous_connection == expected_prepared.previous_connection
and current.created_at_utc == expected_prepared.created_at_utc
and current.stage == "dispatching"
and current.revision == expected_prepared.revision + 1
and current.write_confirmed is None
and current.last_observation is None
and current.resolution is None
):
raise NetworkMutationTransitionError(
"uncertain network dispatch does not match its predecessor"
)
self._persist_locked(current)
return current
def mark_observing(
self,
operation_id: str,
*,
expected_revision: int,
write_confirmed: bool,
observation: NetworkStatusEvidence | None = None,
) -> NetworkMutationRecord:
"""Record transport acknowledgement and bounded post-write evidence."""
_positive_int(expected_revision, field_name="expected_revision")
if not isinstance(write_confirmed, bool):
raise TypeError("write_confirmed must be bool")
if observation is not None and not isinstance(observation, NetworkStatusEvidence):
raise TypeError("observation must be NetworkStatusEvidence")
with self._lock, self._process_lock_locked():
current = self._current_operation_locked(operation_id)
self._require_expected_revision(current, expected_revision)
if current.stage not in {"dispatching", "observing"}:
raise NetworkMutationTransitionError(
"network mutation may observe only after dispatch"
)
if current.write_confirmed is True and not write_confirmed:
raise NetworkMutationTransitionError(
"network mutation write confirmation cannot regress"
)
return self._transition_locked(
current,
stage="observing",
write_confirmed=write_confirmed,
last_observation=observation,
)
def resolve(
self,
operation_id: str,
*,
expected_revision: int,
resolution: NetworkMutationResolution,
observation: NetworkStatusEvidence | None = None,
) -> NetworkMutationRecord:
"""Terminalize one mutation without authorizing an automatic retry.
``interrupted`` and ``superseded`` are audit outcomes. They make no
claim about the device-side result of a previously dispatched write;
they only state that the old host session no longer owns the next
explicit operator action.
"""
_positive_int(expected_revision, field_name="expected_revision")
_validate_resolution(resolution)
if observation is not None and not isinstance(observation, NetworkStatusEvidence):
raise TypeError("observation must be NetworkStatusEvidence")
with self._lock, self._process_lock_locked():
current = self._current_operation_locked(operation_id)
self._require_expected_revision(current, expected_revision)
if current.stage == "resolved":
if current.resolution == resolution:
return current
raise NetworkMutationTransitionError(
"resolved network mutation cannot change its resolution"
)
if resolution == "not-dispatched":
if current.stage != "prepared":
raise NetworkMutationTransitionError(
"not-dispatched resolution requires prepared stage"
)
if observation is not None:
raise NetworkMutationTransitionError(
"not-dispatched resolution cannot attach post-write evidence"
)
elif resolution == "target-observed":
if current.stage not in {"dispatching", "observing"}:
raise NetworkMutationTransitionError(
"target-observed resolution requires a dispatched mutation"
)
observation = observation or current.last_observation
if observation is None:
raise NetworkMutationTransitionError(
"target-observed resolution requires bounded status evidence"
)
else:
if current.stage not in {"dispatching", "observing"}:
raise NetworkMutationTransitionError(
f"{resolution} resolution requires a dispatched mutation"
)
# Session termination is not device-state evidence. Retain a
# previously captured bounded observation when one exists, but
# never require a read-only reconciliation before allowing a
# later explicit action.
observation = observation or current.last_observation
return self._transition_locked(
current,
stage="resolved",
last_observation=observation,
resolution=resolution,
)
@contextmanager
def _process_lock_locked(self) -> Iterator[None]:
"""Serialize one complete ledger transaction across local processes.
Atomic replacement protects the JSON from partial publication, but it
does not make the preceding read/check/write sequence atomic. A stable,
separately opened lock inode fences that whole sequence so two backend
processes cannot both admit a device mutation from the same revision.
"""
# The shared Mission Core data root may predate this plugin and is
# normalized by the same helper used by atomic publication. The
# ledger-specific directory must already be private or fail closed.
data_dir_created = _ensure_private_directory(self._data_dir, parents=True)
parent_created = _ensure_private_lock_directory(self.path.parent, parents=True)
if data_dir_created or parent_created:
_fsync_directory(self._data_dir)
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(self._process_lock_path, flags, 0o600)
except OSError as exc:
raise NetworkMutationLedgerCorrupt(
"network mutation ledger lock cannot be opened safely"
) from exc
lock_stream: IO[bytes] | None = None
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600:
raise NetworkMutationLedgerCorrupt(
"network mutation ledger lock is not a private regular file"
)
lock_stream = os.fdopen(descriptor, "r+b", closefd=True)
descriptor = -1
fcntl.flock(lock_stream.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_stream.fileno(), fcntl.LOCK_UN)
finally:
if lock_stream is not None:
lock_stream.close()
elif descriptor >= 0:
os.close(descriptor)
def _current_operation_locked(self, operation_id: str) -> NetworkMutationRecord:
_validate_identifier(operation_id, field_name="operation_id")
self._reload_locked()
if self._corrupt:
raise NetworkMutationLedgerCorrupt(
"network mutation ledger is corrupt; device writes remain blocked"
)
current = self._record
if current is None or current.operation_id != operation_id:
raise NetworkMutationTransitionError("network mutation operation does not match ledger")
return current
def _require_mutation_allowed_locked(self) -> None:
if self._corrupt:
raise NetworkMutationLedgerCorrupt(
"network mutation ledger is corrupt; device writes remain blocked"
)
if self._record is not None and self._record.unresolved:
raise NetworkMutationBlocked(
"previous network mutation is unresolved; another device write is blocked"
)
@staticmethod
def _require_expected_revision(
record: NetworkMutationRecord,
expected_revision: int,
) -> None:
if record.revision != expected_revision:
raise NetworkMutationTransitionError(
"network mutation transition used a stale record revision"
)
def _transition_locked(
self,
current: NetworkMutationRecord,
*,
stage: NetworkMutationStage,
write_confirmed: bool | None = None,
last_observation: NetworkStatusEvidence | None = None,
resolution: NetworkMutationResolution | None = None,
) -> NetworkMutationRecord:
record = replace(
current,
revision=current.revision + 1,
stage=stage,
write_confirmed=(
write_confirmed if write_confirmed is not None else current.write_confirmed
),
last_observation=(
last_observation if last_observation is not None else current.last_observation
),
resolution=resolution,
updated_at_utc=_nondecreasing_audit_timestamp(
self._clock(),
floor=current.updated_at_utc,
),
)
self._persist_locked(record)
return record
def _persist_locked(self, record: NetworkMutationRecord) -> None:
_write_private_json_atomic(
self.path,
record.as_dict(),
data_dir=self._data_dir,
)
self._record = record
self._corrupt = False
def _reload_locked(self) -> None:
try:
metadata = self.path.lstat()
except FileNotFoundError:
self._record = None
self._corrupt = False
return
except OSError:
self._record = None
self._corrupt = True
return
try:
if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600:
raise ValueError("ledger file is not a private regular file")
parent_metadata = self.path.parent.lstat()
if (
not stat.S_ISDIR(parent_metadata.st_mode)
or stat.S_IMODE(parent_metadata.st_mode) != 0o700
):
raise ValueError("ledger directory is not private")
if metadata.st_size > NETWORK_MUTATION_LEDGER_MAX_BYTES:
raise ValueError("ledger file exceeds the bounded size")
payload = json.loads(
self.path.read_text(encoding="utf-8"),
object_pairs_hook=_unique_json_object,
)
legacy_schema = (
isinstance(payload, dict)
and payload.get("schema_version") == NETWORK_MUTATION_LEDGER_LEGACY_SCHEMA
)
record = _record_from_mapping(payload)
if legacy_schema:
# v1 was briefly published with two shapes: the original
# previous_connection lacked transport_ref, while the final
# in-tree shape already carried it. Only the latter can be
# migrated without inventing which physical K1 owned the
# previous topology. _record_from_mapping deliberately
# rejects the ambiguous shape and preserves it fail-closed.
_write_private_json_atomic(
self.path,
record.as_dict(),
data_dir=self._data_dir,
)
except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError):
self._record = None
self._corrupt = True
return
self._record = record
self._corrupt = False
def _write_private_json_atomic(
path: Path,
payload: Mapping[str, object],
*,
data_dir: Path,
) -> None:
serialized = (
json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
).encode("utf-8")
if len(serialized) > NETWORK_MUTATION_LEDGER_MAX_BYTES:
raise ValueError("network mutation ledger exceeds the bounded size")
data_dir_created = _ensure_private_directory(data_dir, parents=True)
parent_created = _ensure_private_directory(path.parent, parents=True)
if data_dir_created or parent_created:
_fsync_directory(data_dir)
descriptor, temp_name = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temp_path = Path(temp_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(serialized)
stream.flush()
os.fsync(stream.fileno())
os.replace(temp_path, path)
path.chmod(0o600)
_fsync_directory(path.parent)
finally:
if descriptor >= 0:
os.close(descriptor)
temp_path.unlink(missing_ok=True)
def _fsync_directory(path: Path) -> None:
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
descriptor = os.open(path, flags)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _ensure_private_directory(path: Path, *, parents: bool) -> bool:
try:
metadata = path.lstat()
except FileNotFoundError:
try:
path.mkdir(mode=0o700, parents=parents, exist_ok=False)
except FileExistsError:
# A peer process may have created the shared private directory
# between lstat and mkdir. Validate that winner below instead of
# failing a safe concurrent ledger acquisition.
metadata = path.lstat()
else:
path.chmod(0o700)
return True
if not stat.S_ISDIR(metadata.st_mode):
raise NetworkMutationLedgerCorrupt(
"network mutation ledger directory is not a private directory"
)
path.chmod(0o700)
return False
def _ensure_private_lock_directory(path: Path, *, parents: bool) -> bool:
"""Create a lock parent privately or reject an unsafe existing parent."""
try:
metadata = path.lstat()
except FileNotFoundError:
try:
path.mkdir(mode=0o700, parents=parents, exist_ok=False)
except FileExistsError:
metadata = path.lstat()
else:
path.chmod(0o700)
return True
if not stat.S_ISDIR(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o700:
raise NetworkMutationLedgerCorrupt("network mutation ledger lock directory is not private")
return False
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
document: dict[str, object] = {}
for key, value in pairs:
if key in document:
raise ValueError("network mutation ledger contains duplicate fields")
document[key] = value
return document
def _record_from_mapping(value: object) -> NetworkMutationRecord:
document = _exact_mapping(
value,
{
"schema_version",
"revision",
"operation_id",
"transport_ref",
"intended_mode",
"stage",
"write_mode",
"baseline_status",
"previous_connection",
"write_confirmed",
"last_observation",
"resolution",
"created_at_utc",
"updated_at_utc",
},
label="ledger",
)
if document["schema_version"] not in {
NETWORK_MUTATION_LEDGER_SCHEMA,
NETWORK_MUTATION_LEDGER_LEGACY_SCHEMA,
}:
raise ValueError("unsupported network mutation ledger schema")
revision = _positive_int(document["revision"], field_name="revision")
operation_id = _required_string(document["operation_id"], field_name="operation_id")
transport_ref = _required_string(document["transport_ref"], field_name="transport_ref")
_validate_identifier(operation_id, field_name="operation_id")
_validate_identifier(transport_ref, field_name="transport_ref")
intended_mode_raw = _required_string(document["intended_mode"], field_name="intended_mode")
_validate_connection_mode(intended_mode_raw)
intended_mode = cast(NetworkConnectionMode, intended_mode_raw)
stage_raw = _required_string(document["stage"], field_name="stage")
if stage_raw not in _STAGES:
raise ValueError("unsupported network mutation stage")
stage = cast(NetworkMutationStage, stage_raw)
write_mode_raw = _required_string(document["write_mode"], field_name="write_mode")
_validate_write_mode(write_mode_raw)
write_mode = cast(NetworkMutationWriteMode, write_mode_raw)
baseline_status = _status_from_mapping(document["baseline_status"])
previous_raw = document["previous_connection"]
previous_connection = (
None if previous_raw is None else _previous_connection_from_mapping(previous_raw)
)
write_confirmed_raw = document["write_confirmed"]
if write_confirmed_raw is not None and not isinstance(write_confirmed_raw, bool):
raise ValueError("write_confirmed must be bool or null")
observation_raw = document["last_observation"]
last_observation = None if observation_raw is None else _status_from_mapping(observation_raw)
resolution_raw = document["resolution"]
if resolution_raw is None:
resolution = None
else:
resolution_string = _required_string(resolution_raw, field_name="resolution")
_validate_resolution(resolution_string)
resolution = cast(NetworkMutationResolution, resolution_string)
created_at = _validated_timestamp(document["created_at_utc"], field_name="created_at_utc")
updated_at = _validated_timestamp(document["updated_at_utc"], field_name="updated_at_utc")
if updated_at < created_at:
raise ValueError("ledger update precedes creation")
if stage == "prepared":
if (
write_confirmed_raw is not None
or last_observation is not None
or resolution is not None
):
raise ValueError("prepared ledger contains post-write fields")
elif stage in {"dispatching", "observing"}:
if resolution is not None:
raise ValueError("unresolved ledger contains a resolution")
if stage == "dispatching" and (
write_confirmed_raw is not None or last_observation is not None
):
raise ValueError("dispatching ledger contains observation fields")
if stage == "observing" and write_confirmed_raw is None:
raise ValueError("observing ledger lacks write acknowledgement status")
else:
if resolution is None:
raise ValueError("resolved ledger lacks a resolution")
if resolution == "not-dispatched" and (
write_confirmed_raw is not None or last_observation is not None
):
raise ValueError("not-dispatched resolution contains post-write fields")
if resolution == "target-observed" and last_observation is None:
raise ValueError("target-observed resolution lacks status evidence")
return NetworkMutationRecord(
schema_version=NETWORK_MUTATION_LEDGER_SCHEMA,
revision=revision,
operation_id=operation_id,
transport_ref=transport_ref,
intended_mode=intended_mode,
stage=stage,
write_mode=write_mode,
baseline_status=baseline_status,
previous_connection=previous_connection,
write_confirmed=write_confirmed_raw,
last_observation=last_observation,
resolution=resolution,
created_at_utc=_timestamp(created_at),
updated_at_utc=_timestamp(updated_at),
)
def _status_from_mapping(value: object) -> NetworkStatusEvidence:
document = _exact_mapping(
value,
{"mode", "ipv4", "status_code", "reserved"},
label="network status",
)
mode = _optional_string(document["mode"], field_name="mode")
ipv4 = _optional_string(document["ipv4"], field_name="ipv4")
status_code = _byte(document["status_code"], field_name="status_code")
reserved_raw = document["reserved"]
reserved = None if reserved_raw is None else _byte(reserved_raw, field_name="reserved")
return NetworkStatusEvidence(
mode=mode,
ipv4=ipv4,
status_code=status_code,
reserved=reserved,
)
def _previous_connection_from_mapping(value: object) -> PreviousConnectionEvidence:
document = _exact_mapping(
value,
{"transport_ref", "mode", "ipv4", "device_session_id"},
label="previous connection",
)
mode_raw = _required_string(document["mode"], field_name="mode")
_validate_connection_mode(mode_raw)
return PreviousConnectionEvidence(
transport_ref=_required_string(document["transport_ref"], field_name="transport_ref"),
mode=cast(NetworkConnectionMode, mode_raw),
ipv4=_optional_string(document["ipv4"], field_name="ipv4"),
device_session_id=_optional_string(
document["device_session_id"], field_name="device_session_id"
),
)
def _exact_mapping(value: object, keys: set[str], *, label: str) -> Mapping[str, object]:
if not isinstance(value, dict) or set(value) != keys:
raise ValueError(f"{label} does not match the secret-free schema")
return cast(Mapping[str, object], value)
def _validate_identifier(value: str, *, field_name: str) -> None:
if _SAFE_IDENTIFIER.fullmatch(value) is None:
raise ValueError(f"{field_name} is outside the secret-free identifier schema")
def _validate_connection_mode(value: str) -> None:
if value not in _CONNECTION_MODES:
raise ValueError("unsupported network connection mode")
def _validate_write_mode(value: str) -> None:
if value not in _WRITE_MODES:
raise ValueError("unsupported BLE write mode")
def _validate_resolution(value: str) -> None:
if value not in _RESOLUTIONS:
raise ValueError("unsupported network mutation resolution")
def _validate_byte(value: int, *, field_name: str) -> None:
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 255:
raise ValueError(f"{field_name} must be an unsigned byte")
def _byte(value: object, *, field_name: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 255:
raise ValueError(f"{field_name} must be an unsigned byte")
return value
def _positive_int(value: object, *, field_name: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
raise ValueError(f"{field_name} must be a positive integer")
return value
def _required_string(value: object, *, field_name: str) -> str:
if not isinstance(value, str) or not value:
raise ValueError(f"{field_name} must be a non-empty string")
return value
def _optional_string(value: object, *, field_name: str) -> str | None:
if value is None:
return None
return _required_string(value, field_name=field_name)
def _validated_timestamp(value: object, *, field_name: str) -> datetime:
raw = _required_string(value, field_name=field_name)
if not raw.endswith("Z"):
raise ValueError(f"{field_name} must be UTC")
try:
parsed = datetime.fromisoformat(raw.removesuffix("Z") + "+00:00")
except ValueError as exc:
raise ValueError(f"{field_name} is invalid") from exc
if parsed.tzinfo is None or parsed.utcoffset() != UTC.utcoffset(parsed):
raise ValueError(f"{field_name} must be UTC")
return parsed.astimezone(UTC)
def _timestamp(value: datetime) -> str:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("network mutation ledger clock must be timezone-aware")
return value.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _nondecreasing_audit_timestamp(value: datetime, *, floor: str | None) -> str:
"""Canonicalize wall time without using it as transition authority.
Serialized revision and stage checks order ledger transitions. A host clock
may move backwards (NTP correction, RTC repair, suspend/resume), so the
human-readable audit timestamp is clamped to the prior durable value rather
than allowing a valid transition to publish a record that fails its own
``updated >= created`` structural check after restart.
"""
candidate = _validated_timestamp(_timestamp(value), field_name="ledger clock")
if floor is None:
return _timestamp(candidate)
floor_value = _validated_timestamp(floor, field_name="audit timestamp floor")
return _timestamp(max(candidate, floor_value))
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@
from __future__ import annotations
import os
import stat
import threading
from collections.abc import Mapping
@@ -41,8 +42,16 @@ XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
def build_xgrids_k1_observation(repository_root: Path) -> ObservationRuntimeContribution:
"""Compose every K1 evidence root behind the generic observation ABI."""
configured_legacy_root = os.environ.get(
"MISSIONCORE_LEGACY_SESSIONS_DIR", ""
).strip()
legacy_root = (
Path(configured_legacy_root).expanduser().resolve()
if configured_legacy_root
else repository_root.resolve() / "sessions"
)
roots = (
("xgrids-k1.viewer-live.repository", repository_root.resolve() / "sessions"),
("xgrids-k1.viewer-live.repository", legacy_root),
(
"xgrids-k1.viewer-live.evidence",
resolve_missioncore_evidence_dir(repository_root),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
)
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
MODELING_RESPONSE_TOPIC,
ApplicationMqttTransportError,
)
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
OneShotPublishEnvelope,
@@ -65,6 +66,8 @@ class ApplicationBatchExchange(Protocol):
envelopes: Sequence[OneShotPublishEnvelope],
*,
required_response_operation_keys: Collection[str],
dispatch_admission_deadline_reached: Callable[[], bool] | None = None,
dispatch_admission_commit: Callable[[], None] | None = None,
) -> dict[str, bytes]: ...
def maintain_open_for(
@@ -129,8 +132,31 @@ class OperatorDialogueCheckpoint:
)
class PhysicalAcceptancePermitReservation:
"""One commit right that must still be fresh at dispatch admission."""
def __init__(self, permit: PhysicalAcceptancePermit, token: object) -> None:
self._permit = permit
self._token = token
self._finished = False
def commit(self) -> None:
if self._finished:
raise ApplicationAcceptanceError(
"physical acceptance reservation was already finished"
)
self._permit._commit_reservation(self._token) # noqa: SLF001
self._finished = True
def release(self) -> None:
if self._finished:
return
self._permit._release_reservation(self._token) # noqa: SLF001
self._finished = True
class PhysicalAcceptancePermit:
"""Short, single-action capability that is consumed before MQTT publish."""
"""Short, single-action capability committed at physical dispatch admission."""
def __init__(
self,
@@ -152,20 +178,54 @@ class PhysicalAcceptancePermit:
self._monotonic = monotonic
self._expires_at = monotonic() + float(ttl_seconds)
self._consumed = False
self._reservation: object | None = None
@property
def action(self) -> ModelingAction:
return self._checklist.action
def consume(self, action: ModelingAction) -> None:
reservation = self.reserve(action)
reservation.commit()
def reserve(
self,
action: ModelingAction,
) -> PhysicalAcceptancePermitReservation:
with self._lock:
if self._consumed:
raise ApplicationAcceptanceError("physical acceptance permit was already consumed")
if self._reservation is not None:
raise ApplicationAcceptanceError("physical acceptance permit is already reserved")
if self._monotonic() >= self._expires_at:
raise ApplicationAcceptanceError("physical acceptance permit expired")
if action is not self._checklist.action:
raise ApplicationAcceptanceError("physical acceptance permit action mismatch")
token = object()
self._reservation = token
return PhysicalAcceptancePermitReservation(self, token)
def _commit_reservation(self, token: object) -> None:
with self._lock:
if self._consumed or self._reservation is not token:
raise ApplicationAcceptanceError(
"physical acceptance reservation is no longer current"
)
if self._monotonic() >= self._expires_at:
self._reservation = None
raise ApplicationMqttTransportError(
"physical acceptance permit expired before dispatch admission",
reason_code=(
"physical-acceptance-permit-expired-before-dispatch"
),
)
self._consumed = True
self._reservation = None
def _release_reservation(self, token: object) -> None:
with self._lock:
if self._reservation is token and not self._consumed:
self._reservation = None
def snapshot(self) -> dict[str, object]:
with self._lock:
@@ -218,6 +278,7 @@ class PhysicalAcceptanceDialogueExecutor:
self._command_complete = False
self._dialogue_stage = "new"
self._start_complete = False
self._active_session_adopted = False
self._stop_attempted = False
self._stop_complete = False
self._active_authority: ApplicationControlAuthority | None = None
@@ -246,13 +307,48 @@ class PhysicalAcceptanceDialogueExecutor:
) -> LiveDeviceControlBinding:
"""Emit retained ordinals 1-6 at control-session establishment."""
binding = self.run_read_only_inspection_stage(orchestrator)
return self.complete_connection_stage(orchestrator, expected_binding=binding)
def run_read_only_inspection_stage(
self,
orchestrator: ShadowApplicationBootstrapOrchestrator,
) -> LiveDeviceControlBinding:
"""Emit only ordinal-1 DeviceInfo for an explicit passive Verify.
ModelingStatus and the time-setting DeviceConfig request belong to the
canonical preparation dialogue. They are deliberately excluded from
this stage so a recovery Verify cannot cross a device-mutation edge.
A later explicit workspace action may promote this same socket through
:meth:`complete_connection_stage`.
"""
if self._dialogue_stage != "new" or self._bootstrap_complete or self._command_complete:
raise ApplicationAcceptanceError("connection stage is not admissible now")
for expected_batch in (1, 2):
self._exchange_bootstrap_batch(orchestrator, expected_batch=expected_batch)
raise ApplicationAcceptanceError("inspection stage is not admissible now")
self._exchange_bootstrap_batch(orchestrator, expected_batch=1)
binding = orchestrator.binding
if binding is None:
raise ApplicationAcceptanceError("connection stage produced no live device binding")
raise ApplicationAcceptanceError("inspection stage produced no live device binding")
self._prepared_binding = binding
self._dialogue_stage = "inspection-ready"
return binding
def complete_connection_stage(
self,
orchestrator: ShadowApplicationBootstrapOrchestrator,
*,
expected_binding: LiveDeviceControlBinding,
) -> LiveDeviceControlBinding:
"""Promote an ordinal-1 inspection into the canonical ordinals 2-6."""
if self._dialogue_stage != "inspection-ready":
raise ApplicationAcceptanceError("connection completion requires inspection-ready")
if self._prepared_binding != expected_binding:
raise ApplicationAcceptanceError("inspection binding changed before completion")
self._exchange_bootstrap_batch(orchestrator, expected_batch=2)
binding = orchestrator.binding
if binding is None or binding != expected_binding:
raise ApplicationAcceptanceError("connection stage changed the inspected identity")
self._prepared_binding = binding
self._dialogue_stage = "connection-ready"
return binding
@@ -261,10 +357,13 @@ class PhysicalAcceptanceDialogueExecutor:
self,
event: Literal["workspace-entered", "project-prompt-opened", "start-confirmed"],
event_observed: Callable[[], bool],
) -> OperatorDialogueCheckpoint:
*,
reconciled_active_observed: Callable[[], bool] | None = None,
) -> OperatorDialogueCheckpoint | None:
"""Service the original socket until one exact operator UI event occurs."""
expected = {
"inspection-ready": "workspace-entered",
"connection-ready": "workspace-entered",
"workspace-ready": "project-prompt-opened",
"project-ready": "start-confirmed",
@@ -276,9 +375,19 @@ class PhysicalAcceptanceDialogueExecutor:
binding = self._prepared_binding
if binding is None:
raise ApplicationAcceptanceError("canonical preparation binding is unavailable")
while not (
self._transport.pre_start_ready(binding) and event_observed()
):
if reconciled_active_observed is not None and event != "workspace-entered":
raise ApplicationAcceptanceError(
"active recovery may replace only the workspace-entry checkpoint"
)
while True:
if reconciled_active_observed is not None and reconciled_active_observed():
if not self._transport.scan_initialization_complete(binding):
raise ApplicationAcceptanceError(
"active recovery requires fresh bound SCANNING state"
)
return None
if self._transport.pre_start_ready(binding) and event_observed():
break
self._transport.maintain_open_for(
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
@@ -291,16 +400,52 @@ class PhysicalAcceptanceDialogueExecutor:
owner_token=self._checkpoint_owner,
)
def adopt_reconciled_scanning(
self,
*,
authority: ApplicationControlAuthority,
binding: LiveDeviceControlBinding,
) -> None:
"""Adopt externally observed SCANNING without inventing a new START.
The caller has already committed an exact read-only physical-ledger
reconciliation for this control generation. This method performs no
publish; it only gives the existing socket enough local state to wait
for one later operator-confirmed STOP.
"""
if self._dialogue_stage not in {"inspection-ready", "connection-ready"}:
raise ApplicationAcceptanceError(
"active recovery requires an inspected pre-START control session"
)
if self._prepared_binding != binding:
raise ApplicationAcceptanceError("active recovery binding changed")
if self._command_complete or self._start_complete or self._stop_attempted:
raise ApplicationAcceptanceError("active recovery cannot replace a command attempt")
if not self._transport.scan_initialization_complete(binding):
raise ApplicationAcceptanceError(
"active recovery requires the bound K1 to report SCANNING"
)
self._active_authority = authority
self._active_binding = binding
self._prepared_binding = None
self._active_session_adopted = True
self._dialogue_stage = "post-initialization-observed"
def run_workspace_entry_stage(
self,
orchestrator: ShadowApplicationBootstrapOrchestrator,
checkpoint: OperatorDialogueCheckpoint,
*,
dispatch_guard: Callable[[], None] | None = None,
) -> LiveDeviceControlBinding:
"""Emit ordinal 7 only for the observed scan-workspace entry action."""
if self._dialogue_stage != "connection-ready":
raise ApplicationAcceptanceError("workspace entry requires the connection stage")
self._consume_checkpoint(checkpoint, expected="workspace-entered")
if dispatch_guard is not None:
dispatch_guard()
self._exchange_bootstrap_batch(orchestrator, expected_batch=3)
binding = orchestrator.binding
if binding is None:
@@ -312,12 +457,16 @@ class PhysicalAcceptanceDialogueExecutor:
self,
orchestrator: ShadowApplicationBootstrapOrchestrator,
checkpoint: OperatorDialogueCheckpoint,
*,
dispatch_guard: Callable[[], None] | None = None,
) -> LiveDeviceControlBinding:
"""Emit ordinals 8-10 when the operator opens the project-name prompt."""
if self._dialogue_stage != "workspace-ready":
raise ApplicationAcceptanceError("project prompt requires workspace entry")
self._consume_checkpoint(checkpoint, expected="project-prompt-opened")
if dispatch_guard is not None:
dispatch_guard()
self._exchange_bootstrap_batch(orchestrator, expected_batch=4)
if not orchestrator.snapshot().bootstrap_complete:
raise ApplicationAcceptanceError("project prompt did not complete the transcript")
@@ -343,6 +492,7 @@ class PhysicalAcceptanceDialogueExecutor:
binding: LiveDeviceControlBinding,
permit: PhysicalAcceptancePermit,
checkpoint: OperatorDialogueCheckpoint,
dispatch_guard: Callable[[], None] | None = None,
) -> ModelingResponse:
"""Execute retained operations 11-14 on one continuously serviced socket."""
@@ -363,6 +513,8 @@ class PhysicalAcceptanceDialogueExecutor:
if permit.action is not ModelingAction.START:
raise ApplicationAcceptanceError("canonical START requires a fresh START permit")
if dispatch_guard is not None:
dispatch_guard()
permit.consume(ModelingAction.START)
self._start_permit_snapshot = permit.snapshot()
self._command_complete = True
@@ -392,6 +544,8 @@ class PhysicalAcceptanceDialogueExecutor:
) from exc
immediate = post_start.immediate_modeling_status
if dispatch_guard is not None:
dispatch_guard()
self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_dialogue_request(immediate)],
required_response_operation_keys=(),
@@ -414,6 +568,8 @@ class PhysicalAcceptanceDialogueExecutor:
required_operations = {
f"dialogue:{request.ordinal}:{request.message_type}" for request in refresh
}
if dispatch_guard is not None:
dispatch_guard()
refresh_responses = self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_dialogue_request(request) for request in refresh],
required_response_operation_keys=required_operations,
@@ -469,7 +625,9 @@ class PhysicalAcceptanceDialogueExecutor:
) -> None:
"""Continuously service the original socket until the operator requests STOP."""
if self._dialogue_stage != "post-initialization-observed" or not self._start_complete:
if self._dialogue_stage != "post-initialization-observed" or not (
self._start_complete or self._active_session_adopted
):
raise ApplicationAcceptanceError(
"active control ownership requires the complete post-START dialogue"
)
@@ -491,12 +649,17 @@ class PhysicalAcceptanceDialogueExecutor:
self,
command: ShadowModelingCommand,
permit: PhysicalAcceptancePermit,
*,
dispatch_guard: Callable[[], None] | None = None,
dispatch_admission_deadline_reached: Callable[[], bool] | None = None,
) -> ModelingResponse:
"""Emit retained STOP on the same socket and with a separate permit."""
if command.action is not ModelingAction.STOP:
raise ApplicationAcceptanceError("canonical STOP executor requires STOP")
if self._dialogue_stage != "stop-requested" or not self._start_complete:
if self._dialogue_stage != "stop-requested" or not (
self._start_complete or self._active_session_adopted
):
raise ApplicationAcceptanceError(
"STOP requires continuous ownership from the canonical START session"
)
@@ -507,21 +670,66 @@ class PhysicalAcceptanceDialogueExecutor:
if authority is None or binding is None:
raise ApplicationAcceptanceError("canonical START binding is no longer available")
self._require_command_identity(command, authority=authority, binding=binding)
if not self._transport.scan_initialization_complete(binding):
self._require_stop_dispatch_deadline_open(
dispatch_admission_deadline_reached
)
scanning = self._transport.scan_initialization_complete(binding)
# The status projection can wait on the transport lock. Deadline
# admission is therefore sampled again before its result can advance
# the physical STOP dialogue.
self._require_stop_dispatch_deadline_open(
dispatch_admission_deadline_reached
)
if not scanning:
raise ApplicationAcceptanceError(
"canonical STOP requires the bound K1 to still report SCANNING"
)
if permit.action is not ModelingAction.STOP:
raise ApplicationAcceptanceError("canonical STOP requires a separate STOP permit")
permit.consume(ModelingAction.STOP)
self._stop_permit_snapshot = permit.snapshot()
if dispatch_guard is not None:
dispatch_guard()
# Route/control validation is read-only but may block. It cannot
# authorize a publish whose operation deadline elapsed meanwhile.
self._require_stop_dispatch_deadline_open(
dispatch_admission_deadline_reached
)
permit_reservation = permit.reserve(ModelingAction.STOP)
self._stop_attempted = True
self._dialogue_stage = "stop-attempted"
responses = self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_modeling_command(command)],
required_response_operation_keys={"modeling:stop"},
)
# Keep source-compatible test/integration transports on the legacy
# call shape unless a real operation deadline was supplied.
stop_envelope = OneShotPublishEnvelope.from_modeling_command(command)
try:
if dispatch_admission_deadline_reached is None:
responses = self._transport.exchange_batch_once(
[stop_envelope],
required_response_operation_keys={"modeling:stop"},
dispatch_admission_commit=permit_reservation.commit,
)
else:
responses = self._transport.exchange_batch_once(
[stop_envelope],
required_response_operation_keys={"modeling:stop"},
dispatch_admission_deadline_reached=(
dispatch_admission_deadline_reached
),
dispatch_admission_commit=permit_reservation.commit,
)
except ApplicationMqttTransportError as exc:
if exc.reason_code in {
"physical-command-dispatch-deadline-expired",
"physical-acceptance-permit-expired-before-dispatch",
}:
# Atomic physical admission rejected either the operation
# deadline or the still-fresh permit before durable
# DISPATCHING. Preserve that stronger zero-attempt fact.
permit_reservation.release()
self._stop_attempted = False
self._dialogue_stage = "stop-requested"
raise
finally:
self._stop_permit_snapshot = permit.snapshot()
payload = responses["modeling:stop"]
self._record_response_evidence(
phase="modeling",
@@ -545,6 +753,19 @@ class PhysicalAcceptanceDialogueExecutor:
self._dialogue_stage = "stop-acknowledged"
return response
@staticmethod
def _require_stop_dispatch_deadline_open(
dispatch_admission_deadline_reached: Callable[[], bool] | None,
) -> None:
if (
dispatch_admission_deadline_reached is not None
and dispatch_admission_deadline_reached()
):
raise ApplicationMqttTransportError(
"control command dispatch deadline expired before publish admission",
reason_code="physical-command-dispatch-deadline-expired",
)
def maintain_post_stop_until_standby(self) -> None:
"""Keep servicing control reports through save and protocol standby.
@@ -577,6 +798,7 @@ class PhysicalAcceptanceDialogueExecutor:
"command_complete": self._command_complete,
"start_attempted": self._command_complete,
"start_complete": self._start_complete,
"active_session_adopted": self._active_session_adopted,
"stop_attempted": self._stop_attempted,
"stop_complete": self._stop_complete,
"dialogue_stage": self._dialogue_stage,
@@ -17,11 +17,35 @@ KEYCHAIN_SERVICE = "NODEDC Mission Core XGRIDS K1 OpenAPI"
KEYCHAIN_ACCOUNT = "lixelgo-application-fw-3.0.2"
KEYCHAIN_TIMEOUT_SECONDS = 5.0
KEYCHAIN_INTERACTIVE_TIMEOUT_SECONDS = 300.0
_ERR_SEC_USER_CANCELED = -128
_ERR_SEC_AUTH_FAILED = -25293
_ERR_SEC_INTERACTION_NOT_ALLOWED = -25308
class ApplicationAuthorityLoadError(RuntimeError):
"""The private application authority could not be loaded safely."""
def __init__(
self,
message: str,
*,
reason_code: str = "application_authority_unavailable",
) -> None:
super().__init__(message)
self.reason_code = reason_code
def _keychain_authority_reason_code(status: int) -> str:
"""Reduce an OSStatus to a reviewed, secret-free operator class."""
if status == _ERR_SEC_INTERACTION_NOT_ALLOWED:
return "keychain-authorization-required"
if status == _ERR_SEC_AUTH_FAILED:
return "keychain-authorization-denied"
if status == _ERR_SEC_USER_CANCELED:
return "keychain-authorization-cancelled"
return "application_authority_unavailable"
class CommandRunner(Protocol):
def __call__(
@@ -97,8 +121,12 @@ def _read_keychain_secret_via_security_framework(*, service: str, account: str)
if not isinstance(result, tuple) or len(result) != 2:
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed")
status, secret_data = result
if int(status) != 0 or secret_data is None:
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
status_code = int(status)
if status_code != 0 or secret_data is None:
raise ApplicationAuthorityLoadError(
"macOS Keychain authority is unavailable",
reason_code=_keychain_authority_reason_code(status_code),
)
try:
return bytes(secret_data)
except Exception as exc:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,667 @@
from __future__ import annotations
import fcntl
import ipaddress
import json
import os
import re
import stat
import tempfile
import threading
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import IO, Final, Literal, cast
from k1link.sessions.store import resolve_missioncore_data_dir
SEMANTIC_TOPOLOGY_SCHEMA: Final = "missioncore.xgrids-k1-semantic-topology/v1"
SEMANTIC_TOPOLOGY_FILENAME = "semantic-topology.json"
SEMANTIC_TOPOLOGY_LOCK_FILENAME = ".semantic-topology.lock"
SEMANTIC_TOPOLOGY_MAX_BYTES = 16 * 1024
SEMANTIC_TOPOLOGY_MAX_REVISION = (1 << 63) - 1
TopologyConnectionMode = Literal["bridge", "quick-connect", "direct-connect"]
TopologyEvidenceSource = Literal["ble-post-write-status", "ble-read-only-status"]
SemanticTopologyStoreStatus = Literal["empty", "available", "corrupt"]
_CONNECTION_MODES = frozenset({"bridge", "quick-connect", "direct-connect"})
_EVIDENCE_SOURCES = frozenset({"ble-post-write-status", "ble-read-only-status"})
_SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:+-]{0,159}$")
_SAFE_FIRMWARE_VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+() -]{0,63}$")
class SemanticTopologyStoreError(RuntimeError):
"""Base error for durable, non-authoritative K1 topology evidence."""
reason_code = "semantic-topology-store-error"
class SemanticTopologyStoreCorrupt(SemanticTopologyStoreError):
"""The on-disk evidence cannot be trusted and must not be adopted."""
reason_code = "semantic-topology-store-corrupt"
class StaleSemanticTopologyObservation(SemanticTopologyStoreError):
"""An asynchronous writer no longer descends from the durable revision."""
reason_code = "semantic-topology-observation-stale"
@dataclass(frozen=True, slots=True)
class SemanticTopologyRecord:
"""Last topology proved by an exact K1 BLE status read.
This record proves only what the K1 reported at ``observed_at_utc``. It is
deliberately not a host-route, TCP, MQTT, connection, or acquisition lease.
A record loaded after restart is configured/offline evidence until all live
connection-supervisor gates are proved again.
"""
schema_version: Literal["missioncore.xgrids-k1-semantic-topology/v1"]
revision: int
transport_ref: str
connection_mode: TopologyConnectionMode
ipv4: str
compatibility_profile_id: str
firmware_version: str
source: TopologyEvidenceSource
observed_at_utc: str
@property
def live_connection_authority(self) -> Literal[False]:
return False
def as_dict(self) -> dict[str, object]:
return {
"schema_version": self.schema_version,
"revision": self.revision,
"transport_ref": self.transport_ref,
"connection_mode": self.connection_mode,
"ipv4": self.ipv4,
"compatibility_profile_id": self.compatibility_profile_id,
"firmware_version": self.firmware_version,
"source": self.source,
"observed_at_utc": self.observed_at_utc,
}
@dataclass(frozen=True, slots=True)
class SemanticTopologySnapshot:
status: SemanticTopologyStoreStatus
record: SemanticTopologyRecord | None
reason_code: str | None
@property
def configured_offline_evidence(self) -> bool:
return self.status == "available" and self.record is not None
@property
def live_connection_authority(self) -> Literal[False]:
return False
def as_dict(self) -> dict[str, object]:
"""Project durable evidence without implying a live connection."""
return {
"schema_version": SEMANTIC_TOPOLOGY_SCHEMA,
"status": self.status,
"configured_offline_evidence": self.configured_offline_evidence,
"live_connection_authority": self.live_connection_authority,
"reason_code": self.reason_code,
"record": self.record.as_dict() if self.record is not None else None,
}
class SemanticTopologyStore:
"""Private, atomic store for the last semantically proved K1 topology.
The stable flock file serializes the complete reload/check/publish
transaction across backend processes. The store remains independent from
the network-mutation ledger: the ledger fences possible writes, while this
store retains only a successfully decoded, secret-free status observation.
"""
def __init__(self, repository_root: Path) -> None:
data_dir = resolve_missioncore_data_dir(repository_root)
self.path = data_dir / "xgrids-k1" / SEMANTIC_TOPOLOGY_FILENAME
self._lock_path = data_dir / "xgrids-k1" / SEMANTIC_TOPOLOGY_LOCK_FILENAME
self._data_dir = data_dir
self._thread_lock = threading.RLock()
self._record: SemanticTopologyRecord | None = None
self._corrupt = False
with self._thread_lock, self._process_lock_locked():
self._reload_locked()
def snapshot(self) -> SemanticTopologySnapshot:
"""Return configured/offline evidence; never return live authority."""
with self._thread_lock, self._process_lock_locked():
self._reload_locked()
if self._corrupt:
return SemanticTopologySnapshot(
status="corrupt",
record=None,
reason_code=SemanticTopologyStoreCorrupt.reason_code,
)
if self._record is None:
return SemanticTopologySnapshot(status="empty", record=None, reason_code=None)
return SemanticTopologySnapshot(
status="available",
record=self._record,
reason_code=None,
)
def commit(
self,
*,
transport_ref: str,
connection_mode: TopologyConnectionMode,
ipv4: str,
compatibility_profile_id: str,
firmware_version: str,
source: TopologyEvidenceSource,
observed_at_utc: str,
predecessor_revision: int | None = None,
) -> SemanticTopologyRecord:
"""Atomically publish one exact status observation.
Callers must invoke this only after the decoded BLE status has proved
the topology and after the selected transport's compatibility profile
has been attested. ``observed_at_utc`` is audit metadata and never
orders writes; serialized revision publication does. An asynchronous
caller that needs a stale-writer fence supplies the revision from which
its observation descends. No network mutation is performed here.
"""
_validate_identifier(transport_ref, field_name="transport_ref")
_validate_connection_mode(connection_mode)
canonical_ipv4 = _canonical_ipv4(ipv4)
_validate_identifier(
compatibility_profile_id,
field_name="compatibility_profile_id",
)
_validate_firmware_version(firmware_version)
_validate_source(source)
observed_at = _validated_timestamp(observed_at_utc, field_name="observed_at_utc")
canonical_observed_at = _timestamp(observed_at)
if predecessor_revision is not None:
_nonnegative_revision(predecessor_revision, field_name="predecessor_revision")
with self._thread_lock, self._process_lock_locked():
self._reload_locked()
if self._corrupt:
raise SemanticTopologyStoreCorrupt(
"semantic topology store is corrupt; persisted evidence was not replaced"
)
current = self._record
current_revision = current.revision if current is not None else 0
if (
predecessor_revision is not None
and predecessor_revision != current_revision
):
raise StaleSemanticTopologyObservation(
"semantic topology predecessor revision is no longer current"
)
if current is not None and current.revision >= SEMANTIC_TOPOLOGY_MAX_REVISION:
raise SemanticTopologyStoreCorrupt(
"semantic topology revision is exhausted"
)
revision = 1 if current is None else current.revision + 1
record = SemanticTopologyRecord(
schema_version=SEMANTIC_TOPOLOGY_SCHEMA,
revision=revision,
transport_ref=transport_ref,
connection_mode=connection_mode,
ipv4=canonical_ipv4,
compatibility_profile_id=compatibility_profile_id,
firmware_version=firmware_version,
source=source,
observed_at_utc=canonical_observed_at,
)
self._persist_locked(record)
return record
@contextmanager
def _process_lock_locked(self) -> Iterator[None]:
data_dir_created = _ensure_private_directory(self._data_dir, parents=True)
if data_dir_created:
_fsync_directory(self._data_dir.parent)
store_dir_created = _ensure_private_directory(self.path.parent, parents=False)
if store_dir_created:
_fsync_directory(self._data_dir)
flags = os.O_RDWR | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(self._lock_path, flags)
lock_created = False
except FileNotFoundError:
try:
descriptor = os.open(
self._lock_path,
flags | os.O_CREAT | os.O_EXCL,
0o600,
)
lock_created = True
except FileExistsError:
# A peer may win the create race. Open and validate exactly
# that stable inode instead of introducing a second lock.
try:
descriptor = os.open(self._lock_path, flags)
lock_created = False
except OSError as exc:
raise SemanticTopologyStoreCorrupt(
"semantic topology lock cannot be opened safely"
) from exc
except OSError as exc:
raise SemanticTopologyStoreCorrupt(
"semantic topology lock cannot be created safely"
) from exc
except OSError as exc:
raise SemanticTopologyStoreCorrupt(
"semantic topology lock cannot be opened safely"
) from exc
stream: IO[bytes] | None = None
try:
try:
_validate_private_open_file(
descriptor,
self._lock_path,
label="semantic topology lock",
require_empty=True,
)
except ValueError as exc:
raise SemanticTopologyStoreCorrupt(
"semantic topology lock is not a stable private file"
) from exc
stream = os.fdopen(descriptor, "r+b", closefd=True)
descriptor = -1
fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
try:
try:
_validate_private_open_file(
stream.fileno(),
self._lock_path,
label="semantic topology lock",
require_empty=True,
)
except ValueError as exc:
raise SemanticTopologyStoreCorrupt(
"semantic topology lock changed while being acquired"
) from exc
if lock_created:
# The lock file is never replaced. Publish its first
# directory entry before relying on it after a restart.
_fsync_directory(self.path.parent)
yield
finally:
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
finally:
if stream is not None:
stream.close()
elif descriptor >= 0:
os.close(descriptor)
def _persist_locked(self, record: SemanticTopologyRecord) -> None:
_write_private_json_atomic(
self.path,
record.as_dict(),
data_dir=self._data_dir,
)
self._record = record
self._corrupt = False
def _reload_locked(self) -> None:
try:
payload = _read_private_json(self.path)
except FileNotFoundError:
self._record = None
self._corrupt = False
return
except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError):
self._record = None
self._corrupt = True
return
try:
record = _record_from_mapping(payload)
except (TypeError, ValueError):
self._record = None
self._corrupt = True
return
self._record = record
self._corrupt = False
def _read_private_json(path: Path) -> object:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except FileNotFoundError:
raise
except OSError as exc:
raise ValueError("semantic topology file cannot be opened safely") from exc
try:
metadata = _validate_private_open_file(
descriptor,
path,
label="semantic topology file",
require_empty=False,
)
if metadata.st_size > SEMANTIC_TOPOLOGY_MAX_BYTES:
raise ValueError("semantic topology file exceeds the bounded size")
chunks: list[bytes] = []
remaining = SEMANTIC_TOPOLOGY_MAX_BYTES + 1
while remaining > 0:
chunk = os.read(descriptor, min(remaining, 64 * 1024))
if not chunk:
break
chunks.append(chunk)
remaining -= len(chunk)
raw = b"".join(chunks)
if len(raw) > SEMANTIC_TOPOLOGY_MAX_BYTES:
raise ValueError("semantic topology file exceeds the bounded size")
finally:
os.close(descriptor)
return json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
def _write_private_json_atomic(
path: Path,
payload: Mapping[str, object],
*,
data_dir: Path,
) -> None:
serialized = (
json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
)
+ "\n"
).encode("utf-8")
if len(serialized) > SEMANTIC_TOPOLOGY_MAX_BYTES:
raise ValueError("semantic topology payload exceeds the bounded size")
_ensure_private_directory(data_dir, parents=True)
_ensure_private_directory(path.parent, parents=False)
previous_identity = _existing_private_file_identity(path)
descriptor, temp_name = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temp_path = Path(temp_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(serialized)
stream.flush()
os.fsync(stream.fileno())
_require_unchanged_existing_path(path, previous_identity)
os.replace(temp_path, path)
_fsync_directory(path.parent)
finally:
if descriptor >= 0:
os.close(descriptor)
temp_path.unlink(missing_ok=True)
def _existing_private_file_identity(path: Path) -> tuple[int, int] | None:
try:
metadata = path.lstat()
except FileNotFoundError:
return None
_validate_private_metadata(metadata, label="semantic topology file", require_empty=False)
return metadata.st_dev, metadata.st_ino
def _require_unchanged_existing_path(
path: Path,
expected: tuple[int, int] | None,
) -> None:
try:
metadata = path.lstat()
except FileNotFoundError:
if expected is None:
return
raise ValueError("semantic topology file disappeared during publication") from None
_validate_private_metadata(metadata, label="semantic topology file", require_empty=False)
observed = (metadata.st_dev, metadata.st_ino)
if expected is None or observed != expected:
raise ValueError("semantic topology file changed during publication")
def _validate_private_open_file(
descriptor: int,
path: Path,
*,
label: str,
require_empty: bool,
) -> os.stat_result:
metadata = os.fstat(descriptor)
_validate_private_metadata(metadata, label=label, require_empty=require_empty)
_validate_open_path_identity(descriptor, path, metadata, label=label)
return metadata
def _validate_open_path_identity(
descriptor: int,
path: Path,
metadata: os.stat_result,
*,
label: str,
) -> None:
del descriptor # metadata already came from this exact descriptor
try:
path_metadata = path.lstat()
except OSError as exc:
raise ValueError(f"{label} path cannot be verified") from exc
if (path_metadata.st_dev, path_metadata.st_ino) != (metadata.st_dev, metadata.st_ino):
raise ValueError(f"{label} path does not reference the opened inode")
_validate_private_metadata(path_metadata, label=label, require_empty=False)
def _validate_private_metadata(
metadata: os.stat_result,
*,
label: str,
require_empty: bool,
) -> None:
if not stat.S_ISREG(metadata.st_mode):
raise ValueError(f"{label} is not a regular file")
if stat.S_IMODE(metadata.st_mode) != 0o600:
raise ValueError(f"{label} is not private")
if metadata.st_nlink != 1:
raise ValueError(f"{label} has an unsafe hard link")
if require_empty and metadata.st_size != 0:
raise ValueError(f"{label} must remain empty")
def _ensure_private_directory(path: Path, *, parents: bool) -> bool:
try:
metadata = path.lstat()
except FileNotFoundError:
try:
path.mkdir(mode=0o700, parents=parents, exist_ok=False)
except FileExistsError:
metadata = path.lstat()
else:
path.chmod(0o700)
return True
if not stat.S_ISDIR(metadata.st_mode):
raise SemanticTopologyStoreCorrupt(
"semantic topology directory is not a regular private directory"
)
if stat.S_IMODE(metadata.st_mode) != 0o700:
raise SemanticTopologyStoreCorrupt(
"semantic topology directory permissions are not private"
)
return False
def _fsync_directory(path: Path) -> None:
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
descriptor = os.open(path, flags)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
document: dict[str, object] = {}
for key, value in pairs:
if key in document:
raise ValueError("semantic topology store contains duplicate fields")
document[key] = value
return document
def _record_from_mapping(value: object) -> SemanticTopologyRecord:
document = _exact_mapping(
value,
{
"schema_version",
"revision",
"transport_ref",
"connection_mode",
"ipv4",
"compatibility_profile_id",
"firmware_version",
"source",
"observed_at_utc",
},
label="semantic topology",
)
if document["schema_version"] != SEMANTIC_TOPOLOGY_SCHEMA:
raise ValueError("unsupported semantic topology schema")
revision = _positive_revision(document["revision"])
transport_ref = _required_string(document["transport_ref"], field_name="transport_ref")
_validate_identifier(transport_ref, field_name="transport_ref")
mode_raw = _required_string(document["connection_mode"], field_name="connection_mode")
_validate_connection_mode(mode_raw)
source_raw = _required_string(document["source"], field_name="source")
_validate_source(source_raw)
compatibility_profile_id = _required_string(
document["compatibility_profile_id"],
field_name="compatibility_profile_id",
)
_validate_identifier(
compatibility_profile_id,
field_name="compatibility_profile_id",
)
firmware_version = _required_string(
document["firmware_version"],
field_name="firmware_version",
)
_validate_firmware_version(firmware_version)
observed_at = _validated_timestamp(
document["observed_at_utc"],
field_name="observed_at_utc",
)
observed_at_utc = _timestamp(observed_at)
if document["observed_at_utc"] != observed_at_utc:
raise ValueError("observed_at_utc is not canonical")
return SemanticTopologyRecord(
schema_version=SEMANTIC_TOPOLOGY_SCHEMA,
revision=revision,
transport_ref=transport_ref,
connection_mode=cast(TopologyConnectionMode, mode_raw),
ipv4=_canonical_ipv4(_required_string(document["ipv4"], field_name="ipv4")),
compatibility_profile_id=compatibility_profile_id,
firmware_version=firmware_version,
source=cast(TopologyEvidenceSource, source_raw),
observed_at_utc=observed_at_utc,
)
def _exact_mapping(value: object, keys: set[str], *, label: str) -> Mapping[str, object]:
if not isinstance(value, dict) or set(value) != keys:
raise ValueError(f"{label} does not match the secret-free schema")
return cast(Mapping[str, object], value)
def _required_string(value: object, *, field_name: str) -> str:
if not isinstance(value, str) or not value:
raise ValueError(f"{field_name} must be a non-empty string")
return value
def _validate_identifier(value: str, *, field_name: str) -> None:
if not isinstance(value, str) or _SAFE_IDENTIFIER.fullmatch(value) is None:
raise ValueError(f"{field_name} is outside the secret-free identifier schema")
def _validate_firmware_version(value: str) -> None:
if not isinstance(value, str) or _SAFE_FIRMWARE_VERSION.fullmatch(value) is None:
raise ValueError("firmware_version is outside the bounded version schema")
def _validate_connection_mode(value: str) -> None:
if not isinstance(value, str) or value not in _CONNECTION_MODES:
raise ValueError("unsupported semantic topology connection mode")
def _validate_source(value: str) -> None:
if not isinstance(value, str) or value not in _EVIDENCE_SOURCES:
raise ValueError("unsupported semantic topology evidence source")
def _canonical_ipv4(value: str) -> str:
if not isinstance(value, str) or len(value) > 15:
raise ValueError("semantic topology address must be canonical IPv4")
try:
address = ipaddress.ip_address(value)
except ValueError as exc:
raise ValueError("semantic topology address must be an IPv4 address") from exc
if not isinstance(address, ipaddress.IPv4Address) or str(address) != value:
raise ValueError("semantic topology address must be canonical IPv4")
return value
def _positive_revision(value: object) -> int:
if (
isinstance(value, bool)
or not isinstance(value, int)
or value < 1
or value > SEMANTIC_TOPOLOGY_MAX_REVISION
):
raise ValueError("revision must be a bounded positive integer")
return value
def _nonnegative_revision(value: object, *, field_name: str) -> int:
if (
isinstance(value, bool)
or not isinstance(value, int)
or value < 0
or value > SEMANTIC_TOPOLOGY_MAX_REVISION
):
raise ValueError(f"{field_name} must be a bounded nonnegative integer")
return value
def _validated_timestamp(value: object, *, field_name: str) -> datetime:
raw = _required_string(value, field_name=field_name)
if len(raw) > 32 or not raw.endswith("Z"):
raise ValueError(f"{field_name} must be UTC")
try:
parsed = datetime.fromisoformat(raw.removesuffix("Z") + "+00:00")
except ValueError as exc:
raise ValueError(f"{field_name} is invalid") from exc
if parsed.tzinfo is None or parsed.utcoffset() != UTC.utcoffset(parsed):
raise ValueError(f"{field_name} must be UTC")
return parsed.astimezone(UTC)
def _timestamp(value: datetime) -> str:
return value.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")

Some files were not shown because too many files have changed in this diff Show More