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
@@ -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" ? "Включаем точку и подключаем…" : "Подключаем…"