fix(k1): restore canonical local connection lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-08-06 13:52:46 +03:00
parent 52da9b75b7
commit aff331082f
19 changed files with 2169 additions and 274 deletions
+14
View File
@@ -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="Пароль WiFi" 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":