Add calibrated K1 projection, recorded and near-live perception qualification, unified Rerun operator layers, bounded replay admission, audited viewer controls, worker experiments, and lab evidence.
785 lines
31 KiB
TypeScript
785 lines
31 KiB
TypeScript
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<string, string> = {
|
|
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<string, string> = {
|
|
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<XgridsK1State> {
|
|
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<void>((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<string, string> = {
|
|
"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<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 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<XgridsK1State>) => {
|
|
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,
|
|
};
|
|
}
|