From 0752e5c6bf74b61831e02a623d61fe65e1c011bf Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 23 Aug 2026 00:15:28 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D0=B1=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20Bridge=20=D0=B8=20?= =?UTF-8?q?Quick=20Connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Безопасно восстанавливает управляющее подключение и локальные checkpoint без повторных команд сканеру. Добавляет PCAP/Bridge guardrails и регрессионные проверки одношагового переподключения. Известный дефект: после второго подключения интерфейс не присоединяется к новой генерации preview правой камеры. В живой Quick Connect-сессии STOP был принят, но READY не подтвердился до таймаута; автоматический повтор STOP запрещён. --- .../test/k1SupervisorPresentation.test.mjs | 215 ++- docs/04_K1_WIFI_PROVISIONING_PROFILE.md | 9 + docs/adr/0013-k1-local-connection-matrix.md | 7 + plugins/xgrids-k1/frontend/src/api.ts | 10 + .../src/components/K1AcquisitionPipeline.tsx | 15 +- .../src/components/K1ProvisioningPipeline.tsx | 54 +- .../src/components/K1SpatialControls.tsx | 6 +- scripts/check_k1_quick_connect_guardrails.py | 233 +++ .../active_acquisition_recovery_checkpoint.py | 19 +- src/k1link/device_plugins/xgrids_k1/facade.py | 1495 +++++++++++++-- tests/test_xgrids_acquisition_lifecycle.py | 1614 ++++++++++++++++- ..._active_acquisition_recovery_checkpoint.py | 38 + 12 files changed, 3481 insertions(+), 234 deletions(-) create mode 100755 scripts/check_k1_quick_connect_guardrails.py diff --git a/apps/control-station/test/k1SupervisorPresentation.test.mjs b/apps/control-station/test/k1SupervisorPresentation.test.mjs index d23cb45..b26c255 100644 --- a/apps/control-station/test/k1SupervisorPresentation.test.mjs +++ b/apps/control-station/test/k1SupervisorPresentation.test.mjs @@ -17,6 +17,7 @@ let physicalRecoveryConnectionDetail; let shouldRenderK1OperationalPanels; let K1ProvisioningPipeline; let RuntimeActionFenceTestContext; +let LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS; let emptySearchPresentation; let emptyProvisioningAttemptPresentation; let emptyReadOnlyReconnectPresentation; @@ -127,6 +128,7 @@ before(async () => { ({ K1ProvisioningPipeline, RuntimeActionFenceTestContext, + LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS, emptySearchPresentation, emptyProvisioningAttemptPresentation, emptyReadOnlyReconnectPresentation, @@ -3245,6 +3247,7 @@ test("replay and local-only stop branches use bounded process copy", () => { const stopState = runtimeState(); stopState.compatibility.vendor_writes_enabled = false; + stopState.acquisition.control_mode = "operator-manual"; stopState.connection_policy = { schema_version: "missioncore.xgrids-k1-connection-policy/v1", facts: { retained_context_is_presence: false }, @@ -3272,7 +3275,68 @@ test("replay and local-only stop branches use bounded process copy", () => { assertCanonicalConnectionCopy(stopMarkup); }); -test("physical STOP is fail-closed and both acquisition surfaces fall back to local cleanup", () => { +test("software-commanded calibration exposes no teardown until canonical STOP is safe", () => { + const state = runtimeState(); + state.snapshot_runtime_id = "runtime-calibration-001"; + state.snapshot_revision = 31; + state.phase = "starting_live"; + state.source_mode = "live"; + state.acquisition.state = "awaiting_external_start"; + state.acquisition.state_revision = 3; + state.application_control_session = { + session_generation: 5, + state_revision: 7, + state: "device-initializing", + can_stop: false, + control_socket_open: true, + }; + state.connection_policy = { + schema_version: "missioncore.xgrids-k1-connection-policy/v1", + facts: { retained_context_is_presence: false }, + allowed_actions: ["stop-local-receiver"], + actions: { + "stop-acquisition": { + allowed: false, + reason_codes: ["supervisor-action-not-allowed"], + target_source: "connection-supervisor", + required_transport_ref: null, + required_connection_mode: null, + requires_live_gatt_validation: false, + automatic_retry: false, + }, + "stop-local-receiver": { + allowed: true, + reason_codes: [], + target_source: "local-runtime", + required_transport_ref: null, + required_connection_mode: null, + requires_live_gatt_validation: false, + automatic_retry: false, + }, + }, + }; + + const pipelineMarkup = renderToStaticMarkup(createElement(K1AcquisitionPipeline, { + controller: acquisitionController(state), + desiredConnectionMode: "bridge", + openSpatialScene() {}, + activateAutomaticSpatialSource() {}, + })); + const spatialMarkup = renderToStaticMarkup(createElement(K1SpatialControlsView, { + controller: acquisitionController(state), + })); + + assert.match(pipelineMarkup, /Кнопка остановки появится только после подтверждённого SCANNING/); + assert.match(spatialMarkup, /K1 калибруется и готовит облако точек/); + for (const markup of [pipelineMarkup, spatialMarkup]) { + assert.equal(buttonMarkupWithText(markup, "Завершить локальный приём").length, 0); + assert.equal(buttonMarkupWithText(markup, "Аварийно завершить локальный приём").length, 0); + assert.equal(buttonMarkupWithText(markup, "Остановить устройство и запись").length, 0); + assert.equal(buttonMarkupWithText(markup, "Отменить запуск до START").length, 0); + } +}); + +test("physical STOP is fail-closed across both acquisition surfaces", () => { const state = runtimeState(); state.snapshot_runtime_id = "runtime-stop-001"; state.snapshot_revision = 40; @@ -3442,13 +3506,15 @@ test("physical STOP is fail-closed and both acquisition surfaces fall back to lo }, )); assert.doesNotMatch(dismissedFailureMarkup, /Остановить устройство и запись/); - assert.match(dismissedFailureMarkup, /Повторная команда устройству не отправляется/); const localButtons = buttonMarkupWithText( dismissedFailureMarkup, "Завершить локальный приём", ); - assert.equal(localButtons.length, 1); - assert.doesNotMatch(localButtons[0], /\bdisabled(?:=|\s|>)/); + assert.equal(localButtons.length, 0); + assert.match( + dismissedFailureMarkup, + /Повторная команда и локальное завершение заблокированы/, + ); assertCanonicalConnectionCopy(dismissedFailureMarkup); const classifiedStopState = structuredClone(state); @@ -3485,7 +3551,11 @@ test("physical STOP is fail-closed and both acquisition surfaces fall back to lo activateAutomaticSpatialSource() {}, }, )); - assert.match(localCleanupPendingMarkup, /Завершение локального приёма…/); + assert.doesNotMatch(localCleanupPendingMarkup, /Завершение локального приёма…/); + assert.match( + localCleanupPendingMarkup, + /Повторная команда и локальное завершение заблокированы/, + ); assert.doesNotMatch(localCleanupPendingMarkup, /Остановка устройства…/); const physicalStopPendingMarkup = renderToStaticMarkup(createElement( @@ -5802,6 +5872,78 @@ test("physical recovery new-device action performs only an explicit scenario res assert.equal(reopenCalls, 0); }); +test("topology switch stays silent for two seconds and then uses only the inline loader", async () => { + const state = terminalConnectionRecoveryState(); + state.connection_attempt = null; + let resolveReset; + const resetSettlement = new Promise((resolve) => { + resolveReset = resolve; + }); + const resetRequests = []; + const controller = { + ...provisioningController(state), + selectConnectionMode: (request) => { + resetRequests.push(request); + return resetSettlement; + }, + }; + const props = { + controller, + desiredMode: "bridge", + onDesiredModeChange: () => undefined, + }; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const timers = []; + globalThis.setTimeout = (callback, delay, ...args) => { + const timer = { callback, delay, args, cleared: false }; + timers.push(timer); + return timer; + }; + globalThis.clearTimeout = (timer) => { + timer.cleared = true; + }; + const harness = createStatefulProvisioningHarness(props); + try { + let tree = harness.render(); + harness.flushEffects(); + const modeSelect = elementByProp(tree, "label", "Способ подключения"); + assert.ok(modeSelect); + modeSelect.props.onChange("quick-connect"); + assert.equal(resetRequests.length, 1); + + tree = harness.render(); + harness.flushEffects(); + let markup = renderToStaticMarkup(tree); + assert.doesNotMatch(markup, /Переключаем способ подключения/); + assert.doesNotMatch(markup, /Завершение прежней локальной границы/); + const progressTimer = timers.find( + (timer) => timer.delay === LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS, + ); + assert.ok(progressTimer); + assert.equal(progressTimer.cleared, false); + + progressTimer.callback(...progressTimer.args); + tree = harness.render(); + markup = renderToStaticMarkup(tree); + assert.match(markup, /Переключаем способ подключения/); + assert.match(markup, /connection-action-progress/); + assert.doesNotMatch(markup, /dialog|modal|window/i); + + resolveReset(true); + await Promise.resolve(); + await Promise.resolve(); + tree = harness.render(); + harness.flushEffects(); + markup = renderToStaticMarkup(tree); + assert.doesNotMatch(markup, /Переключаем способ подключения/); + } finally { + harness.dispose(); + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } +}); + test("physical retirement dispatch refuses a rendered runtime A after runtime B is current", async () => { let currentRuntimeId = "runtime-B"; let mutationRequests = 0; @@ -6360,7 +6502,7 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p ...provisioningController(coldQuickState), getConnectionRecoveryObservationTarget: () => coldQuickTarget, }, - desiredMode: "bridge", + desiredMode: "quick-connect", }); const coldQuickReconnect = buttonMarkupWithText( coldQuickMarkup, @@ -6374,6 +6516,32 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p ); assert.match(coldQuickMarkup, /Quick Connect/); + const quickTargetInsideBridgeMarkup = renderProvisioning({ + controller: { + ...provisioningController(coldQuickState), + getConnectionRecoveryObservationTarget: () => coldQuickTarget, + }, + desiredMode: "bridge", + }); + assert.equal( + buttonMarkupWithText( + quickTargetInsideBridgeMarkup, + "Переподключиться", + ).length, + 0, + ); + assert.equal( + buttonMarkupWithText( + quickTargetInsideBridgeMarkup, + "Найти по Bluetooth", + ).length, + 1, + ); + assert.doesNotMatch( + quickTargetInsideBridgeMarkup, + /Сохранённое подключение/, + ); + const unrelatedMarkup = renderProvisioningWithAttempt({ controller: { ...controller, @@ -6385,6 +6553,41 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p assert.match(unrelatedMarkup, /Сохранённое подключение/); }); +test("saved Bridge reconnect stays hidden in a fresh Quick Connect draft", () => { + const state = terminalConnectionRecoveryState(); + state.connection_attempt = null; + const bridgeTarget = recommendedConnectionRecoveryObservationTarget(state); + assert.equal(bridgeTarget?.connectionMode, "bridge"); + const controller = { + ...provisioningController(state), + getConnectionRecoveryObservationTarget: () => bridgeTarget, + }; + + const quickConnectMarkup = renderProvisioning({ + controller, + desiredMode: "quick-connect", + }); + assert.equal( + buttonMarkupWithText(quickConnectMarkup, "Переподключиться").length, + 0, + ); + assert.equal( + buttonMarkupWithText(quickConnectMarkup, "Найти по Bluetooth").length, + 1, + ); + assert.doesNotMatch(quickConnectMarkup, /Сохранённое подключение/); + + const bridgeMarkup = renderProvisioning({ + controller, + desiredMode: "bridge", + }); + assert.equal( + buttonMarkupWithText(bridgeMarkup, "Переподключиться").length, + 1, + ); + assert.match(bridgeMarkup, /Сохранённое подключение/); +}); + test("read-only reconnect keeps its card and loader across a transient projection", async () => { const initialState = terminalConnectionRecoveryState(); initialState.connection_attempt = null; diff --git a/docs/04_K1_WIFI_PROVISIONING_PROFILE.md b/docs/04_K1_WIFI_PROVISIONING_PROFILE.md index fa95b66..758b02e 100644 --- a/docs/04_K1_WIFI_PROVISIONING_PROFILE.md +++ b/docs/04_K1_WIFI_PROVISIONING_PROFILE.md @@ -96,6 +96,15 @@ Core Quick Connect intent emits one AP-enable frame even when the baseline mode already says `WIFI_AP`, then polls for the byte-51 ready flag for at most 15 seconds. It never retries the device write automatically. +For that exact explicit intent, a completed write-with-response followed by +the same canonical `WIFI_AP / 192.168.56.1 / ready` observation is an +idempotent ensure-target success: the K1 AP was already ready, and the host may +continue into the bounded exact-SSID CoreWLAN association. This exception +requires the durable ledger to record `write_confirmed=true`. An unconfirmed +dispatch, a timeout, or an unchanged read-only observation without that exact +confirmed write remains ambiguous and cannot authorize host association or an +automatic retry. + Static review of the original client also established a lifecycle requirement: LixelGO keeps the same BLE manager connected after AP-ready and invokes native Wi-Fi association from that live session. Mission Core now retains the same diff --git a/docs/adr/0013-k1-local-connection-matrix.md b/docs/adr/0013-k1-local-connection-matrix.md index d8f84b7..37b2de9 100644 --- a/docs/adr/0013-k1-local-connection-matrix.md +++ b/docs/adr/0013-k1-local-connection-matrix.md @@ -163,6 +163,13 @@ contract coverage but remains a distinct physical acceptance gate; it must not be reported as field-accepted until one redacted live run records both sides of the transition. +Quick Connect Apply is an exact idempotent ensure-target operation when the +selected K1 already reports the canonical AP-ready state. Mission Core accepts +that unchanged state only after the one reviewed write-with-response is +durably confirmed, then performs the normal exact device-SSID host association. +An unconfirmed or merely read-only unchanged observation remains fail-closed; +this rule neither retries the BLE write nor weakens Bridge admission. + The corrected host boundary derives a non-secret, device-scoped profile ID from the selected SSID. The reviewed client contains per-device `WiFiAP_SSID` and `WiFiAP_Password` fields, but the 2026-07-20 review of the exact official K1 diff --git a/plugins/xgrids-k1/frontend/src/api.ts b/plugins/xgrids-k1/frontend/src/api.ts index 6ad1963..0ac088a 100644 --- a/plugins/xgrids-k1/frontend/src/api.ts +++ b/plugins/xgrids-k1/frontend/src/api.ts @@ -1036,6 +1036,16 @@ export interface XgridsK1State { network_write_performed: false; automatic_scan: false; operation_sequence: number; + timing?: { + schema_version: "missioncore.xgrids-k1-connection-scenario-reset-timing/v1"; + started_at_utc: string; + completed_at_utc: string; + total_ms: number; + lifecycle_boundary_wait_ms: number; + monitor_quiescence_wait_ms: number; + local_retirement_ms: number; + intent_commit_ms: number; + }; } | null; connection_scenario_reset_pending?: { reset_id: string; diff --git a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx index 345a69e..39f68de 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx @@ -207,9 +207,16 @@ export function K1AcquisitionPipeline({ && canIssueCanonicalStop(state, physicalStopIntentSpent), ); const physicalStopPresented = physicalStopInFlight || physicalStopExecutable; + const protectedSoftwareCommandedTransition = Boolean( + activeAcquisition + && activeAcquisition.control_mode === "plugin-commanded" + && activeAcquisition.state !== "prepared" + && !isTerminalAcquisitionState(activeAcquisition.state), + ); const localReceiverStopExecutable = Boolean( connectionPolicyAllows(state, "stop-local-receiver") - && preparedAcquisition === null, + && preparedAcquisition === null + && !protectedSoftwareCommandedTransition, ); const terminalPhysicalStopPending = terminalPhysicalStopObserved && physicalStopInFlight; @@ -306,6 +313,10 @@ export function K1AcquisitionPipeline({ : "Управляющая команда устройству сейчас недоступна. Завершите только разрешённый сервером локальный приём или выполните read-only восстановление." : terminalReadOnlyRecovery ? "Управляющие действия сейчас не разрешены. Дождитесь нового подтверждённого состояния или выполните read-only восстановление." + : protectedSoftwareCommandedTransition && !physicalStopPresented + ? physicalStopIntentSpent + ? "Команда остановки уже была принята интерфейсом. Повторная команда и локальное завершение заблокированы до нового подтверждённого состояния K1." + : "K1 выполняет переход к сканированию. Кнопка остановки появится только после подтверждённого SCANNING." : physicalStopGuidance ? `${physicalStopGuidance.reason} ${physicalStopGuidance.nextAction}` : gracefulStopTarget && !physicalStopPresented && physicalStopIntentSpent @@ -572,7 +583,7 @@ export function K1AcquisitionPipeline({ : physicalStopPresented ? recoveredPhysicalStop ? "Остановить сканирование" : "Остановить устройство и запись" : state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Завершить локальный приём"} ) : null} - {activeAcquisition ? ( + {activeAcquisition && !protectedSoftwareCommandedTransition ? ( diff --git a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx index 1ba00d8..f91325a 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx @@ -100,6 +100,31 @@ export function emptyReadOnlyReconnectPresentation(): return null; } +export const LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS = 2_000; + +/** + * Keep short local lifecycle settlements visually silent. Safety interlocks + * still take effect immediately; only the progress presentation is deferred. + */ +export function useDelayedLocalOperationProgress( + active: boolean, + delayMilliseconds = LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS, +): boolean { + const [visible, setVisible] = useState(false); + useEffect(() => { + if (!active) { + setVisible(false); + return undefined; + } + const timer = globalThis.setTimeout( + () => setVisible(true), + delayMilliseconds, + ); + return () => globalThis.clearTimeout(timer); + }, [active, delayMilliseconds]); + return active && visible; +} + export interface PhysicalReopenPresentation { key: string; snapshotRuntimeId: string; @@ -1240,6 +1265,9 @@ export function K1ProvisioningPipeline({ targetMode: ConnectionMode; } | null>(null); const modeResetInFlight = pendingAction === "mode" || modeResetPending !== null; + const modeResetProgressVisible = useDelayedLocalOperationProgress( + modeResetInFlight, + ); const [selectedDeviceSnapshot, setSelectedDeviceSnapshot] = useState(null); const [ssid, setSsid] = useState(""); const [password, setPassword] = useState(""); @@ -1657,7 +1685,8 @@ export function K1ProvisioningPipeline({ ? readOnlyReconnectPresentation : null; const connectionRecoveryVerificationPending = - currentReadOnlyReconnectPresentation?.kind === "connection"; + currentReadOnlyReconnectPresentation?.kind === "connection" + && currentReadOnlyReconnectPresentation.connectionMode === connectionMode; const physicalRecoveryVerificationPending = currentReadOnlyReconnectPresentation?.kind === "physical"; const provisioningMutationBusy = Boolean( @@ -1796,7 +1825,7 @@ export function K1ProvisioningPipeline({ state, connectionMode, ); - const connectionRecoveryTarget = !physicalRecoveryRequired + const recommendedConnectionRecoveryTarget = !physicalRecoveryRequired && !unresolvedAppliedAttempt && !selectedModeConnected && !reconfigurationActive @@ -1804,6 +1833,13 @@ export function K1ProvisioningPipeline({ && connectionRecoveryObservationAllowed ? recommendedConnectionRecoveryObservationTarget(state) : null; + // A durable recovery target remains available when the operator returns to + // its mode, but it must never leak a Bridge reconnect action into a freshly + // selected Quick Connect draft (or vice versa). + const connectionRecoveryTarget = + recommendedConnectionRecoveryTarget?.connectionMode === connectionMode + ? recommendedConnectionRecoveryTarget + : null; const connectionRecoveryKey = connectionRecoveryEscapeKey({ snapshotRuntimeId, attempt: connectionRecoveryAttempt, @@ -3162,10 +3198,16 @@ export function K1ProvisioningPipeline({ disabled={modeResetInFlight} variant="split" /> - {modeResetInFlight ? ( -

- Завершение прежней локальной границы. Новый поиск не начнётся автоматически. -

+ {modeResetProgressVisible ? ( +
+ + Переключаем способ подключения… +
) : null} {activeScenarioReset ? (

diff --git a/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx b/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx index 9edaffe..be2ef37 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx @@ -197,6 +197,7 @@ export function K1SpatialControlsView({ && canIssueCanonicalStop(state, physicalStopIntentSpent), ); const physicalStopPresented = physicalStopInFlight || physicalStopExecutable; + const pluginCommandedAcquisition = acquisition?.control_mode === "plugin-commanded"; const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes( acquisition?.state ?? "", ); @@ -327,7 +328,10 @@ export function K1SpatialControlsView({ ) : null} - {!physicalStopPresented && localReceiverStopAllowed && !stopping ? ( + {!physicalStopPresented + && localReceiverStopAllowed + && !pluginCommandedAcquisition + && !stopping ? (