feat(plugins): isolate device integrations

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 19:29:32 +03:00
parent f9ffb7bd1c
commit 24a47318f2
122 changed files with 3304 additions and 1892 deletions
@@ -0,0 +1,353 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { BackendStatus, ViewerSettings } from "@mission-core/plugin-sdk";
import {
ApiError,
xgridsK1Api,
openEventSocket,
type ConnectRequest,
type EventSocketStatus,
type PrepareAcquisitionRequest,
type ReplayRequest,
type XgridsK1State,
} from "./api";
import {
isTerminalAcquisitionState,
liveStartPlan,
operationByIdempotencyKey,
operationNeedsReconciliation,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import { selectMonotonicXgridsState } from "./stateOrdering";
export type PendingAction =
| "scan"
| "connect"
| "live"
| "replay"
| "stop"
| "abort"
| "camera"
| "viewer";
function messageFor(error: unknown): string {
if (error instanceof ApiError) {
const message = localizeRuntimeMessage(error.message) ?? error.message;
return error.status
? `${message} (HTTP ${error.status})`
: message;
}
return "Запрос к локальному сервису устройства завершился ошибкой.";
}
function measuredLatency(state: XgridsK1State | null): number | null {
if (state?.source_mode !== "live") return null;
const metrics = state?.metrics;
if (!metrics) return null;
const reported = metrics.pipeline_ms ?? metrics.end_to_end_ms;
if (typeof reported === "number" && Number.isFinite(reported)) return reported;
const segments = [
metrics.mqtt_to_decode_ms,
metrics.publish_ms,
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return segments.length === 2 ? segments.reduce((total, value) => total + value, 0) : null;
}
export function useXgridsK1Runtime(enabled: boolean) {
const [state, setState] = useState<XgridsK1State | null>(null);
const [backendStatus, setBackendStatus] = useState<BackendStatus>("checking");
const [eventStatus, setEventStatus] = useState<EventSocketStatus>("connecting");
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
const [error, setError] = useState<string | null>(null);
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
const mounted = useRef(true);
const acceptState = useCallback((nextState: XgridsK1State) => {
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
setBackendStatus("online");
}, []);
const refresh = useCallback(async (reportErrors = true) => {
if (!enabled) return;
const [healthResult, stateResult] = await Promise.allSettled([
xgridsK1Api.getHealth(),
xgridsK1Api.getState(),
]);
if (!mounted.current) return;
if (stateResult.status === "fulfilled") {
acceptState(stateResult.value);
if (reportErrors) setError(null);
}
if (healthResult.status === "fulfilled") {
const health = healthResult.value;
const healthy = health.ok !== false && health.status !== "error";
setBackendStatus(healthy && stateResult.status === "fulfilled" ? "online" : "degraded");
} else if (stateResult.status === "rejected") {
setBackendStatus("offline");
}
if (stateResult.status === "rejected" && reportErrors) {
setError(messageFor(stateResult.reason));
}
}, [acceptState, enabled]);
const run = useCallback(
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
if (!enabled) return false;
setPendingAction(action);
setError(null);
try {
const nextState = await operation();
if (mounted.current) acceptState(nextState);
return true;
} catch (operationError) {
if (mounted.current) {
setError(messageFor(operationError));
if (operationError instanceof ApiError && operationError.status === 0) {
setBackendStatus("offline");
}
}
return false;
} finally {
if (mounted.current) setPendingAction(null);
}
},
[acceptState, enabled],
);
const scan = useCallback(
() => run("scan", () => xgridsK1Api.scanBle({ duration_seconds: 6 })),
[run],
);
const connect = useCallback(
(request: ConnectRequest) =>
run("connect", async () => {
const previous = operationByIdempotencyKey(
state,
"network.provision",
request.idempotency_key,
);
if (previous?.status === "succeeded" && state) return state;
if (operationNeedsReconciliation(previous)) {
throw new ApiError(
"Предыдущая запись настроек завершилась с неопределённым результатом. Автоматический повтор заблокирован; измените параметры только после проверки устройства.",
);
}
const nextState = await xgridsK1Api.connect(request);
const operation = operationByIdempotencyKey(
nextState,
"network.provision",
request.idempotency_key,
);
if (operationNeedsReconciliation(operation)) {
throw new ApiError(
"Результат записи настроек требует ручной проверки. Повторная аппаратная запись не выполнена.",
);
}
if (operation && operation.status !== "succeeded") {
throw new ApiError("Запись настроек ещё выполняется; дождитесь обновления состояния.");
}
return nextState;
}),
[run, state],
);
const prepareAndStartAcquisition = useCallback(
(request: PrepareAcquisitionRequest) =>
run("live", async () => {
const plan = liveStartPlan(state);
if (plan === "blocked") {
throw new ApiError(
"Сначала завершите текущий приём или повтор записи.",
);
}
if (plan === "already-running" && state) return state;
const prepared =
plan === "resume-prepared" && state
? state
: await xgridsK1Api.prepareAcquisition(request);
const acquisition = prepared.acquisition;
if (!acquisition?.acquisition_id) {
throw new ApiError("Локальный сервис не вернул идентификатор подготовленного приёма.");
}
return xgridsK1Api.startAcquisition({
acquisition_id: acquisition.acquisition_id,
expected_state_revision: acquisition.state_revision,
});
}),
[run, state],
);
const startReplay = useCallback(
(request: ReplayRequest) => run("replay", () => xgridsK1Api.startReplay(request)),
[run],
);
const stop = useCallback(
() =>
run("stop", () => {
const acquisition = state?.acquisition;
const acquisitionTerminal = isTerminalAcquisitionState(acquisition?.state);
if (acquisition && !acquisitionTerminal) {
return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id,
mode: "capture-only",
});
}
// Replay and pre-v1alpha2 sessions remain a compatibility-only path.
return xgridsK1Api.stopSessionCompatibility();
}),
[run, state?.acquisition],
);
const abort = useCallback(() => {
const acquisition = state?.acquisition;
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
return Promise.resolve(false);
}
return run("abort", () =>
xgridsK1Api.abortAcquisition({ acquisition_id: acquisition.acquisition_id }),
);
}, [run, state?.acquisition]);
const setObservationSourceActive = useCallback(
(sourceId: string, active: boolean) =>
run("camera", async () => {
if (!state) {
throw new ApiError("Состояние устройства ещё не загружено.");
}
const deviceSessionId = state.device_session?.device_session_id?.trim();
if (!deviceSessionId) {
throw new ApiError("Для камеры нет активной сессии устройства.");
}
const catalogSource = state.sensor_catalog?.streams?.find(
(candidate) => candidate.source_id === sourceId,
);
if (!catalogSource || catalogSource.activation?.controllable !== true) {
throw new ApiError("Плагин не разрешает управление выбранным видеоканалом.");
}
if (active) {
if (
catalogSource.activation.selected &&
state.camera_preview?.active_source_id === sourceId &&
state.camera_preview?.phase !== "error" &&
state.camera_preview?.delivery
) {
return state;
}
return xgridsK1Api.selectCameraPreview({
source_id: sourceId,
device_session_id: deviceSessionId,
});
}
if (state.camera_preview?.active_source_id !== sourceId) return state;
const generation = state.camera_preview.generation;
if (!Number.isInteger(generation) || (generation ?? 0) < 1) {
throw new ApiError("Плагин не вернул поколение активной camera-preview сессии.");
}
return xgridsK1Api.stopCameraPreview({
device_session_id: deviceSessionId,
generation: generation as number,
});
}),
[run, state],
);
const updateViewerSettings = useCallback(
(request: ViewerSettings) => run("viewer", () => xgridsK1Api.updateViewerSettings(request)),
[run],
);
useEffect(() => {
if (!enabled) {
mounted.current = true;
setState(null);
setBackendStatus("checking");
setEventStatus("closed");
setPendingAction(null);
setError(null);
setLatencyHistory([]);
return;
}
mounted.current = true;
void refresh(true);
const poll = window.setInterval(() => void refresh(false), 4_000);
return () => {
mounted.current = false;
window.clearInterval(poll);
};
}, [enabled, refresh]);
useEffect(() => {
if (!enabled) return;
let dispose: (() => void) | undefined;
let retry: number | undefined;
let cancelled = false;
const connectEvents = () => {
if (cancelled) return;
dispose = openEventSocket(acceptState, (status) => {
if (cancelled) return;
setEventStatus(status);
if ((status === "closed" || status === "error") && retry === undefined) {
retry = window.setTimeout(() => {
retry = undefined;
connectEvents();
}, 3_000);
}
});
};
connectEvents();
return () => {
cancelled = true;
if (retry !== undefined) window.clearTimeout(retry);
dispose?.();
};
}, [acceptState, enabled]);
useEffect(() => {
const latency = measuredLatency(state);
if (latency === null) {
setLatencyHistory((values) => (values.length ? [] : values));
return;
}
setLatencyHistory((values) => [...values.slice(-23), latency]);
}, [state]);
return {
state,
backendStatus,
eventStatus,
pendingAction,
error,
latencyHistory,
refresh: () => refresh(true),
clearError: () => setError(null),
scan,
connect,
prepareAndStartAcquisition,
startReplay,
stop,
abort,
setObservationSourceActive,
updateViewerSettings,
};
}