2411 lines
102 KiB
TypeScript
2411 lines
102 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
||
|
||
import type { BackendStatus, ViewerSettings } from "@mission-core/plugin-sdk";
|
||
|
||
import {
|
||
ApiError,
|
||
xgridsK1Api,
|
||
openEventSocket,
|
||
type ConnectionVerifyRequest,
|
||
type ConnectRequest,
|
||
type EventSocketStatus,
|
||
type OpenApplicationControlSessionRequest,
|
||
type OperatorPresenceConfirmation,
|
||
type PrepareConnectionReconfigurationRequest,
|
||
type PrepareAcquisitionRequest,
|
||
type ReopenRetiredPhysicalReconciliationRequest,
|
||
type ReplayRequest,
|
||
type RetireUnavailablePhysicalCommandRequest,
|
||
type SelectConnectionModeRequest,
|
||
type XgridsOperation,
|
||
type XgridsApplicationControlPhase,
|
||
type XgridsConnectionMode,
|
||
type XgridsConnectionPolicyAction,
|
||
type XgridsHostFailureDiagnostic,
|
||
type XgridsK1State,
|
||
} from "./api";
|
||
import {
|
||
acceptedBleSessionKeyAfterConnect,
|
||
authoritativeStateSupersedesPhysicalStopIntent,
|
||
bleSessionTargetForTransport,
|
||
connectionPolicyAllows,
|
||
currentAppliedConnectionTopology,
|
||
authoritativeStateSupersedesRuntimeError,
|
||
isRecoveredPhysicalScanning,
|
||
isProvenLocalReceiverInactive,
|
||
isTerminalAcquisitionState,
|
||
liveStartPlan,
|
||
localReceiverStopPlan,
|
||
newMutationContext,
|
||
newOperationId,
|
||
operationAllowsFreshProvisioningIntent,
|
||
operationByIdempotencyKey,
|
||
operationNeedsReconciliation,
|
||
physicalStopIntentCheckpoint,
|
||
recommendedConnectionRecoveryObservationTarget,
|
||
readOnlyVerificationClearedReconciliation,
|
||
requiresCanonicalStopAfterTerminalLocalFailure,
|
||
isSoftwareCommandedAcquisition,
|
||
shouldSurfaceRuntimeActionError,
|
||
transportRefEquivalenceKey,
|
||
type PhysicalStopIntentCheckpoint,
|
||
type RuntimeErrorCorrelation,
|
||
} from "./lifecycle";
|
||
import { localizeRuntimeMessage } from "./messages";
|
||
import {
|
||
acquisitionMutationUsesControlSession,
|
||
exactAcquisitionControlCas,
|
||
exactApplicationControlCas,
|
||
} from "./controlSessionCas";
|
||
import {
|
||
awaitWhileIntentCurrent,
|
||
isSnapshotRuntimeCurrent as snapshotRuntimeIdsMatch,
|
||
OperatorIntentGeneration,
|
||
} from "./operatorIntentGeneration";
|
||
import { selectMonotonicXgridsState } from "./stateOrdering";
|
||
import { operationHostFailureDiagnostic } from "./hostDiagnosticPresentation";
|
||
import { activeStreamForceFinishAuthority } from "./activeStreamRecovery";
|
||
import { DEFAULT_CONNECTION_MODE } from "./configuration";
|
||
|
||
export type PendingAction =
|
||
| "scan"
|
||
| "mode"
|
||
| "reconfigure"
|
||
| "connect"
|
||
| "verify"
|
||
| "retire"
|
||
| "reopen"
|
||
| "probe"
|
||
| "control"
|
||
| "live"
|
||
| "replay"
|
||
| "stop"
|
||
| "force-finish"
|
||
| "abort"
|
||
| "camera"
|
||
| "viewer";
|
||
|
||
export interface RuntimeActionToken {
|
||
runtimeGeneration: number;
|
||
actionSequence: number;
|
||
}
|
||
|
||
export class SnapshotRuntimeActionArbiter {
|
||
private current: RuntimeActionToken | null = null;
|
||
private sequence = 0;
|
||
|
||
begin(
|
||
runtimeGeneration: number,
|
||
supersedeCurrent = false,
|
||
): RuntimeActionToken | null {
|
||
if (
|
||
this.current?.runtimeGeneration === runtimeGeneration
|
||
&& !supersedeCurrent
|
||
) return null;
|
||
this.sequence += 1;
|
||
this.current = {
|
||
runtimeGeneration,
|
||
actionSequence: this.sequence,
|
||
};
|
||
return this.current;
|
||
}
|
||
|
||
isCurrent(token: RuntimeActionToken): boolean {
|
||
return this.current?.runtimeGeneration === token.runtimeGeneration
|
||
&& this.current.actionSequence === token.actionSequence;
|
||
}
|
||
|
||
retireForSnapshotChange(
|
||
previousSnapshotRuntimeId: string | null,
|
||
acceptedSnapshotRuntimeId: string | null,
|
||
): boolean {
|
||
if (
|
||
!previousSnapshotRuntimeId
|
||
|| !acceptedSnapshotRuntimeId
|
||
|| previousSnapshotRuntimeId === acceptedSnapshotRuntimeId
|
||
) return false;
|
||
this.current = null;
|
||
return true;
|
||
}
|
||
|
||
settle(token: RuntimeActionToken): boolean {
|
||
if (!this.isCurrent(token)) return false;
|
||
this.current = null;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
export function runtimeActionResponseAlreadyAccepted(
|
||
responseState: XgridsK1State,
|
||
acceptedState: XgridsK1State | null,
|
||
): boolean {
|
||
const responseRuntimeId = responseState.snapshot_runtime_id?.trim() ?? "";
|
||
const acceptedRuntimeId = acceptedState?.snapshot_runtime_id?.trim() ?? "";
|
||
const responseRevision = responseState.snapshot_revision;
|
||
const acceptedRevision = acceptedState?.snapshot_revision;
|
||
return Boolean(
|
||
responseRuntimeId
|
||
&& acceptedRuntimeId === responseRuntimeId
|
||
&& Number.isSafeInteger(responseRevision)
|
||
&& Number.isSafeInteger(acceptedRevision)
|
||
&& (acceptedRevision as number) >= (responseRevision as number)
|
||
);
|
||
}
|
||
|
||
export type AcquisitionPreparationDraft = Omit<
|
||
PrepareAcquisitionRequest,
|
||
| "operation_id"
|
||
| "idempotency_key"
|
||
| "expected_control_session_generation"
|
||
| "expected_control_state_revision"
|
||
>;
|
||
|
||
export interface CanonicalLivePreparationRequest {
|
||
acquisition: AcquisitionPreparationDraft;
|
||
physicalAcceptance: OperatorPresenceConfirmation;
|
||
}
|
||
|
||
function runtimeTimezoneName(): string {
|
||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
return typeof timezone === "string" && timezone.trim() ? timezone : "UTC";
|
||
}
|
||
|
||
export interface ProvisioningSubmitResult {
|
||
/** The exact Apply response was accepted, including applied-but-unready control. */
|
||
succeeded: boolean;
|
||
/** The explicit network mutation completed even if the later control proof failed. */
|
||
networkIntentCompleted: boolean;
|
||
intentDisposition: "retain" | "release";
|
||
/** Exact journaled failure for this provisioning attempt. */
|
||
failureReasonCode: string | null;
|
||
acceptedSessionKey: string | null;
|
||
observedState: XgridsK1State | null;
|
||
}
|
||
|
||
export interface ConnectionVerificationSubmitResult {
|
||
succeeded: boolean;
|
||
reconciliationCompleted: boolean;
|
||
observedState: XgridsK1State | null;
|
||
/** Exact journaled failure for this Verify operation, never an unrelated last error. */
|
||
reasonCode: string | null;
|
||
}
|
||
|
||
export interface RuntimeActionOptions {
|
||
/** Keep a bounded background observation inside its owning surface. */
|
||
surfaceErrors?: boolean;
|
||
/** Explicit BLE discovery window owned by the visible Scan action. */
|
||
durationSeconds?: number;
|
||
/** Exact durable connection attempt owned by this action, if any. */
|
||
connectionAttemptId?: () => string | null;
|
||
/** Retire an older UI callback; the backend remains the mutation authority. */
|
||
supersedePending?: boolean;
|
||
/**
|
||
* Pin a composite UI action to the runtime rendered at its explicit click.
|
||
* The literal id is checked again at the dispatch boundary; it is never
|
||
* replaced with a newer runtime discovered while the action is settling.
|
||
*/
|
||
expectedSnapshotRuntimeId?: string;
|
||
}
|
||
|
||
export interface ConnectionActionAuthoritySnapshot {
|
||
snapshotRuntimeId: string;
|
||
connectionMode: XgridsConnectionMode;
|
||
desiredModeRevision: number;
|
||
reconfigurationRevision: number;
|
||
reconfigurationIntentId: string | null;
|
||
activeBindingKey: string | null;
|
||
discoveryGeneration: number;
|
||
}
|
||
|
||
export interface BleDiscoverySubmitResult {
|
||
succeeded: boolean;
|
||
snapshotRuntimeId: string | null;
|
||
discoveryGeneration: number | null;
|
||
transportRefs: readonly string[];
|
||
}
|
||
|
||
export interface ConnectionReconfigurationSubmitResult {
|
||
succeeded: boolean;
|
||
observedState: XgridsK1State | null;
|
||
}
|
||
|
||
export function connectionActionAuthoritySnapshot(
|
||
state: XgridsK1State | null | undefined,
|
||
connectionMode: XgridsConnectionMode,
|
||
): ConnectionActionAuthoritySnapshot | null {
|
||
const snapshotRuntimeId = state?.snapshot_runtime_id?.trim() || null;
|
||
const desiredModeRevision = state?.desired_connection_mode_revision;
|
||
const discoveryGeneration = state?.ble_discovery_generation;
|
||
const reconfigurationRevision = state?.connection_reconfiguration?.revision ?? 0;
|
||
const reconfiguration = state?.connection_reconfiguration;
|
||
const reconfigurationIntentId = reconfiguration
|
||
&& reconfiguration.intent !== null
|
||
&& reconfiguration.status !== "idle"
|
||
? reconfiguration.intent_id
|
||
: null;
|
||
if (
|
||
!snapshotRuntimeId
|
||
|| state?.desired_connection_mode !== connectionMode
|
||
|| !Number.isInteger(desiredModeRevision)
|
||
|| (desiredModeRevision ?? -1) < 0
|
||
|| !Number.isInteger(discoveryGeneration)
|
||
|| (discoveryGeneration ?? -1) < 0
|
||
|| !Number.isInteger(reconfigurationRevision)
|
||
|| reconfigurationRevision < 0
|
||
) return null;
|
||
return {
|
||
snapshotRuntimeId,
|
||
connectionMode,
|
||
desiredModeRevision: desiredModeRevision as number,
|
||
reconfigurationRevision,
|
||
reconfigurationIntentId,
|
||
activeBindingKey: state?.connection_lifecycle?.active_binding_key ?? null,
|
||
discoveryGeneration: discoveryGeneration as number,
|
||
};
|
||
}
|
||
|
||
const CONTROL_STATE_READ_INTERVAL_MS = 250;
|
||
const NETWORK_PROVISION_SETTLEMENT_FALLBACK_MS = 30_000;
|
||
const NETWORK_PROVISION_SETTLEMENT_MAX_MS = 300_000;
|
||
const NETWORK_PROVISION_SETTLEMENT_GRACE_MS = 1_000;
|
||
|
||
function networkProvisionSettlementDeadline(
|
||
operation: XgridsOperation,
|
||
startedAtMs: number,
|
||
): number {
|
||
const operationDeadlineMs = operation.deadline_at
|
||
? Date.parse(operation.deadline_at)
|
||
: Number.NaN;
|
||
const requestedDeadlineMs = Number.isFinite(operationDeadlineMs)
|
||
&& operationDeadlineMs > startedAtMs
|
||
? operationDeadlineMs + NETWORK_PROVISION_SETTLEMENT_GRACE_MS
|
||
: startedAtMs + NETWORK_PROVISION_SETTLEMENT_FALLBACK_MS;
|
||
return Math.min(
|
||
requestedDeadlineMs,
|
||
startedAtMs + NETWORK_PROVISION_SETTLEMENT_MAX_MS,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Follow one already-admitted Apply after a browser response is interrupted by
|
||
* the host Wi-Fi handoff. This loop only reads the local journal; it never
|
||
* retries the HTTP mutation, BLE write, CoreWLAN association or control open.
|
||
*/
|
||
export async function awaitNetworkProvisionSettlementAfterLostResponse(
|
||
initialState: XgridsK1State,
|
||
idempotencyKey: string,
|
||
readState: () => Promise<XgridsK1State>,
|
||
acceptState: (state: XgridsK1State) => void,
|
||
assertOperatorIntentCurrent: () => void,
|
||
options: {
|
||
now?: () => number;
|
||
wait?: (delayMs: number) => Promise<void>;
|
||
} = {},
|
||
): Promise<XgridsK1State> {
|
||
const now = options.now ?? Date.now;
|
||
const wait = options.wait ?? ((delayMs: number) => new Promise<void>((resolve) => {
|
||
globalThis.setTimeout(resolve, delayMs);
|
||
}));
|
||
let state = initialState;
|
||
let operation = operationByIdempotencyKey(
|
||
state,
|
||
"network.provision",
|
||
idempotencyKey,
|
||
);
|
||
if (!operation || !["accepted", "running"].includes(operation.status)) {
|
||
return state;
|
||
}
|
||
const deadlineMs = networkProvisionSettlementDeadline(operation, now());
|
||
|
||
while (["accepted", "running"].includes(operation.status)) {
|
||
assertOperatorIntentCurrent();
|
||
const remainingMs = deadlineMs - now();
|
||
if (remainingMs <= 0) return state;
|
||
await wait(Math.min(CONTROL_STATE_READ_INTERVAL_MS, remainingMs));
|
||
assertOperatorIntentCurrent();
|
||
try {
|
||
const nextState = await readState();
|
||
assertOperatorIntentCurrent();
|
||
acceptState(nextState);
|
||
state = nextState;
|
||
} catch (readError) {
|
||
// A host Wi-Fi transition can briefly abort even a localhost fetch.
|
||
// Preserve the admitted operation and perform only the next bounded
|
||
// journal read; the original Apply is never reissued.
|
||
if (!(readError instanceof ApiError) || !readError.transportUnavailable) {
|
||
throw readError;
|
||
}
|
||
continue;
|
||
}
|
||
operation = operationByIdempotencyKey(
|
||
state,
|
||
"network.provision",
|
||
idempotencyKey,
|
||
);
|
||
if (!operation) return state;
|
||
}
|
||
return state;
|
||
}
|
||
const CONTROL_PHASE_WAIT_TIMEOUT_MS = 120_000;
|
||
|
||
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:
|
||
"Ожидаемый ответ сканера не пришёл до безопасной границы ожидания.",
|
||
mqtt_network_loop_failed:
|
||
"Локальный MQTT-клиент потерял управляющее соединение со сканером.",
|
||
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:
|
||
"Ответ подключённого устройства не соответствует выбранной модели K1, версии прошивки или состоянию активации.",
|
||
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
|
||
? failure.status_reconciliation?.decision === "safe-explicit-prestart-retry"
|
||
? "Живой статус READY без связанного проекта подтверждён; новая попытка возможна только отдельным нажатием оператора."
|
||
: "Новая попытка возможна только отдельным нажатием оператора."
|
||
: "Повтор заблокирован до ручной проверки состояния.";
|
||
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}`,
|
||
0,
|
||
false,
|
||
failure?.host_diagnostic,
|
||
);
|
||
}
|
||
|
||
async function waitForControlPhase(
|
||
expected: XgridsApplicationControlPhase,
|
||
acceptState: (state: XgridsK1State) => void,
|
||
assertOperatorIntentCurrent: () => void,
|
||
timeoutMs = CONTROL_PHASE_WAIT_TIMEOUT_MS,
|
||
): Promise<XgridsK1State> {
|
||
const deadline = Date.now() + timeoutMs;
|
||
for (;;) {
|
||
assertOperatorIntentCurrent();
|
||
if (Date.now() >= deadline) {
|
||
throw new ApiError(
|
||
`K1 не подтвердил этап «${expected}» за ${Math.ceil(timeoutMs / 1_000)} с. START не отправлялся.`,
|
||
);
|
||
}
|
||
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 hasExactConnectionReady(
|
||
state: XgridsK1State,
|
||
connectionMode: NonNullable<XgridsK1State["connection_mode"]>,
|
||
): boolean {
|
||
return Boolean(
|
||
state.connection_lifecycle?.schema_version
|
||
=== "missioncore.xgrids-k1-connection-lifecycle/v1"
|
||
&& state.connection_lifecycle.connection_ready === true
|
||
&& state.connection_lifecycle.desired_mode === connectionMode
|
||
&& state.connection_lifecycle.configured_mode === connectionMode
|
||
&& state.connection_lifecycle.active_mode === connectionMode
|
||
&& state.desired_connection_mode === connectionMode
|
||
&& state.active_connection_mode === connectionMode
|
||
&& state.application_control_session?.state === "connection-ready"
|
||
&& currentAppliedConnectionTopology(state, connectionMode)?.status === "active",
|
||
);
|
||
}
|
||
|
||
function requireExactConnectionReady(
|
||
state: XgridsK1State,
|
||
connectionMode: NonNullable<XgridsK1State["connection_mode"]>,
|
||
): XgridsK1State {
|
||
if (hasExactConnectionReady(state, connectionMode)) return state;
|
||
if (state.application_control_session?.state === "failed") {
|
||
throw controlFailure(state);
|
||
}
|
||
throw new ApiError(
|
||
"Подключение к выбранному K1 не завершено. START не отправлялся; вернитесь в «Парк» и завершите подключение устройства.",
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Prove that the exact Apply request crossed its one network-mutation
|
||
* boundary, even when the separate control bootstrap is not ready yet.
|
||
* This is intentionally stricter than `phase=network_applied` alone: the
|
||
* attempt, idempotent operation, durable ledger and BLE-observed target must
|
||
* all name the same operation, transport and mode.
|
||
*/
|
||
export function exactAppliedNetworkIntentCompleted(
|
||
state: XgridsK1State,
|
||
request: ConnectRequest,
|
||
operation: XgridsOperation | null | undefined,
|
||
): boolean {
|
||
const attempt = state.connection_attempt;
|
||
const ledger = state.network_mutation_ledger;
|
||
const deviceNetwork = state.connection_supervisor?.observed.device_network;
|
||
const operationMode = operation?.context
|
||
&& typeof operation.context.connection_mode === "string"
|
||
? operation.context.connection_mode
|
||
: null;
|
||
const operationPhase = operation?.result
|
||
&& typeof operation.result.phase === "string"
|
||
? operation.result.phase
|
||
: null;
|
||
const requestTransportKey = transportRefEquivalenceKey(request.device_id);
|
||
return Boolean(
|
||
attempt?.schema_version === "missioncore.xgrids-k1-connection-attempt/v1"
|
||
&& attempt.phase === "network_applied"
|
||
&& attempt.connection_mode === request.connection_mode
|
||
&& operation?.action === "network.provision"
|
||
&& operation.status === "succeeded"
|
||
&& operation.idempotency_key === request.idempotency_key
|
||
&& attempt.attempt_id === operation.operation_id
|
||
&& operationMode === request.connection_mode
|
||
&& operationPhase === "network_applied"
|
||
&& ledger?.status === "resolved"
|
||
&& ledger.mutation_allowed === true
|
||
&& ledger.operation_id === operation.operation_id
|
||
&& ledger.intended_mode === request.connection_mode
|
||
&& ledger.resolution === "target-observed"
|
||
&& transportRefEquivalenceKey(ledger.transport_ref) === requestTransportKey
|
||
&& deviceNetwork?.state === "applied"
|
||
&& deviceNetwork.connection_mode === request.connection_mode
|
||
&& transportRefEquivalenceKey(deviceNetwork.transport_ref)
|
||
=== requestTransportKey,
|
||
);
|
||
}
|
||
|
||
function requireExactReadOnlyVerificationOutcome(
|
||
state: XgridsK1State,
|
||
connectionMode: NonNullable<XgridsK1State["connection_mode"]>,
|
||
): XgridsK1State {
|
||
if (
|
||
hasExactConnectionReady(state, connectionMode)
|
||
|| isRecoveredPhysicalScanning(state, connectionMode)
|
||
) return state;
|
||
return requireExactConnectionReady(state, connectionMode);
|
||
}
|
||
|
||
function connectionModeForVerification(
|
||
request: ConnectionVerifyRequest,
|
||
): XgridsConnectionMode {
|
||
if (request.compatibility_attestation.topology === "device-ap") {
|
||
return "quick-connect";
|
||
}
|
||
if (request.compatibility_attestation.topology === "controller-hotspot") {
|
||
return "direct-connect";
|
||
}
|
||
return "bridge";
|
||
}
|
||
|
||
async function waitForPhysicalReconciliationProof(
|
||
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 === "failed") throw controlFailure(nextState);
|
||
if (phase !== "connection-ready") {
|
||
throw new ApiError(
|
||
"Управляющая сессия изменилась до завершения read-only сверки физического состояния K1.",
|
||
);
|
||
}
|
||
const physical = nextState.application_control_session?.physical_command;
|
||
if (physical?.requires_reconciliation !== true) return nextState;
|
||
if (physical.reconciliation_ready) return nextState;
|
||
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 apiErrorForOperation(
|
||
message: string,
|
||
operation: XgridsOperation | null | undefined,
|
||
): ApiError {
|
||
return new ApiError(
|
||
message,
|
||
0,
|
||
false,
|
||
operationHostFailureDiagnostic(operation),
|
||
);
|
||
}
|
||
|
||
const CONNECT_SESSION_RESET_COPY =
|
||
"Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.";
|
||
|
||
function resetConnectSessionMessage(detail: string): string {
|
||
return `${detail} ${CONNECT_SESSION_RESET_COPY}`;
|
||
}
|
||
|
||
export 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;
|
||
|
||
if (code === "BleakGATTProtocolError") {
|
||
const attCode = operation.error?.ble_att_error_code;
|
||
const attName = operation.error?.ble_att_error_name;
|
||
const attDetail = typeof attCode === "number" && typeof attName === "string"
|
||
? ` Код периферии: ATT ${attCode} ${attName}.`
|
||
: "";
|
||
if (operation.error?.device_write_attempted === false) {
|
||
return `Bluetooth-сеанс K1 завершился ошибкой до команды изменения сети.${attDetail} Запись сетевого профиля не выполнялась; выполните новый поиск после освобождения Bluetooth.`;
|
||
}
|
||
if (typeof attCode === "number" && typeof attName === "string") {
|
||
return resetConnectSessionMessage(
|
||
`Bluetooth-периферия завершила сетевую операцию ошибкой.${attDetail} Команда могла быть принята K1; итог текущей попытки не подтверждён.`,
|
||
);
|
||
}
|
||
return resetConnectSessionMessage(
|
||
"Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён.",
|
||
);
|
||
}
|
||
|
||
if (code === "network-not-found") {
|
||
const attemptCount = operation.error?.scan_attempt_count;
|
||
const elapsedMs = operation.error?.scan_elapsed_ms;
|
||
const scanDetail = typeof attemptCount === "number" && typeof elapsedMs === "number"
|
||
? ` macOS выполнила ${attemptCount} проверок за ${(elapsedMs / 1000).toFixed(1)} с.`
|
||
: "";
|
||
return `K1 принял команду Quick Connect и подтвердил готовность точки доступа, но macOS не увидела её Wi‑Fi-сеть за отведённое время.${scanDetail} Quick Connect не установлен; автоматического повтора не было. Выполните новый поиск перед следующей явной попыткой или используйте Bridge.`;
|
||
}
|
||
|
||
if (
|
||
code === "keychain-authorization-required"
|
||
|| code === "keychain-authorization-denied"
|
||
|| code === "keychain-authorization-cancelled"
|
||
|| code === "keychain-access-failed"
|
||
) {
|
||
const failedBeforeDeviceWrite = operation.error?.side_effect_status === "none";
|
||
return failedBeforeDeviceWrite
|
||
? "Локальный профиль K1 недоступен в связке ключей. Команда устройству не отправлялась; подготовьте разрешение профиля отдельным действием и затем повторите подключение."
|
||
: resetConnectSessionMessage(
|
||
"После подтверждённого включения точки K1 локальный профиль стал недоступен в связке ключей. Дополнительный пароль не запрашивался; итог текущей попытки не подтверждён.",
|
||
);
|
||
}
|
||
|
||
const messages: Record<string, string> = {
|
||
"host-wifi-operation-timeout":
|
||
resetConnectSessionMessage("Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён."),
|
||
"profile-ssid-mismatch":
|
||
"Сохранённый профиль относится к другому устройству. Подключение остановлено без повторной команды сканеру.",
|
||
"profile-credential-source-mismatch":
|
||
"Сохранённый профиль K1 не подтверждён для точной версии прошивки. Автоматического выбора другого пароля нет; подготовьте профиль отдельно перед новой попыткой.",
|
||
"profile-unavailable":
|
||
resetConnectSessionMessage("Локальный профиль выбранного K1 отсутствует; текущая попытка подключения завершилась ошибкой."),
|
||
"corewlan-error":
|
||
"macOS не смогла подключиться к точке доступа K1. Проверьте пароль сохранённой сети этого K1; автоматического повтора не было.",
|
||
"wifi-interface-unavailable":
|
||
"Системный Wi-Fi-интерфейс macOS недоступен. Команда сканеру автоматически не повторялась.",
|
||
"unsupported-platform":
|
||
"Для этой операционной системы адаптер подключения к точке K1 ещё не реализован.",
|
||
"credential-source-unavailable":
|
||
"Локальный профиль выбранного K1 не готов. Команда устройству не отправлялась; после подготовки профиля разрешена новая явная попытка.",
|
||
"network-provision-candidate-not-fresh":
|
||
"Результат Bluetooth-поиска отсутствует или устарел. Команда K1 не отправлялась; выполните один свежий поиск.",
|
||
"network-provision-candidate-changed":
|
||
"Bluetooth-кандидат изменился до команды K1. Записи не было; выполните один свежий поиск.",
|
||
"network-provision-candidate-name-unavailable":
|
||
"K1 не сообщил имя своей точки доступа. Команда устройству не отправлялась; выполните новый поиск.",
|
||
"network-provision-target-not-distinguishable-from-baseline":
|
||
resetConnectSessionMessage("После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён."),
|
||
"network-provision-lifecycle-busy":
|
||
"Сетевая операция заблокирована активной сессией или локальной очисткой. Команда K1 не отправлялась; завершите текущую сессию и повторите явно.",
|
||
};
|
||
return messages[code] ?? null;
|
||
}
|
||
|
||
export function discoveryScanFailureMessage(
|
||
operation: XgridsOperation | null | undefined,
|
||
): string | null {
|
||
if (!operation || operation.action !== "discovery.scan" || operation.status !== "failed") {
|
||
return null;
|
||
}
|
||
const code = operation.error?.code;
|
||
if (typeof code !== "string") return null;
|
||
const messages: Record<string, string> = {
|
||
"ble-discovery-already-running":
|
||
"Поиск Bluetooth уже выполняется в другой вкладке. Текущий запрос не запускал второй системный скан; дождитесь завершения первого и обновите состояние.",
|
||
"ble-runtime-busy":
|
||
"Bluetooth занят другой локальной операцией K1. Второй системный сеанс не запускался; дождитесь завершения текущей операции и обновите состояние.",
|
||
"ble-runtime-cleanup-pending":
|
||
"Предыдущий Bluetooth-сеанс ещё подтверждает отключение. Новый поиск не запускался; дождитесь завершения очистки и обновите состояние.",
|
||
"ble-runtime-owner-loop-conflict":
|
||
"Локальный Bluetooth runtime привязан к другому активному циклу. Перезапустите локальный Mission Core; команды K1 не отправлялись.",
|
||
"ble-runtime-restart-required":
|
||
"Локальный Bluetooth runtime не подтвердил очистку предыдущего сеанса. Перезапустите Mission Core перед новым поиском; команды K1 не отправлялись.",
|
||
"ble-discovery-blocked-by-provisioning":
|
||
"Поиск Bluetooth не запускался: сейчас выполняется операция подключения K1.",
|
||
"ble-discovery-timeout":
|
||
"Системный поиск Bluetooth не завершился вовремя и был принудительно остановлен. Команды K1 не отправлялись; повторный поиск безопасен.",
|
||
"ble-discovery-cancelled":
|
||
"Поиск Bluetooth отменён и системный скан остановлен. Команды K1 не отправлялись.",
|
||
};
|
||
return messages[code]
|
||
?? "Поиск Bluetooth завершился ошибкой до обращения к K1. Команды устройству не отправлялись; проверьте доступ macOS к Bluetooth и повторите поиск.";
|
||
}
|
||
|
||
export function connectionVerificationFailureMessage(
|
||
operation: XgridsOperation | null | undefined,
|
||
): string | null {
|
||
if (!operation || operation.action !== "connection.verify" || operation.status !== "failed") {
|
||
return null;
|
||
}
|
||
const code = operation.error?.code;
|
||
if (typeof code !== "string") return null;
|
||
const messages: Record<string, string> = {
|
||
"connection-verify-candidate-not-fresh":
|
||
"Сохранённое подключение не удалось восстановить: результат поиска K1 отсутствует или устарел. Выполните новый поиск Bluetooth.",
|
||
"connection-verify-device-not-connectable":
|
||
"K1 был в списке поиска, но сейчас не принимает Bluetooth-подключение. Убедитесь, что другое приложение не держит устройство, затем выполните новый поиск.",
|
||
"connection-verify-device-not-rediscovered":
|
||
"Mission Core не получил объявление сохранённого CoreBluetooth-устройства в отведённое окно. Команды K1 не отправлялись; повторите явную read-only сверку.",
|
||
"connection-verify-runtime-loop-unavailable":
|
||
"Локальный Bluetooth runtime недоступен. Перезапустите локальный Mission Core; команды K1 не отправлялись.",
|
||
"connection-verify-status-read-timeout":
|
||
"Mission Core не завершил read-only этап Bluetooth вовремя. Настройки K1 не менялись и команды не отправлялись; этап сохранён в журнале операции.",
|
||
"connection-verify-exact-uuid-scan-timeout":
|
||
"Mission Core не получил объявление точного сохранённого CoreBluetooth UUID в отведённое окно. Команды K1 не отправлялись; это не является выводом о состоянии устройства.",
|
||
"connection-verify-address-unavailable":
|
||
"K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Старый Bridge использовать нельзя; подключите K1 к общей сети заново — настройки устройства не менялись.",
|
||
"connection-verify-target-not-distinguishable-from-baseline":
|
||
"K1 ответил, но приложение не смогло подтвердить, что прежние настройки сети были применены. Автоматического повтора и новой записи не было.",
|
||
"connection-verify-route-mismatch":
|
||
"K1 сообщил локальный адрес, но компьютер находится в другой сети. Подключите компьютер к той же сети; настройки K1 не изменялись.",
|
||
"connection-verify-mqtt-unreachable":
|
||
"Старый адрес K1 недоступен. Устройство найдено по Bluetooth: можно заново применить настройки общей сети. Команда Wi-Fi не отправлялась.",
|
||
"connection-verify-local-address-conflict":
|
||
"K1 сообщил адрес этого компьютера вместо собственного; подключение не принято и запись устройству не выполнялась.",
|
||
"connection-verify-status-read-invalid":
|
||
"Ответ не принадлежит выбранному K1. Подключение не принято; настройки устройства не менялись.",
|
||
"connection-verify-candidate-changed":
|
||
"Список Bluetooth изменился во время подключения. Результат отброшен без изменения K1; выполните новый поиск.",
|
||
"connection-verify-lease-changed":
|
||
"Подключение K1 изменилось во время восстановления. Результат отброшен без изменения устройства; обновите состояние и подключитесь заново.",
|
||
"connection-verify-busy":
|
||
"Другая операция подключения уже выполняется. Вторая Bluetooth-сессия не запускалась.",
|
||
"connection-verify-cleanup-pending":
|
||
"Предыдущая Bluetooth-сессия ещё завершается. Дождитесь её закрытия и выполните новый поиск.",
|
||
"connection-verify-lifecycle-busy":
|
||
"Подключение нельзя менять во время активного сканирования K1. Новые команды устройству не отправлялись.",
|
||
"connection-verify-connection-missing":
|
||
"В локальном runtime нет подключения K1, которое можно обновить. Выполните новый поиск Bluetooth.",
|
||
"connection-verify-status-read-failed":
|
||
"Mission Core не завершил read-only чтение Bluetooth. Настройки K1 не менялись и команды не отправлялись; этап сбоя сохранён в журнале операции.",
|
||
"application-connection-binding-lost":
|
||
"Сеть изменилась во время подключения. Команды устройству не отправлялись; после восстановления сети нажмите «Подключиться заново».",
|
||
"physical-command-reconciliation-proof-timeout":
|
||
"K1 подключился, но не сообщил текущее состояние вовремя. START и STOP не отправлялись; нажмите «Подключиться заново».",
|
||
};
|
||
return messages[code]
|
||
?? "Подключение не восстановлено. Настройки K1 не менялись; выполните новый поиск Bluetooth.";
|
||
}
|
||
|
||
export function operationById(
|
||
state: XgridsK1State,
|
||
action: string,
|
||
operationId: string,
|
||
): XgridsOperation | null {
|
||
const operations = state.operations ?? [];
|
||
for (let index = operations.length - 1; index >= 0; index -= 1) {
|
||
const operation = operations[index];
|
||
if (operation?.action === action && operation.operation_id === operationId) {
|
||
return operation;
|
||
}
|
||
}
|
||
return state.last_operation?.action === action
|
||
&& state.last_operation.operation_id === operationId
|
||
? state.last_operation
|
||
: 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 [errorDiagnostic, setErrorDiagnostic] =
|
||
useState<XgridsHostFailureDiagnostic | null>(null);
|
||
const [presentedErrorCorrelation, setPresentedErrorCorrelation] =
|
||
useState<RuntimeErrorCorrelation | null>(null);
|
||
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
|
||
const [physicalStopIntentSpent, setPhysicalStopIntentSpent] = useState(false);
|
||
const [physicalStopInFlight, setPhysicalStopInFlight] = useState(false);
|
||
const latestState = useRef<XgridsK1State | null>(null);
|
||
const errorCorrelation = useRef<RuntimeErrorCorrelation | null>(null);
|
||
const spentPhysicalStopIntent = useRef<PhysicalStopIntentCheckpoint | null>(null);
|
||
const physicalStopPresentationOwner = useRef<object | null>(null);
|
||
const operatorIntents = useRef(new OperatorIntentGeneration());
|
||
const runtimeActionArbiter = useRef(new SnapshotRuntimeActionArbiter());
|
||
|
||
const spendPhysicalStopIntent = useCallback((
|
||
checkpoint: PhysicalStopIntentCheckpoint,
|
||
): boolean => {
|
||
if (
|
||
authoritativeStateSupersedesPhysicalStopIntent(
|
||
checkpoint,
|
||
latestState.current,
|
||
)
|
||
) return false;
|
||
spentPhysicalStopIntent.current = checkpoint;
|
||
setPhysicalStopIntentSpent(true);
|
||
return true;
|
||
}, []);
|
||
|
||
const acceptState = useCallback((nextState: XgridsK1State) => {
|
||
const previousState = latestState.current;
|
||
const previousSnapshotRuntimeId =
|
||
previousState?.snapshot_runtime_id?.trim() || null;
|
||
const acceptedState = selectMonotonicXgridsState(previousState, nextState);
|
||
if (
|
||
previousState
|
||
&& acceptedState === previousState
|
||
&& acceptedState !== nextState
|
||
) return false;
|
||
const acceptedSnapshotRuntimeId =
|
||
acceptedState.snapshot_runtime_id?.trim() || null;
|
||
if (
|
||
previousSnapshotRuntimeId
|
||
&& acceptedSnapshotRuntimeId
|
||
&& previousSnapshotRuntimeId !== acceptedSnapshotRuntimeId
|
||
) {
|
||
// A backend runtime replacement retires the old in-flight UI action
|
||
// synchronously. Its finally block is token-guarded, so it cannot clear
|
||
// a newer action started against the accepted runtime.
|
||
runtimeActionArbiter.current.retireForSnapshotChange(
|
||
previousSnapshotRuntimeId,
|
||
acceptedSnapshotRuntimeId,
|
||
);
|
||
setPendingAction(null);
|
||
physicalStopPresentationOwner.current = null;
|
||
setPhysicalStopInFlight(false);
|
||
}
|
||
latestState.current = acceptedState;
|
||
setState(acceptedState);
|
||
if (
|
||
authoritativeStateSupersedesPhysicalStopIntent(
|
||
spentPhysicalStopIntent.current,
|
||
acceptedState,
|
||
)
|
||
) {
|
||
spentPhysicalStopIntent.current = null;
|
||
setPhysicalStopIntentSpent(false);
|
||
}
|
||
if (
|
||
authoritativeStateSupersedesRuntimeError(
|
||
errorCorrelation.current,
|
||
acceptedState,
|
||
)
|
||
) {
|
||
errorCorrelation.current = null;
|
||
setPresentedErrorCorrelation(null);
|
||
setError(null);
|
||
setErrorDiagnostic(null);
|
||
}
|
||
setBackendStatus("online");
|
||
return true;
|
||
}, []);
|
||
|
||
const isSnapshotRuntimeCurrent = useCallback((
|
||
expectedSnapshotRuntimeId: string,
|
||
): boolean => {
|
||
const currentSnapshotRuntimeId =
|
||
latestState.current?.snapshot_runtime_id?.trim() || null;
|
||
return snapshotRuntimeIdsMatch(
|
||
expectedSnapshotRuntimeId,
|
||
currentSnapshotRuntimeId,
|
||
);
|
||
}, []);
|
||
|
||
const getConnectionActionAuthority = useCallback((
|
||
connectionMode: XgridsConnectionMode,
|
||
): ConnectionActionAuthoritySnapshot | null =>
|
||
connectionActionAuthoritySnapshot(latestState.current, connectionMode), []);
|
||
|
||
const getCurrentState = useCallback(
|
||
(): XgridsK1State | null => latestState.current,
|
||
[],
|
||
);
|
||
|
||
const getConnectionRecoveryObservationTarget = useCallback(
|
||
() => recommendedConnectionRecoveryObservationTarget(latestState.current),
|
||
[],
|
||
);
|
||
|
||
const isConnectionPolicyActionAllowedCurrent = useCallback((
|
||
action: XgridsConnectionPolicyAction,
|
||
) => connectionPolicyAllows(latestState.current, action), []);
|
||
|
||
const isConnectionActionAuthorityCurrent = useCallback((
|
||
expected: ConnectionActionAuthoritySnapshot,
|
||
): boolean => {
|
||
const current = connectionActionAuthoritySnapshot(
|
||
latestState.current,
|
||
expected.connectionMode,
|
||
);
|
||
return Boolean(
|
||
current
|
||
&& current.snapshotRuntimeId === expected.snapshotRuntimeId
|
||
&& current.desiredModeRevision === expected.desiredModeRevision
|
||
&& current.reconfigurationRevision === expected.reconfigurationRevision
|
||
&& current.reconfigurationIntentId === expected.reconfigurationIntentId
|
||
&& current.activeBindingKey === expected.activeBindingKey
|
||
&& current.discoveryGeneration === expected.discoveryGeneration,
|
||
);
|
||
}, []);
|
||
|
||
const expectedSnapshotRuntimeId = useCallback((): string => {
|
||
const snapshotRuntimeId = latestState.current?.snapshot_runtime_id?.trim() || null;
|
||
if (!snapshotRuntimeId) {
|
||
throw new ApiError(
|
||
"Состояние локального сервиса обновилось. Обновите страницу перед следующим действием.",
|
||
);
|
||
}
|
||
return snapshotRuntimeId;
|
||
}, []);
|
||
|
||
const refresh = useCallback(async (
|
||
reportErrors = true,
|
||
): Promise<XgridsK1State | null> => {
|
||
const runtimeToken = operatorIntents.current.captureRuntime();
|
||
if (!enabled || !runtimeToken) return null;
|
||
const [healthResult, stateResult] = await Promise.allSettled([
|
||
xgridsK1Api.getHealth(),
|
||
xgridsK1Api.getState(),
|
||
]);
|
||
|
||
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return null;
|
||
|
||
let acceptedRefreshedState: XgridsK1State | null = null;
|
||
if (stateResult.status === "fulfilled") {
|
||
acceptState(stateResult.value);
|
||
// The event socket can win the race and publish the committed reopen
|
||
// before this REST refresh returns. In that case the duplicate REST
|
||
// snapshot is correctly rejected by monotonic ordering, while the
|
||
// already-accepted latest state is still the proof this click needs.
|
||
acceptedRefreshedState = latestState.current;
|
||
if (reportErrors && errorCorrelation.current?.action === "refresh") {
|
||
errorCorrelation.current = null;
|
||
setPresentedErrorCorrelation(null);
|
||
setError(null);
|
||
setErrorDiagnostic(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) {
|
||
errorCorrelation.current = {
|
||
action: "refresh",
|
||
runtimeId: latestState.current?.snapshot_runtime_id ?? null,
|
||
leaseGeneration:
|
||
latestState.current?.connection_supervisor?.lease.generation ?? null,
|
||
connectionAttemptId: null,
|
||
};
|
||
setPresentedErrorCorrelation(errorCorrelation.current);
|
||
setError(messageFor(stateResult.reason));
|
||
setErrorDiagnostic(null);
|
||
}
|
||
return acceptedRefreshedState;
|
||
}, [acceptState, enabled]);
|
||
|
||
const run = useCallback(
|
||
async (
|
||
action: PendingAction,
|
||
operation: () => Promise<XgridsK1State>,
|
||
options: RuntimeActionOptions = {},
|
||
) => {
|
||
const runtimeToken = operatorIntents.current.captureRuntime();
|
||
if (!enabled || !runtimeToken) return false;
|
||
const actionToken = runtimeActionArbiter.current.begin(
|
||
runtimeToken.runtimeGeneration,
|
||
options.supersedePending === true,
|
||
);
|
||
if (!actionToken) return false;
|
||
setPendingAction(action);
|
||
errorCorrelation.current = null;
|
||
setPresentedErrorCorrelation(null);
|
||
setError(null);
|
||
setErrorDiagnostic(null);
|
||
|
||
try {
|
||
if (
|
||
!operatorIntents.current.isRuntimeCurrent(runtimeToken)
|
||
|| !runtimeActionArbiter.current.isCurrent(actionToken)
|
||
) return false;
|
||
const nextState = await operation();
|
||
if (
|
||
!operatorIntents.current.isRuntimeCurrent(runtimeToken)
|
||
|| !runtimeActionArbiter.current.isCurrent(actionToken)
|
||
) return false;
|
||
if (
|
||
!acceptState(nextState)
|
||
&& !runtimeActionResponseAlreadyAccepted(nextState, latestState.current)
|
||
) return false;
|
||
if (!runtimeActionArbiter.current.isCurrent(actionToken)) return false;
|
||
return true;
|
||
} catch (operationError) {
|
||
if (
|
||
operatorIntents.current.isRuntimeCurrent(runtimeToken)
|
||
&& runtimeActionArbiter.current.isCurrent(actionToken)
|
||
) {
|
||
if (
|
||
options.surfaceErrors !== false
|
||
&& shouldSurfaceRuntimeActionError(action, latestState.current)
|
||
) {
|
||
errorCorrelation.current = {
|
||
action,
|
||
runtimeId: latestState.current?.snapshot_runtime_id ?? null,
|
||
leaseGeneration:
|
||
latestState.current?.connection_supervisor?.lease.generation ?? null,
|
||
connectionAttemptId: options.connectionAttemptId?.() ?? null,
|
||
};
|
||
setPresentedErrorCorrelation(errorCorrelation.current);
|
||
setError(messageFor(operationError));
|
||
setErrorDiagnostic(
|
||
operationError instanceof ApiError
|
||
? operationError.hostDiagnostic
|
||
: null,
|
||
);
|
||
} else {
|
||
errorCorrelation.current = null;
|
||
setPresentedErrorCorrelation(null);
|
||
setError(null);
|
||
setErrorDiagnostic(null);
|
||
}
|
||
if (
|
||
operationError instanceof ApiError
|
||
&& operationError.transportUnavailable
|
||
) {
|
||
setBackendStatus("offline");
|
||
}
|
||
}
|
||
return false;
|
||
} finally {
|
||
if (runtimeActionArbiter.current.settle(actionToken)) {
|
||
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
|
||
setPendingAction(null);
|
||
}
|
||
}
|
||
}
|
||
},
|
||
[acceptState, enabled],
|
||
);
|
||
|
||
const scanWithResult = useCallback(
|
||
async (
|
||
options: RuntimeActionOptions = {},
|
||
): Promise<BleDiscoverySubmitResult> => {
|
||
let scannedState: XgridsK1State | null = null;
|
||
const succeeded = await run("scan", async () => {
|
||
const operationId = newOperationId();
|
||
try {
|
||
const nextState = await xgridsK1Api.scanBle({
|
||
duration_seconds: options.durationSeconds ?? 6,
|
||
operation_id: operationId,
|
||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||
});
|
||
scannedState = nextState;
|
||
return nextState;
|
||
} catch (scanError) {
|
||
let failedState: XgridsK1State;
|
||
try {
|
||
failedState = await xgridsK1Api.getState();
|
||
} catch {
|
||
throw scanError;
|
||
}
|
||
acceptState(failedState);
|
||
const failedOperation = operationById(
|
||
failedState,
|
||
"discovery.scan",
|
||
operationId,
|
||
);
|
||
const failureMessage = discoveryScanFailureMessage(failedOperation);
|
||
if (failureMessage) {
|
||
throw apiErrorForOperation(failureMessage, failedOperation);
|
||
}
|
||
throw scanError;
|
||
}
|
||
}, options);
|
||
if (!succeeded || !scannedState) {
|
||
return {
|
||
succeeded: false,
|
||
snapshotRuntimeId: null,
|
||
discoveryGeneration: null,
|
||
transportRefs: [],
|
||
};
|
||
}
|
||
const acceptedScanState = scannedState as XgridsK1State;
|
||
const snapshotRuntimeId =
|
||
acceptedScanState.snapshot_runtime_id?.trim() || null;
|
||
const discoveryGeneration = Number.isInteger(
|
||
acceptedScanState.ble_discovery_generation,
|
||
) ? acceptedScanState.ble_discovery_generation as number : null;
|
||
return {
|
||
succeeded: Boolean(snapshotRuntimeId && discoveryGeneration !== null),
|
||
snapshotRuntimeId,
|
||
discoveryGeneration,
|
||
transportRefs: (acceptedScanState.devices ?? [])
|
||
.map((device) => device.device_id?.trim())
|
||
.filter((deviceId): deviceId is string => Boolean(deviceId)),
|
||
};
|
||
},
|
||
[acceptState, expectedSnapshotRuntimeId, run],
|
||
);
|
||
|
||
const scan = useCallback(
|
||
async (options: RuntimeActionOptions = {}) =>
|
||
(await scanWithResult(options)).succeeded,
|
||
[scanWithResult],
|
||
);
|
||
|
||
const selectConnectionMode = useCallback(
|
||
(request: SelectConnectionModeRequest) =>
|
||
run("mode", async () => {
|
||
try {
|
||
return await xgridsK1Api.selectConnectionMode({
|
||
...request,
|
||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||
});
|
||
} catch (selectionError) {
|
||
// A second tab may have advanced the process-local CAS. Adopt the
|
||
// current backend draft before surfacing the conflict; this read is
|
||
// non-mutating and avoids a manual Refresh ceremony.
|
||
try {
|
||
acceptState(await xgridsK1Api.getState());
|
||
} catch {
|
||
// Preserve the original selection error when state is unavailable.
|
||
}
|
||
throw selectionError;
|
||
}
|
||
}, {
|
||
supersedePending: request.reset_scenario === true,
|
||
}),
|
||
[acceptState, expectedSnapshotRuntimeId, run],
|
||
);
|
||
|
||
const resetConnectionScenario = useCallback((): Promise<boolean> => {
|
||
const expectedRevision = latestState.current?.desired_connection_mode_revision;
|
||
if (
|
||
!Number.isInteger(expectedRevision)
|
||
|| (expectedRevision ?? -1) < 0
|
||
|| !latestState.current?.snapshot_runtime_id?.trim()
|
||
) {
|
||
return Promise.resolve(false);
|
||
}
|
||
return selectConnectionMode({
|
||
connection_mode: DEFAULT_CONNECTION_MODE,
|
||
expected_revision: expectedRevision as number,
|
||
reset_scenario: true,
|
||
reset_id: newOperationId(),
|
||
});
|
||
}, [selectConnectionMode]);
|
||
|
||
const prepareConnectionReconfigurationWithResult = useCallback(
|
||
async (
|
||
request: PrepareConnectionReconfigurationRequest,
|
||
): Promise<ConnectionReconfigurationSubmitResult> => {
|
||
let observedState: XgridsK1State | null = null;
|
||
const succeeded = await run(
|
||
"reconfigure",
|
||
async () => {
|
||
const nextState = await xgridsK1Api.prepareConnectionReconfiguration({
|
||
...request,
|
||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||
});
|
||
observedState = nextState;
|
||
return nextState;
|
||
},
|
||
);
|
||
return { succeeded, observedState };
|
||
},
|
||
[expectedSnapshotRuntimeId, run],
|
||
);
|
||
|
||
const prepareConnectionReconfiguration = useCallback(
|
||
async (request: PrepareConnectionReconfigurationRequest) =>
|
||
(await prepareConnectionReconfigurationWithResult(request)).succeeded,
|
||
[prepareConnectionReconfigurationWithResult],
|
||
);
|
||
|
||
const retireUnavailablePhysicalCommandWithResult = useCallback(
|
||
async (
|
||
request: RetireUnavailablePhysicalCommandRequest,
|
||
actionSnapshotRuntimeId: string,
|
||
): Promise<ConnectionReconfigurationSubmitResult> => {
|
||
let observedState: XgridsK1State | null = null;
|
||
const exactSnapshotRuntimeId = actionSnapshotRuntimeId.trim();
|
||
if (
|
||
!exactSnapshotRuntimeId
|
||
|| !isSnapshotRuntimeCurrent(exactSnapshotRuntimeId)
|
||
) {
|
||
return { succeeded: false, observedState: latestState.current };
|
||
}
|
||
const succeeded = await run("retire", async () => {
|
||
if (!isSnapshotRuntimeCurrent(exactSnapshotRuntimeId)) {
|
||
throw new ApiError(
|
||
"Состояние локального сервиса обновилось. Прежний K1 не исключён.",
|
||
);
|
||
}
|
||
const nextState = await xgridsK1Api.retireUnavailablePhysicalCommand({
|
||
...request,
|
||
expected_snapshot_runtime_id: exactSnapshotRuntimeId,
|
||
});
|
||
observedState = nextState;
|
||
return nextState;
|
||
}, { surfaceErrors: false });
|
||
if (!succeeded) {
|
||
// A 409 means the local safety facts changed between projection and
|
||
// click. Re-read them so the UI can explain the exact safe next step.
|
||
// A lost response may also have committed the same retirement; this
|
||
// read discovers that result without issuing a second mutation.
|
||
observedState = (await refresh(false)) ?? latestState.current;
|
||
} else {
|
||
observedState = latestState.current;
|
||
}
|
||
return { succeeded, observedState };
|
||
},
|
||
[isSnapshotRuntimeCurrent, latestState, refresh, run],
|
||
);
|
||
|
||
const retireUnavailablePhysicalCommand = useCallback(
|
||
async (
|
||
request: RetireUnavailablePhysicalCommandRequest,
|
||
actionSnapshotRuntimeId: string,
|
||
) => (
|
||
await retireUnavailablePhysicalCommandWithResult(
|
||
request,
|
||
actionSnapshotRuntimeId,
|
||
)
|
||
).succeeded,
|
||
[retireUnavailablePhysicalCommandWithResult],
|
||
);
|
||
|
||
const reopenRetiredPhysicalReconciliation = useCallback(
|
||
async (
|
||
request: ReopenRetiredPhysicalReconciliationRequest,
|
||
actionSnapshotRuntimeId: string,
|
||
): Promise<ConnectionReconfigurationSubmitResult> => {
|
||
let observedState: XgridsK1State | null = null;
|
||
const exactSnapshotRuntimeId = actionSnapshotRuntimeId.trim();
|
||
if (
|
||
!exactSnapshotRuntimeId
|
||
|| !isSnapshotRuntimeCurrent(exactSnapshotRuntimeId)
|
||
) {
|
||
return { succeeded: false, observedState: latestState.current };
|
||
}
|
||
const succeeded = await run(
|
||
"reopen",
|
||
async () => {
|
||
if (!isSnapshotRuntimeCurrent(exactSnapshotRuntimeId)) {
|
||
throw new ApiError(
|
||
"Состояние локального сервиса обновилось. Прежняя сверка не открыта.",
|
||
);
|
||
}
|
||
const nextState = await xgridsK1Api.reopenRetiredPhysicalReconciliation({
|
||
...request,
|
||
expected_snapshot_runtime_id: exactSnapshotRuntimeId,
|
||
});
|
||
observedState = nextState;
|
||
return nextState;
|
||
},
|
||
{ surfaceErrors: false, supersedePending: true },
|
||
);
|
||
if (!succeeded) {
|
||
observedState = (await refresh(false)) ?? latestState.current;
|
||
} else {
|
||
// `run` may have accepted the reopen REST snapshot and then yielded
|
||
// after a newer same-runtime WebSocket state was already accepted.
|
||
// Continue only from the current monotonic truth; returning the stale
|
||
// REST proof here could issue one unnecessary Verify after another
|
||
// tab explicitly re-retired the device.
|
||
observedState = latestState.current;
|
||
}
|
||
return { succeeded, observedState };
|
||
},
|
||
[isSnapshotRuntimeCurrent, latestState, refresh, run],
|
||
);
|
||
|
||
const connect = useCallback(
|
||
async (request: ConnectRequest): Promise<ProvisioningSubmitResult> => {
|
||
let intentDisposition: ProvisioningSubmitResult["intentDisposition"] = "retain";
|
||
let failureReasonCode: string | null = null;
|
||
let acceptedSessionKey: string | null = null;
|
||
let networkIntentCompleted = false;
|
||
let observedState: XgridsK1State | null = null;
|
||
let failedConnectionAttemptId: string | null = null;
|
||
const sessionKeyBeforeConnect = bleSessionTargetForTransport(
|
||
state,
|
||
request.device_id,
|
||
request.connection_mode,
|
||
)?.key ?? null;
|
||
const acceptSuccessfulConnectState = (nextState: XgridsK1State) => {
|
||
networkIntentCompleted = true;
|
||
observedState = nextState;
|
||
acceptedSessionKey = acceptedBleSessionKeyAfterConnect(
|
||
nextState,
|
||
request.device_id,
|
||
request.connection_mode,
|
||
sessionKeyBeforeConnect,
|
||
);
|
||
return nextState;
|
||
};
|
||
const captureFailureReason = (
|
||
operation: XgridsOperation | null | undefined,
|
||
) => {
|
||
const code = operation?.error?.code;
|
||
failureReasonCode = typeof code === "string" ? code : null;
|
||
failedConnectionAttemptId = operation?.action === "network.provision"
|
||
&& operation.idempotency_key === request.idempotency_key
|
||
&& typeof operation.operation_id === "string"
|
||
? operation.operation_id
|
||
: null;
|
||
};
|
||
const succeeded = await run("connect", async () => {
|
||
const intentToken = operatorIntents.current.beginOperatorIntent();
|
||
if (!intentToken) {
|
||
throw new ApiError(
|
||
"Экран подключения закрыт; дальнейшая проверка K1 не запускалась.",
|
||
);
|
||
}
|
||
const assertOperatorIntentCurrent = () => {
|
||
if (!operatorIntents.current.isOperatorIntentCurrent(intentToken)) {
|
||
throw new ApiError(
|
||
"Подключение заменено новым действием оператора; START не отправлялся.",
|
||
);
|
||
}
|
||
};
|
||
const previous = operationByIdempotencyKey(
|
||
state,
|
||
"network.provision",
|
||
request.idempotency_key,
|
||
);
|
||
if (operationNeedsReconciliation(previous)) {
|
||
captureFailureReason(previous);
|
||
throw apiErrorForOperation(
|
||
resetConnectSessionMessage(
|
||
"Текущая попытка подключения не получила подтверждённого результата.",
|
||
),
|
||
previous,
|
||
);
|
||
}
|
||
if (operationAllowsFreshProvisioningIntent(previous)) {
|
||
intentDisposition = "release";
|
||
captureFailureReason(previous);
|
||
const failureMessage = networkProvisionFailureMessage(previous);
|
||
throw apiErrorForOperation(
|
||
failureMessage
|
||
?? "Подготовительный этап завершился до команды K1. После устранения причины разрешена новая явная попытка.",
|
||
previous,
|
||
);
|
||
}
|
||
|
||
let nextState: XgridsK1State;
|
||
if (previous?.status === "succeeded" && state) {
|
||
nextState = state;
|
||
} else try {
|
||
nextState = await xgridsK1Api.connect({
|
||
...request,
|
||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||
});
|
||
} 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);
|
||
failedState = await awaitNetworkProvisionSettlementAfterLostResponse(
|
||
failedState,
|
||
request.idempotency_key,
|
||
() => xgridsK1Api.getState(),
|
||
acceptState,
|
||
assertOperatorIntentCurrent,
|
||
);
|
||
const failedOperation = operationByIdempotencyKey(
|
||
failedState,
|
||
"network.provision",
|
||
request.idempotency_key,
|
||
);
|
||
// The response body can be lost after the backend has already
|
||
// committed the exact idempotent operation. The read-only journal is
|
||
// authoritative: acknowledge that success instead of presenting a
|
||
// false failure which could invite another operator click.
|
||
if (failedOperation?.status === "succeeded") {
|
||
nextState = failedState;
|
||
if (
|
||
!exactAppliedNetworkIntentCompleted(
|
||
nextState,
|
||
request,
|
||
failedOperation,
|
||
)
|
||
&& !hasExactConnectionReady(nextState, request.connection_mode)
|
||
) {
|
||
throw connectError;
|
||
}
|
||
} else {
|
||
captureFailureReason(failedOperation);
|
||
if (operationNeedsReconciliation(failedOperation)) {
|
||
throw apiErrorForOperation(
|
||
resetConnectSessionMessage(
|
||
"Текущая попытка подключения завершилась с неизвестным результатом.",
|
||
),
|
||
failedOperation,
|
||
);
|
||
}
|
||
if (operationAllowsFreshProvisioningIntent(failedOperation)) {
|
||
intentDisposition = "release";
|
||
const failureMessage = networkProvisionFailureMessage(failedOperation);
|
||
throw apiErrorForOperation(
|
||
failureMessage
|
||
?? "Подготовительный этап завершился до команды K1. После устранения причины разрешена новая явная попытка.",
|
||
failedOperation,
|
||
);
|
||
}
|
||
const failureMessage = networkProvisionFailureMessage(failedOperation);
|
||
if (failureMessage) {
|
||
throw apiErrorForOperation(failureMessage, failedOperation);
|
||
}
|
||
if (failedOperation) {
|
||
throw apiErrorForOperation(
|
||
resetConnectSessionMessage(
|
||
"Текущая попытка подключения не получила подтверждённого результата.",
|
||
),
|
||
failedOperation,
|
||
);
|
||
}
|
||
throw connectError;
|
||
}
|
||
}
|
||
const operation = operationByIdempotencyKey(
|
||
nextState,
|
||
"network.provision",
|
||
request.idempotency_key,
|
||
);
|
||
if (operationNeedsReconciliation(operation)) {
|
||
captureFailureReason(operation);
|
||
throw apiErrorForOperation(
|
||
resetConnectSessionMessage(
|
||
"Текущая попытка подключения завершилась с неизвестным результатом.",
|
||
),
|
||
operation,
|
||
);
|
||
}
|
||
if (operationAllowsFreshProvisioningIntent(operation)) {
|
||
intentDisposition = "release";
|
||
captureFailureReason(operation);
|
||
const failureMessage = networkProvisionFailureMessage(operation);
|
||
throw apiErrorForOperation(
|
||
failureMessage
|
||
?? "Подготовительный этап завершился до команды K1. После устранения причины разрешена новая явная попытка.",
|
||
operation,
|
||
);
|
||
}
|
||
if (operation && operation.status !== "succeeded") {
|
||
captureFailureReason(operation);
|
||
throw apiErrorForOperation(
|
||
resetConnectSessionMessage(
|
||
"Текущая попытка подключения завершилась без подтверждения успеха.",
|
||
),
|
||
operation,
|
||
);
|
||
}
|
||
const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted(
|
||
nextState,
|
||
request,
|
||
operation,
|
||
);
|
||
if (
|
||
!exactNetworkIntentCompleted
|
||
&& !hasExactConnectionReady(nextState, request.connection_mode)
|
||
) {
|
||
return requireExactConnectionReady(nextState, request.connection_mode);
|
||
}
|
||
nextState = acceptSuccessfulConnectState(nextState);
|
||
assertOperatorIntentCurrent();
|
||
return nextState;
|
||
}, {
|
||
connectionAttemptId: () => failedConnectionAttemptId,
|
||
});
|
||
return {
|
||
succeeded,
|
||
networkIntentCompleted,
|
||
intentDisposition,
|
||
failureReasonCode,
|
||
acceptedSessionKey: networkIntentCompleted ? acceptedSessionKey : null,
|
||
observedState,
|
||
};
|
||
},
|
||
[acceptState, expectedSnapshotRuntimeId, run, state],
|
||
);
|
||
|
||
const verifyConnection = useCallback(
|
||
async (
|
||
request: ConnectionVerifyRequest,
|
||
options: RuntimeActionOptions = {},
|
||
): Promise<ConnectionVerificationSubmitResult> => {
|
||
let reconciliationCompleted = false;
|
||
let observedState: XgridsK1State | null = null;
|
||
let reasonCode: string | null = null;
|
||
const requestedConnectionMode = connectionModeForVerification(request);
|
||
const actionSnapshotRuntimeId =
|
||
options.expectedSnapshotRuntimeId?.trim() || null;
|
||
if (
|
||
actionSnapshotRuntimeId
|
||
&& !isSnapshotRuntimeCurrent(actionSnapshotRuntimeId)
|
||
) {
|
||
return {
|
||
succeeded: false,
|
||
reconciliationCompleted: false,
|
||
observedState: latestState.current,
|
||
reasonCode: null,
|
||
};
|
||
}
|
||
const succeeded = await run("verify", async () => {
|
||
if (
|
||
actionSnapshotRuntimeId
|
||
&& !isSnapshotRuntimeCurrent(actionSnapshotRuntimeId)
|
||
) {
|
||
throw new ApiError(
|
||
"Состояние локального сервиса обновилось. Прежняя сверка не запущена.",
|
||
);
|
||
}
|
||
const operationId = newOperationId();
|
||
try {
|
||
const verifiedState = await xgridsK1Api.verifyConnection({
|
||
...request,
|
||
operation_id: operationId,
|
||
expected_snapshot_runtime_id:
|
||
actionSnapshotRuntimeId ?? expectedSnapshotRuntimeId(),
|
||
});
|
||
observedState = verifiedState;
|
||
reconciliationCompleted = readOnlyVerificationClearedReconciliation(
|
||
state,
|
||
verifiedState,
|
||
request?.device_id,
|
||
);
|
||
return requireExactReadOnlyVerificationOutcome(
|
||
verifiedState,
|
||
requestedConnectionMode,
|
||
);
|
||
} catch (verificationError) {
|
||
let failedState: XgridsK1State;
|
||
try {
|
||
failedState = await xgridsK1Api.getState();
|
||
} catch {
|
||
throw verificationError;
|
||
}
|
||
reconciliationCompleted = readOnlyVerificationClearedReconciliation(
|
||
state,
|
||
failedState,
|
||
request?.device_id,
|
||
);
|
||
observedState = failedState;
|
||
acceptState(failedState);
|
||
const failedOperation = operationById(
|
||
failedState,
|
||
"connection.verify",
|
||
operationId,
|
||
);
|
||
reasonCode = typeof failedOperation?.error?.code === "string"
|
||
? failedOperation.error.code
|
||
: typeof failedState.connection_verification?.reason_code === "string"
|
||
? failedState.connection_verification.reason_code
|
||
: null;
|
||
// The HTTP response can be lost after the backend completed the BLE
|
||
// observation. That journal proves only the read-only BLE stage; the
|
||
// composite action is successful only with exact DeviceInfo/control
|
||
// Ready, or a verified SCANNING adoption that grants STOP only.
|
||
if (failedOperation?.status === "succeeded") {
|
||
if (
|
||
hasExactConnectionReady(failedState, requestedConnectionMode)
|
||
|| isRecoveredPhysicalScanning(failedState, requestedConnectionMode)
|
||
) {
|
||
return failedState;
|
||
}
|
||
throw verificationError;
|
||
}
|
||
const failureMessage = connectionVerificationFailureMessage(failedOperation);
|
||
if (failureMessage) {
|
||
throw apiErrorForOperation(failureMessage, failedOperation);
|
||
}
|
||
throw verificationError;
|
||
}
|
||
}, options);
|
||
return { succeeded, reconciliationCompleted, observedState, reasonCode };
|
||
},
|
||
[
|
||
acceptState,
|
||
expectedSnapshotRuntimeId,
|
||
isSnapshotRuntimeCurrent,
|
||
latestState,
|
||
run,
|
||
state,
|
||
],
|
||
);
|
||
|
||
const probeConfiguredEndpoint = useCallback(
|
||
() => run("probe", () => xgridsK1Api.probeConfiguredEndpoint({
|
||
operation_id: newOperationId(),
|
||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||
})),
|
||
[expectedSnapshotRuntimeId, run],
|
||
);
|
||
|
||
const openApplicationControlSession = useCallback(
|
||
(request: OpenApplicationControlSessionRequest) =>
|
||
run("control", () => xgridsK1Api.openApplicationControlSession({
|
||
...request,
|
||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||
})),
|
||
[expectedSnapshotRuntimeId, run],
|
||
);
|
||
|
||
const enterApplicationWorkspace = useCallback(
|
||
() =>
|
||
run("control", () => {
|
||
const controlCas = exactApplicationControlCas(
|
||
latestState.current,
|
||
"ENTER рабочего пространства",
|
||
);
|
||
return xgridsK1Api.enterApplicationWorkspace({
|
||
operator_confirmed: true,
|
||
...controlCas,
|
||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||
});
|
||
}),
|
||
[expectedSnapshotRuntimeId, run],
|
||
);
|
||
|
||
const closeApplicationControlSession = useCallback(
|
||
() => run("control", () => {
|
||
const controlCas = exactApplicationControlCas(
|
||
latestState.current,
|
||
"CLOSE управляющей сессии",
|
||
);
|
||
return xgridsK1Api.closeApplicationControlSession({
|
||
...controlCas,
|
||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||
});
|
||
}),
|
||
[expectedSnapshotRuntimeId, run],
|
||
);
|
||
|
||
const prepareCanonicalAcquisition = useCallback(
|
||
(request: CanonicalLivePreparationRequest) =>
|
||
run("live", async () => {
|
||
const actionSnapshotRuntimeId = expectedSnapshotRuntimeId();
|
||
const intentToken = operatorIntents.current.beginOperatorIntent();
|
||
if (!intentToken) {
|
||
throw new ApiError(
|
||
"Экран управления закрыт; дальнейшие команды канонического диалога не отправлялись.",
|
||
);
|
||
}
|
||
const assertOperatorIntentCurrent = () => {
|
||
if (
|
||
!operatorIntents.current.isOperatorIntentCurrent(intentToken)
|
||
|| !isSnapshotRuntimeCurrent(actionSnapshotRuntimeId)
|
||
) {
|
||
throw new ApiError(
|
||
"Операторское действие или локальный сервис изменились; дальнейшие команды канонического диалога не отправлялись.",
|
||
);
|
||
}
|
||
};
|
||
let nextState = await awaitWhileIntentCurrent(
|
||
assertOperatorIntentCurrent,
|
||
() => xgridsK1Api.getState(),
|
||
);
|
||
acceptState(nextState);
|
||
assertOperatorIntentCurrent();
|
||
if (!currentAppliedConnectionTopology(nextState)) {
|
||
throw new ApiError(
|
||
"K1 ещё не подключён. Вернитесь в «Парк» и завершите подключение; START не используется для подключения.",
|
||
);
|
||
}
|
||
const plan = liveStartPlan(nextState);
|
||
if (plan === "blocked") {
|
||
throw new ApiError("Сначала завершите текущий приём или повтор записи.");
|
||
}
|
||
if (plan === "already-running") return nextState;
|
||
|
||
for (;;) {
|
||
assertOperatorIntentCurrent();
|
||
// WebSocket/polling may have accepted a newer monotonic snapshot
|
||
// while the previous stage was awaiting its response. Every next
|
||
// decision and CAS therefore starts from the latest accepted state,
|
||
// never from the closure or an earlier stage response.
|
||
nextState = latestState.current ?? nextState;
|
||
const phase = controlPhase(nextState);
|
||
const acquisition = nextState.acquisition;
|
||
if (phase === "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 (phase === "connecting") {
|
||
throw new ApiError(
|
||
"Подключение K1 ещё выполняется. Дождитесь результата; START не продолжает незавершённое подключение.",
|
||
);
|
||
}
|
||
|
||
if (["idle", "closed", "completed"].includes(phase)) {
|
||
throw new ApiError(
|
||
"K1 ещё не подключён. Сначала завершите подключение устройства в «Парке».",
|
||
);
|
||
}
|
||
|
||
if (phase === "connection-ready") {
|
||
const physical = nextState.application_control_session?.physical_command
|
||
?? nextState.physical_command;
|
||
if (physical?.requires_reconciliation === true) {
|
||
if (!physical.reconciliation_ready) {
|
||
nextState = await awaitWhileIntentCurrent(
|
||
assertOperatorIntentCurrent,
|
||
() => waitForPhysicalReconciliationProof(
|
||
acceptState,
|
||
assertOperatorIntentCurrent,
|
||
),
|
||
);
|
||
}
|
||
const reconciliationPhysical =
|
||
nextState.application_control_session?.physical_command
|
||
?? nextState.physical_command;
|
||
if (reconciliationPhysical?.requires_reconciliation === true) {
|
||
if (!reconciliationPhysical.reconciliation_ready) {
|
||
throw new ApiError(
|
||
"K1 не сообщил текущее состояние вовремя. Новая команда устройству не отправлялась.",
|
||
);
|
||
}
|
||
nextState = await awaitWhileIntentCurrent(
|
||
assertOperatorIntentCurrent,
|
||
() => {
|
||
const controlCas = exactApplicationControlCas(
|
||
latestState.current,
|
||
"восстановления физического состояния",
|
||
);
|
||
return xgridsK1Api.reconcilePhysicalCommand({
|
||
reconciliation_id: newOperationId(),
|
||
...controlCas,
|
||
expected_snapshot_runtime_id: actionSnapshotRuntimeId,
|
||
});
|
||
},
|
||
);
|
||
acceptState(nextState);
|
||
assertOperatorIntentCurrent();
|
||
}
|
||
const observedPhysical =
|
||
nextState.application_control_session?.physical_command
|
||
?? nextState.physical_command;
|
||
if (observedPhysical?.observed_session_state !== "ready") {
|
||
throw new ApiError(
|
||
"K1 всё ещё сканирует. Новый START заблокирован; сначала остановите текущую запись.",
|
||
);
|
||
}
|
||
continue;
|
||
}
|
||
if (nextState.application_control_session?.inspection_only === true) {
|
||
nextState = await awaitWhileIntentCurrent(
|
||
assertOperatorIntentCurrent,
|
||
() => xgridsK1Api.openApplicationControlSession({
|
||
...request.physicalAcceptance,
|
||
timezone_name: runtimeTimezoneName(),
|
||
expected_snapshot_runtime_id: actionSnapshotRuntimeId,
|
||
}),
|
||
);
|
||
acceptState(nextState);
|
||
assertOperatorIntentCurrent();
|
||
nextState = await awaitWhileIntentCurrent(
|
||
assertOperatorIntentCurrent,
|
||
() => waitForControlPhase(
|
||
"connection-ready",
|
||
acceptState,
|
||
assertOperatorIntentCurrent,
|
||
),
|
||
);
|
||
continue;
|
||
}
|
||
nextState = await awaitWhileIntentCurrent(
|
||
assertOperatorIntentCurrent,
|
||
() => {
|
||
const controlCas = exactApplicationControlCas(
|
||
latestState.current,
|
||
"ENTER рабочего пространства",
|
||
);
|
||
return xgridsK1Api.enterApplicationWorkspace({
|
||
operator_confirmed: true,
|
||
...controlCas,
|
||
expected_snapshot_runtime_id: actionSnapshotRuntimeId,
|
||
});
|
||
},
|
||
);
|
||
acceptState(nextState);
|
||
assertOperatorIntentCurrent();
|
||
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,
|
||
() => {
|
||
const controlCas = exactAcquisitionControlCas(
|
||
latestState.current,
|
||
"PREPARE acquisition",
|
||
);
|
||
return xgridsK1Api.prepareAcquisition({
|
||
...request.acquisition,
|
||
...newMutationContext("acquisition.prepare"),
|
||
...controlCas,
|
||
expected_snapshot_runtime_id: actionSnapshotRuntimeId,
|
||
});
|
||
},
|
||
);
|
||
acceptState(nextState);
|
||
assertOperatorIntentCurrent();
|
||
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.");
|
||
}
|
||
// Preparation deliberately stops on an exact
|
||
// prepared/project-ready snapshot. The same explicit operator
|
||
// action may then call startPreparedAcquisition, whose fresh CAS
|
||
// and supervisor authority checks remain the physical write gate.
|
||
return nextState;
|
||
}
|
||
|
||
if (["start-requested", "initializing", "scanning"].includes(phase)) {
|
||
return nextState;
|
||
}
|
||
|
||
throw new ApiError(`Запуск K1 недоступен из состояния «${phase}».`);
|
||
}
|
||
}),
|
||
[acceptState, expectedSnapshotRuntimeId, run],
|
||
);
|
||
|
||
const prepareAcquisition = useCallback(
|
||
(request: AcquisitionPreparationDraft) =>
|
||
run("live", async () => {
|
||
const currentState = latestState.current;
|
||
const plan = liveStartPlan(currentState);
|
||
if (plan === "blocked") {
|
||
throw new ApiError(
|
||
"Сначала завершите текущий приём или повтор записи.",
|
||
);
|
||
}
|
||
if (plan === "already-running" && currentState) return currentState;
|
||
|
||
return (
|
||
plan === "resume-prepared" && currentState
|
||
? currentState
|
||
: await xgridsK1Api.prepareAcquisition(
|
||
acquisitionMutationUsesControlSession(currentState)
|
||
? {
|
||
...request,
|
||
...newMutationContext("acquisition.prepare"),
|
||
...exactAcquisitionControlCas(
|
||
latestState.current,
|
||
"PREPARE acquisition",
|
||
),
|
||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||
}
|
||
: {
|
||
...request,
|
||
...newMutationContext("acquisition.prepare"),
|
||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||
},
|
||
)
|
||
);
|
||
}),
|
||
[expectedSnapshotRuntimeId, run],
|
||
);
|
||
|
||
const startPreparedAcquisition = useCallback(
|
||
(physicalAcceptance: OperatorPresenceConfirmation) =>
|
||
run("live", async () => {
|
||
const currentState = latestState.current;
|
||
const acquisition = currentState?.acquisition;
|
||
if (!acquisition?.acquisition_id || acquisition.state !== "prepared") {
|
||
throw new ApiError("Сначала сохраните проект и подготовьте локальный приём.");
|
||
}
|
||
if (acquisition.control_mode !== "plugin-commanded") {
|
||
throw new ApiError(
|
||
"Физический START недоступен: подготовленная acquisition не принадлежит управляющей сессии плагина.",
|
||
);
|
||
}
|
||
const controlCas = exactAcquisitionControlCas(
|
||
latestState.current,
|
||
"START acquisition",
|
||
);
|
||
return xgridsK1Api.startAcquisition({
|
||
acquisition_id: acquisition.acquisition_id,
|
||
expected_state_revision: acquisition.state_revision,
|
||
...newMutationContext("acquisition.start"),
|
||
...controlCas,
|
||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||
physical_acceptance: physicalAcceptance,
|
||
});
|
||
}),
|
||
[expectedSnapshotRuntimeId, run],
|
||
);
|
||
|
||
const startReplay = useCallback(
|
||
(request: ReplayRequest) => run("replay", () => xgridsK1Api.startReplay(request)),
|
||
[run],
|
||
);
|
||
|
||
const stop = useCallback(
|
||
async (physicalAcceptance?: OperatorPresenceConfirmation) => {
|
||
let stopPresentationOwner: object | null = null;
|
||
try {
|
||
return await run("stop", async () => {
|
||
const currentState = latestState.current;
|
||
const acquisition = currentState?.acquisition;
|
||
const acquisitionTerminal = isTerminalAcquisitionState(acquisition?.state);
|
||
if (
|
||
acquisition
|
||
&& (
|
||
!acquisitionTerminal
|
||
|| requiresCanonicalStopAfterTerminalLocalFailure(currentState)
|
||
)
|
||
) {
|
||
const softwareCommanded = isSoftwareCommandedAcquisition(currentState);
|
||
if (softwareCommanded && !physicalAcceptance) {
|
||
throw new ApiError(
|
||
"Подтвердите присутствие рядом с K1 перед каноническим STOP.",
|
||
);
|
||
}
|
||
if (softwareCommanded) {
|
||
if (spentPhysicalStopIntent.current) {
|
||
throw new ApiError(
|
||
"Предыдущий физический STOP уже израсходовал текущий control checkpoint. Дождитесь нового подтверждённого состояния K1; повторная команда не отправлялась.",
|
||
);
|
||
}
|
||
// Re-read the atomic authority immediately at the mutation
|
||
// boundary. UI admission and an earlier closure snapshot are
|
||
// never sufficient authority for a physical device command.
|
||
const dispatchState = latestState.current;
|
||
const dispatchAcquisition = dispatchState?.acquisition;
|
||
if (
|
||
!dispatchAcquisition
|
||
|| dispatchAcquisition.acquisition_id !== acquisition.acquisition_id
|
||
|| !isSoftwareCommandedAcquisition(dispatchState)
|
||
) {
|
||
throw new ApiError(
|
||
"Цель STOP изменилась до отправки. Команда устройству не отправлялась.",
|
||
);
|
||
}
|
||
const checkpoint = physicalStopIntentCheckpoint(dispatchState);
|
||
if (!checkpoint) {
|
||
throw new ApiError(
|
||
"Точный SCANNING/can_stop/policy snapshot для STOP отсутствует. Команда устройству не отправлялась.",
|
||
);
|
||
}
|
||
const controlCas = exactAcquisitionControlCas(
|
||
dispatchState,
|
||
"STOP acquisition",
|
||
);
|
||
if (
|
||
controlCas.expected_control_session_generation
|
||
!== checkpoint.controlSessionGeneration
|
||
|| controlCas.expected_control_state_revision
|
||
!== checkpoint.controlStateRevision
|
||
) {
|
||
throw new ApiError(
|
||
"Control checkpoint изменился до отправки STOP. Команда устройству не отправлялась.",
|
||
);
|
||
}
|
||
if (!spendPhysicalStopIntent(checkpoint)) {
|
||
throw new ApiError(
|
||
"Состояние K1 изменилось до отправки STOP. Команда устройству не отправлялась.",
|
||
);
|
||
}
|
||
stopPresentationOwner = { checkpoint };
|
||
physicalStopPresentationOwner.current = stopPresentationOwner;
|
||
setPhysicalStopInFlight(true);
|
||
return await xgridsK1Api.stopAcquisition({
|
||
acquisition_id: dispatchAcquisition.acquisition_id,
|
||
mode: "graceful",
|
||
...newMutationContext("acquisition.stop"),
|
||
...controlCas,
|
||
expected_snapshot_runtime_id: checkpoint.snapshotRuntimeId,
|
||
physical_acceptance: physicalAcceptance,
|
||
});
|
||
}
|
||
if (!connectionPolicyAllows(currentState, "stop-local-receiver")) {
|
||
if (isProvenLocalReceiverInactive(currentState)) return currentState;
|
||
throw new ApiError(
|
||
"Текущее состояние K1 больше не разрешает остановку локального приёма. Команда не отправлялась.",
|
||
);
|
||
}
|
||
return await xgridsK1Api.stopAcquisition({
|
||
acquisition_id: acquisition.acquisition_id,
|
||
mode: "capture-only",
|
||
...newMutationContext("acquisition.stop"),
|
||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||
...(physicalAcceptance
|
||
? { physical_acceptance: physicalAcceptance }
|
||
: {}),
|
||
});
|
||
}
|
||
if (!connectionPolicyAllows(currentState, "stop-local-receiver")) {
|
||
if (isProvenLocalReceiverInactive(currentState)) return currentState;
|
||
throw new ApiError(
|
||
"Текущее состояние K1 больше не разрешает остановку локального приёма. Команда не отправлялась.",
|
||
);
|
||
}
|
||
return await xgridsK1Api.stopSessionCompatibility({
|
||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||
});
|
||
});
|
||
} finally {
|
||
if (
|
||
stopPresentationOwner
|
||
&& physicalStopPresentationOwner.current === stopPresentationOwner
|
||
) {
|
||
physicalStopPresentationOwner.current = null;
|
||
setPhysicalStopInFlight(false);
|
||
}
|
||
}
|
||
},
|
||
[expectedSnapshotRuntimeId, run, spendPhysicalStopIntent],
|
||
);
|
||
|
||
const stopLocalReceiver = useCallback(
|
||
() => run("stop", () => {
|
||
const currentState = latestState.current;
|
||
if (!connectionPolicyAllows(currentState, "stop-local-receiver")) {
|
||
if (isProvenLocalReceiverInactive(currentState)) {
|
||
return Promise.resolve(currentState);
|
||
}
|
||
throw new ApiError(
|
||
"Текущее состояние K1 больше не разрешает остановку локального приёма. Команда не отправлялась.",
|
||
);
|
||
}
|
||
const snapshotRuntimeId = expectedSnapshotRuntimeId();
|
||
const stopPlan = localReceiverStopPlan(currentState);
|
||
if (stopPlan.kind === "acquisition") {
|
||
return xgridsK1Api.stopAcquisition({
|
||
acquisition_id: stopPlan.acquisitionId,
|
||
mode: "capture-only",
|
||
...newMutationContext("acquisition.stop"),
|
||
expected_snapshot_runtime_id: snapshotRuntimeId,
|
||
});
|
||
}
|
||
return xgridsK1Api.stopSessionCompatibility({
|
||
expected_snapshot_runtime_id: snapshotRuntimeId,
|
||
});
|
||
}),
|
||
[expectedSnapshotRuntimeId, run],
|
||
);
|
||
|
||
const forceFinishActiveStreamLocally = useCallback(
|
||
() => run("force-finish", () => {
|
||
// Re-read the complete public authority at the mutation boundary. A
|
||
// rendered button or an earlier poll is never sufficient authority for
|
||
// local cleanup of an active acquisition.
|
||
const authority = activeStreamForceFinishAuthority(latestState.current);
|
||
if (!authority) {
|
||
throw new ApiError(
|
||
"Активное восстановление уже изменилось. Локальный приём не завершён; обновите состояние.",
|
||
);
|
||
}
|
||
return xgridsK1Api.forceFinishAcquisitionLocally({
|
||
acquisition_id: authority.acquisitionId,
|
||
expected_state_revision: authority.acquisitionStateRevision,
|
||
expected_recovery_generation: authority.recoveryGeneration,
|
||
operator_confirmed: true,
|
||
...newMutationContext("acquisition.force-finish-local"),
|
||
deadline_seconds: 30,
|
||
expected_snapshot_runtime_id: authority.snapshotRuntimeId,
|
||
});
|
||
}),
|
||
[run],
|
||
);
|
||
|
||
const abort = useCallback(() => {
|
||
const currentState = latestState.current;
|
||
const acquisition = currentState?.acquisition;
|
||
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
|
||
return Promise.resolve(false);
|
||
}
|
||
return run("abort", () => {
|
||
const controlCas = acquisitionMutationUsesControlSession(currentState)
|
||
? exactAcquisitionControlCas(latestState.current, "ABORT acquisition")
|
||
: {};
|
||
return xgridsK1Api.abortAcquisition({
|
||
acquisition_id: acquisition.acquisition_id,
|
||
...newMutationContext("acquisition.abort"),
|
||
...controlCas,
|
||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||
});
|
||
});
|
||
}, [expectedSnapshotRuntimeId, run]);
|
||
|
||
const setObservationSourceActive = useCallback(
|
||
(sourceId: string, active: boolean) =>
|
||
run("camera", async () => {
|
||
if (!state) {
|
||
throw new ApiError("Состояние устройства ещё не загружено.");
|
||
}
|
||
const supervisor = state.connection_supervisor;
|
||
const verifiedControl = state.application_control_session?.verified_control;
|
||
if (
|
||
supervisor?.authority.control_allowed !== true
|
||
|| !verifiedControl
|
||
|| supervisor.observed.control_plane.session_id
|
||
!== verifiedControl.control_session_id
|
||
|| supervisor.observed.device_identity.logical_device_id
|
||
!== verifiedControl.logical_device_id
|
||
) {
|
||
throw new ApiError(
|
||
"Управляющая сессия выбранного K1 не подтверждена; команда камеры не отправлялась.",
|
||
);
|
||
}
|
||
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();
|
||
latestState.current = null;
|
||
errorCorrelation.current = null;
|
||
setPresentedErrorCorrelation(null);
|
||
setState(null);
|
||
setBackendStatus("checking");
|
||
setEventStatus("closed");
|
||
setPendingAction(null);
|
||
physicalStopPresentationOwner.current = null;
|
||
setPhysicalStopInFlight(false);
|
||
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,
|
||
errorDiagnostic,
|
||
errorCorrelation: presentedErrorCorrelation,
|
||
physicalStopIntentSpent,
|
||
physicalStopInFlight,
|
||
latencyHistory,
|
||
isSnapshotRuntimeCurrent,
|
||
getCurrentState,
|
||
getConnectionActionAuthority,
|
||
getConnectionRecoveryObservationTarget,
|
||
isConnectionPolicyActionAllowedCurrent,
|
||
isConnectionActionAuthorityCurrent,
|
||
refresh: () => refresh(true),
|
||
clearError: () => {
|
||
errorCorrelation.current = null;
|
||
setPresentedErrorCorrelation(null);
|
||
setError(null);
|
||
setErrorDiagnostic(null);
|
||
},
|
||
scan,
|
||
scanWithResult,
|
||
selectConnectionMode,
|
||
resetConnectionScenario,
|
||
prepareConnectionReconfiguration,
|
||
prepareConnectionReconfigurationWithResult,
|
||
retireUnavailablePhysicalCommand,
|
||
retireUnavailablePhysicalCommandWithResult,
|
||
reopenRetiredPhysicalReconciliation,
|
||
connect,
|
||
verifyConnection,
|
||
probeConfiguredEndpoint,
|
||
openApplicationControlSession,
|
||
enterApplicationWorkspace,
|
||
closeApplicationControlSession,
|
||
prepareCanonicalAcquisition,
|
||
prepareAcquisition,
|
||
startPreparedAcquisition,
|
||
startReplay,
|
||
stop,
|
||
stopLocalReceiver,
|
||
forceFinishActiveStreamLocally,
|
||
abort,
|
||
setObservationSourceActive,
|
||
updateViewerSettings,
|
||
};
|
||
}
|