chore(node): preserve pre-canonicalization experiment snapshot
Historical working copy retained for audit before consolidation into main. The canonicalized plugin architecture and later fixes already live in main; this snapshot is not a release or a request to restore obsolete source layout.
This commit is contained in:
@@ -275,6 +275,11 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
: sourceRuntimeBusy || preparedAcquisition
|
||||
? "warning"
|
||||
: "neutral";
|
||||
const connectionAttemptFailed = !sourceRuntimeBusy && !preparedAcquisition
|
||||
&& connectionTopology?.status !== "active"
|
||||
&& state?.connection_attempt?.connection_mode === effectiveDesiredConnectionMode
|
||||
&& ["failed", "cancelled", "timed_out", "interrupted", "operator_action_required"]
|
||||
.includes(state.connection_attempt.status);
|
||||
const connectionControlBootstrapSettling =
|
||||
isConnectionControlBootstrapSettling(state);
|
||||
const connectionPhaseLabel = livePreparationPending
|
||||
@@ -293,6 +298,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
? savedBridgeNetworkSetupRequired
|
||||
? "Нужна настройка сети"
|
||||
: "Требуется действие"
|
||||
: connectionAttemptFailed
|
||||
? "Подключение не завершено"
|
||||
: projectedPhase === "error"
|
||||
? connectionPhaseFallbackLabel(projectedPhase)
|
||||
: connectionTopology?.status === "active"
|
||||
@@ -316,6 +323,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
? "accent"
|
||||
: physicalRecoveryRequired
|
||||
? "warning"
|
||||
: connectionAttemptFailed
|
||||
? "warning"
|
||||
: projectedPhase === "error"
|
||||
? phaseTone(projectedPhase)
|
||||
: connectionTopology?.status === "active"
|
||||
@@ -336,6 +345,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
: physicalRecoveryRequired
|
||||
? physicalRecoveryDetail
|
||||
?? "Безопасное восстановление прежнего K1 сейчас недоступно."
|
||||
: connectionAttemptFailed
|
||||
? "Проверьте состояние K1 или начните новое подключение."
|
||||
: !sourceRuntimeBusy && connectionTopology?.status === "configured-unverified"
|
||||
? "Начните новое подключение."
|
||||
: !sourceRuntimeBusy && connectionTopology?.status === "active"
|
||||
|
||||
@@ -3,13 +3,14 @@ import { Button } from "@nodedc/ui-react";
|
||||
|
||||
import type { XgridsConnectionAttempt } from "../api";
|
||||
import { hostFailureDiagnosticPresentation } from "../hostDiagnosticPresentation";
|
||||
import { STATION_WIFI_FAILURE_MESSAGES } from "../networkFailurePresentation";
|
||||
|
||||
const connectionAttemptStageLabels: Record<string, string> = {
|
||||
accepted: "Запрос принят",
|
||||
"scan-selection-admitted": "Результат выбран",
|
||||
"host-wifi-profile-preflight": "Подготовка профиля Wi‑Fi",
|
||||
"device-ap-activation": "Подготовка локальной сети",
|
||||
"ble-provisioning-write": "Передаются настройки сети",
|
||||
"ble-provisioning-write": "Подключение K1 к Wi‑Fi",
|
||||
"ble-write-dispatched": "Настройки переданы",
|
||||
"status-observing": "Ожидание ответа",
|
||||
"device-topology-applied": "Целевая сеть подтверждена",
|
||||
@@ -74,6 +75,9 @@ export function attemptNextActionLabel(
|
||||
}
|
||||
|
||||
const publicConnectionErrorLabels: Readonly<Record<string, string>> = {
|
||||
...STATION_WIFI_FAILURE_MESSAGES,
|
||||
BleakGATTProtocolError:
|
||||
"K1 ответил ошибкой Bluetooth. Подключение к Wi‑Fi не подтверждено; проверьте состояние устройства или начните новое подключение.",
|
||||
"network-provision-discovery-generation-conflict":
|
||||
"Результат Bluetooth-поиска устарел до отправки. Настройки устройства не изменялись; выполните новый поиск.",
|
||||
"connection-mode-draft-revision-conflict":
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
} from "../api";
|
||||
import { profileSelectionForConnectionMode } from "../compatibility";
|
||||
import {
|
||||
BLE_DISCOVERY_TIMEOUT_SECONDS,
|
||||
DEFAULT_CONNECTION_MODE,
|
||||
connectionModeOptions,
|
||||
type ConnectionMode,
|
||||
@@ -66,10 +67,12 @@ import {
|
||||
type ReadOnlyConnectionObservationTarget,
|
||||
} from "../lifecycle";
|
||||
import { finiteMetric } from "../presentation";
|
||||
import { STATION_WIFI_FAILURE_MESSAGES } from "../networkFailurePresentation";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
import { K1OperatorError } from "./K1OperatorError";
|
||||
import {
|
||||
connectionActionAuthoritySnapshot,
|
||||
networkProvisionFailureMessage,
|
||||
type BleDiscoverySubmitResult,
|
||||
type ConnectionActionAuthoritySnapshot,
|
||||
} from "../useXgridsK1Runtime";
|
||||
@@ -1866,7 +1869,7 @@ export function K1ProvisioningPipeline({
|
||||
const connectionRecoveryRequired = connectionRecoveryIsRequired(
|
||||
connectionRecoveryKey,
|
||||
escapedConnectionRecoveryKey,
|
||||
) && !(searchRequested && !searchDisplayActive);
|
||||
) && (connectionAttemptFailed || !(searchRequested && !searchDisplayActive));
|
||||
const presentedConnectionRecoveryRequired = !livePreparationPending && (
|
||||
connectionRecoveryRequired || connectionRecoveryVerificationPending
|
||||
);
|
||||
@@ -2089,7 +2092,7 @@ export function K1ProvisioningPipeline({
|
||||
const startedAt = Date.now();
|
||||
const updateCountdown = () => {
|
||||
const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1_000);
|
||||
setScanSecondsRemaining(Math.max(0, 6 - elapsedSeconds));
|
||||
setScanSecondsRemaining(Math.max(0, BLE_DISCOVERY_TIMEOUT_SECONDS - elapsedSeconds));
|
||||
};
|
||||
updateCountdown();
|
||||
const timer = window.setInterval(updateCountdown, 250);
|
||||
@@ -2350,7 +2353,7 @@ export function K1ProvisioningPipeline({
|
||||
let scanResult: BleDiscoverySubmitResult | null = null;
|
||||
try {
|
||||
if (!runtimeActionIsCurrent(actionFence)) return;
|
||||
scanResult = await scanWithResult({ durationSeconds: 6 });
|
||||
scanResult = await scanWithResult({ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS });
|
||||
if (
|
||||
!localScenarioActionEpochIsCurrent(
|
||||
actionEpoch,
|
||||
@@ -2573,12 +2576,12 @@ export function K1ProvisioningPipeline({
|
||||
}
|
||||
|
||||
const freshStartAllowed = result.intentDisposition === "release";
|
||||
const failureMessage = freshStartAllowed
|
||||
const failureMessage = networkProvisionFailureMessage(observedOperation) ?? (freshStartAllowed
|
||||
&& provisioningFailureRequiresFreshCandidate(result.failureReasonCode)
|
||||
? "Результат Bluetooth-поиска устарел до отправки. Настройки не отправлены; выполните явный повторный поиск."
|
||||
: freshStartAllowed
|
||||
? "Подключение не началось. Настройки удалены; после устранения причины начните новый явный поиск."
|
||||
: "Результат отправки неизвестен. Повторное применение заблокировано, чтобы не отправить BLE-команду дважды; дождитесь, пока система определит безопасное продолжение.";
|
||||
: "Подключение не подтверждено. Проверьте состояние K1 или начните новое подключение; автоматического повтора не было.");
|
||||
setConnectionAttemptPresentation((current) => (
|
||||
current?.idempotencyKey === idempotencyKey
|
||||
? {
|
||||
@@ -3203,7 +3206,9 @@ export function K1ProvisioningPipeline({
|
||||
disabled={isBusy || modeResetInFlight}
|
||||
onClick={() => void changeDesiredConnectionMode(connectionMode)}
|
||||
>
|
||||
{savedBridgeNetworkSetupRequired
|
||||
{STATION_WIFI_FAILURE_MESSAGES[connectionRecoveryAttempt?.public_error_code ?? ""]
|
||||
? "Указать сеть Wi‑Fi заново"
|
||||
: savedBridgeNetworkSetupRequired
|
||||
? "Подключить K1 к общей сети"
|
||||
: "Подключить новый K1"}
|
||||
</Button>
|
||||
@@ -3256,7 +3261,7 @@ export function K1ProvisioningPipeline({
|
||||
title="Подключение"
|
||||
status={
|
||||
searchActive
|
||||
? `Поиск · ${scanSecondsRemaining ?? 6} с`
|
||||
? `Поиск · до ${scanSecondsRemaining ?? BLE_DISCOVERY_TIMEOUT_SECONDS} с`
|
||||
: connectionAttemptSettling
|
||||
? "Устройство выбрано"
|
||||
: presentedPhysicalRecoveryRequired
|
||||
@@ -3316,7 +3321,7 @@ export function K1ProvisioningPipeline({
|
||||
>
|
||||
<ActivityIndicator />
|
||||
<strong>
|
||||
Поиск Bluetooth · {scanSecondsRemaining ?? 6} с
|
||||
Поиск Bluetooth · до {scanSecondsRemaining ?? BLE_DISCOVERY_TIMEOUT_SECONDS} с
|
||||
</strong>
|
||||
</div>
|
||||
) : networkRecoveryRequired
|
||||
|
||||
@@ -4,6 +4,9 @@ export type ConnectionMode = "bridge" | "quick-connect" | "direct-connect";
|
||||
export type MountType = "handheld" | "vehicle-mounted" | "uav" | "backpack";
|
||||
export type GnssMode = "none" | "rtk" | "ppk";
|
||||
|
||||
// Upper bound for one continuous discovery session; it can finish earlier when K1 is visible.
|
||||
export const BLE_DISCOVERY_TIMEOUT_SECONDS = 20;
|
||||
|
||||
export const DEFAULT_CONNECTION_MODE = "bridge" as const satisfies ConnectionMode;
|
||||
export const SUPPORTED_MOUNT_TYPE = "handheld" as const satisfies MountType;
|
||||
export const SUPPORTED_GNSS_MODE = "none" as const satisfies GnssMode;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Reviewed K1 station replies; Quick Connect host errors have separate copy. */
|
||||
export const STATION_WIFI_FAILURE_MESSAGES: Readonly<Record<string, string>> = {
|
||||
"k1-wifi-network-not-found":
|
||||
"K1 не смог найти указанную сеть Wi‑Fi. Проверьте точное название сети и её доступность рядом с K1. Для новой попытки выберите K1 через поиск Bluetooth и укажите сеть заново.",
|
||||
"k1-wifi-credentials-required":
|
||||
"K1 не смог подключиться с переданным паролем Wi‑Fi. Для новой попытки выберите K1 через поиск Bluetooth и введите пароль нужной сети заново.",
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
import { ApiError, type ConnectRequest, type XgridsK1State, type XgridsOperation } from "./api";
|
||||
import { operationByIdempotencyKey } from "./lifecycle";
|
||||
import { selectMonotonicXgridsState } from "./stateOrdering";
|
||||
import { STATION_WIFI_FAILURE_MESSAGES } from "./networkFailurePresentation";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ProvisioningObservation {
|
||||
state: XgridsK1State;
|
||||
operation: XgridsOperation | null;
|
||||
/** HTTP failure is subordinate to the journal; it never authorizes a retry. */
|
||||
transportError: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* One explicit intent: submit at most once, then observe its exact journal row.
|
||||
* Bluetooth discovery, host WLAN changes and the vendor control dialogue stay
|
||||
* server-owned. No error path can resubmit or silently choose another device.
|
||||
*/
|
||||
export async function observeProvisioningRequest(
|
||||
request: ConnectRequest,
|
||||
context: {
|
||||
initialState: XgridsK1State | null;
|
||||
send: (request: ConnectRequest) => Promise<XgridsK1State>;
|
||||
readState: () => Promise<XgridsK1State>;
|
||||
acceptState: (state: XgridsK1State) => void;
|
||||
currentState: () => XgridsK1State | null;
|
||||
assertCurrent: () => void;
|
||||
settlementOptions?: Parameters<typeof awaitNetworkProvisionSettlementAfterLostResponse>[5];
|
||||
},
|
||||
): Promise<ProvisioningObservation> {
|
||||
let state = context.initialState;
|
||||
let transportError: unknown = null;
|
||||
const adopt = (incoming: XgridsK1State): XgridsK1State => {
|
||||
context.acceptState(incoming);
|
||||
context.assertCurrent();
|
||||
state = selectMonotonicXgridsState(context.currentState(), incoming);
|
||||
return state;
|
||||
};
|
||||
context.assertCurrent();
|
||||
const existing = operationByIdempotencyKey(state, "network.provision", request.idempotency_key);
|
||||
if (!existing) {
|
||||
let response: XgridsK1State;
|
||||
try {
|
||||
response = await context.send(request);
|
||||
} catch (error) {
|
||||
transportError = error;
|
||||
context.assertCurrent();
|
||||
try {
|
||||
response = await context.readState();
|
||||
} catch {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
adopt(response);
|
||||
}
|
||||
if (!state) throw new ApiError("Состояние подключения недоступно. Обновите экран перед новым действием.");
|
||||
const settled = await awaitNetworkProvisionSettlementAfterLostResponse(
|
||||
state,
|
||||
request.idempotency_key,
|
||||
async () => adopt(await context.readState()),
|
||||
adopt,
|
||||
context.assertCurrent,
|
||||
context.settlementOptions,
|
||||
);
|
||||
state = adopt(settled);
|
||||
return {
|
||||
state,
|
||||
operation: operationByIdempotencyKey(state, "network.provision", request.idempotency_key),
|
||||
transportError,
|
||||
};
|
||||
}
|
||||
|
||||
const CONNECT_FAILURE_NEXT_STEP_COPY =
|
||||
"Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.";
|
||||
|
||||
function networkFailureNextStepMessage(detail: string): string {
|
||||
return `${detail} ${CONNECT_FAILURE_NEXT_STEP_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 (STATION_WIFI_FAILURE_MESSAGES[code]) return STATION_WIFI_FAILURE_MESSAGES[code];
|
||||
|
||||
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 networkFailureNextStepMessage(
|
||||
`Bluetooth-периферия завершила сетевую операцию ошибкой.${attDetail} Команда могла быть принята K1; итог текущей попытки не подтверждён.`,
|
||||
);
|
||||
}
|
||||
return networkFailureNextStepMessage(
|
||||
"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 недоступен в связке ключей. Команда устройству не отправлялась; подготовьте разрешение профиля отдельным действием и затем повторите подключение."
|
||||
: networkFailureNextStepMessage(
|
||||
"После подтверждённого включения точки K1 локальный профиль стал недоступен в связке ключей. Дополнительный пароль не запрашивался; итог текущей попытки не подтверждён.",
|
||||
);
|
||||
}
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
"host-wifi-operation-timeout":
|
||||
networkFailureNextStepMessage("Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён."),
|
||||
"profile-ssid-mismatch":
|
||||
"Сохранённый профиль относится к другому устройству. Подключение остановлено без повторной команды сканеру.",
|
||||
"profile-credential-source-mismatch":
|
||||
"Сохранённый профиль K1 не подтверждён для точной версии прошивки. Автоматического выбора другого пароля нет; подготовьте профиль отдельно перед новой попыткой.",
|
||||
"profile-unavailable":
|
||||
networkFailureNextStepMessage("Локальный профиль выбранного 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":
|
||||
networkFailureNextStepMessage("После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён."),
|
||||
"network-provision-lifecycle-busy":
|
||||
"Сетевая операция заблокирована активной сессией или локальной очисткой. Команда K1 не отправлялась; завершите текущую сессию и повторите явно.",
|
||||
};
|
||||
return messages[code] ?? null;
|
||||
}
|
||||
@@ -90,9 +90,9 @@ const connectionPolicyNextActionCopy: Record<string, string> = {
|
||||
"diagnose-physical-command-ledger": "Не повторяйте команду; сначала проверьте журнал физических команд.",
|
||||
"restart-ble-runtime": "Контролируемо перезапустите локальный BLE-контур и обновите состояние.",
|
||||
"scan-ble": "Выполните свежий поиск Bluetooth-устройств.",
|
||||
"observe-fresh-device-network": "Дождитесь автоматического восстановления связи с выбранным K1.",
|
||||
"observe-current-device-network": "Дождитесь автоматического восстановления связи с тем же K1.",
|
||||
"observe-configured-device-network": "Дождитесь автоматического восстановления сохранённого подключения K1.",
|
||||
"observe-fresh-device-network": "Нажмите «Переподключиться», чтобы проверить состояние выбранного K1.",
|
||||
"observe-current-device-network": "Нажмите «Переподключиться», чтобы проверить связь с тем же K1.",
|
||||
"observe-configured-device-network": "Нажмите «Переподключиться», чтобы проверить сохранённое подключение K1.",
|
||||
"recover-current-device-network": "Нажмите «Подключиться заново».",
|
||||
"inspect-host-network": "Проверьте активную локальную сеть и маршрут этого компьютера.",
|
||||
"probe-endpoint": "Дождитесь обновления подключения K1.",
|
||||
|
||||
@@ -39,8 +39,6 @@ import {
|
||||
newMutationContext,
|
||||
newOperationId,
|
||||
operationAllowsFreshProvisioningIntent,
|
||||
operationByIdempotencyKey,
|
||||
operationNeedsReconciliation,
|
||||
physicalStopIntentCheckpoint,
|
||||
recommendedConnectionRecoveryObservationTarget,
|
||||
readOnlyVerificationClearedReconciliation,
|
||||
@@ -65,7 +63,9 @@ import {
|
||||
import { selectMonotonicXgridsState } from "./stateOrdering";
|
||||
import { operationHostFailureDiagnostic } from "./hostDiagnosticPresentation";
|
||||
import { activeStreamForceFinishAuthority } from "./activeStreamRecovery";
|
||||
import { DEFAULT_CONNECTION_MODE } from "./configuration";
|
||||
import { BLE_DISCOVERY_TIMEOUT_SECONDS, DEFAULT_CONNECTION_MODE } from "./configuration";
|
||||
import { observeProvisioningRequest, networkProvisionFailureMessage } from "./networkProvisioning";
|
||||
export { awaitNetworkProvisionSettlementAfterLostResponse, networkProvisionFailureMessage } from "./networkProvisioning";
|
||||
|
||||
export type PendingAction =
|
||||
| "scan"
|
||||
@@ -265,87 +265,6 @@ export function connectionActionAuthoritySnapshot(
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -642,93 +561,6 @@ function apiErrorForOperation(
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -1138,7 +970,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
const operationId = newOperationId();
|
||||
try {
|
||||
const nextState = await xgridsK1Api.scanBle({
|
||||
duration_seconds: options.durationSeconds ?? 6,
|
||||
duration_seconds: options.durationSeconds ?? BLE_DISCOVERY_TIMEOUT_SECONDS,
|
||||
operation_id: operationId,
|
||||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||||
});
|
||||
@@ -1412,143 +1244,36 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
);
|
||||
}
|
||||
};
|
||||
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,
|
||||
const observation = await observeProvisioningRequest(request, {
|
||||
initialState: latestState.current,
|
||||
send: (input) => xgridsK1Api.connect({
|
||||
...input,
|
||||
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";
|
||||
}),
|
||||
readState: () => xgridsK1Api.getState(),
|
||||
acceptState,
|
||||
currentState: () => latestState.current,
|
||||
assertCurrent: assertOperatorIntentCurrent,
|
||||
});
|
||||
// Return the same authoritative snapshot on failure and success. The
|
||||
// form must not reconstruct an outcome from an empty result object.
|
||||
observedState = observation.state;
|
||||
let nextState = observation.state;
|
||||
const operation = observation.operation;
|
||||
if (operation?.status !== "succeeded") {
|
||||
captureFailureReason(operation);
|
||||
intentDisposition = operationAllowsFreshProvisioningIntent(operation)
|
||||
? "release"
|
||||
: "retain";
|
||||
const failureMessage = networkProvisionFailureMessage(operation);
|
||||
throw apiErrorForOperation(
|
||||
failureMessage
|
||||
?? "Подготовительный этап завершился до команды K1. После устранения причины разрешена новая явная попытка.",
|
||||
operation,
|
||||
);
|
||||
}
|
||||
if (operation && operation.status !== "succeeded") {
|
||||
captureFailureReason(operation);
|
||||
throw apiErrorForOperation(
|
||||
resetConnectSessionMessage(
|
||||
"Текущая попытка подключения завершилась без подтверждения успеха.",
|
||||
),
|
||||
operation,
|
||||
if (operation) {
|
||||
throw apiErrorForOperation(
|
||||
failureMessage ?? "Подключение не подтверждено. Проверьте состояние K1 или начните новое подключение; автоматического повтора не было.",
|
||||
operation,
|
||||
);
|
||||
}
|
||||
throw observation.transportError ?? new ApiError(
|
||||
"Сервис не подтвердил результат подключения. Обновите состояние; команда автоматически не повторяется.",
|
||||
);
|
||||
}
|
||||
const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted(
|
||||
|
||||
Reference in New Issue
Block a user