Files
NODEDC_MISSION_CORE/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts
T

2411 lines
102 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}