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 OpenApplicationControlSessionRequest, type OperatorPresenceConfirmation, type PrepareAcquisitionRequest, type ReplayRequest, type XgridsOperation, type XgridsApplicationControlPhase, type XgridsK1State, } from "./api"; import { controlSessionEntryPlan, isTerminalAcquisitionState, liveStartPlan, operationByIdempotencyKey, operationNeedsReconciliation, isSoftwareCommandedAcquisition, } from "./lifecycle"; import { localizeRuntimeMessage } from "./messages"; import { awaitWhileIntentCurrent, OperatorIntentGeneration, } from "./operatorIntentGeneration"; import { selectMonotonicXgridsState } from "./stateOrdering"; export type PendingAction = | "scan" | "connect" | "control" | "live" | "replay" | "stop" | "abort" | "camera" | "viewer"; export interface CanonicalLiveStartRequest { control: OpenApplicationControlSessionRequest; acquisition: PrepareAcquisitionRequest; physicalAcceptance: OperatorPresenceConfirmation; } const CONTROL_STATE_READ_INTERVAL_MS = 250; function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase { return state.application_control_session?.state ?? "idle"; } function controlFailure(state: XgridsK1State): ApiError { const failure = state.application_control_session?.failure; const localizedDetail = localizeRuntimeMessage(failure?.message); const reasonLabels: Record = { application_authority_unavailable: "Локальный допуск управления устройством недоступен; команды не отправлялись.", mqtt_connect_call_failed: "Управляющее MQTT-соединение со сканером не открылось.", mqtt_connect_rejected: "Сканер отклонил управляющее MQTT-соединение.", mqtt_connection_timeout: "Подключение или подписки MQTT не подтвердились вовремя.", mqtt_subscription_failed: "Сканер не подтвердил канонические MQTT-подписки.", mqtt_response_timeout: "Ожидаемый ответ сканера не пришёл до безопасной границы ожидания.", response_identity_decode_failed: "Ответ сканера не удалось безопасно разобрать и привязать к операции.", modeling_response_decode_failed: "Ответ START/STOP не удалось безопасно разобрать.", duplicate_application_response: "Сканер прислал повторный ответ на уже завершённую операцию.", unexpected_response_identity: "Получен ответ неизвестной операции; диалог остановлен.", response_identity_mismatch: "Идентичность ответа не совпала с ожидаемой операцией.", response_session_mismatch: "Session ответа не совпал с точной подготовительной операцией.", response_device_identity_mismatch: "Ответ относится не к тому экземпляру устройства.", response_authority_mismatch: "Ответ не совпал с локальным допуском приложения.", response_rejected: "Сканер отклонил подготовительную операцию.", compatibility_profile_mismatch: "Живой DeviceInfo не соответствует выбранному профилю модели, platform type, прошивки или активации.", scan_initialization_timeout: "После подтверждённого START сканер не завершил инициализацию в безопасный срок.", operation_reuse_forbidden: "Повтор уже использованной операции заблокирован.", }; const reasonDetail = failure?.reason_code ? reasonLabels[failure.reason_code] : undefined; const stageLabels: Record = { connecting: "подключение и первичный диалог", connection: "первичный диалог", "connection-ready": "подключение подтверждено", "workspace-requested": "вход в рабочую область", "workspace-ready": "рабочая область готова", "project-requested": "подготовка проекта", "project-ready": "проект готов", "start-requested": "подтверждение запуска", "start-attempted": "команда запуска", initializing: "калибровка оборудования", scanning: "сканирование", "stop-requested": "подтверждение остановки", "stop-attempted": "команда остановки", stopping: "остановка", }; const stageCode = failure?.dialogue_stage || failure?.failed_phase; const stage = stageCode ? stageLabels[stageCode] ?? "неопознанный этап канонического диалога" : "этап не зафиксирован"; const commandStatus = failure?.modeling_command_attempted === true ? "Команда START или STOP могла быть отправлена; автоматический повтор запрещён." : failure?.modeling_command_attempted === false ? "Команды START и STOP не отправлялись." : "Факт отправки START или STOP диагностически не подтверждён; повтор запрещён."; const retryStatus = failure?.safe_to_retry ? "Новая попытка возможна только отдельным нажатием оператора." : "Повтор заблокирован до ручной проверки состояния."; const exchanges = typeof failure?.publish_attempts === "number" ? `MQTT-публикаций до остановки: ${failure.publish_attempts}.` : "Количество MQTT-публикаций не зафиксировано."; const failedProtocolEvidence = failure?.compatibility_failure ?? failure?.correlation_failure; const failedOperation = failedProtocolEvidence?.operation_key ? `Шаг протокола: ${failedProtocolEvidence.operation_key}.` : ""; const diagnostics = failure?.diagnostic_evidence_unavailable?.length ? "Часть диагностических доказательств недоступна; результат считается неизвестным." : ""; return new ApiError( `${reasonDetail || localizedDetail || "Канонический диалог K1 остановлен."} Этап: ${stage}. ${failedOperation} ${exchanges} ${commandStatus} ${diagnostics} ${retryStatus}`, ); } async function waitForControlPhase( expected: XgridsApplicationControlPhase, acceptState: (state: XgridsK1State) => void, assertOperatorIntentCurrent: () => void, ): Promise { for (;;) { assertOperatorIntentCurrent(); const nextState = await awaitWhileIntentCurrent( assertOperatorIntentCurrent, () => xgridsK1Api.getState(), ); acceptState(nextState); const phase = controlPhase(nextState); if (phase === expected) return nextState; if (phase === "failed") throw controlFailure(nextState); if (["idle", "closed", "completed"].includes(phase)) { throw new ApiError( `Управляющая сессия K1 завершилась до ожидаемого этапа «${expected}».`, ); } // This cadence only reads local server state. It never schedules, retries, // or times a K1 command; every next write remains gated by device response. await awaitWhileIntentCurrent( assertOperatorIntentCurrent, () => new Promise((resolve) => { window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS); }), ); } } function messageFor(error: unknown): string { if (error instanceof ApiError) { // Domain errors are already written for the operator. Only HTTP details // originate at the backend and need vendor/runtime normalization. const message = error.status ? localizeRuntimeMessage(error.message) ?? error.message : error.message; return error.status ? `${message} (HTTP ${error.status})` : message; } return "Запрос к локальному сервису устройства завершился ошибкой."; } function networkProvisionFailureMessage( operation: XgridsOperation | null | undefined, ): string | null { if (!operation || operation.status !== "failed") return null; const code = operation.error?.code; if (typeof code !== "string") return null; const messages: Record = { "network-not-found": "Точка доступа выбранного K1 не найдена. Команда включения точки не повторялась; проверьте питание и состояние K1.", "credential-entry-cancelled": "Первичная регистрация пароля K1 отменена. Получите пароль сохранённой сети этого K1 на авторизованном устройстве и повторите подключение отдельным действием.", "credential-invalid": "Пароль точки доступа K1 имеет недопустимую длину. Получите сохранённый пароль этого K1 в LixelGO/iPhone и повторите подключение.", "host-wifi-operation-timeout": "Первичное системное подключение к K1 не было завершено вовремя. BLE-команда автоматически не повторялась; получите пароль сохранённой сети этого K1 и запустите подключение заново.", "profile-ssid-mismatch": "Сохранённый профиль относится к другому K1. Подключение остановлено без повторной команды сканеру.", "corewlan-error": "macOS не смогла подключиться к точке доступа K1. Проверьте пароль сохранённой сети этого K1; автоматического повтора не было.", "wifi-interface-unavailable": "Системный Wi-Fi-интерфейс macOS недоступен. Команда сканеру автоматически не повторялась.", "unsupported-platform": "Для этой операционной системы адаптер подключения к точке K1 ещё не реализован.", }; return messages[code] ?? null; } 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(null); const [backendStatus, setBackendStatus] = useState("checking"); const [eventStatus, setEventStatus] = useState("connecting"); const [pendingAction, setPendingAction] = useState(null); const [error, setError] = useState(null); const [latencyHistory, setLatencyHistory] = useState([]); const operatorIntents = useRef(new OperatorIntentGeneration()); const actionSequence = useRef(0); const actionInFlight = useRef<{ runtimeGeneration: number; actionSequence: number; } | null>(null); const acceptState = useCallback((nextState: XgridsK1State) => { setState((currentState) => selectMonotonicXgridsState(currentState, nextState)); setBackendStatus("online"); }, []); const refresh = useCallback(async (reportErrors = true) => { const runtimeToken = operatorIntents.current.captureRuntime(); if (!enabled || !runtimeToken) return; const [healthResult, stateResult] = await Promise.allSettled([ xgridsK1Api.getHealth(), xgridsK1Api.getState(), ]); if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) 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) => { const runtimeToken = operatorIntents.current.captureRuntime(); if (!enabled || !runtimeToken) return false; if ( actionInFlight.current?.runtimeGeneration === runtimeToken.runtimeGeneration ) return false; actionSequence.current += 1; const actionToken = { runtimeGeneration: runtimeToken.runtimeGeneration, actionSequence: actionSequence.current, }; actionInFlight.current = actionToken; setPendingAction(action); setError(null); try { if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false; const nextState = await operation(); if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false; acceptState(nextState); return true; } catch (operationError) { if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) { setError(messageFor(operationError)); if ( operationError instanceof ApiError && operationError.transportUnavailable ) { setBackendStatus("offline"); } } return false; } finally { if ( actionInFlight.current?.runtimeGeneration === actionToken.runtimeGeneration && actionInFlight.current.actionSequence === actionToken.actionSequence ) { actionInFlight.current = null; if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) { 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( "Предыдущая запись настроек завершилась с неопределённым результатом. Автоматический повтор заблокирован; измените параметры только после проверки устройства.", ); } let nextState: XgridsK1State; try { nextState = await xgridsK1Api.connect(request); } catch (connectError) { // A failed network action is terminal and persisted by the backend. // Re-read state only; never replay the device command. This replaces // an opaque HTTP 502 banner with the exact host-association outcome. let failedState: XgridsK1State; try { failedState = await xgridsK1Api.getState(); } catch { throw connectError; } acceptState(failedState); const failedOperation = operationByIdempotencyKey( failedState, "network.provision", request.idempotency_key, ); const failureMessage = networkProvisionFailureMessage(failedOperation); if (failureMessage) throw new ApiError(failureMessage); throw connectError; } 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; }), [acceptState, run, state], ); const openApplicationControlSession = useCallback( (request: OpenApplicationControlSessionRequest) => run("control", () => xgridsK1Api.openApplicationControlSession(request)), [run], ); const enterApplicationWorkspace = useCallback( () => run("control", () => xgridsK1Api.enterApplicationWorkspace({ operator_confirmed: true }), ), [run], ); const closeApplicationControlSession = useCallback( () => run("control", () => xgridsK1Api.closeApplicationControlSession()), [run], ); const startCanonicalAcquisition = useCallback( (request: CanonicalLiveStartRequest) => run("live", async () => { const intentToken = operatorIntents.current.beginOperatorIntent(); if (!intentToken) { throw new ApiError( "Экран управления закрыт; дальнейшие команды канонического диалога не отправлялись.", ); } const assertOperatorIntentCurrent = () => { if (!operatorIntents.current.isOperatorIntentCurrent(intentToken)) { throw new ApiError( "Операторское действие завершено или заменено; дальнейшие команды канонического диалога не отправлялись.", ); } }; let nextState = await awaitWhileIntentCurrent( assertOperatorIntentCurrent, () => xgridsK1Api.getState(), ); let openedControlSession = false; acceptState(nextState); const plan = liveStartPlan(nextState); if (plan === "blocked") { throw new ApiError("Сначала завершите текущий приём или повтор записи."); } if (plan === "already-running") return nextState; for (;;) { assertOperatorIntentCurrent(); const phase = controlPhase(nextState); const acquisition = nextState.acquisition; const entryPlan = controlSessionEntryPlan( phase, openedControlSession, nextState.application_control_session?.can_open === true, ); if (entryPlan === "failed") { // A failed dialogue always ends this operator intent. Even when // backend reconciliation says a fresh attempt may be safe, that // attempt requires another explicit click. throw controlFailure(nextState); } if (entryPlan === "duplicate-open") { throw new ApiError( "Управляющая сессия завершилась сразу после открытия. Автоматический повтор заблокирован; проверьте состояние и повторите только отдельным нажатием.", ); } if (entryPlan === "open") { if (acquisition && !isTerminalAcquisitionState(acquisition.state)) { throw new ApiError( "Незавершённая подготовка не привязана к открытой control-сессии. Отмените её перед новым запуском.", ); } openedControlSession = true; nextState = await awaitWhileIntentCurrent( assertOperatorIntentCurrent, () => xgridsK1Api.openApplicationControlSession(request.control), ); acceptState(nextState); continue; } if (phase === "connecting") { nextState = await awaitWhileIntentCurrent( assertOperatorIntentCurrent, () => waitForControlPhase( "connection-ready", acceptState, assertOperatorIntentCurrent, ), ); continue; } if (phase === "connection-ready") { nextState = await awaitWhileIntentCurrent( assertOperatorIntentCurrent, () => xgridsK1Api.enterApplicationWorkspace({ operator_confirmed: true, }), ); acceptState(nextState); continue; } if (phase === "workspace-requested") { nextState = await awaitWhileIntentCurrent( assertOperatorIntentCurrent, () => waitForControlPhase( "workspace-ready", acceptState, assertOperatorIntentCurrent, ), ); continue; } if (phase === "workspace-ready") { if (!acquisition || isTerminalAcquisitionState(acquisition.state)) { nextState = await awaitWhileIntentCurrent( assertOperatorIntentCurrent, () => xgridsK1Api.prepareAcquisition(request.acquisition), ); acceptState(nextState); continue; } if (acquisition.state !== "prepared" || acquisition.control_mode !== "plugin-commanded") { throw new ApiError( "Текущая подготовка не принадлежит канонической control-сессии K1.", ); } nextState = await awaitWhileIntentCurrent( assertOperatorIntentCurrent, () => waitForControlPhase( "project-ready", acceptState, assertOperatorIntentCurrent, ), ); continue; } if (phase === "project-requested") { nextState = await awaitWhileIntentCurrent( assertOperatorIntentCurrent, () => waitForControlPhase( "project-ready", acceptState, assertOperatorIntentCurrent, ), ); continue; } if (phase === "project-ready") { if (!acquisition || acquisition.state !== "prepared") { throw new ApiError("Локальный приём не подготовлен к каноническому START."); } nextState = await awaitWhileIntentCurrent( assertOperatorIntentCurrent, () => xgridsK1Api.startAcquisition({ acquisition_id: acquisition.acquisition_id, expected_state_revision: acquisition.state_revision, physical_acceptance: request.physicalAcceptance, }), ); acceptState(nextState); return nextState; } if (["start-requested", "initializing", "scanning"].includes(phase)) { return nextState; } throw new ApiError(`Запуск K1 недоступен из состояния «${phase}».`); } }), [acceptState, run], ); const prepareAcquisition = useCallback( (request: PrepareAcquisitionRequest) => run("live", async () => { const plan = liveStartPlan(state); if (plan === "blocked") { throw new ApiError( "Сначала завершите текущий приём или повтор записи.", ); } if (plan === "already-running" && state) return state; return ( plan === "resume-prepared" && state ? state : await xgridsK1Api.prepareAcquisition(request) ); }), [run, state], ); const startPreparedAcquisition = useCallback( (physicalAcceptance: OperatorPresenceConfirmation) => run("live", async () => { const acquisition = state?.acquisition; if (!acquisition?.acquisition_id || acquisition.state !== "prepared") { throw new ApiError("Сначала сохраните проект и подготовьте локальный приём."); } return xgridsK1Api.startAcquisition({ acquisition_id: acquisition.acquisition_id, expected_state_revision: acquisition.state_revision, physical_acceptance: physicalAcceptance, }); }), [run, state], ); const startReplay = useCallback( (request: ReplayRequest) => run("replay", () => xgridsK1Api.startReplay(request)), [run], ); const stop = useCallback( (physicalAcceptance?: OperatorPresenceConfirmation) => run("stop", () => { const acquisition = state?.acquisition; const acquisitionTerminal = isTerminalAcquisitionState(acquisition?.state); if (acquisition && !acquisitionTerminal) { const softwareCommanded = isSoftwareCommandedAcquisition(state); if (softwareCommanded && !physicalAcceptance) { throw new ApiError( "Подтвердите присутствие рядом с K1 перед каноническим STOP.", ); } return xgridsK1Api.stopAcquisition({ acquisition_id: acquisition.acquisition_id, mode: softwareCommanded ? "graceful" : "capture-only", ...(physicalAcceptance ? { physical_acceptance: physicalAcceptance } : {}), }); } return xgridsK1Api.stopSessionCompatibility(); }), [run, state], ); 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) { operatorIntents.current.deactivateRuntime(); setState(null); setBackendStatus("checking"); setEventStatus("closed"); setPendingAction(null); setError(null); setLatencyHistory([]); return; } operatorIntents.current.activateRuntime(); void refresh(true); const poll = window.setInterval(() => void refresh(false), 4_000); return () => { operatorIntents.current.deactivateRuntime(); 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, openApplicationControlSession, enterApplicationWorkspace, closeApplicationControlSession, startCanonicalAcquisition, prepareAcquisition, startPreparedAcquisition, startReplay, stop, abort, setObservationSourceActive, updateViewerSettings, }; }