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:
@@ -383,8 +383,9 @@ telemetry and the stop action. Operator-manual acquisition still finalizes only
|
||||
local reception; plugin-commanded v0.5.0 acquisition uses the separately gated
|
||||
canonical K1 START/STOP dialogue.
|
||||
|
||||
Plugin v0.6.0 makes the local connection direction explicit. Bridge remains the
|
||||
default and accepted product path; Direct Connect sends the reviewed station
|
||||
Plugin v0.7.0 supervises the v0.6.0 local connection matrix with separate
|
||||
desired, configured and active modes plus exact DeviceInfo-backed Ready.
|
||||
Bridge remains the default and accepted product path; Direct Connect sends the reviewed station
|
||||
provisioning frame for an already-running controller hotspot. Quick Connect
|
||||
sends one separately reviewed AP-enable frame and can associate a prepared Mac
|
||||
through CoreWLAN. Its device activation and prepared-host association were
|
||||
|
||||
@@ -321,10 +321,12 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
|
||||
активным между сессиями; для его закрытия нужно остановить `k1link serve`.
|
||||
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
|
||||
очищается после успешного ответа и не сохраняется в URL/local storage.
|
||||
- Bridge и Direct Connect выполняют только отдельно рассмотренную provisioning-
|
||||
запись; Quick Connect не пишет в GATT и передаёт пароль короткоживущему
|
||||
CoreWLAN helper только через stdin. Случайные GATT writes и автоматические
|
||||
повторы запрещены.
|
||||
- Bridge и Direct Connect выполняют только отдельно рассмотренную 99-байтную
|
||||
provisioning-запись. Quick Connect выполняет отдельную рассмотренную
|
||||
100-байтную AP-enable запись и после AP-ready допускает ровно одну системную
|
||||
ассоциацию с заранее материализованным exact-firmware профилем. Пароль не
|
||||
попадает в браузер/API/argv; системный Wi-Fi Keychain, post-write prompt и
|
||||
автоматические повторы запрещены.
|
||||
- MQTT data live/replay остаётся subscribe-only. Команды изолированы в
|
||||
plugin-owned canonical control session и доступны только через отдельные
|
||||
operator-present UI checkpoints.
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
workspacesForRoot,
|
||||
type RootId,
|
||||
} from "./productModel";
|
||||
import { backendLabel, phaseLabel, phaseTone } from "./presentation";
|
||||
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
|
||||
import {
|
||||
defaultSceneSettings,
|
||||
type PointColorMode,
|
||||
@@ -595,8 +595,8 @@ export default function App() {
|
||||
setRecordedReplayLabel(null);
|
||||
setSourceUrl("");
|
||||
setSourceDraft("");
|
||||
}, []);
|
||||
|
||||
observationLayout.activateAutomaticDefaults();
|
||||
}, [observationLayout.activateAutomaticDefaults]);
|
||||
useEffect(() => {
|
||||
if (!["recordings", "lab-archive"].includes(activeDefinition?.kind ?? "")) {
|
||||
setReplayTransitioning(false);
|
||||
@@ -638,6 +638,8 @@ export default function App() {
|
||||
const contentActions = useApplicationPanelActions({
|
||||
definition: activeDefinition,
|
||||
refreshRuntime: runtime.refresh,
|
||||
resetConnectionScenario: runtime.resetConnectionScenario,
|
||||
connectionScenarioResetting: runtime.pendingAction === "mode",
|
||||
saveWorkspaceLayout,
|
||||
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
|
||||
systemUtilityActions: computeContourSettings.utilityActions,
|
||||
@@ -728,10 +730,7 @@ export default function App() {
|
||||
{
|
||||
id: "local-contour",
|
||||
label: "Локальный контур",
|
||||
description:
|
||||
runtime.state?.activeDevice?.endpointLabel ||
|
||||
selection?.model.displayName ||
|
||||
"Модель не выбрана",
|
||||
description: selection ? "Подключение" : "Модель не выбрана",
|
||||
icon: <Icon name="network" />,
|
||||
active:
|
||||
runtime.backendStatus !== "offline"
|
||||
@@ -777,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>;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ export function phaseLabel(phase: RuntimePhase | null | undefined): string {
|
||||
return phase ? phaseLabels[phase] : "Нет состояния";
|
||||
}
|
||||
|
||||
export function localConnectionPhaseLabel(
|
||||
phase: RuntimePhase | null | undefined,
|
||||
): string {
|
||||
if (phase === "configuring") return "Подключение";
|
||||
if (phase === "connected") return "Подключение установлено";
|
||||
return phaseLabel(phase);
|
||||
}
|
||||
|
||||
export function phaseTone(phase: RuntimePhase | null | undefined): StatusTone {
|
||||
if (!phase || phase === "unconfigured" || phase === "idle") return "neutral";
|
||||
if (phase === "error") return "danger";
|
||||
|
||||
@@ -190,8 +190,8 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
{
|
||||
id: "local-device",
|
||||
root: "fleet",
|
||||
label: "Локальное устройство",
|
||||
title: "Локальное устройство",
|
||||
label: "Подключение",
|
||||
title: "Подключение",
|
||||
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
|
||||
description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
|
||||
icon: "network",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -111,6 +111,27 @@ handle from the operator's scan and connects that exact selected handle in the
|
||||
following network action. A fallback lookup remains only for non-UI callers
|
||||
that did not perform discovery first.
|
||||
|
||||
The public advertisement cache is deliberately separate from an admitted
|
||||
device session. Rows from the latest explicit scan generation remain stable
|
||||
without wall-clock expiry while the operator completes the form. They still
|
||||
grant no mutation authority without exact retained-handle capture and live
|
||||
GATT validation. An admitted selected session ends only on proven disconnect,
|
||||
explicit stop, app/backend restart, selection of another K1, or connection-mode
|
||||
switch. A later scan may replace unselected candidates but never auto-connects
|
||||
any of them.
|
||||
|
||||
An explicit Quick Connect to Bridge request for that same device can therefore
|
||||
continue when K1 no longer advertises after AP activation. The preconditions
|
||||
are: no active acquisition, no pending evidence cleanup, no active local source,
|
||||
and a terminal/released control session. A mode switch closes the old selected
|
||||
session first; the operator then scans, selects the K1, and creates a clean new
|
||||
GATT session for Bridge. Before the station command the code performs the
|
||||
normal internal `7f02` baseline read and allows exactly one 99-byte `7f01`
|
||||
write. A powered-off or unreachable peripheral ends that attempt. An
|
||||
unobserved post-write result is recorded as terminal `outcome-unknown`; it is
|
||||
never retried automatically and never blocks a later distinct explicit
|
||||
scan-select-connect attempt.
|
||||
|
||||
The 2026-07-20 prepared-host acceptance installed the exact firmware provider,
|
||||
found one expected K1 candidate, emitted one AP-enable write, observed AP-ready
|
||||
and completed one CoreWLAN association without an iPhone or manual credential.
|
||||
@@ -136,12 +157,19 @@ mode. It never fragments or retries the payload automatically.
|
||||
|
||||
A completed GATT write only proves transport completion. It does not prove that
|
||||
the K1 joined Wi-Fi or began beaconing. The application polls `7f02`; the
|
||||
observed response frame contains a fixed-width mode slot, an address slot, a
|
||||
status byte at offset 50 and the AP-ready flag at offset 51. The stale AP
|
||||
observed response frame contains a fixed-width text slot, an address slot, a
|
||||
status byte at offset 50 and the AP-ready flag at offset 51. The text slot is
|
||||
not a uniform mode enum: AP state uses the `WIFI_AP` control literal, while the
|
||||
2026-08-08 FW 3.0.2 Bridge observation returned the joined network name. The stale AP
|
||||
baseline reports `WIFI_AP / 192.168.56.1 / byte51=0`; the physically observed
|
||||
ready transition reports the same mode/address with `byte51=1`.
|
||||
|
||||
For Bridge/Direct Connect, acceptance requires at least one of:
|
||||
For Bridge/Direct, acceptance requires the post-write `7f02` text slot to match
|
||||
the exact requested network name and the address slot to contain a valid
|
||||
non-AP private IPv4. This proves the desired target even when the K1 was
|
||||
already joined to the same network before the explicit idempotent command. A
|
||||
legacy literal-only `WIFI_CLIENT` observation retains the older conservative
|
||||
cross-family rules and requires at least one of:
|
||||
|
||||
1. `7f02` reports a non-AP IPv4 address;
|
||||
2. the same address appears as a new router/ARP client after the write;
|
||||
@@ -150,6 +178,15 @@ For Bridge/Direct Connect, acceptance requires at least one of:
|
||||
|
||||
Do not infer success from a write callback alone.
|
||||
|
||||
An interrupted attempt with no exact post-write network-name observation is not
|
||||
made successful by a write callback, changed DHCP address, router/ARP row or
|
||||
reachable endpoint. Likewise, an already AP-ready baseline alone cannot prove
|
||||
the outcome of an interrupted Quick-to-Quick attempt. Such an attempt remains
|
||||
`outcome-unknown` in historical audit and is never replayed automatically. It
|
||||
does not create a permanent mutation barrier: after the old active operation
|
||||
and cleanup have terminated, a later explicit operator scan, selection, and
|
||||
connect is a distinct session with its own single reviewed write.
|
||||
|
||||
The Bridge/Direct Connect address is a DHCP lease, not configuration and not
|
||||
device identity. Mission Core re-reads `7f02` without writing before every new
|
||||
LAN control session, implicit-host acquisition and factory-calibration read.
|
||||
@@ -157,6 +194,10 @@ If the value changes, it rotates `device_session_id`; it never retargets an
|
||||
active acquisition. A correlated MQTT `DeviceInfo` response supplies the live
|
||||
model/firmware/serial identity barrier.
|
||||
|
||||
The joined network name is used only for exact in-process comparison with the
|
||||
current explicit request. Durable network audit stores the normalized semantic
|
||||
family and never stores or publishes the raw network name.
|
||||
|
||||
The 2026-07-20 reboot/power-cycle check observed the startup race directly:
|
||||
one read returned the earlier `.54` lease while that exact address had no ARP or
|
||||
application endpoint; a later read returned `.52`, where exact probes found
|
||||
@@ -166,7 +207,9 @@ and why a BLE lease observation alone is not reported as live DeviceInfo.
|
||||
|
||||
For Quick Connect, host association is not admitted until the canonical
|
||||
byte-51 ready flag is observed. CoreWLAN then searches only for the exact
|
||||
device-profile SSID for at most 15 seconds and performs at most one association.
|
||||
device-profile SSID for at most 30 seconds and performs at most one association.
|
||||
AP-ready is a device-state barrier, not proof that the host has already observed
|
||||
the RF beacon; a retained successful run required 18.142 seconds of discovery.
|
||||
|
||||
## Safety, recovery and stop conditions
|
||||
|
||||
@@ -176,9 +219,17 @@ device-profile SSID for at most 15 seconds and performs at most one association.
|
||||
secure store. Missing or mismatched firmware material fails before the AP
|
||||
write. Never extrapolate this provider to another firmware or model.
|
||||
- The macOS adapter materializes a device-scoped Keychain item from the exact
|
||||
firmware source, then performs one association. Standard Wi-Fi Keychain and
|
||||
native prompt paths remain compatibility fallbacks, not the reviewed
|
||||
zero-touch path. It never asks the browser for a password.
|
||||
firmware source before the BLE write, then performs one association using
|
||||
only that exact profile. Standard Wi-Fi Keychain lookup, native password
|
||||
prompts and post-write profile rewrites are prohibited. It never asks the
|
||||
browser for a password. Preflight reads are non-interactive and validate the
|
||||
exact SSID/source inside the helper before K1 changes network state.
|
||||
- The prepared-host laboratory adapter launches the reviewed Swift source only
|
||||
through `/usr/bin/xcrun swift`. Runtime `swiftc` compilation to an ad-hoc
|
||||
executable is prohibited because its unstable process identity regressed
|
||||
Keychain ACL and CoreWLAN behavior. Product packaging still requires a
|
||||
prebuilt, properly signed helper with a stable designated identity and
|
||||
explicit CoreWLAN authorization.
|
||||
- Do not alter Deco settings, scan the subnet, or guess any credential.
|
||||
- If the status does not change, do not retry automatically.
|
||||
- If the supplied credentials are wrong, reconnect over BLE and overwrite them
|
||||
|
||||
@@ -110,12 +110,11 @@ directly and use their sibling metadata receive timestamps when present.
|
||||
## Connect and stream live
|
||||
|
||||
1. Power K1 to its normal steady-green standby state.
|
||||
2. Confirm the manual power checklist in **Парк → Локальное устройство**.
|
||||
3. Run the real six-second BLE scan and select the intended device from the
|
||||
2. Run the real six-second BLE scan and select the intended device from the
|
||||
complete visible-device list.
|
||||
4. Enter the existing router SSID/password and explicitly authorize the reviewed
|
||||
3. Enter the existing router SSID/password and explicitly authorize the reviewed
|
||||
provisioning write. The backend does not retry the write automatically.
|
||||
5. Enter the required project name, confirm operator presence, closed LixelGO,
|
||||
4. Enter the required project name, confirm operator presence, closed LixelGO,
|
||||
storage/power and steady green, then choose **Запустить сканирование и
|
||||
локальный приём** once.
|
||||
6. Mission Core emits operations 1–6, waits for their correlated device
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -136,7 +136,7 @@ The current XGRIDS contribution maps its proven internal workflow into those
|
||||
platform states without changing the wire protocol:
|
||||
|
||||
```text
|
||||
confirm power -> scan BLE -> select candidate -> enter Wi-Fi
|
||||
scan BLE -> select candidate -> enter Wi-Fi
|
||||
-> provision once -> receive LAN address -> start source
|
||||
-> wait for first point frame -> streaming
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR 0013: explicit K1 local connection matrix
|
||||
|
||||
- Status: amended 2026-07-20; Bridge is the product path, Quick Connect retained as a prepared-host laboratory path
|
||||
- Status: amended 2026-08-08; Bridge is the product path, Quick Connect retained as a prepared-host laboratory path
|
||||
- Date: 2026-07-19
|
||||
- Extends: ADR 0004, ADR 0005 and ADR 0012
|
||||
|
||||
@@ -47,6 +47,23 @@ for the previous session. An active acquisition is never retargeted in place.
|
||||
The later correlated MQTT `DeviceInfo` response supplies model, firmware,
|
||||
serial and vendor identity; IP equality alone cannot identify a K1.
|
||||
|
||||
The 2026-08-08 physical Bridge trace corrected the earlier field model: the
|
||||
first `7f02` text slot is `WIFI_AP` in AP state but contains the joined network
|
||||
name in FW 3.0.2 station state. Mission Core therefore normalizes that station
|
||||
response to `WIFI_CLIENT` internally and admits Bridge/Direct only when the raw
|
||||
post-write name exactly matches the current explicit request plus a valid
|
||||
non-AP private address. The raw name is not persisted in the secret-free
|
||||
network audit or published through API state.
|
||||
|
||||
The limitation still applies when recording an interrupted attempt that has no
|
||||
exact post-write network-name observation. A changed private DHCP address alone
|
||||
cannot identify the selected network. An already AP-ready baseline likewise
|
||||
cannot prove the outcome of an interrupted Quick-to-Quick attempt. Mission Core
|
||||
therefore records that attempt as terminal `outcome-unknown` and never replays it automatically. The
|
||||
historical uncertainty is not a permanent barrier: after the old active
|
||||
operation and cleanup have ended, a later explicit operator scan, selection,
|
||||
and connect is a distinct session with its own one reviewed write.
|
||||
|
||||
Product decision on 2026-07-20: Bridge/direct-LAN is the continuing route.
|
||||
Quick Connect remains visible and executable on an already prepared host, but
|
||||
is not a deployment dependency or portability claim.
|
||||
@@ -86,12 +103,65 @@ and the following cold Swift/CoreWLAN process missed the beacon. The corrected
|
||||
implementation holds the selected `BleakClient` open through bounded native
|
||||
SSID discovery and the single association call.
|
||||
|
||||
BLE discovery and the selected device action form one host session. A physical
|
||||
run proved that immediately rediscovering the same K1 by its CoreBluetooth UUID
|
||||
can fail even though the preceding scan exposed it. Mission Core retains the
|
||||
non-serializable `BLEDevice` handle process-locally and uses that exact handle
|
||||
for the next selected network action; it never exposes the handle through API
|
||||
state or treats the macOS UUID as durable device identity.
|
||||
One explicit six-second BLE discovery and the later Apply action form one
|
||||
operator intent without a second discovery. A physical run proved that
|
||||
immediately rediscovering the same K1 by its CoreBluetooth UUID can fail even
|
||||
though the preceding scan exposed it. Mission Core retains the non-serializable
|
||||
`BLEDevice` handle process-locally and uses that exact handle for Apply; it never
|
||||
exposes the handle through API state or treats the macOS UUID as durable device
|
||||
identity. UI row selection itself performs no GATT or backend I/O.
|
||||
|
||||
The operator-visible candidate list, local selected draft and admitted active
|
||||
session are separate contracts. Every explicit scan replaces the candidate
|
||||
set. Selection only binds a local form to one result from the latest admitted
|
||||
generation. Wall-clock age does not remove that generation while the operator
|
||||
completes the form. Apply admits only its exact retained handle and live GATT
|
||||
validation may create the active session; a remembered UUID is never mutation
|
||||
authority. Proven disconnect, explicit stop, app/backend restart, another
|
||||
explicit Scan, or a committed mode transition revokes the applicable candidate
|
||||
or live session. Rediscovery never auto-connects.
|
||||
|
||||
Quick Connect to Bridge is an explicit topology transition, not another scan
|
||||
heuristic. Selecting Bridge — or choosing another K1 while Bridge is already
|
||||
selected — sends one idempotent local `reset_scenario` CAS. It seals retained
|
||||
receiver/camera/control ownership, invalidates candidates and credentials and
|
||||
retires old physical lineage truthfully, while sending no device command, BLE,
|
||||
host-network write or automatic Scan. The next explicit Scan starts the clean
|
||||
discovery flow, while Apply remains the topology and device-mutation boundary.
|
||||
The operator selects one discovered K1, sees the Bridge credentials immediately
|
||||
and submits once. The new GATT
|
||||
session reads internal baseline `7f02` and emits exactly one reviewed 99-byte
|
||||
station write. A connect failure ends that attempt. An ambiguous post-write
|
||||
failure is terminal `outcome-unknown` audit, not a permanent cross-session
|
||||
fence. There is no automatic BLE or network-write retry.
|
||||
|
||||
Likewise, an exact REST `network_applied` result with unready/unknown control
|
||||
spends that Apply and its credentials without making the read-model attempt a
|
||||
permanent topology lock. Recommended Verify is pinned to the backend
|
||||
current/configured target. A separately explicit new intent still requires
|
||||
current server policy: Bridge prepares `select-device` before a later fresh
|
||||
scan; Quick and Direct run an admitted fresh scan, select only its latest row
|
||||
and create a new idempotency Apply; a mode or same-mode new-device transition
|
||||
uses one idempotent local-only `reset_scenario` before that fresh scan. None of
|
||||
these new-intent UI paths reuses the old
|
||||
intent or runs as a hidden frontend/mutating continuation. The service-owned
|
||||
same-intent read-only bootstrap declared below is the sole post-ACK exception.
|
||||
|
||||
The Apply REST call returns as soon as the exact durable
|
||||
`network_applied` proof is available. The service may continue the same
|
||||
intent's supervised control bootstrap read-only after that ACK. This performs
|
||||
no BLE/host mutation or retry and creates no frontend Verify, Scan, Apply or
|
||||
blocking Apply loader. While the exact child is accepted/running, the UI may
|
||||
show only a passive **Сеть настроена · подтверждаем управление** indicator and
|
||||
must keep every recovery action disabled. Later control state arrives only as
|
||||
backend presentation convergence; terminal unready/unknown state then exposes
|
||||
the explicit server-policy recovery choices.
|
||||
|
||||
Bridge and Quick Connect were physically accepted as separate paths before
|
||||
this amendment. The combined Quick Connect to Bridge transition has automated
|
||||
contract coverage but remains a distinct physical acceptance gate; it must not
|
||||
be reported as field-accepted until one redacted live run records both sides of
|
||||
the transition.
|
||||
|
||||
The corrected host boundary derives a non-secret, device-scoped profile ID from
|
||||
the selected SSID. The reviewed client contains per-device `WiFiAP_SSID` and
|
||||
@@ -110,13 +180,40 @@ that opaque source before any BLE write. A missing provider fails closed. The
|
||||
browser, API, argv, logs, manifests and evidence never receive the secret; the
|
||||
importer's short-lived mutable buffer is zeroized after the Keychain handoff.
|
||||
|
||||
The 2026-08-06 field regression established that process identity is part of
|
||||
this prepared-host contract. Runtime `swiftc` compilation produced an ad-hoc
|
||||
helper with an unstable designated identity. macOS then requested Keychain
|
||||
authorization repeatedly and the same process context failed to expose the
|
||||
exact K1 SSID through CoreWLAN even after K1 had acknowledged AP-ready. That
|
||||
runtime-compiled route is rejected. The laboratory adapter uses the previously
|
||||
physically accepted Apple-signed interpreter path,
|
||||
`/usr/bin/xcrun swift <reviewed-source>`, and validates the source path before
|
||||
launch. A portable product implementation still requires a packaged,
|
||||
precompiled and properly signed helper with a stable bundle identifier,
|
||||
designated requirement, Location/CoreWLAN authorization and Keychain ACL; the
|
||||
current prepared-host path does not claim that packaging work is complete.
|
||||
|
||||
Before any BLE write, the helper's preflight is non-interactive. It first checks
|
||||
Keychain item existence through metadata, then validates the selected profile's
|
||||
SSID and `exact-firmware-profile` provenance inside the helper without returning
|
||||
secret data. Provider material is also read with interaction disabled if a
|
||||
missing device profile must be materialized. The association phase accepts only
|
||||
that already materialized exact profile. It never
|
||||
falls back to the system Wi-Fi Keychain, rewrites a profile opportunistically,
|
||||
or opens a password/authorization dialog after K1 has changed network state.
|
||||
An unavailable or unauthorized profile therefore fails closed with a precise
|
||||
reason code and no automatic device retry.
|
||||
|
||||
The host-network boundary, rather than the XGRIDS frontend, owns platform
|
||||
association. Browsers expose no Wi-Fi join API, and Apple's iOS
|
||||
`NEHotspotConfiguration` consent flow is unavailable on macOS. The current
|
||||
implementation therefore uses a short-lived Swift/CoreWLAN + macOS Keychain
|
||||
helper; Windows Credential Manager and Linux Secret Service adapters remain
|
||||
separate platform work. The helper performs repeated read-only exact-SSID scans
|
||||
inside one 15-second discovery window and at most one association. It never
|
||||
inside one 30-second discovery window and at most one association. The larger
|
||||
window covers the physically observed 18.142-second beacon-discovery case;
|
||||
AP-ready confirms K1 state but does not prove that macOS has already observed
|
||||
the RF beacon. It never
|
||||
repeats the BLE command, guesses a password or treats `7f01` as a credential-read
|
||||
command. The credential-bearing 99-byte station-provisioning frame and fixed
|
||||
100-byte AP-enable frame are separate reviewed payloads.
|
||||
@@ -128,12 +225,13 @@ The owner also observed no explicit device/account pairing in the normal
|
||||
LixelGo onboarding flow; this is consistent with a firmware-defined AP secret,
|
||||
but does not establish account-wide authorization for arbitrary scanners.
|
||||
|
||||
Connection verification refreshes the session-scoped lease with the same
|
||||
read-only BLE status operation. It does not write a characteristic, re-provision
|
||||
Apply may read BLE baseline internally while establishing a new selected
|
||||
session. Selection never does so, and the normal flow has no mandatory or hidden
|
||||
"verify without write" recovery step. The baseline read does not re-provision
|
||||
Wi-Fi, scan the subnet, change a host route, or touch VPN configuration. The
|
||||
later canonical MQTT session supplies the real data-plane connection and live
|
||||
`DeviceInfo` identity check. A BLE lease observation is therefore not by itself
|
||||
a claim that MQTT/RTSP is reachable.
|
||||
later canonical MQTT
|
||||
session supplies the real data-plane connection and live `DeviceInfo` identity
|
||||
check; BLE status alone is not a claim that MQTT/RTSP is reachable.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -148,6 +246,10 @@ a claim that MQTT/RTSP is reachable.
|
||||
therefore not scheduled for this Quick Connect path.
|
||||
- Direct Connect requires an already-running hotspot and a controller route;
|
||||
Mission Core does not create or manage that hotspot.
|
||||
- Discovery never auto-connects devices. Mode, selection and input are local
|
||||
only. App restart, disconnect, explicit stop and mode transition require a
|
||||
fresh explicit scan-select-Apply session. Apply performs no hidden rescan or
|
||||
Verify and may cross at most one device-mutation boundary.
|
||||
- The application-control, START/STOP and raw-first acquisition protocol is
|
||||
unchanged after a target address is admitted.
|
||||
- Direct Connect remains explicitly pending one owner-operated physical
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# ADR 0014: long-lived macOS host-association observer
|
||||
|
||||
Status: planned production boundary; software contract may be developed behind
|
||||
a disabled feature flag.
|
||||
|
||||
Related acceptance item: `CONN-66` in
|
||||
[`../20_K1_CONNECTION_SUPERVISION_CANON.md`](../20_K1_CONNECTION_SUPERVISION_CANON.md).
|
||||
|
||||
## Context
|
||||
|
||||
Mission Core must distinguish a K1 that is configured for a network from a Mac
|
||||
that is currently attached to the same network. Route, TCP, DeviceInfo,
|
||||
control and data evidence are bound to a host-path epoch; a Wi-Fi switch,
|
||||
sleep/wake cycle or observer restart must invalidate that epoch before any late
|
||||
TCP/MQTT result can restore command authority.
|
||||
|
||||
The current laboratory implementation is fail-closed but not a production
|
||||
observer. One normal connection-monitor poll samples the host path before and
|
||||
after its TCP probe. Each sample synchronously invokes:
|
||||
|
||||
```text
|
||||
/usr/bin/xcrun swift plugins/xgrids-k1/macos/associate_wifi.swift
|
||||
```
|
||||
|
||||
under one process-local lock with a 30-second timeout. At the one-second
|
||||
monitor interval this can launch two Swift processes per second. A failed
|
||||
cycle can occupy the lock for roughly sixty seconds, and cancellation of the
|
||||
Python `asyncio.to_thread()` waiter does not terminate the native process or
|
||||
thread. Physical-command validation shares this observation path. Shorter
|
||||
timeouts, cached shell output or automatic fallback would hide rather than
|
||||
remove the lifecycle defect.
|
||||
|
||||
## Decision
|
||||
|
||||
Production host-association evidence will come from one signed, long-lived,
|
||||
read-only agent in the user's macOS login session.
|
||||
|
||||
- The agent owns one `CWWiFiClient` for its process lifetime.
|
||||
- It observes CoreWLAN link/association/power events and macOS sleep/wake.
|
||||
- It never scans BLE, changes Wi-Fi, reads K1 credentials, reconnects MQTT or
|
||||
sends START/STOP.
|
||||
- It is packaged in a minimal container app and registered with `SMAppService`;
|
||||
it is not a `LaunchDaemon` and is not launched through `xcrun` at runtime.
|
||||
- The required Wi-Fi event entitlement and Location authorization are checked
|
||||
before K1 network mutation is offered. Missing authorization produces
|
||||
explicit unavailable evidence, not a crash loop or guessed association.
|
||||
- The existing Wi-Fi mutator remains a separate component under the exclusive
|
||||
network process lease. Observer authority and mutation authority are never
|
||||
combined.
|
||||
|
||||
The backend communicates with the observer through bounded local IPC. Each
|
||||
backend session supplies a random HMAC key. SSID and BSSID remain inside the
|
||||
agent; only an opaque continuity token is returned and it cannot be correlated
|
||||
between backend processes. The token material is interface plus BSSID; SSID is
|
||||
used only to report evidence quality. This keeps one AP identity stable when
|
||||
macOS alternates between `ssid+bssid` and `bssid-only` disclosure.
|
||||
|
||||
## Observer contract
|
||||
|
||||
```text
|
||||
schema_version: missioncore.macos-host-association/v2
|
||||
agent_instance_id: random 128-bit process instance
|
||||
sequence: uint64
|
||||
association_epoch: uint64
|
||||
interface_name: string | null
|
||||
wifi_interface: true | false | null
|
||||
state: associated | not-associated | inactive | not-wifi | unavailable
|
||||
evidence_quality: ssid+bssid | bssid-only | not-wifi | unavailable
|
||||
continuity_token: 64 lowercase hex | null
|
||||
reason_code: string | null
|
||||
observed_monotonic_ns: uint64
|
||||
sample_age_ms: uint32
|
||||
cause: initial | link-change | association-change | power-change |
|
||||
permission-change | will-sleep | did-wake | poll-correction |
|
||||
observer-restart
|
||||
```
|
||||
|
||||
`sequence` changes for every event or heartbeat. `association_epoch` changes
|
||||
when interface, power, state, SSID or BSSID changes. Sleep and wake each create
|
||||
a barrier even if the visible network looks unchanged afterward. A new agent
|
||||
instance, IPC reconnect, sequence rollback/gap, malformed frame or timeout is
|
||||
also a discontinuity.
|
||||
|
||||
The backend adds its own `observer_session_epoch`; the effective host-route
|
||||
fingerprint includes the agent instance, observer session, association epoch
|
||||
and opaque token. A response from an old session or sequence is discarded.
|
||||
Unknown schema/state or incomplete evidence is `unavailable` and immediately
|
||||
revokes host authority.
|
||||
|
||||
## Timing and failure semantics
|
||||
|
||||
- Heartbeat: 1 second.
|
||||
- Maximum cached-snapshot age: 750 ms.
|
||||
- Snapshot RPC deadline: 250 ms.
|
||||
- Initial handshake deadline: 2 seconds.
|
||||
- Two missed heartbeats or one invalid IPC frame revoke authority immediately.
|
||||
- Reconnect backoff: 250 ms, 500 ms, 1 s, 2 s, then at most 5 s.
|
||||
- There is no automatic fallback to the Swift source runner.
|
||||
- Agent loss affects only read-only host evidence. It never triggers a K1
|
||||
network write, MQTT reconnect or physical command.
|
||||
- A discontinuity first marks the supervisor host path unavailable and rotates
|
||||
its epoch. Recovery then requires fresh route, TCP and DeviceInfo/control
|
||||
evidence in that order.
|
||||
|
||||
## Delivery phases
|
||||
|
||||
Phase A is safe without signing or a physical K1:
|
||||
|
||||
1. Define the Python observer protocol and validate the v2 schema.
|
||||
2. Add a fake/in-memory transport and backend session/sequence validator.
|
||||
3. Implement immediate epoch invalidation and bounded cached lookup.
|
||||
4. Inject the observer into the monitor behind a disabled feature flag.
|
||||
5. Implement the Swift reducer and local transport as a testable Swift package.
|
||||
6. Test sleep/wake, timeout, event gaps, delayed replies, crash/restart and
|
||||
manual network changes.
|
||||
7. Expose secret-free observer health and next action to the UI.
|
||||
8. Prove 10,000 samples launch no child process and cause no lock starvation.
|
||||
|
||||
Phase B requires the actual Mac signing and permission environment:
|
||||
|
||||
1. Package and register the user-session agent.
|
||||
2. Obtain the Wi-Fi events entitlement and complete Location onboarding.
|
||||
3. Run the observer in shadow mode beside the current fail-closed probe.
|
||||
4. Cut over only after the physical fault matrix and an eight-hour soak show no
|
||||
unexplained divergence.
|
||||
|
||||
## Acceptance gate
|
||||
|
||||
- No `xcrun`, `swift` or `swiftc` occurs on the observer path.
|
||||
- One agent and one CoreWLAN client serve one login session.
|
||||
- Snapshot p99 is below 50 ms, hard deadline 250 ms, monitor-cycle p99 below
|
||||
1.5 seconds.
|
||||
- No mutex is held across native or IPC calls.
|
||||
- Sleep, wake, agent restart, sequence gap and timeout always invalidate the
|
||||
effective host epoch.
|
||||
- Late TCP/DeviceInfo evidence from an old epoch is rejected.
|
||||
- SSID, BSSID and credentials never enter IPC logs, API state or artifacts.
|
||||
- Quick-to-Bridge, Bridge-to-Quick, manual Wi-Fi switch, Wi-Fi off/on,
|
||||
router loss/return with the same SSID/IP, backend restart and Location denial
|
||||
all revoke control authority within two seconds and recover only through
|
||||
fresh route, TCP and DeviceInfo evidence.
|
||||
|
||||
Until this gate passes, the current association probe remains explicitly a
|
||||
laboratory implementation and `CONN-66` remains open.
|
||||
|
||||
## Laboratory containment while Location evidence is hidden
|
||||
|
||||
The source-runner helper can return `association-identity-unavailable` on a
|
||||
connected Mac when macOS privacy rules hide SSID and BSSID from the CLI child
|
||||
process. Rotating a random fallback token on every one-second poll made a
|
||||
stable route and a successful TCP probe mutually impossible: every following
|
||||
sample revoked the preceding endpoint result as a fictitious network switch.
|
||||
|
||||
Until the signed observer above replaces the source runner, the laboratory
|
||||
probe uses one random, process-scoped token for the same interface and
|
||||
unavailable-evidence scope. This is not promoted to association evidence:
|
||||
|
||||
- the public evidence quality remains `unavailable`;
|
||||
- interface, source address, kernel route, availability, a proven different
|
||||
BSSID and process restart remain epoch barriers;
|
||||
- endpoint reachability alone remains `configured-unverified`;
|
||||
- only fresh exact DeviceInfo/control evidence can grant control authority;
|
||||
- `CONN-66`, sleep/wake and same-subnet network-switch acceptance remain open.
|
||||
|
||||
For an already reachable lease whose exact DeviceInfo identity and control
|
||||
session remain healthy, a temporary helper timeout or privacy-limited
|
||||
association sample may retain the preceding proven association fingerprint
|
||||
only while the kernel route fingerprint, interface, source, intent and target
|
||||
are unchanged. That retained sample still performs TCP contact and a second
|
||||
kernel-route check, refreshing only route/TCP observation TTLs. Endpoint loss,
|
||||
control loss, control-proof expiry, target/intent change, a proven association
|
||||
identity change or any raw route change revokes immediately. A
|
||||
`configured-unverified` path does not receive this bridge and remains bounded
|
||||
by the existing technical-failure debounce and transport TTL.
|
||||
|
||||
This containment removes the false per-poll epoch churn observed on the field
|
||||
Mac without claiming that the planned production observer has been delivered.
|
||||
@@ -0,0 +1,309 @@
|
||||
# ADR 0015: explicit K1 recovery beside the one-intent connection flow
|
||||
|
||||
Status: accepted product, recovery and presentation contract; executable
|
||||
coverage and remaining hardware acceptance are tracked in
|
||||
`docs/k1-connection-acceptance.manifest.json`.
|
||||
|
||||
Related acceptance items: `CONN-16` through `CONN-19`, `CONN-28`, `CONN-29`,
|
||||
`CONN-65`, and `CONN-68` through `CONN-78` in
|
||||
[`../20_K1_CONNECTION_SUPERVISION_CANON.md`](../20_K1_CONNECTION_SUPERVISION_CANON.md).
|
||||
|
||||
## Problem
|
||||
|
||||
Loss of K1 power, the router, Mac Wi-Fi, MQTT control or the backend does not
|
||||
prove whether K1 is physically scanning. Retained points, an open TCP port and a
|
||||
historical START are insufficient. Replaying START or STOP after an ambiguous
|
||||
dispatch boundary can create a second physical edge.
|
||||
|
||||
The durable physical-command ledger, exact read-only classification and
|
||||
fail-closed supervisor must remain. They must not make ordinary connection slow
|
||||
or surprising. In particular, selecting a device must not secretly connect,
|
||||
Verify, retire/reopen history or delay network credentials.
|
||||
|
||||
## Decision
|
||||
|
||||
### Existing product surface
|
||||
|
||||
K1 connection stays in the existing device plugin section headed
|
||||
**Подключение XGRIDS LixelKity K1**. The surrounding job, entity and lifecycle
|
||||
models do not change. This is novelty A: an improvement to an existing product
|
||||
surface. A separate wizard, modal flow and mandatory preflight/recovery surface
|
||||
are rejected.
|
||||
|
||||
The section reuses canonical shared `Button`, `TextField`, `ActivityIndicator`
|
||||
and `StatusBadge`. It creates no shared entity and uses no raw local HTML
|
||||
controls or literal local status colors.
|
||||
|
||||
### One-intent normal flow
|
||||
|
||||
The normal flow is:
|
||||
|
||||
1. choose Bridge, Direct Connect or Quick Connect locally;
|
||||
2. press the explicit Bluetooth search action;
|
||||
3. wait for exactly one six-second discovery;
|
||||
4. press **Выбрать** on one result;
|
||||
5. enter Bridge/Direct credentials immediately, or review the Quick Connect
|
||||
summary;
|
||||
6. press **Применить** once.
|
||||
|
||||
Opening the section, changing mode, selecting a row and every
|
||||
SSID/password keystroke perform zero browser-controller, device or host I/O.
|
||||
They create no backend operation and show no operation loader. An admitted fresh
|
||||
selection retains the selected card and exposes applicable inputs immediately.
|
||||
A candidate without current draft authority is omitted or presented only as
|
||||
non-actionable evidence; it never receives a misleading disabled primary.
|
||||
|
||||
Each explicit search owns exactly one bounded six-second discovery. It performs
|
||||
no connect, Verify, selection or mutation. Results are never auto-selected.
|
||||
|
||||
One Apply owns the normal connection intent. It may commit the local desired-mode
|
||||
draft under backend CAS and may cross at most one reviewed K1 mutation boundary.
|
||||
Its frontend handler performs no hidden Scan, Verify, reconnect, retirement,
|
||||
reopen, candidate substitution or retry. Quick, Bridge and Direct use the same single primary
|
||||
**Применить** action; credentials are required only for Bridge and Direct.
|
||||
|
||||
Ordinary Bridge Apply never opts into changing the controlling Mac's Wi-Fi
|
||||
association. Host switching is a separate future consequential operator action,
|
||||
not an Apply substep. K1 provisioning can therefore succeed as
|
||||
`network_applied` while control is `control_not_ready`. That result must not
|
||||
repeat that intent's BLE write. Recommended separately explicit read-only
|
||||
Verify/recovery may establish route, endpoint and DeviceInfo/control evidence
|
||||
for the applied topology; a new intent remains separately policy-gated.
|
||||
|
||||
The exact REST response owns completion of the Apply mutation. A snapshot with
|
||||
`connection_attempt.phase=network_applied` is accepted immediately when
|
||||
`control_state` is `control_not_ready` or `unknown`; the controller does not
|
||||
wait for WebSocket/poll convergence or call the full connection-ready
|
||||
requirement. The service may continue supervised same-intent control bootstrap
|
||||
after this fast durable ACK, but only read-only: no BLE/host mutation, mutation
|
||||
retry, new UI action or second Apply. This is not a hidden frontend Scan or
|
||||
Verify. Exact connection-ready remains mandatory before control or physical
|
||||
START. This separation spends the old intent before a delayed state channel
|
||||
could invite its duplicate replay. `connection_attempt` is a read model, not
|
||||
permanent lifecycle authority; current server policy may admit a separately
|
||||
explicit new intent.
|
||||
|
||||
While the exact service-owned bootstrap child is `accepted` or `running` and
|
||||
projects `safe_next_action=wait-for-current-attempt`, the UI shows only one
|
||||
passive **Сеть настроена · подтверждаем управление** indicator. It enables no
|
||||
Verify, mode change, Scan, row or Apply action. Terminal unready/unknown child
|
||||
state then exposes the separately explicit policy-gated recovery choices.
|
||||
|
||||
`network_applied` plus unready or unknown control spends the old Apply and gates
|
||||
ordinary mode change, Scan, row selection and Apply. It first waits passively
|
||||
for an exact active service child; after terminal settlement it presents an
|
||||
explicit recovery choice, regardless of browser-local mode. It never authorizes
|
||||
automatic or same-intent replay. Recommended Verify is pinned to the backend
|
||||
`serverBound` current/configured transport and mode; it never falls back to a
|
||||
selected browser row and is not a prerequisite for every new intent.
|
||||
|
||||
Current server policy may admit a distinct, explicit new-intent path. Bridge
|
||||
uses `prepare-select-device`, a local-only CAS with zero device/host I/O; only
|
||||
after its success may the operator initiate a fresh six-second Scan. Quick and
|
||||
Direct use explicit policy-gated `scan-ble`, then the latest fresh row and a new
|
||||
idempotency Apply. A mode change requires backend `mode_selection` authority and
|
||||
then a fresh explicit Scan. No recovery choice performs hidden Scan, selection,
|
||||
Verify, provisioning or continuation of the old Apply, and the browser never
|
||||
manufactures authority.
|
||||
|
||||
### Freshness and outcome semantics
|
||||
|
||||
Apply is admitted only for the exact selected transport, completed discovery
|
||||
generation, backend runtime, desired-mode revision, reconfiguration intent and
|
||||
policy snapshot. Authority drift before dispatch is a terminal, zero-device-I/O
|
||||
`stale` result. The UI keeps the result understandable, labels it explicitly and
|
||||
offers a new explicit six-second search. It never starts that search itself.
|
||||
|
||||
A failure before the reviewed mutation boundary is `not-dispatched` or
|
||||
`failed`, with zero K1 mutation. A lost response, timeout, power failure or
|
||||
process death after dispatch is `outcome-unknown`, with
|
||||
`safe_to_retry=false`. The durable network-attempt ledger prevents replay.
|
||||
Credentials are never reused automatically. A later operator Apply is a new
|
||||
intent and must pass all current gates.
|
||||
|
||||
## Physical safety remains separate
|
||||
|
||||
Network attempts are disposable; physical START/STOP ambiguity is durable:
|
||||
|
||||
- START and STOP never replay automatically;
|
||||
- control loss does not prove scanning stopped;
|
||||
- local receiver/camera/ingress cleanup is not physical STOP;
|
||||
- a wrong K1/transport/profile/project cannot reconcile the record;
|
||||
- READY records cessation without rewriting historical command outcome;
|
||||
- exact same-project SCANNING may mint one single-use confirmed STOP permit on
|
||||
the still-open exact control binding;
|
||||
- accepted STOP without READY or SCAN_STOPPING by the backend deadline closes
|
||||
only host-owned resources, records `timed_out` / `standby-unknown`, preserves
|
||||
the unresolved ledger and keeps every mutation fenced.
|
||||
|
||||
The composite supervisor and physical-command ledger can disable Apply before
|
||||
device I/O. Their denial does not turn mode, selection or input into recovery.
|
||||
|
||||
### Explicit read-only recovery
|
||||
|
||||
Recovery is a distinct, explicitly requested exceptional action. It is never a
|
||||
continuation of row selection or Apply. The browser supplies neither endpoint,
|
||||
substitute transport nor ledger authority. The backend pins the durable record's
|
||||
exact transport, identity/profile, operation/revision, acquisition/project,
|
||||
topology revision and host epoch.
|
||||
|
||||
The non-reconnecting observation is:
|
||||
|
||||
```text
|
||||
topology-probed
|
||||
-> pre-start-control-opened
|
||||
-> device-info-requested (ordinal 1; exactly one publish)
|
||||
-> device-info-verified
|
||||
-> awaiting-passive-fresh-status
|
||||
-> cessation | active-same-project | foreign-active | inconclusive | failed
|
||||
```
|
||||
|
||||
It publishes exactly one canonical DeviceInfo request and then accepts only a
|
||||
fresh non-retained DeviceStatus from the same socket generation after that
|
||||
barrier. It publishes no status solicitation, DeviceConfig, time sync,
|
||||
workspace, project, START or STOP; it never scans, reconnects, provisions or
|
||||
continues into Apply.
|
||||
|
||||
Canonical READY records cessation/standby. Initialized SCANNING may rebind only
|
||||
when operation/acquisition, identity/profile, transport, host epoch and project
|
||||
all match; it exposes one separate single-use confirmed STOP checkpoint.
|
||||
Foreign, stale or inconclusive evidence changes no topology or authority.
|
||||
|
||||
### Explicit retirement and reopen
|
||||
|
||||
`physical-command.retire-unavailable` is a separately confirmed local durable
|
||||
recovery action for one unresolved target that is truly unavailable or replaced.
|
||||
Admission requires stable idempotency identity and exact backend runtime,
|
||||
operation, ledger revision and transport CAS plus safe lifecycle ownership. It
|
||||
preserves the original unknown outcome, activates the exact-transport deny,
|
||||
performs zero device/host I/O and starts no discovery.
|
||||
|
||||
`physical-command.reopen-retired-reconciliation` is also separately confirmed.
|
||||
It requires an exact fresh same-transport candidate, stable `reopening_id`, exact
|
||||
runtime/revision/retirement/transport/discovery CAS and safe lifecycle ownership.
|
||||
It preserves retirement audit, removes only that retirement's active deny and
|
||||
performs zero device/host I/O. The explicit recovery intent may then run one
|
||||
exact read-only observation. **Выбрать** never invokes retirement, reopen or
|
||||
Verify. The only Apply exception is an internal, request-bound local reopen
|
||||
checkpoint for an explicit scenario reset plus its exact successor Scan. It is
|
||||
ordered after network PREPARED and before the sole dispatch edge, remains
|
||||
invisible in the wizard and grants no command authority. The same applied
|
||||
intent may then settle it read-only from fresh DeviceInfo plus non-retained
|
||||
READY/SCANNING evidence.
|
||||
|
||||
FW 3.0.2 BLE `7f02` contains no stable DeviceInfo identity. Mission Core cannot
|
||||
prove during BLE-only discovery that the same physical unit has a new
|
||||
CoreBluetooth UUID. This remains an explicit protocol/hardware gap.
|
||||
|
||||
### Bounded durable audit rollover
|
||||
|
||||
An explicit local scenario reset must not become unavailable merely because
|
||||
closed retire/reopen history filled the 64 KiB hot ledger. Before a transition
|
||||
would exceed that bound, Mission Core durably publishes the complete previous
|
||||
ledger as a private, owner-only, content-addressed archive segment and then
|
||||
atomically publishes a compact v4 main record. The main record retains every
|
||||
active retirement deny, the newest lost-response retire/reopen checkpoint, and
|
||||
all reconciliation/confirmation proof required by the current physical
|
||||
operation. Compaction never changes a device outcome and performs no device,
|
||||
network or host I/O.
|
||||
|
||||
Archive segments form a predecessor hash chain with exact sequence and byte
|
||||
accounting. Reload verifies directory and file ownership/mode, rejects symlink
|
||||
traversal, bounds total segments and bytes, reparses every embedded ledger and
|
||||
fails closed for a missing, replayed, reordered or tampered segment. Operation,
|
||||
reconciliation, verification, confirmation, retirement and reopening identities
|
||||
remain globally one-use across the hot record and archive. The archive segment
|
||||
is fsynced before the main-file replace: a crash may leave only an inert orphan,
|
||||
while retry of the same CAS reuses identical bytes and cannot duplicate the
|
||||
referenced chain.
|
||||
|
||||
Scenario reset asks the ledger to build the exact prospective retirement or
|
||||
prepared→not-dispatched plan before closing any local receiver, camera,
|
||||
control-session or network ownership. That shared planner applies the same hot
|
||||
serialization, compaction, segment, count and total-byte bounds as commit. When
|
||||
rollover is required, preflight may idempotently prepublish only the immutable
|
||||
content-addressed predecessor; the main revision/CAS and physical disposition
|
||||
remain unchanged. This also proves owner/mode, symlink and content-collision
|
||||
conditions before teardown.
|
||||
|
||||
Archive publication is restart-safe at the hard-link boundary. A process death
|
||||
after destination link and directory fsync but before temporary-name unlink may
|
||||
leave exactly two private names for one inode. Retry removes only a strictly
|
||||
named, owner-only temporary alias whose bytes and inode exactly match the
|
||||
expected destination and whose link count is exactly two, fsyncs that cleanup,
|
||||
then reuses the destination. Any unrelated hard link, extra temporary, symlink,
|
||||
metadata mismatch or byte mismatch remains a fail-closed corruption condition.
|
||||
|
||||
## Failure and restart semantics
|
||||
|
||||
- UI entry, mode, selection, input, polling, refresh and layout changes
|
||||
start no device operation.
|
||||
- Search starts only when pressed, runs once for six seconds and terminalizes.
|
||||
- Apply starts only when pressed, uses one exact fresh candidate and may perform
|
||||
at most one K1 mutation.
|
||||
- Candidate/runtime/intent drift is explicit stale, never hidden rescan.
|
||||
- Post-dispatch uncertainty is explicit outcome-unknown, never automatic replay.
|
||||
- K1 power loss revokes the active session without inventing standby.
|
||||
- Wi-Fi loss and WAN loss are distinct: local LAN control may survive WAN loss;
|
||||
route/association loss revokes only dependent host/control evidence.
|
||||
- Browser refresh restores no live local selection and causes no I/O.
|
||||
- Backend restart restores durable audit and safety ledgers, but no live BLE,
|
||||
control or operator intent.
|
||||
- Mac sleep/restart rotates host/runtime authority and rejects late work.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Mode, selection and input result in zero controller calls.
|
||||
- Each Search click issues exactly one scan with duration `6`; no effect, timer,
|
||||
selection or Apply path calls Scan.
|
||||
- Every result keeps the same ordinary **Выбрать** action. Selection retains
|
||||
the card, shows applicable inputs immediately, shows no loader and calls no
|
||||
controller. After an explicit committed scenario reset and its successfully
|
||||
completed successor Scan, this includes the exact UUID used by the retired
|
||||
prior scenario; the row never exposes a reconnect/reopen/Verify CTA.
|
||||
- During unresolved physical recovery, a completed explicit Scan still renders
|
||||
passive BLE evidence but cannot substitute a foreign target for the durable
|
||||
recovery record. Exact recovery remains a separate established-session
|
||||
action outside the cold result list; ordinary **Выбрать** never invokes its
|
||||
reopen or read-only Verify. A new network flow first requires explicit reset
|
||||
and a successor Scan.
|
||||
- Bridge/Direct show SSID/password; Quick Connect does not.
|
||||
- Exactly one primary **Применить** owns the connection request. Its frontend
|
||||
handler calls no Scan/Verify/reopen helper and it permits at most one device
|
||||
mutation. For an exact reset-owned retired UUID, the backend may append only
|
||||
the internal local settlement checkpoint described above before dispatch.
|
||||
A later SCANNING settlement grants only explicit STOP authority and never
|
||||
restarts the reset-owned receiver, camera, writer or acquisition.
|
||||
- Stale/pre-dispatch and unknown/post-dispatch outcomes are visibly distinct.
|
||||
- Applied-but-unready/unknown spends the old Apply and gates ordinary mode,
|
||||
Scan, selection and Apply behind an explicit recovery choice; recommended
|
||||
Verify has only a server-bound backend target and no browser fallback.
|
||||
- A new intent remains possible only through current backend policy. Bridge
|
||||
uses explicit local-only `prepare-select-device`; Quick/Direct use an explicit
|
||||
admitted Scan and latest fresh row; mode change requires `mode_selection`.
|
||||
Each route starts no hidden frontend or mutating continuation and ends in a
|
||||
later fresh Scan/new idempotency Apply. The declared service-owned
|
||||
same-intent read-only bootstrap after the durable ACK is the sole continuation
|
||||
exception and creates no UI action.
|
||||
- The exact Apply REST snapshot with `phase=network_applied` completes the
|
||||
network intent for both `control_not_ready` and `unknown`, without requiring
|
||||
connection-ready or waiting for WebSocket/poll convergence.
|
||||
- A service-owned supervised control bootstrap may continue read-only after
|
||||
that ACK. It performs no BLE/host mutation or retry and creates no frontend
|
||||
Scan/Verify/new-Apply action or blocking Apply loader. Its exact
|
||||
accepted/running state may own one passive settling indicator only.
|
||||
- Operator error copy comes only from an allowlisted public error-code mapping;
|
||||
unknown/raw messages use a canonical secret-free fallback and never render
|
||||
credentials, SSIDs, payloads or stack traces.
|
||||
- No timeout, disconnect, refresh, restart or state update starts a continuation
|
||||
or replays an ended action.
|
||||
- Supervisor, identity pin, network-attempt ledger, physical-command ledger,
|
||||
process/BLE lease and one-use recovery STOP remain authoritative.
|
||||
- The plugin uses shared `Button`, `TextField`, `ActivityIndicator` and
|
||||
`StatusBadge`; contract tests reject raw local controls and literal colors.
|
||||
- Geometry and long-copy tests keep all actions reachable without overlap.
|
||||
- Bridge and Quick Connect retain separate real-hardware acceptance.
|
||||
|
||||
This ADR does not itself declare hardware coverage. The manifest may mark a
|
||||
scenario software-covered only when named executable tests cover the software
|
||||
invariant; remaining K1/macOS/router and Quick Connect gaps stay explicit.
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"schema_version": "missioncore.k1-connection-acceptance/v1",
|
||||
"canonical_document": "docs/20_K1_CONNECTION_SUPERVISION_CANON.md",
|
||||
"meaning": {
|
||||
"software-covered": "The listed automated tests cover the software invariant; this is not hardware acceptance.",
|
||||
"partial": "At least one software layer is covered and an explicit remaining gap is listed.",
|
||||
"planned": "The scenario is specified but does not yet have adequate executable coverage."
|
||||
},
|
||||
"scenarios": [
|
||||
{"id":"CONN-01","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs"],"remaining":["real K1 Quick-to-Bridge evidence"]},
|
||||
{"id":"CONN-02","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs"],"remaining":["real K1 Bridge-to-Quick evidence"]},
|
||||
{"id":"CONN-03","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hardware pre-dispatch fault injection"]},
|
||||
{"id":"CONN-04","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hardware pre-dispatch fault injection"]},
|
||||
{"id":"CONN-05","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_device_identity_pin_store.py"],"remaining":["two-K1 hardware evidence"]},
|
||||
{"id":"CONN-06","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_ble_scanner.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["CoreBluetooth hardware evidence"]},
|
||||
{"id":"CONN-07","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real K1 wait-beyond-TTL acceptance"]},
|
||||
{"id":"CONN-08","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real app/backend restart reconnect acceptance"]},
|
||||
|
||||
{"id":"CONN-10","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hardware pre-prepare power-loss fault injection"]},
|
||||
{"id":"CONN-11","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["process-kill acceptance at the prepared boundary"]},
|
||||
{"id":"CONN-12","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real K1 post-dispatch power-loss acceptance"]},
|
||||
{"id":"CONN-13","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real K1 observation-loss evidence"]},
|
||||
{"id":"CONN-14","status":"software-covered","test_files":["tests/test_xgrids_semantic_topology_store.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hardware host-association loss"]},
|
||||
{"id":"CONN-15","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hard-power hardware evidence"]},
|
||||
{"id":"CONN-16","status":"partial","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_physical_command_ledger.py"],"remaining":["end-to-end passive Scan policy after acquisition power loss","exact-target read-only recovery/rebind integration","Bridge hardware power-loss acceptance","Quick Connect recovery not exercised"]},
|
||||
{"id":"CONN-17","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py"],"remaining":["transport dispatch integration","restart acceptance"]},
|
||||
{"id":"CONN-18","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_application_mqtt.py"],"remaining":["facade exact-target resolved-active rebind","single-use explicit recovery STOP presentation/action integration","same-project Bridge hardware acceptance","Quick Connect recovery not exercised"]},
|
||||
{"id":"CONN-19","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_application_mqtt.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["facade exact-target resolved-active READY cessation integration","Bridge reboot hardware acceptance","Quick Connect recovery not exercised"]},
|
||||
|
||||
{"id":"CONN-20","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_macos_wifi.py"],"remaining":["router-loss hardware evidence"]},
|
||||
{"id":"CONN-21","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_macos_wifi.py"],"remaining":["same-SSID router-return evidence"]},
|
||||
{"id":"CONN-22","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_macos_wifi.py"],"remaining":["manual macOS switch evidence"]},
|
||||
{"id":"CONN-23","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real Quick AP leave/return"]},
|
||||
{"id":"CONN-24","status":"partial","test_files":["tests/test_connection_supervisor.py","tests/test_ble_scanner.py"],"remaining":["macOS sleep/wake hardware acceptance"]},
|
||||
{"id":"CONN-25","status":"software-covered","test_files":["tests/test_connection_supervisor.py"],"remaining":["route-race integration evidence"]},
|
||||
{"id":"CONN-26","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["wrong-service integration evidence"]},
|
||||
{"id":"CONN-27","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_application_mqtt.py","tests/test_xgrids_application_session.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["half-open MQTT hardware acceptance"]},
|
||||
{"id":"CONN-28","status":"partial","test_files":["tests/test_xgrids_application_mqtt.py","tests/test_xgrids_physical_command_coordinator.py","tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["full facade policy for passive Scan with unknown/active physical state","physical-record transport_ref pinning across bounded observation and wrong-K1 no-topology-change","resolved-active SCANNING one-STOP integration","real K1 passive READY/SCANNING DeviceStatus acceptance"]},
|
||||
{"id":"CONN-29","status":"planned","test_files":[],"remaining":["durable external-active takeover contract","operator-confirmed same-binding STOP"]},
|
||||
|
||||
{"id":"CONN-30","status":"planned","test_files":[],"remaining":["browser/app close clean-session acceptance at every stage"]},
|
||||
{"id":"CONN-31","status":"software-covered","test_files":["tests/test_xgrids_network_mutation_ledger.py","tests/test_xgrids_semantic_topology_store.py"],"remaining":["restart integration acceptance"]},
|
||||
{"id":"CONN-32","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real backend restart acceptance from a prepared network mutation"]},
|
||||
{"id":"CONN-33","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real backend restart acceptance from a dispatching network mutation"]},
|
||||
{"id":"CONN-34","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real backend restart acceptance from an observing network mutation"]},
|
||||
{"id":"CONN-35","status":"software-covered","test_files":["tests/test_xgrids_network_mutation_ledger.py","tests/test_xgrids_semantic_topology_store.py"],"remaining":["restart integration acceptance"]},
|
||||
{"id":"CONN-36","status":"planned","test_files":[],"remaining":["restart acceptance proving no old live session restoration"]},
|
||||
{"id":"CONN-37","status":"planned","test_files":[],"remaining":["corrupt historical audit quarantine without permanent K1 block","operator diagnosis UI"]},
|
||||
{"id":"CONN-38","status":"software-covered","test_files":["tests/test_xgrids_network_mutation_ledger.py","tests/test_xgrids_ble_runtime_arbiter.py"],"remaining":["two-service integration acceptance"]},
|
||||
{"id":"CONN-39","status":"software-covered","test_files":["tests/test_xgrids_network_mutation_ledger.py"],"remaining":[]},
|
||||
|
||||
{"id":"CONN-40","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_application_session.py"],"remaining":["real wrong/failed DeviceInfo evidence"]},
|
||||
{"id":"CONN-41","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["MQTT fault-injection integration"]},
|
||||
{"id":"CONN-42","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["camera and point stall integration"]},
|
||||
{"id":"CONN-43","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["late packet integration evidence"]},
|
||||
{"id":"CONN-44","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_application_session.py"],"remaining":["late DeviceInfo integration evidence"]},
|
||||
{"id":"CONN-45","status":"partial","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_application_control_process_lease.py"],"remaining":["durable physical-command integration"]},
|
||||
{"id":"CONN-46","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_camera_gateway.py"],"remaining":["combined MQTT/camera late-producer integration"]},
|
||||
{"id":"CONN-47","status":"software-covered","test_files":["tests/test_xgrids_application_control_process_lease.py"],"remaining":["two-service integration acceptance"]},
|
||||
{"id":"CONN-48","status":"software-covered","test_files":["tests/test_xgrids_camera_gateway.py"],"remaining":["drain-timeout integration evidence"]},
|
||||
{"id":"CONN-49","status":"software-covered","test_files":["tests/test_connection_supervisor.py"],"remaining":["long-running fault-injection acceptance"]},
|
||||
|
||||
{"id":"CONN-50","status":"partial","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["macOS sleep/wake hardware acceptance"]},
|
||||
{"id":"CONN-51","status":"software-covered","test_files":["tests/test_xgrids_macos_wifi.py","tests/test_connection_supervisor.py"],"remaining":["compiled association observer"]},
|
||||
{"id":"CONN-52","status":"software-covered","test_files":["tests/test_xgrids_device_identity_pin_store.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["two-identity hardware evidence"]},
|
||||
{"id":"CONN-53","status":"software-covered","test_files":["tests/test_xgrids_semantic_topology_store.py","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["restart browser acceptance"]},
|
||||
{"id":"CONN-54","status":"planned","test_files":[],"remaining":["historical unknown audit does not block fresh explicit connect","restart browser acceptance"]},
|
||||
{"id":"CONN-55","status":"partial","test_files":["apps/control-station/test/devicePluginFrontendBoundary.test.mjs"],"remaining":["automated browser geometry matrix"]},
|
||||
{"id":"CONN-56","status":"partial","test_files":["apps/control-station/test/devicePluginFrontendBoundary.test.mjs"],"remaining":["automated long-copy browser geometry"]},
|
||||
{"id":"CONN-57","status":"planned","test_files":[],"remaining":["operator-confirmed physical-ledger archive and identity rotation"]},
|
||||
{"id":"CONN-58","status":"software-covered","test_files":["tests/test_xgrids_ble_runtime_arbiter.py","tests/test_xgrids_application_control_process_lease.py","tests/test_ble_scanner.py","tests/test_wifi_provisioning.py","tests/test_xgrids_ap_activation.py"],"remaining":["two-service CoreBluetooth hardware acceptance","native cleanup fault injection on macOS"]},
|
||||
{"id":"CONN-59","status":"partial","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_network_provisioning_idempotency_journal.py"],"remaining":["prove failed audit admission releases active ownership for a new explicit attempt"]},
|
||||
{"id":"CONN-60","status":"software-covered","test_files":["apps/control-station/test/devicePluginContracts.test.mjs"],"remaining":["manual browser confirmation-dismissal acceptance"]},
|
||||
{"id":"CONN-61","status":"planned","test_files":[],"remaining":["legacy unresolved record terminalization without BLE or cross-session block","process-kill acceptance"]},
|
||||
{"id":"CONN-62","status":"software-covered","test_files":["tests/test_web_validation_security.py"],"remaining":["manual browser refresh/close acceptance"]},
|
||||
{"id":"CONN-63","status":"planned","test_files":[],"remaining":["composite policy denies active contention but ignores terminal historical network audit","manual policy presentation acceptance"]},
|
||||
{"id":"CONN-64","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs"],"remaining":["real K1 control/data loss acceptance"]},
|
||||
{"id":"CONN-65","status":"partial","test_files":["tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["resolved-active same-project one-STOP UI action acceptance","real restart/browser host-route and passive DeviceStatus acceptance","Quick Connect recovery not exercised"]},
|
||||
{"id":"CONN-66","status":"planned","test_files":[],"remaining":["compiled or long-lived macOS association observer","long-running monitor latency/fault acceptance"]},
|
||||
{"id":"CONN-67","status":"partial","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real K1 repeated same-mode and cross-mode reconnect acceptance"]},
|
||||
{"id":"CONN-68","status":"partial","test_files":["tests/test_xgrids_application_session.py","tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["Bridge STOP-ack plus Wi-Fi-loss hardware rerun","Quick Connect recovery not exercised"]},
|
||||
{"id":"CONN-69","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["resolved-active same-project one explicit STOP browser acceptance","Bridge hardware rerun with redacted evidence","Quick Connect recovery not exercised"]},
|
||||
{"id":"CONN-70","status":"software-covered","test_files":["apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real Bridge one-scan/select/immediate-credentials/Apply acceptance","Quick Connect recovery not exercised"]},
|
||||
{"id":"CONN-71","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real Bridge STOP-deadline fault injection","Quick Connect recovery not exercised"]},
|
||||
{"id":"CONN-72","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_plugin_runtime.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real Bridge existing-plugin-section acceptance","manual two-tab browser acceptance","Quick Connect live acceptance remains separate"]},
|
||||
{"id":"CONN-73","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_semantic_topology_store.py","tests/test_xgrids_device_identity_pin_store.py"],"remaining":["real cold Bridge and two-K1 identity-mismatch/restart evidence","Quick Connect live acceptance remains separate"]},
|
||||
{"id":"CONN-74","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_network_mutation_ledger.py","tests/test_xgrids_network_provisioning_idempotency_journal.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real Bridge Apply with no hidden discovery/Verify","post-dispatch hardware fault injection","Quick Connect live acceptance remains separate"]},
|
||||
{"id":"CONN-75","status":"software-covered","test_files":["tests/test_xgrids_connection_scenario_reset.py","tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_application_control_process_lease.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs"],"remaining":["real disconnected/idle desired-mode draft no-I/O acceptance with unresolved durable physical history plus live-owner denial","real pre-START orphan and backend-runtime credential invalidation acceptance","manual top-right emergency-reset acceptance","Quick Connect live acceptance remains separate"]},
|
||||
{"id":"CONN-76","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_physical_command_coordinator.py","tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_application_control_process_lease.py","tests/test_xgrids_ble_runtime_arbiter.py","tests/test_xgrids_camera_gateway.py","tests/test_cli.py","tests/test_plugin_runtime.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real separate explicit retirement confirmation while selection and Apply remain mutation-free","same-hardware/new-CoreBluetooth-UUID cannot be identified before provisioning because FW 3.0.2 BLE 7f02 exposes no stable DeviceInfo identity","Quick Connect live acceptance remains separate"]},
|
||||
{"id":"CONN-77","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_physical_command_coordinator.py","tests/test_xgrids_acquisition_lifecycle.py","tests/test_plugin_runtime.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real retired exact-UUID selection remains local and Apply denied","real separately explicit reopen followed by READY and same-project SCANNING outcomes","Quick Connect live acceptance remains separate"]},
|
||||
{"id":"CONN-78","status":"software-covered","test_files":["apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["manual Bridge and Quick one-intent timing acceptance","manual top-right idle/pending accessible-label acceptance","real stale-before-dispatch and unknown-after-dispatch fault injection","real fast REST network_applied plus delayed service-owned read-only control-bootstrap convergence","real Bridge prepare-select-device and Quick/Direct scan-new-intent recovery acceptance","manual canonical shared-control visual acceptance"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,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__,
|
||||
}
|
||||
|
||||
@@ -95,6 +95,14 @@ Validate the current exact-match profile without device I/O with:
|
||||
uv run python plugins/xgrids-k1/profile_loader.py
|
||||
```
|
||||
|
||||
Plugin v0.7.0 adds the backend-owned supervised connection lifecycle. Operator
|
||||
mode choice is a CAS-fenced draft; an explicit Scan commits a safe pre-START
|
||||
mode switch, while Connect reaches Ready only after the exact current
|
||||
`DeviceInfo` authority is confirmed. Configured, active and desired modes are
|
||||
separate facts. Terminal pre-START failures and purely local prepared sessions
|
||||
self-retire without a device command, and an applied network configuration is
|
||||
recovered through a separate read-only Verify instead of replaying Wi-Fi.
|
||||
|
||||
Plugin v0.6.0 retains the physically accepted v0.5.0 control transport and adds
|
||||
the connection matrix behind the existing explicit `network.provision` action.
|
||||
Bridge remains the default. Direct Connect sends the same single reviewed
|
||||
@@ -102,13 +110,19 @@ Bridge remains the default. Direct Connect sends the same single reviewed
|
||||
Connect accepts no browser/API credential: it sends one reviewed fixed 100-byte
|
||||
AP-enable frame to the selected K1, waits up to 15 seconds for the canonical
|
||||
byte-51 AP-ready flag, and keeps that BLE session alive while the macOS adapter
|
||||
performs bounded exact-SSID CoreWLAN discovery and one association. Credentials
|
||||
performs up to 30 seconds of exact-SSID CoreWLAN discovery and one association.
|
||||
AP-ready does not imply that macOS has already observed the RF beacon. Credentials
|
||||
are resolved by a preinstalled exact `3.0.2` firmware provider. Its optional laboratory importer
|
||||
validates the reviewed official archive, extracts the single AP declaration and
|
||||
installs firmware-scoped material in the OS secure store. The macOS helper then
|
||||
materializes the selected device profile entirely inside Keychain before any
|
||||
BLE write. The secret never enters the browser, API, argv, logs or evidence;
|
||||
the importer's short-lived mutable buffer is zeroized after the stdin handoff.
|
||||
The prepared-host adapter uses the accepted Apple-signed
|
||||
`/usr/bin/xcrun swift` runner. It does not runtime-compile an ad-hoc executable,
|
||||
query the standard Wi-Fi Keychain or open a password dialog after the K1 write.
|
||||
Production portability still requires a packaged, properly signed helper with
|
||||
a stable designated identity and explicit CoreWLAN authorization.
|
||||
There is no automatic BLE-write or association retry. A clean host cannot
|
||||
obtain the provider from BLE and the product does not download firmware during
|
||||
connection. Windows/Linux Quick Connect adapters are not planned while that
|
||||
|
||||
@@ -6,17 +6,23 @@ generic application source tree.
|
||||
|
||||
The contribution contains:
|
||||
|
||||
- `K1ProvisioningPipeline` for power confirmation, BLE discovery and the three
|
||||
explicit local connection directions: Bridge, Quick Connect and Direct
|
||||
Connect;
|
||||
- `K1ProvisioningPipeline` for explicit BLE discovery and the three local
|
||||
connection directions: Bridge, Quick Connect and Direct Connect;
|
||||
- `K1AcquisitionPipeline` for explicit canonical connection/workspace/project/
|
||||
START checkpoints, local receiver preparation and compatibility file replay;
|
||||
- `K1SpatialControls` for an explicit no-retry STOP followed by the separate
|
||||
READY plus steady-green completion gate;
|
||||
- plugin-local diagnostics, metrics, API state, lifecycle mapping,
|
||||
observation-source mapping and scoped styles;
|
||||
- typed v0.6.0 local-network and interactive application-control state plus legacy shadow
|
||||
- typed v0.7.0 supervised connection lifecycle and interactive application-control state plus legacy shadow
|
||||
inspection contracts;
|
||||
- a click-correlated, non-secret provisioning presentation latch: after Apply,
|
||||
Steps 01–02 keep their selected-device/form anatomy with disabled controls
|
||||
until the exact connection attempt becomes reachable or reaches bounded
|
||||
recovery; the Wi-Fi password is cleared before asynchronous dispatch;
|
||||
- policy-gated retirement of an unavailable historical K1 as an explicit
|
||||
local ledger action; it never emits a device command and never bypasses the
|
||||
public `retire-unavailable-physical-target` decision;
|
||||
- `plugin.ts`, which binds the manifest `device.connection` component key to
|
||||
the runtime provider and connection view.
|
||||
|
||||
|
||||
@@ -1,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/Wi‑Fi provisioning и acquisition pipeline принадлежат этому device plugin; Control Station предоставляет только host slot и переход в пространственную сцену.</p>
|
||||
<p>Выберите способ связи и последовательно установите подключение.</p>
|
||||
</div>
|
||||
<div className="workspace-lead__status">
|
||||
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
|
||||
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
|
||||
<span>{connectionPhaseDetail}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<K1Metrics controller={controller} />
|
||||
|
||||
<div className="device-workspace__grid">
|
||||
<K1ProvisioningPipeline
|
||||
controller={controller}
|
||||
phaseLabel={connectionPhaseLabel}
|
||||
phaseTone={connectionPhaseTone}
|
||||
/>
|
||||
<div className="device-workspace__side">
|
||||
<K1AcquisitionPipeline
|
||||
controller={controller}
|
||||
openSpatialScene={host.openSpatialScene}
|
||||
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
|
||||
/>
|
||||
<K1Diagnostics controller={controller} sourceLabel={sourceLabel} />
|
||||
</div>
|
||||
</div>
|
||||
<K1ConnectionPipelines
|
||||
controller={controller}
|
||||
desiredConnectionMode={effectiveDesiredConnectionMode}
|
||||
onDesiredConnectionModeChange={updateDesiredConnectionMode}
|
||||
operationalPanelsVisible={operationalPanelsVisible}
|
||||
openSpatialScene={host.openSpatialScene}
|
||||
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
|
||||
sourceLabel={sourceLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
isXgridsActiveStreamRecovery,
|
||||
type XgridsActiveStreamRecovery,
|
||||
type XgridsK1State,
|
||||
} from "./api";
|
||||
|
||||
export interface ActiveStreamRecoveryLineage {
|
||||
snapshotRuntimeId: string;
|
||||
acquisitionId: string;
|
||||
acquisitionStateRevision: number;
|
||||
recoveryGeneration: number;
|
||||
runtimeProducerGeneration: number;
|
||||
recovery: XgridsActiveStreamRecovery;
|
||||
}
|
||||
|
||||
export type ActiveStreamForceFinishAuthority = ActiveStreamRecoveryLineage;
|
||||
|
||||
export type ActiveStreamRecoveryPresentationAuthority = ActiveStreamRecoveryLineage;
|
||||
|
||||
export type ActiveStreamRecoveryVisibleState =
|
||||
| "reconnecting"
|
||||
| "blocked"
|
||||
| "standby"
|
||||
| "fault";
|
||||
|
||||
export interface ActiveStreamRecoveryPresentation {
|
||||
state: ActiveStreamRecoveryVisibleState;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
statusLabel: string;
|
||||
tone: "neutral" | "warning" | "danger";
|
||||
detail: string;
|
||||
progressLabel: string | null;
|
||||
showSpinner: boolean;
|
||||
forceFinishAvailable: boolean;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one exact active-stream lineage from the public runtime snapshot.
|
||||
*
|
||||
* A recovery-shaped object alone is not authority. The browser also requires
|
||||
* the current runtime id, the same acquisition id and the exact producer
|
||||
* generation on both sides of the projection. This keeps a late recovery
|
||||
* update from an older producer out of both presentation and mutation gates.
|
||||
*/
|
||||
export function exactActiveStreamRecoveryLineage(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): ActiveStreamRecoveryLineage | null {
|
||||
const recovery = state?.connection_recovery;
|
||||
const acquisition = state?.acquisition;
|
||||
const snapshotRuntimeId = state?.snapshot_runtime_id?.trim() || null;
|
||||
const producerGeneration = state?.producer_generation;
|
||||
const acquisitionId = acquisition?.acquisition_id?.trim() || null;
|
||||
const recoveryAcquisitionId = recovery?.acquisition_id?.trim() || null;
|
||||
if (
|
||||
!snapshotRuntimeId
|
||||
|| !isXgridsActiveStreamRecovery(recovery)
|
||||
|| !acquisition
|
||||
|| !acquisitionId
|
||||
|| recoveryAcquisitionId !== acquisitionId
|
||||
|| !positiveInteger(acquisition.state_revision)
|
||||
|| !positiveInteger(recovery.generation)
|
||||
|| !positiveInteger(producerGeneration)
|
||||
|| recovery.runtime_producer_generation !== producerGeneration
|
||||
|| recovery.automatic_read_only_rebind !== true
|
||||
) return null;
|
||||
return {
|
||||
snapshotRuntimeId,
|
||||
acquisitionId,
|
||||
acquisitionStateRevision: acquisition.state_revision,
|
||||
recoveryGeneration: recovery.generation,
|
||||
runtimeProducerGeneration: producerGeneration,
|
||||
recovery,
|
||||
};
|
||||
}
|
||||
|
||||
/** Exact, current and backend-policy-admitted authority for local-only finish. */
|
||||
export function activeStreamForceFinishAuthority(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): ActiveStreamForceFinishAuthority | null {
|
||||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||||
if (
|
||||
!lineage
|
||||
|| !["reconnecting", "blocked"].includes(lineage.recovery.state)
|
||||
|| lineage.recovery.force_finish_allowed !== true
|
||||
|| state?.phase !== "reconnecting"
|
||||
|| state.source_mode !== "live"
|
||||
|| ![
|
||||
"starting",
|
||||
"awaiting_external_start",
|
||||
"acquiring",
|
||||
].includes(state.acquisition?.state ?? "")
|
||||
) return null;
|
||||
return lineage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact authority for retaining browser presentation while the backend owns a
|
||||
* read-only reconnect. This is deliberately narrower than the recovery card:
|
||||
* terminal/blocked recovery states and an inactive acquisition cannot retain
|
||||
* a prior spatial or camera transport.
|
||||
*/
|
||||
export function activeStreamRecoveryPresentationAuthority(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): ActiveStreamRecoveryPresentationAuthority | null {
|
||||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||||
if (
|
||||
!lineage
|
||||
|| lineage.recovery.state !== "reconnecting"
|
||||
|| state?.phase !== "reconnecting"
|
||||
|| state.source_mode !== "live"
|
||||
|| ![
|
||||
"starting",
|
||||
"awaiting_external_start",
|
||||
"acquiring",
|
||||
].includes(state.acquisition?.state ?? "")
|
||||
) return null;
|
||||
return lineage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the exact recovered lineage available to disposable browser receivers
|
||||
* after the recovery card has disappeared. Spatial admission can complete on
|
||||
* the first authoritative PCL before the acquisition-owned camera produces
|
||||
* its first playable frame. This authority carries only the no-write
|
||||
* presentation lease: it grants neither force-finish nor START/STOP policy.
|
||||
*/
|
||||
export function activeStreamRecoveredBrowserAuthority(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): ActiveStreamRecoveryPresentationAuthority | null {
|
||||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||||
if (
|
||||
!lineage
|
||||
|| lineage.recovery.state !== "recovered"
|
||||
|| lineage.recovery.camera_recovery !== "owned"
|
||||
|| state?.phase !== "live"
|
||||
|| state.source_mode !== "live"
|
||||
|| state.acquisition?.state !== "acquiring"
|
||||
) return null;
|
||||
return lineage;
|
||||
}
|
||||
|
||||
/**
|
||||
* While a validated recovery contract is active it owns the presentation
|
||||
* decision. Ordinary supervisor data flags may be stale across the network
|
||||
* gap, so only an exact reconnect lease can retain browser transports.
|
||||
*/
|
||||
export function activeStreamRecoveryOwnsPresentationDecision(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): boolean {
|
||||
const recovery = state?.connection_recovery;
|
||||
return Boolean(
|
||||
isXgridsActiveStreamRecovery(recovery)
|
||||
&& !["inactive", "recovered"].includes(recovery.state),
|
||||
);
|
||||
}
|
||||
|
||||
export function activeStreamForceFinishAuthorityMatches(
|
||||
expected: ActiveStreamForceFinishAuthority,
|
||||
state: XgridsK1State | null | undefined,
|
||||
): boolean {
|
||||
const current = activeStreamForceFinishAuthority(state);
|
||||
return Boolean(
|
||||
current
|
||||
&& current.snapshotRuntimeId === expected.snapshotRuntimeId
|
||||
&& current.acquisitionId === expected.acquisitionId
|
||||
&& current.acquisitionStateRevision === expected.acquisitionStateRevision
|
||||
&& current.recoveryGeneration === expected.recoveryGeneration
|
||||
&& current.runtimeProducerGeneration === expected.runtimeProducerGeneration,
|
||||
);
|
||||
}
|
||||
|
||||
export function formatActiveStreamRecoveryElapsed(
|
||||
elapsedMs: number | null,
|
||||
): string | null {
|
||||
if (!Number.isFinite(elapsedMs) || elapsedMs === null || elapsedMs < 0) return null;
|
||||
const elapsedSeconds = Math.floor(elapsedMs / 1_000);
|
||||
if (elapsedSeconds < 60) return `${elapsedSeconds} с`;
|
||||
const minutes = Math.floor(elapsedSeconds / 60);
|
||||
const seconds = elapsedSeconds % 60;
|
||||
return seconds > 0 ? `${minutes} мин ${seconds} с` : `${minutes} мин`;
|
||||
}
|
||||
|
||||
function recoveryProgressLabel(
|
||||
recovery: XgridsActiveStreamRecovery,
|
||||
): string | null {
|
||||
const elapsed = formatActiveStreamRecoveryElapsed(recovery.elapsed_ms);
|
||||
const attempt = recovery.attempt > 0
|
||||
? `Попытка ${recovery.attempt}`
|
||||
: "Подготовка проверки";
|
||||
return elapsed ? `${attempt} · ${elapsed}` : attempt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Present only an exact current lineage. `recovered` deliberately returns
|
||||
* null so the ordinary confirmed live UI resumes without a transitional card.
|
||||
*/
|
||||
export function activeStreamRecoveryPresentation(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): ActiveStreamRecoveryPresentation | null {
|
||||
const lineage = exactActiveStreamRecoveryLineage(state);
|
||||
if (!lineage) return null;
|
||||
const recovery = lineage.recovery;
|
||||
if (recovery.state === "reconnecting") {
|
||||
return {
|
||||
state: "reconnecting",
|
||||
eyebrow: "СВЯЗЬ · АКТИВНЫЙ ПРИЁМ",
|
||||
title: "Восстанавливаем соединение",
|
||||
statusLabel: "Восстановление связи",
|
||||
tone: "neutral",
|
||||
detail: "Проверяем прежний активный контур только для чтения. START, STOP и настройки сети не отправляются.",
|
||||
progressLabel: recoveryProgressLabel(recovery),
|
||||
showSpinner: true,
|
||||
forceFinishAvailable: activeStreamForceFinishAuthority(state) !== null,
|
||||
};
|
||||
}
|
||||
if (recovery.state === "blocked") {
|
||||
return {
|
||||
state: "blocked",
|
||||
eyebrow: "СВЯЗЬ · ТРЕБУЕТСЯ ДЕЙСТВИЕ",
|
||||
title: recovery.camera_recovery === "blocked"
|
||||
? "Видеопоток не восстановлен"
|
||||
: "Связь не восстановлена",
|
||||
statusLabel: "Восстановление остановлено",
|
||||
tone: "warning",
|
||||
detail: recovery.camera_recovery === "blocked"
|
||||
? "Связь с K1 проверена, но камера не возобновила передачу. Можно завершить только локальный приём."
|
||||
: "Автоматическая проверка остановлена. Можно завершить только локальный приём; команда устройству не отправится.",
|
||||
progressLabel: recoveryProgressLabel(recovery),
|
||||
showSpinner: false,
|
||||
forceFinishAvailable: activeStreamForceFinishAuthority(state) !== null,
|
||||
};
|
||||
}
|
||||
if (recovery.state === "standby") {
|
||||
return {
|
||||
state: "standby",
|
||||
eyebrow: "СВЯЗЬ · СОСТОЯНИЕ ПРОВЕРЕНО",
|
||||
title: "Устройство перешло в ожидание",
|
||||
statusLabel: "Приём завершён",
|
||||
tone: "neutral",
|
||||
detail: "K1 сообщил, что активное сканирование уже завершено. Локальный приём закрывается без команды STOP.",
|
||||
progressLabel: recoveryProgressLabel(recovery),
|
||||
showSpinner: false,
|
||||
forceFinishAvailable: false,
|
||||
};
|
||||
}
|
||||
if (recovery.state === "fault") {
|
||||
return {
|
||||
state: "fault",
|
||||
eyebrow: "СВЯЗЬ · СОСТОЯНИЕ ПРОВЕРЕНО",
|
||||
title: "K1 сообщил об ошибке",
|
||||
statusLabel: "Восстановление невозможно",
|
||||
tone: "danger",
|
||||
detail: "Безопасная проверка обнаружила ошибку устройства. Автоматических команд и повторов нет.",
|
||||
progressLabel: recoveryProgressLabel(recovery),
|
||||
showSpinner: false,
|
||||
forceFinishAvailable: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only an exact, still-active background reconnect may hide the generic red
|
||||
* error banner. A failed explicit local finish is operator-facing evidence and
|
||||
* must remain visible even while the last accepted snapshot says reconnecting.
|
||||
*/
|
||||
export function suppressGenericErrorDuringActiveStreamRecovery(
|
||||
state: XgridsK1State | null | undefined,
|
||||
errorAction?: string | null,
|
||||
): boolean {
|
||||
if (errorAction === "force-finish") return false;
|
||||
return activeStreamRecoveryPresentationAuthority(state) !== null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
GlassSurface,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { ActiveStreamRecoveryPresentation } from "../activeStreamRecovery";
|
||||
|
||||
export type ActiveStreamRecoverySurfaceVariant = "panel" | "compact";
|
||||
|
||||
export interface ActiveStreamRecoverySurfaceProps {
|
||||
presentation: ActiveStreamRecoveryPresentation | null;
|
||||
forceFinishing: boolean;
|
||||
actionBusy: boolean;
|
||||
onForceFinish: () => void;
|
||||
variant?: ActiveStreamRecoverySurfaceVariant;
|
||||
}
|
||||
|
||||
interface ActiveStreamRecoverySurfaceCopy {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
statusLabel: string;
|
||||
detail: string;
|
||||
showSpinner: boolean;
|
||||
forceFinishAvailable: boolean;
|
||||
}
|
||||
|
||||
function surfaceCopy({
|
||||
presentation,
|
||||
forceFinishing,
|
||||
}: Pick<
|
||||
ActiveStreamRecoverySurfaceProps,
|
||||
"presentation" | "forceFinishing"
|
||||
>): ActiveStreamRecoverySurfaceCopy {
|
||||
return {
|
||||
eyebrow: forceFinishing
|
||||
? "СВЯЗЬ · ЛОКАЛЬНОЕ ЗАВЕРШЕНИЕ"
|
||||
: presentation?.eyebrow ?? "СВЯЗЬ · АКТИВНЫЙ ПРИЁМ",
|
||||
title: forceFinishing
|
||||
? "Завершаем локальный приём"
|
||||
: presentation?.title ?? "Восстанавливаем соединение",
|
||||
statusLabel: forceFinishing
|
||||
? "Локальное завершение"
|
||||
: presentation?.statusLabel ?? "Восстановление связи",
|
||||
detail: forceFinishing
|
||||
? "Закрываем только локальный приём и сохранение. Команда STOP устройству не отправляется."
|
||||
: presentation?.detail ?? "Проверяем состояние активного приёма.",
|
||||
showSpinner: forceFinishing || presentation?.showSpinner === true,
|
||||
forceFinishAvailable:
|
||||
!forceFinishing && presentation?.forceFinishAvailable === true,
|
||||
};
|
||||
}
|
||||
|
||||
function RecoveryState({
|
||||
presentation,
|
||||
copy,
|
||||
}: {
|
||||
presentation: ActiveStreamRecoveryPresentation | null;
|
||||
copy: ActiveStreamRecoverySurfaceCopy;
|
||||
}) {
|
||||
const stateClassName = copy.showSpinner
|
||||
? "active-stream-recovery__state"
|
||||
: "active-stream-recovery__state active-stream-recovery__state--static";
|
||||
return (
|
||||
<div className={stateClassName}>
|
||||
{copy.showSpinner ? <ActivityIndicator size="compact" /> : null}
|
||||
<div className="active-stream-recovery__copy">
|
||||
<strong>{copy.title}</strong>
|
||||
<span>{copy.detail}</span>
|
||||
{presentation?.progressLabel ? (
|
||||
<small>{presentation.progressLabel}</small>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecoveryAction({
|
||||
visible,
|
||||
actionBusy,
|
||||
compact,
|
||||
onForceFinish,
|
||||
}: {
|
||||
visible: boolean;
|
||||
actionBusy: boolean;
|
||||
compact: boolean;
|
||||
onForceFinish: () => void;
|
||||
}) {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className="active-stream-recovery__actions">
|
||||
<Button
|
||||
size={compact ? "compact" : undefined}
|
||||
variant="secondary"
|
||||
disabled={actionBusy}
|
||||
onClick={onForceFinish}
|
||||
>
|
||||
Прервать соединение
|
||||
</Button>
|
||||
<p>
|
||||
Завершит только локальный front/back-приём и сохранение. START, STOP,
|
||||
Bluetooth и настройки устройства не отправляются.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One shared recovery owner for the connection and spatial workspaces.
|
||||
*
|
||||
* The surface never chooses a mutation by itself: its sole callback is the
|
||||
* explicitly fenced local force-finish action supplied by the K1 controller.
|
||||
*/
|
||||
export function ActiveStreamRecoverySurface({
|
||||
presentation,
|
||||
forceFinishing,
|
||||
actionBusy,
|
||||
onForceFinish,
|
||||
variant = "panel",
|
||||
}: ActiveStreamRecoverySurfaceProps) {
|
||||
const copy = surfaceCopy({ presentation, forceFinishing });
|
||||
const tone = forceFinishing ? "neutral" : presentation?.tone ?? "neutral";
|
||||
const content = (
|
||||
<>
|
||||
<RecoveryState presentation={presentation} copy={copy} />
|
||||
<RecoveryAction
|
||||
visible={copy.forceFinishAvailable}
|
||||
actionBusy={actionBusy}
|
||||
compact={variant === "compact"}
|
||||
onForceFinish={onForceFinish}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<section
|
||||
className="xgrids-k1-spatial-controls xgrids-k1-spatial-controls--recovery"
|
||||
aria-label="Восстановление активной сессии XGRIDS K1"
|
||||
aria-live="polite"
|
||||
aria-busy={copy.showSpinner}
|
||||
data-recovery-state={
|
||||
forceFinishing ? "force-finishing" : presentation?.state ?? "reconnecting"
|
||||
}
|
||||
>
|
||||
<div className="active-stream-recovery__compact-heading">
|
||||
<span>{copy.eyebrow}</span>
|
||||
<StatusBadge tone={tone}>{copy.statusLabel}</StatusBadge>
|
||||
</div>
|
||||
<div className="active-stream-recovery active-stream-recovery--compact">
|
||||
{content}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassSurface className="session-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">{copy.eyebrow}</span>
|
||||
<h2>{copy.title}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={tone}>{copy.statusLabel}</StatusBadge>
|
||||
</header>
|
||||
<div className="active-stream-recovery" aria-live="polite">
|
||||
{content}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
Checker,
|
||||
GlassSurface,
|
||||
@@ -12,6 +13,11 @@ import {
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { profileSelectionForConnectionMode } from "../compatibility";
|
||||
import {
|
||||
activeStreamForceFinishAuthority,
|
||||
activeStreamRecoveryPresentation,
|
||||
exactActiveStreamRecoveryLineage,
|
||||
} from "../activeStreamRecovery";
|
||||
import {
|
||||
SUPPORTED_GNSS_MODE,
|
||||
SUPPORTED_MOUNT_TYPE,
|
||||
@@ -22,48 +28,63 @@ import {
|
||||
} from "../configuration";
|
||||
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
|
||||
import {
|
||||
canIssueCanonicalStop,
|
||||
connectionPolicyAllows,
|
||||
isConfirmedLiveState,
|
||||
isSoftwareCommandedAcquisition,
|
||||
isReleasedTerminalAcquisitionFailure,
|
||||
currentAppliedConnectionTopology,
|
||||
isSourceRuntimeBusy,
|
||||
isVendorWriteCapable,
|
||||
isTerminalAcquisitionState,
|
||||
recoverableAcquisition,
|
||||
requiresCanonicalStopAfterTerminalLocalFailure,
|
||||
sourceStatusLabel,
|
||||
} from "../lifecycle";
|
||||
import { normalizeProjectName, validateProjectName } from "../projectName";
|
||||
import { connectionPolicyOperatorGuidance } from "../presentation";
|
||||
import {
|
||||
normalizeProjectName,
|
||||
projectNameAfterConnectionModeSelection,
|
||||
shouldHydratePreparedProject,
|
||||
validateProjectName,
|
||||
} from "../projectName";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
import type { OperatorPresenceConfirmation } from "../api";
|
||||
import {
|
||||
activeStopTarget,
|
||||
operatorActionPhysicalAcceptance,
|
||||
preparationTarget,
|
||||
preparedStartTarget,
|
||||
} from "../physicalCommandConfirmation";
|
||||
import { ActiveStreamRecoverySurface } from "./ActiveStreamRecoverySurface";
|
||||
|
||||
type SessionIntent = "live" | "replay";
|
||||
|
||||
const sessionItems = [
|
||||
{ value: "live", label: "Реальное устройство" },
|
||||
{ value: "live", label: "Прямой приём" },
|
||||
{ value: "replay", label: "Повтор записи" },
|
||||
] satisfies Array<{ value: SessionIntent; label: string }>;
|
||||
|
||||
const PHYSICAL_ACCEPTANCE = {
|
||||
operator_present: true,
|
||||
owner_controlled_device: true,
|
||||
lixelgo_closed: true,
|
||||
battery_storage_confirmed: true,
|
||||
expected_physical_state_confirmed: true,
|
||||
} satisfies OperatorPresenceConfirmation;
|
||||
|
||||
export function K1AcquisitionPipeline({
|
||||
controller,
|
||||
desiredConnectionMode = "bridge",
|
||||
openSpatialScene,
|
||||
activateAutomaticSpatialSource,
|
||||
}: {
|
||||
controller: XgridsK1Controller;
|
||||
desiredConnectionMode?: "bridge" | "quick-connect" | "direct-connect";
|
||||
openSpatialScene: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
pendingAction,
|
||||
physicalStopIntentSpent,
|
||||
physicalStopInFlight,
|
||||
closeApplicationControlSession,
|
||||
startCanonicalAcquisition,
|
||||
prepareCanonicalAcquisition,
|
||||
startPreparedAcquisition,
|
||||
startReplay,
|
||||
stop,
|
||||
stopLocalReceiver,
|
||||
forceFinishActiveStreamLocally,
|
||||
abort,
|
||||
} = controller;
|
||||
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||
@@ -75,13 +96,59 @@ export function K1AcquisitionPipeline({
|
||||
const [mountType, setMountType] = useState<MountType>(SUPPORTED_MOUNT_TYPE);
|
||||
const [gnssMode, setGnssMode] = useState<GnssMode>(SUPPORTED_GNSS_MODE);
|
||||
const hydratedAcquisitionId = useRef<string | null>(null);
|
||||
const previousDesiredConnectionMode = useRef(desiredConnectionMode);
|
||||
|
||||
const activeAcquisition = recoverableAcquisition(state);
|
||||
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
|
||||
const projectNameValidation = validateProjectName(projectName);
|
||||
const vendorWriteCapable = isVendorWriteCapable(state);
|
||||
const control = state?.application_control_session;
|
||||
const controlPhase = control?.state ?? "idle";
|
||||
const appliedTopology = currentAppliedConnectionTopology(state);
|
||||
const connectionMode = desiredConnectionMode;
|
||||
const backendDesiredConnectionMode = state?.desired_connection_mode
|
||||
?? desiredConnectionMode;
|
||||
const configuredConnectionMode = state?.configured_connection_mode
|
||||
?? state?.connection_mode
|
||||
?? null;
|
||||
const activeConnectionMode = state?.active_connection_mode
|
||||
?? (appliedTopology?.status === "active" ? appliedTopology.connectionMode : null);
|
||||
const desiredSelectionCommitted = backendDesiredConnectionMode
|
||||
=== desiredConnectionMode;
|
||||
const desiredModeMatchesActive = desiredSelectionCommitted
|
||||
&& activeConnectionMode === desiredConnectionMode;
|
||||
const modeSwitchRequired = Boolean(
|
||||
!desiredSelectionCommitted
|
||||
|| (activeConnectionMode && !desiredModeMatchesActive)
|
||||
|| (configuredConnectionMode && configuredConnectionMode !== desiredConnectionMode),
|
||||
);
|
||||
const connectionConfigured = Boolean(
|
||||
appliedTopology?.status === "active"
|
||||
&& desiredModeMatchesActive
|
||||
&& state?.connection_lifecycle?.ready_to_start === true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousDesiredConnectionMode.current === desiredConnectionMode) return;
|
||||
previousDesiredConnectionMode.current = desiredConnectionMode;
|
||||
const projectNameAfterSelection = projectNameAfterConnectionModeSelection(
|
||||
preparedAcquisition?.project_name,
|
||||
);
|
||||
// A prepared acquisition is immutable backend state, not a draft owned by
|
||||
// this selector. Preserve its project while the operator previews another
|
||||
// mode so selecting the active mode again can resume START immediately.
|
||||
if (preparedAcquisition) {
|
||||
setProjectName(projectNameAfterSelection);
|
||||
setProjectNameTouched(false);
|
||||
return;
|
||||
}
|
||||
// Draft project fields belong to the previously selected transport. The
|
||||
// dropdown sends no physical command; Connect performs the later bounded
|
||||
// mode transaction, while START remains fenced in the meantime.
|
||||
setProjectName(projectNameAfterSelection);
|
||||
setProjectNameTouched(false);
|
||||
setMountType(SUPPORTED_MOUNT_TYPE);
|
||||
setGnssMode(SUPPORTED_GNSS_MODE);
|
||||
}, [desiredConnectionMode, preparedAcquisition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.source_mode === "live" || state?.source_mode === "replay") {
|
||||
@@ -93,11 +160,35 @@ export function K1AcquisitionPipeline({
|
||||
|
||||
useEffect(() => {
|
||||
const acquisitionId = preparedAcquisition?.acquisition_id ?? null;
|
||||
if (!acquisitionId || hydratedAcquisitionId.current === acquisitionId) return;
|
||||
if (!shouldHydratePreparedProject({
|
||||
acquisitionId,
|
||||
hydratedAcquisitionId: hydratedAcquisitionId.current,
|
||||
modeSwitchRequired,
|
||||
})) return;
|
||||
hydratedAcquisitionId.current = acquisitionId;
|
||||
setProjectName(preparedAcquisition?.project_name ?? "");
|
||||
setProjectNameTouched(false);
|
||||
}, [preparedAcquisition?.acquisition_id, preparedAcquisition?.project_name]);
|
||||
}, [
|
||||
modeSwitchRequired,
|
||||
preparedAcquisition?.acquisition_id,
|
||||
preparedAcquisition?.project_name,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const acquisition = state?.acquisition;
|
||||
if (
|
||||
hydratedAcquisitionId.current === null
|
||||
|| !acquisition
|
||||
|| acquisition.acquisition_id !== hydratedAcquisitionId.current
|
||||
|| !isTerminalAcquisitionState(acquisition.state)
|
||||
|| state?.source_mode !== "idle"
|
||||
) return;
|
||||
hydratedAcquisitionId.current = null;
|
||||
setProjectName("");
|
||||
setProjectNameTouched(false);
|
||||
setMountType(SUPPORTED_MOUNT_TYPE);
|
||||
setGnssMode(SUPPORTED_GNSS_MODE);
|
||||
}, [state?.acquisition, state?.source_mode]);
|
||||
|
||||
const isBusy = pendingAction !== null;
|
||||
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
|
||||
@@ -108,10 +199,50 @@ export function K1AcquisitionPipeline({
|
||||
: activeAcquisition
|
||||
? "live"
|
||||
: sessionIntent;
|
||||
const sourceLabel = sourceStatusLabel(state);
|
||||
const relevantAcquisitionFailed = state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
|
||||
const gracefulStopTarget = activeStopTarget(state);
|
||||
const terminalPhysicalStopObserved =
|
||||
requiresCanonicalStopAfterTerminalLocalFailure(state);
|
||||
const physicalStopExecutable = Boolean(
|
||||
gracefulStopTarget
|
||||
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
|
||||
);
|
||||
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
|
||||
const localReceiverStopExecutable = Boolean(
|
||||
connectionPolicyAllows(state, "stop-local-receiver")
|
||||
&& preparedAcquisition === null,
|
||||
);
|
||||
const terminalPhysicalStopPending = terminalPhysicalStopObserved
|
||||
&& physicalStopInFlight;
|
||||
const recoveredPhysicalStop = terminalPhysicalStopObserved
|
||||
&& physicalStopExecutable
|
||||
&& !physicalStopInFlight;
|
||||
const terminalLocalRecovery = terminalPhysicalStopObserved
|
||||
&& !physicalStopPresented
|
||||
&& localReceiverStopExecutable;
|
||||
const terminalReadOnlyRecovery = terminalPhysicalStopObserved
|
||||
&& !physicalStopPresented
|
||||
&& !localReceiverStopExecutable;
|
||||
const terminalLocalCapturePending = Boolean(
|
||||
state?.acquisition?.cleanup_pending === true
|
||||
|| state?.source_mode === "live",
|
||||
);
|
||||
const sourceLabel = terminalPhysicalStopPending
|
||||
? "Команда отправлена"
|
||||
: recoveredPhysicalStop
|
||||
? "Требуется остановка"
|
||||
: terminalLocalRecovery
|
||||
? "Локальное завершение доступно"
|
||||
: terminalReadOnlyRecovery
|
||||
? "Действия заблокированы"
|
||||
: sourceStatusLabel(state);
|
||||
const releasedAcquisitionFailure = isReleasedTerminalAcquisitionFailure(state);
|
||||
const relevantAcquisitionFailed = state?.source_mode !== "replay"
|
||||
&& state?.acquisition?.state === "failed"
|
||||
&& !releasedAcquisitionFailure;
|
||||
const sourceTone: StatusTone =
|
||||
state?.phase === "error" || relevantAcquisitionFailed
|
||||
terminalPhysicalStopObserved
|
||||
? "warning"
|
||||
: (state?.phase === "error" && !releasedAcquisitionFailure) || relevantAcquisitionFailed
|
||||
? "danger"
|
||||
: isConfirmedLiveState(state) || state?.source_mode === "replay"
|
||||
? "success"
|
||||
@@ -122,42 +253,105 @@ export function K1AcquisitionPipeline({
|
||||
() => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })),
|
||||
[sessionLocked],
|
||||
);
|
||||
const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
|
||||
const activeRecoveryForceFinishAuthority = activeStreamForceFinishAuthority(state);
|
||||
const activeRecoveryLineage = exactActiveStreamRecoveryLineage(state);
|
||||
const recoveredActiveSession = Boolean(
|
||||
activeRecoveryLineage?.recovery.state === "recovered"
|
||||
&& state?.phase === "live"
|
||||
&& state.source_mode === "live"
|
||||
&& activeAcquisition?.state === "acquiring"
|
||||
&& activeRecoveryLineage.acquisitionId === activeAcquisition.acquisition_id,
|
||||
);
|
||||
const recoveredActiveSessionLabel = activeAcquisition?.project_name?.trim()
|
||||
|| activeAcquisition?.acquisition_id
|
||||
|| "текущая сессия";
|
||||
const localForceFinishPending = pendingAction === "force-finish";
|
||||
|
||||
if (activeRecoveryPresentation || localForceFinishPending) {
|
||||
return (
|
||||
<ActiveStreamRecoverySurface
|
||||
presentation={activeRecoveryPresentation}
|
||||
forceFinishing={localForceFinishPending}
|
||||
actionBusy={pendingAction !== null}
|
||||
onForceFinish={() => {
|
||||
if (!activeRecoveryForceFinishAuthority) return;
|
||||
void forceFinishActiveStreamLocally();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const preparedCanonicalLaunch =
|
||||
preparedAcquisition?.control_mode === "plugin-commanded";
|
||||
const launchBlockedByAcquisition =
|
||||
activeAcquisition !== null && !preparedCanonicalLaunch;
|
||||
const controlRetryBlocked =
|
||||
controlPhase === "failed" && control?.can_open !== true;
|
||||
const finalStartTarget = preparedStartTarget(state);
|
||||
const draftPreparationTarget = preparationTarget(
|
||||
state,
|
||||
projectNameValidation.value,
|
||||
);
|
||||
const physicalStartAllowed = connectionPolicyAllows(state, "start-acquisition");
|
||||
const 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":
|
||||
"Wi‑Fi на этом 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: "Wi‑Fi 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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user