diff --git a/apps/control-station/test/devicePluginContracts.test.mjs b/apps/control-station/test/devicePluginContracts.test.mjs index 9ae693e..ca5fce9 100644 --- a/apps/control-station/test/devicePluginContracts.test.mjs +++ b/apps/control-station/test/devicePluginContracts.test.mjs @@ -635,6 +635,72 @@ test("a provisioned address is not presented as a verified device connection", ( }), "connected"); }); +test("K1 provisioning mutations require the operator's fresh BLE candidate", () => { + const backendLease = { + selected_device_id: "stale-backend-lease-device", + connection_mode: "bridge", + devices: [ + { + device_id: "fresh-scan-candidate", + name: "K1 candidate", + connectable: true, + }, + ], + }; + + assert.equal( + lifecycle.provisioningCandidateById(backendLease.devices, ""), + null, + ); + assert.equal( + lifecycle.provisioningCandidateById( + backendLease.devices, + backendLease.selected_device_id, + ), + null, + ); + assert.equal(lifecycle.canSubmitProvisioningMutation({ + devices: backendLease.devices, + selectedDeviceId: "expired-scan-candidate", + powerConfirmed: true, + credentialsReady: true, + isBusy: false, + }), false); + assert.equal(lifecycle.canSubmitProvisioningMutation({ + devices: backendLease.devices, + selectedDeviceId: "fresh-scan-candidate", + powerConfirmed: true, + credentialsReady: true, + isBusy: false, + }), true); +}); + +test("K1 connection status is green only for a reachable matching lease", () => { + const unreachableLease = { + k1_ip: "192.168.68.50", + connection_mode: "bridge", + connection_verification: { + lease_state: "disconnected", + network_reachability: "unreachable", + }, + }; + const reachableLease = { + ...unreachableLease, + connection_verification: { + lease_state: "reachable", + network_reachability: "reachable", + }, + }; + + assert.equal(lifecycle.isReachableConnectionLease(unreachableLease, "bridge"), false); + assert.equal(lifecycle.isReachableConnectionLease(reachableLease, "bridge"), true); + assert.equal(lifecycle.isReachableConnectionLease(reachableLease, "quick-connect"), false); + assert.equal(lifecycle.isReachableConnectionLease({ + k1_ip: "192.168.68.50", + connection_mode: "bridge", + }, "bridge"), false); +}); + test("provisioning intent keeps one idempotency key and exposes unsafe outcomes", () => { let created = 0; const createUuid = () => { @@ -751,3 +817,52 @@ test("rejected network-profile writes use safe operator copy", () => { ); assert.doesNotMatch(message, /Bleak|GATT|ATT/i); }); + +test("host Wi-Fi helper failures do not fabricate a missing-password diagnosis", () => { + const operationTimeout = networkProvisionFailureMessage({ + status: "failed", + error: { code: "host-wifi-operation-timeout" }, + }); + const buildTimeout = networkProvisionFailureMessage({ + status: "failed", + error: { + code: "host-wifi-helper-build-timeout", + side_effect_status: "none", + safe_to_retry: true, + }, + }); + const buildFailed = networkProvisionFailureMessage({ + status: "failed", + error: { + code: "host-wifi-helper-build-failed", + side_effect_status: "none", + safe_to_retry: true, + }, + }); + const postWriteBuildFailed = networkProvisionFailureMessage({ + status: "failed", + error: { + code: "host-wifi-helper-build-failed", + side_effect_status: "confirmed", + safe_to_retry: false, + }, + }); + + assert.equal( + operationTimeout, + "Локальная операция подготовки Wi‑Fi не завершилась вовремя. Это могло произойти до изменения состояния K1; наличие сохранённого пароля этим кодом не подтверждается и не опровергается. Проверьте состояние K1 и повторите подключение отдельным действием.", + ); + assert.doesNotMatch(operationTimeout, /получите пароль|пароль отсутствует/i); + assert.equal( + buildTimeout, + "Локальный компонент Wi‑Fi не успел собраться за отведённое время. Команда K1 не отправлялась; подготовьте локальный компонент и повторите подключение отдельным действием.", + ); + assert.equal( + buildFailed, + "Локальный компонент Wi‑Fi не удалось собрать. Команда K1 не отправлялась; подготовьте локальный компонент и повторите подключение отдельным действием.", + ); + assert.equal( + postWriteBuildFailed, + "Локальный компонент Wi‑Fi не удалось собрать уже после начала операции с K1. Состояние устройства нельзя выводить из этой локальной ошибки; автоматического повтора команды не было. Выполните read-only проверку K1 перед новым подключением.", + ); +}); diff --git a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs index f716eeb..712cb43 100644 --- a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs +++ b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs @@ -167,12 +167,6 @@ test("K1 Bridge adoption remains one explicit read-only plugin action", () => { join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"), "utf8", ); - const selectionEffectStart = provisioning.indexOf("useEffect(() => {"); - const selectionEffectEnd = provisioning.indexOf( - "useEffect(() => {", - selectionEffectStart + 1, - ); - const selectionEffect = provisioning.slice(selectionEffectStart, selectionEffectEnd); assert.match(provisioning, /connectionMode === "bridge"/); assert.match(provisioning, /Подхватить существующее подключение/); assert.match(provisioning, /pendingAction === "verify"/); @@ -181,9 +175,23 @@ test("K1 Bridge adoption remains one explicit read-only plugin action", () => { /compatibility_attestation: profileSelectionForConnectionMode\("bridge"\)/, ); assert.match(provisioning, /без изменения настроек Wi‑Fi/); - assert.match(provisioning, /deviceSummary !== undefined/); - assert.match(selectionEffect, /!state\.devices\.some/); - assert.match(selectionEffect, /setSelectedDeviceId\(""\)/); + assert.match(provisioning, /deviceSummary !== null/); + assert.match(provisioning, /device_id: deviceSummary\.device_id/); +}); + +test("K1 provisioning keeps the operator draft separate from the backend lease", () => { + const provisioning = readFileSync( + join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"), + "utf8", + ); + + assert.doesNotMatch(provisioning, /setSelectedDeviceId\(state\.selected_device_id\)/); + assert.doesNotMatch(provisioning, /setConnectionMode\(state\.connection_mode\)/); + assert.match(provisioning, /canSubmitProvisioningMutation\(\{/); + assert.match(provisioning, /isReachableConnectionLease\(state, connectionMode\)/); + assert.doesNotMatch(provisioning, /else if \(connectionMode === "quick-connect"\)/); + assert.match(provisioning, /const succeeded = await verifyConnection\(\{/); + assert.match(provisioning, /if \(succeeded\) \{\s*provisioningIntentRef\.current = null;/); }); test("generic Control Station has one composition import and no K1 implementation knowledge", () => { diff --git a/plugins/xgrids-k1/frontend/src/api.ts b/plugins/xgrids-k1/frontend/src/api.ts index 538e445..639cc98 100644 --- a/plugins/xgrids-k1/frontend/src/api.ts +++ b/plugins/xgrids-k1/frontend/src/api.ts @@ -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; diff --git a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx index e532b2d..feef3c1 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx @@ -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({

Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; точные модель, platform type и прошивка будут проверены по живому DeviceInfo перед START.