feat(plugins): isolate device integrations
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user