fix(k1): restore canonical local connection lifecycle
This commit is contained in:
@@ -82,6 +82,19 @@ export interface XgridsConnectionVerification {
|
||||
reason_code?: string | null;
|
||||
}
|
||||
|
||||
export interface XgridsNetworkWriteReconciliation {
|
||||
status: "device-state-unknown-after-write";
|
||||
operation_id: string;
|
||||
transport_ref: string;
|
||||
connection_mode: "bridge" | "quick-connect" | "direct-connect";
|
||||
operation_stage: string;
|
||||
reason_code: string;
|
||||
device_write_confirmed: boolean;
|
||||
required_action: "explicit-read-only-ble-status-observation";
|
||||
scope: "process-runtime";
|
||||
observed_at: string;
|
||||
}
|
||||
|
||||
export interface XgridsCompatibilityState {
|
||||
profile_id?: string | null;
|
||||
decision?: "compatible" | "limited" | "unknown" | "incompatible";
|
||||
@@ -352,6 +365,7 @@ export interface XgridsK1State {
|
||||
device_ref?: XgridsDeviceRef | null;
|
||||
device_session?: XgridsDeviceSession | null;
|
||||
connection_verification?: XgridsConnectionVerification | null;
|
||||
network_write_reconciliation?: XgridsNetworkWriteReconciliation | null;
|
||||
acquisition?: XgridsAcquisition | null;
|
||||
operations?: XgridsOperation[];
|
||||
last_operation?: XgridsOperation | null;
|
||||
|
||||
@@ -17,7 +17,12 @@ import {
|
||||
connectionModeOptions,
|
||||
type ConnectionMode,
|
||||
} from "../configuration";
|
||||
import { provisioningIntentKey } from "../lifecycle";
|
||||
import {
|
||||
canSubmitProvisioningMutation,
|
||||
isReachableConnectionLease,
|
||||
provisioningCandidateById,
|
||||
provisioningIntentKey,
|
||||
} from "../lifecycle";
|
||||
import { finiteMetric } from "../presentation";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
|
||||
@@ -131,39 +136,34 @@ export function K1ProvisioningPipeline({
|
||||
const isBusy = pendingAction !== null;
|
||||
const credentialsReady = connectionMode === "quick-connect"
|
||||
|| (ssid.trim().length > 0 && password.length > 0);
|
||||
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||
const modeCopy = connectionCopy[connectionMode];
|
||||
const selectedModeConnected = Boolean(
|
||||
state?.k1_ip && state.connection_mode === connectionMode,
|
||||
const networkWriteReconciliationPending = Boolean(
|
||||
state?.network_write_reconciliation,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.selected_device_id) {
|
||||
if (state.selected_device_id !== selectedDeviceId) {
|
||||
provisioningIntentRef.current = null;
|
||||
}
|
||||
setSelectedDeviceId(state.selected_device_id);
|
||||
return;
|
||||
}
|
||||
if (selectedDeviceId && state?.devices && !state.devices.some((device) => device.device_id === selectedDeviceId)) {
|
||||
setSelectedDeviceId("");
|
||||
}
|
||||
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.connection_mode) {
|
||||
setConnectionMode(state.connection_mode);
|
||||
}
|
||||
}, [state?.connection_mode]);
|
||||
|
||||
const deviceSummary = useMemo(
|
||||
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||
() => provisioningCandidateById(devices, selectedDeviceId),
|
||||
[devices, selectedDeviceId],
|
||||
);
|
||||
const canConnect = !networkWriteReconciliationPending && canSubmitProvisioningMutation({
|
||||
devices,
|
||||
selectedDeviceId,
|
||||
powerConfirmed,
|
||||
credentialsReady,
|
||||
isBusy,
|
||||
});
|
||||
const modeCopy = connectionCopy[connectionMode];
|
||||
const selectedModeConnected = isReachableConnectionLease(state, connectionMode);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId && !deviceSummary) {
|
||||
provisioningIntentRef.current = null;
|
||||
setSelectedDeviceId("");
|
||||
}
|
||||
}, [deviceSummary, selectedDeviceId]);
|
||||
|
||||
const canAdoptExistingBridge = connectionMode === "bridge"
|
||||
&& powerConfirmed
|
||||
&& selectedDeviceId.length > 0
|
||||
&& deviceSummary !== undefined
|
||||
&& deviceSummary !== null
|
||||
&& deviceSummary.connectable !== false
|
||||
&& !isBusy;
|
||||
|
||||
const resetProvisioningIntent = () => {
|
||||
@@ -171,14 +171,14 @@ export function K1ProvisioningPipeline({
|
||||
};
|
||||
|
||||
const submitConnect = async () => {
|
||||
if (!canConnect) return;
|
||||
if (!canConnect || !deviceSummary) return;
|
||||
const idempotencyKey = provisioningIntentKey(provisioningIntentRef.current);
|
||||
provisioningIntentRef.current = idempotencyKey;
|
||||
const networkCredentials = connectionMode === "quick-connect"
|
||||
? {}
|
||||
: { ssid: ssid.trim(), password };
|
||||
const succeeded = await connect({
|
||||
device_id: selectedDeviceId,
|
||||
device_id: deviceSummary.device_id,
|
||||
...networkCredentials,
|
||||
connection_mode: connectionMode,
|
||||
compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
|
||||
@@ -187,20 +187,24 @@ export function K1ProvisioningPipeline({
|
||||
if (succeeded) {
|
||||
provisioningIntentRef.current = null;
|
||||
setPassword("");
|
||||
} else if (connectionMode === "quick-connect") {
|
||||
// The backend has already persisted and reconciled the failed bounded
|
||||
// attempt. A later click is a new explicit Quick Connect intent, not an
|
||||
// automatic replay of the consumed operation key.
|
||||
} else {
|
||||
// Every later click is a new explicit operator intent, never an
|
||||
// automatic replay of a consumed failed journal entry. If the prior
|
||||
// write outcome is ambiguous, the backend reconciliation fence blocks
|
||||
// this new intent before another device write for both modes.
|
||||
provisioningIntentRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const submitExistingBridgeAdoption = async () => {
|
||||
if (!canAdoptExistingBridge) return;
|
||||
await verifyConnection({
|
||||
device_id: selectedDeviceId,
|
||||
if (!canAdoptExistingBridge || !deviceSummary) return;
|
||||
const succeeded = await verifyConnection({
|
||||
device_id: deviceSummary.device_id,
|
||||
compatibility_attestation: profileSelectionForConnectionMode("bridge"),
|
||||
});
|
||||
if (succeeded) {
|
||||
provisioningIntentRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -241,8 +245,8 @@ export function K1ProvisioningPipeline({
|
||||
<WizardStep
|
||||
number="02"
|
||||
title="Выберите Bluetooth-устройство"
|
||||
status={pendingAction === "scan" ? "Поиск…" : selectedDeviceId ? "Устройство выбрано" : `Найдено: ${devices.length}`}
|
||||
tone={pendingAction === "scan" ? "accent" : selectedDeviceId ? "success" : "neutral"}
|
||||
status={pendingAction === "scan" ? "Поиск…" : deviceSummary ? "Устройство выбрано" : `Найдено: ${devices.length}`}
|
||||
tone={pendingAction === "scan" ? "accent" : deviceSummary ? "success" : "neutral"}
|
||||
>
|
||||
<p className="step-copy">Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; точные модель, platform type и прошивка будут проверены по живому DeviceInfo перед START.</p>
|
||||
<Button
|
||||
@@ -282,7 +286,12 @@ export function K1ProvisioningPipeline({
|
||||
<TextField label="Пароль Wi‑Fi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
|
||||
</div>
|
||||
)}
|
||||
<div className="connection-summary"><span>Устройство</span><strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong></div>
|
||||
<div className="connection-summary"><span>Устройство</span><strong>{deviceSummary?.name || deviceSummary?.device_id || "Сначала выберите устройство"}</strong></div>
|
||||
{networkWriteReconciliationPending ? (
|
||||
<p className="safety-note">
|
||||
Предыдущая BLE-запись завершилась до подтверждения актуального состояния K1. Новая запись заблокирована: выберите Bridge и выполните read-only подхват существующего подключения.
|
||||
</p>
|
||||
) : null}
|
||||
<Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}>
|
||||
{pendingAction === "connect"
|
||||
? connectionMode === "quick-connect" ? "Включаем точку и подключаем…" : "Подключаем…"
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "@mission-cor
|
||||
|
||||
import type {
|
||||
AcquisitionState,
|
||||
BleDevice,
|
||||
XgridsApplicationControlPhase,
|
||||
XgridsAcquisition,
|
||||
XgridsK1State,
|
||||
@@ -200,6 +201,50 @@ export function operationNeedsReconciliation(
|
||||
return operation.error?.safe_to_retry !== true;
|
||||
}
|
||||
|
||||
export function provisioningCandidateById(
|
||||
devices: readonly BleDevice[],
|
||||
selectedDeviceId: string,
|
||||
): BleDevice | null {
|
||||
if (!selectedDeviceId) return null;
|
||||
return devices.find((device) => device.device_id === selectedDeviceId) ?? null;
|
||||
}
|
||||
|
||||
export function canSubmitProvisioningMutation({
|
||||
devices,
|
||||
selectedDeviceId,
|
||||
powerConfirmed,
|
||||
credentialsReady,
|
||||
isBusy,
|
||||
}: {
|
||||
devices: readonly BleDevice[];
|
||||
selectedDeviceId: string;
|
||||
powerConfirmed: boolean;
|
||||
credentialsReady: boolean;
|
||||
isBusy: boolean;
|
||||
}): boolean {
|
||||
const candidate = provisioningCandidateById(devices, selectedDeviceId);
|
||||
return Boolean(
|
||||
powerConfirmed &&
|
||||
credentialsReady &&
|
||||
!isBusy &&
|
||||
candidate &&
|
||||
candidate.connectable !== false,
|
||||
);
|
||||
}
|
||||
|
||||
export function isReachableConnectionLease(
|
||||
state: XgridsK1State | null | undefined,
|
||||
connectionMode: NonNullable<XgridsK1State["connection_mode"]>,
|
||||
): boolean {
|
||||
const verification = state?.connection_verification;
|
||||
return Boolean(
|
||||
state?.k1_ip &&
|
||||
state.connection_mode === connectionMode &&
|
||||
verification?.lease_state === "reachable" &&
|
||||
verification.network_reachability === "reachable",
|
||||
);
|
||||
}
|
||||
|
||||
function defaultUuid(): string {
|
||||
const cryptoApi = globalThis.crypto;
|
||||
if (!cryptoApi) {
|
||||
|
||||
@@ -200,6 +200,21 @@ export function networkProvisionFailureMessage(
|
||||
const code = operation.error?.code;
|
||||
if (typeof code !== "string") return null;
|
||||
|
||||
if (
|
||||
code === "host-wifi-helper-build-timeout"
|
||||
|| code === "host-wifi-helper-build-failed"
|
||||
) {
|
||||
const failedBeforeDeviceWrite = operation.error?.side_effect_status === "none"
|
||||
&& operation.error?.safe_to_retry === true;
|
||||
const failure = code === "host-wifi-helper-build-timeout"
|
||||
? "не успел собраться за отведённое время"
|
||||
: "не удалось собрать";
|
||||
if (failedBeforeDeviceWrite) {
|
||||
return `Локальный компонент Wi‑Fi ${failure}. Команда K1 не отправлялась; подготовьте локальный компонент и повторите подключение отдельным действием.`;
|
||||
}
|
||||
return `Локальный компонент Wi‑Fi ${failure} уже после начала операции с K1. Состояние устройства нельзя выводить из этой локальной ошибки; автоматического повтора команды не было. Выполните read-only проверку K1 перед новым подключением.`;
|
||||
}
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
BleakGATTProtocolError:
|
||||
"Сканер отклонил запись сетевого профиля. Результат изменения сети неизвестен; автоматический повтор запрещён. Проверьте текущее состояние K1 или подхватите существующее подключение без изменения настроек Wi‑Fi.",
|
||||
@@ -210,7 +225,7 @@ export function networkProvisionFailureMessage(
|
||||
"credential-invalid":
|
||||
"Пароль точки доступа K1 имеет недопустимую длину. Получите сохранённый пароль этого K1 в LixelGO/iPhone и повторите подключение.",
|
||||
"host-wifi-operation-timeout":
|
||||
"Первичное системное подключение к K1 не было завершено вовремя. BLE-команда автоматически не повторялась; получите пароль сохранённой сети этого K1 и запустите подключение заново.",
|
||||
"Локальная операция подготовки Wi‑Fi не завершилась вовремя. Это могло произойти до изменения состояния K1; наличие сохранённого пароля этим кодом не подтверждается и не опровергается. Проверьте состояние K1 и повторите подключение отдельным действием.",
|
||||
"profile-ssid-mismatch":
|
||||
"Сохранённый профиль относится к другому K1. Подключение остановлено без повторной команды сканеру.",
|
||||
"corewlan-error":
|
||||
|
||||
Reference in New Issue
Block a user