diff --git a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs index 8a51642..6551aa2 100644 --- a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs +++ b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs @@ -260,7 +260,8 @@ test("K1 connection surface enforces one scan, local selection, and one Apply", assert.doesNotMatch(provisioning, /allow_host_wifi_switch/); assert.doesNotMatch(provisioning, /(?:color|background(?:-color)?):\s*(?:#[0-9a-f]{3,8}|rgba?\()/i); for (const sharedControl of ["Button", "IconButton", "TextField", "ActivityIndicator", "StatusBadge"]) { - assert.match(provisioning, new RegExp(`<${sharedControl}\\b`), sharedControl); + const password = readFileSync(join(pluginFrontendRoot, "components/K1WifiPasswordField.tsx"), "utf8"); + assert.match(provisioning + password, new RegExp(`<${sharedControl}\\b`), sharedControl); } assert.doesNotMatch( provisioning, @@ -736,7 +737,7 @@ test("K1 provisioning keeps the operator draft separate from the backend lease", assert.match(provisioning, /scanWithResult\(\{ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS \}\)/); assert.match( provisioning, - /onChange=\{\(event\) => setPassword\(event\.target\.value\)\}/, + / { logLevel: "silent", server: { middlewareMode: true }, }); + ({K1WifiPasswordField} = await server.ssrLoadModule( + "@xgrids-k1/frontend/components/K1WifiPasswordField.tsx", + )); ({ normalizeXgridsK1MissionState } = await server.ssrLoadModule( "@xgrids-k1/frontend/runtimeContext.tsx", )); @@ -277,7 +281,7 @@ test("K1 one-intent source contract makes mode reset explicit and keeps device I assert.match(resultRows, /onSelect=\{\(\) => selectCandidate\(device\)\}/); assert.doesNotMatch(resultRows, /Переподключиться|reopen|verifyConnection/); assert.match(source, /onChange=\{\(event\) => setSsid\(event\.target\.value\)\}/); - assert.match(source, /onChange=\{\(event\) => setPassword\(event\.target\.value\)\}/); + assert.match(source, / { assert.match( form, - /:\s*\(\s*
/, + /:\s*\(\s*
/, ); assert.match(form, /disabled=\{provisioningFieldsDisabled\}/); assert.match(form, /value=\{connectionAttemptOwnsDraft \? "" : password\}/); @@ -7769,3 +7765,26 @@ test("a failed Connect after completed Bluetooth search exposes recovery instead assert.match(markup, /K1 ответил ошибкой Bluetooth/); assert.doesNotMatch(markup, /Подключение не выполнено|дождитесь, пока система|private transport exception|Пароль передан/); }); + + +test("shared K1 password field reveals on demand and hides after clearing", () => { + let value = ""; + const props = () => ({value, onChange:next=>{value=next;}, disabled:false}); + const harness = createStatefulProvisioningHarness(props(), K1WifiPasswordField); + try { + let tree = harness.render(props()); + assert.equal(elementByProp(tree, "label", "Показать пароль").props.disabled, true); + elementByProp(tree, "label", "Пароль Wi‑Fi").props.onChange({target:{value:"test-only-value"}}); + tree = harness.render(props()); harness.flushEffects(); + elementByProp(tree, "label", "Показать пароль").props.onClick(); + tree = harness.render(props()); + assert.equal(elementByProp(tree, "label", "Пароль Wi‑Fi").props.type, "text"); + assert.equal(elementByProp(tree, "label", "Скрыть пароль").props['aria-pressed'], true); + value = ""; + tree = harness.render(props()); harness.flushEffects(); + tree = harness.render(props()); + assert.equal(elementByProp(tree, "label", "Пароль Wi‑Fi").props.type, "password"); + assert.equal(elementByProp(tree, "label", "Показать пароль").props.disabled, true); + assert.equal(elementByProp(tree, "label", "Пароль Wi‑Fi").props.autoComplete, "off"); + } finally { harness.dispose(); } +}); diff --git a/apps/control-station/test/sensorEnrollment.test.mjs b/apps/control-station/test/sensorEnrollment.test.mjs index 530e65c..c93e0ab 100644 --- a/apps/control-station/test/sensorEnrollment.test.mjs +++ b/apps/control-station/test/sensorEnrollment.test.mjs @@ -204,3 +204,31 @@ test('pending enrollment is presented by its action button without a second noti }}),''); } }); + + +test('discovery errors preserve their cause and never imply a Wi-Fi refusal',()=>{ + for(const [code,text,requiresScan] of [ + ['ble-discovery-busy',/поиск уже выполняется/i,false], + ['ble-adapter-unavailable',/Bluetooth недоступен/,false], + ['ble-discovery-failed',/Служба Bluetooth/,false], + ['ble-selected-device-unavailable',/Выбранный K1 не найден/,true], + ]){ + const state={...initial,connection_attempt:{schema_version:'missioncore.xgrids-k1-connection-attempt/v1', + attempt_id:'test',status:'failed',stage:'resolution-failed',phase:'network_not_applied', + public_error_code:code,side_effect_status:'none'}}; + assert.match(api.enrollmentNotice(state),text); + assert.match(api.enrollmentNotice(state),/Настройки Wi-Fi не были отправлены/); + assert.doesNotMatch(api.enrollmentNotice(state),/Проверьте.*пароль/); + assert.equal(api.enrollmentBluetoothFailure(state),requiresScan); + state.connection_attempt.side_effect_status='unknown'; + state.connection_attempt.phase='network_outcome_unknown'; + assert.doesNotMatch(api.enrollmentNotice(state),/не были отправлены/); + } +}); + +test('legacy resolution failure invalidates the form before a second stale submission',()=>{ + const state={...initial,connection_attempt:{schema_version:'missioncore.xgrids-k1-connection-attempt/v1', + attempt_id:'test',status:'failed',stage:'resolution-failed',public_error_code:'BleakDBusError',side_effect_status:'none'}}; + assert.equal(api.enrollmentBluetoothFailure(state),true); + assert.match(api.enrollmentNotice(state),/Найдите K1 ещё раз/); +}); diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index dad76f0..2ab8136 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -11,7 +11,7 @@ import sys ROOT = Path(__file__).resolve().parents[1] -VERSION = "0.8.11" +VERSION = "0.8.12" sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging")) from debian import package diff --git a/docs/audits/2026-09-07-k1-connection-core-r15.md b/docs/audits/2026-09-07-k1-connection-core-r15.md new file mode 100644 index 0000000..f4351d8 --- /dev/null +++ b/docs/audits/2026-09-07-k1-connection-core-r15.md @@ -0,0 +1,113 @@ +# K1 connection-path audit and R15 + +## Observed failure sequence + +The owner reported four failed enrollment results between 19:08 and 19:11 MSK +on 2026-09-07. The board is running the matched R14 pair: Node 0.8.11 and K1 +0.1.11. The K1 service entered active state at 19:06:33 MSK with zero restarts. +This is not an old-package or service-restart explanation. + +| Journal time (MSK) | Recorded failure boundary | Device Wi-Fi write | +| --- | --- | --- | +| 19:08:36 | `BleakDBusError`, `ensure_device_for_gatt → find_device_by_address → active_scan:477 → assert_reply` | Not reached | +| 19:08:51 | `NetworkProvisioningConflict`, exact capture admission at `facade.connect:11127` | Not reached | +| 19:09:51 | Same nested discovery boundary as 19:08:36 | Not reached | +| 19:10:41 | Same revoked-capture admission as 19:08:51 | Not reached | + +The first and third failures occur before `BleakClient.__aenter__`, service +validation, baseline read and the journalled Wi-Fi write. The second and fourth +occur even earlier. A wrong Wi-Fi password cannot cause these four failures; +this does not prove the supplied credentials correct, because K1 never received +them in these calls. No credential or raw exception payload is copied here. + +R14 held a `BleakScanner` open while its nested resolver started a second +scanner. Bleak 3.0.2 uses one BlueZ manager/D-Bus client per loop. BlueZ permits +one discovery session per client and adapter, not one per Python scanner. +See the [BlueZ Adapter API](https://github.com/bluez/bluez/blob/master/doc/org.bluez.Adapter.rst). +The retained path was absent, so these calls took that nested branch. + +The Wi-Fi function then revoked the exact selected capture in `finally` simply +because no GATT baseline had completed. Discovery failure was thus incorrectly +treated as a failed GATT exchange. The UI classified only `connect-failed` as +a Bluetooth failure, leaving the form usable after `resolution-failed`. +The next explicit Apply met the already-revoked capture and produced the +misleading expired-search message. This is explicit invalidation, not a form +timeout or a Wi-Fi refusal. + +The old journal formatter omitted structured `extra` fields and native D-Bus +codes. The exact native code of the historical calls was not retained. A test +using the installed Bleak manager and a synthetic D-Bus wire enforcing BlueZ's +documented single-session rule reproduces R14's exact call chain with +`org.bluez.Error.InProgress`. This is reproduction evidence, not a claim that +the missing historical code was recovered. The bounded Bluetooth service +journal contains no additional entries for this interval. Protected stores +were not bypassed to obtain more diagnostics. + +## Path ownership review + +| Path | Owner and final authority | Result of review | +| --- | --- | --- | +| Search / select in enrollment | UI selects one row from runtime + discovery + mode revision; selecting a row performs no physical connection | Retained; no automatic Apply or scan added | +| UI → Core Fleet → Node Go broker | One operation ID, durable delivery journal, deadline, exact node/runtime binding; lost response observes the same ID | Retained; delivery completion does not mean device connected | +| Node bridge → plugin facade | One bridge lock; plugin journal, lifecycle/dispatch gates and OS BLE arbiter | Retained; no second writer or recovery loop introduced | +| Fresh / retained / durable BLE identity | Exact address/native path, owner epoch and scan/session lineage; these identities have distinct admission purposes | Retained; age alone does not revoke a form selection | +| Native path resolution → Connect | Previously separate holder plus nested resolver | Replaced by one `selected_device_discovery` context; exact observations come from its own scanner; held through Connect and released before GATT I/O | +| Station provisioning / status read / AP activation | Different reviewed payloads and policies, same native connection lifecycle | All three use that single discovery context; one Connect, no automatic write repeat | +| Failure / cancellation cleanup | Transport lease, native callbacks and command journal have separate ownership | Removed duplicate revocation in exception and finally paths; only an attempted Connect/GATT exchange without baseline revokes a capture | +| Verify in enrollment / Verify in device detail | Facade policy, ledger lineage, exact persisted target, route, DeviceInfo and physical reconciliation | Removed the adapter-level divergence: both use the same `verification_parameters`; another selected device cannot inherit the saved target | +| Host Wi-Fi network list | Linux host network manager, read-only listing | Separate from BLE discovery; not a second K1 scanner | +| Wi-Fi response → control readiness | Same parent intent and owned DeviceInfo bootstrap; network-applied and control-ready are distinct | Retained; missing control confirmation never authorizes resending Wi-Fi | +| START / STOP / preview | Named acquisition, physical command journal, active session and persistent preview recording | Existing R13/R14 behavior retained; this change issues no physical test commands | + +The simplification removes overlapping native discovery, duplicate failure +cleanup, divergent Node verification selection and duplicated password markup. +It does not combine different physical modes into a guessed fallback or erase +their side-effect/identity fences. + +## Operator presentation and diagnostics + +- Both direct and onboard enrollment consume `K1WifiPasswordField`, extracted + from the existing direct composition using Design Guideline `TextField`, + `IconButton` and eye icons. Visibility resets when the value is cleared. + Passwords remain absent from command journals and are cleared after submission. +- Search busy, unavailable Bluetooth, missing selected K1, and other discovery + failures have distinct shared messages. They state that Wi-Fi was not sent + only with `side_effect_status=none`. Unknown/after-write outcomes keep the + existing read-only reconciliation path. +- Legacy resolution errors are recognized as Bluetooth failures so an old + result cannot leave the same invalid form open for another Apply. +- Node failure logs now print a finite allowlist of stage, native BlueZ error + and write-attempt/confirmation booleans alongside the existing causal stack. + Exception messages, SSID, password, frame data and locals are excluded. + +## Verification and delivery + +The regression first failed on unmodified R14 with the same nested stack, then +passed after replacing the resolver. The test uses the real pinned Bleak BlueZ +manager/scanner and dbus-fast with a synthetic wire; no adapter is accessed. +Tests cover cached/missing paths, timeout, busy reply, cancellation, connection +failure and callback/session release for all three BLE operation kinds. Other +tests cover exact address/adapter, owner and capture changes, write boundary, +single submission, shared Verify admission, and password visibility. + +The dev dependency explicitly includes the same pinned dbus-fast version so +this contract is also tested on macOS. Linux runtime dependencies are unchanged. +Detailed final check counts and artifact provenance are recorded after the +release checks. R15 is Node 0.8.12 plus K1 0.1.12 and uses the existing encrypted +onboard application credential. Installation and physical UI acceptance are +separate from synthetic checks; neither is claimed from a successful build. + +### Source acceptance + +- Backend connection/arbiter/journal suite: 189 passed. +- Installer and package lifecycle tests: 14 passed. +- Complete Control Station unit suite: 808 passed, including direct K1 + recovery, frontend boundaries and shared password visibility. +- Node UI boundary test: 1 passed. +- Node Go enrollment tests passed. +- Control Station production build and TypeScript check passed (8.00 s). +- Ruff and Git whitespace checks passed. + +The board was online and published idle acquisition before staging. The local +Docker daemon was not running; no Docker startup or parallel heavy builds were +used. The original canonical Core process remained on port 8000. diff --git a/docs/audits/2026-09-07-k1-enrollment-recheck-and-launch-r14.md b/docs/audits/2026-09-07-k1-enrollment-recheck-and-launch-r14.md index 9c3521c..694b382 100644 --- a/docs/audits/2026-09-07-k1-enrollment-recheck-and-launch-r14.md +++ b/docs/audits/2026-09-07-k1-enrollment-recheck-and-launch-r14.md @@ -93,3 +93,12 @@ Mission Core returned HTTP 200; the updated home page rendered in the in-app browser. The single canonical process remains on 8000, with no listener on 8765. The native “Mission Core · K1 R14” installation window is open for owner-entered sudo. Installation exit status and clean-cache physical acceptance are pending. + +## Subsequent installation and failed acceptance + +On 2026-09-07 the board reported installed Node 0.8.11 and K1 0.1.11. +The K1 service entered active state at 19:06:33 MSK with NRestarts=0. +Owner testing at 19:08–19:11 failed enrollment. R14 is therefore installed but +not accepted. Its synthetic scanner test permitted nested discovery sessions +that the actual Bleak/BlueZ client does not support. See the R15 connection +audit for the exact sequence, reproduction and replacement of that mechanism. diff --git a/plugins/xgrids-k1/frontend/src/components/K1OperatorError.tsx b/plugins/xgrids-k1/frontend/src/components/K1OperatorError.tsx index acd3ae6..1beadde 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1OperatorError.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1OperatorError.tsx @@ -3,7 +3,7 @@ import { Button } from "@nodedc/ui-react"; import type { XgridsConnectionAttempt } from "../api"; import { hostFailureDiagnosticPresentation } from "../hostDiagnosticPresentation"; -import { STATION_WIFI_FAILURE_MESSAGES } from "../networkFailurePresentation"; +import { BLE_DISCOVERY_FAILURE_MESSAGES, STATION_WIFI_FAILURE_MESSAGES } from "../networkFailurePresentation"; const connectionAttemptStageLabels: Record = { accepted: "Запрос принят", @@ -97,6 +97,7 @@ function publicConnectionErrorLabel( structured: ReturnType, ): string { const publicCode = attempt?.public_error_code?.trim(); + if (attempt?.side_effect_status === "none" && publicCode && BLE_DISCOVERY_FAILURE_MESSAGES[publicCode]) return BLE_DISCOVERY_FAILURE_MESSAGES[publicCode]; if (publicCode && publicConnectionErrorLabels[publicCode]) { return publicConnectionErrorLabels[publicCode]; } diff --git a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx index 3453a6d..8f93377 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx @@ -13,13 +13,14 @@ import { Button, GlassSurface, Icon, - IconButton, Select, StatusBadge, TextField, type StatusTone, } from "@nodedc/ui-react"; +import {K1WifiPasswordField} from "./K1WifiPasswordField"; + import type { BleDevice, ConnectionVerifyRequest, @@ -1291,12 +1292,6 @@ export function K1ProvisioningPipeline({ const [selectedDeviceSnapshot, setSelectedDeviceSnapshot] = useState(null); const [ssid, setSsid] = useState(""); const [password, setPassword] = useState(""); - const [passwordVisible, setPasswordVisible] = useState(false); - useEffect(() => { - if (password.length === 0 && passwordVisible) { - setPasswordVisible(false); - } - }, [password, passwordVisible]); const connectionMode = desiredMode; const [successfulLocalConnect, setSuccessfulLocalConnect] = useState(null); @@ -2729,7 +2724,6 @@ export function K1ProvisioningPipeline({ setExplicitProvisioningDraft(null); setSsid(""); setPassword(""); - setPasswordVisible(false); setConnectionAttemptPresentation(null); setCandidateUnavailableMessage(null); setReadOnlyReconnectPresentation(null); @@ -3107,31 +3101,13 @@ export function K1ProvisioningPipeline({ spellCheck={false} placeholder={modeCopy.ssidPlaceholder} /> -
- setPassword(event.target.value)} - disabled={provisioningFieldsDisabled} - autoComplete="off" - placeholder={connectionAttemptOwnsDraft - ? "Пароль передан" - : "Введите пароль"} - /> - setPasswordVisible((visible) => !visible)} - > - - -
+ {applyAction}
)} diff --git a/plugins/xgrids-k1/frontend/src/components/K1WifiPasswordField.css b/plugins/xgrids-k1/frontend/src/components/K1WifiPasswordField.css new file mode 100644 index 0000000..ab347c0 --- /dev/null +++ b/plugins/xgrids-k1/frontend/src/components/K1WifiPasswordField.css @@ -0,0 +1,7 @@ +.password-field-row { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 0.55rem; +} diff --git a/plugins/xgrids-k1/frontend/src/components/K1WifiPasswordField.tsx b/plugins/xgrids-k1/frontend/src/components/K1WifiPasswordField.tsx new file mode 100644 index 0000000..f3da21e --- /dev/null +++ b/plugins/xgrids-k1/frontend/src/components/K1WifiPasswordField.tsx @@ -0,0 +1,21 @@ +import {useEffect, useState} from 'react'; +import {Icon, IconButton, TextField} from '@nodedc/ui-react'; +import './K1WifiPasswordField.css'; + +/** The same Wi-Fi entry composition for direct and onboard K1 enrollment. */ +export function K1WifiPasswordField({value, onChange, disabled, hint, placeholder}: { + value:string; onChange:(value:string)=>void; disabled?:boolean; + hint?:string; placeholder?:string; +}) { + const [visible,setVisible]=useState(false); + useEffect(()=>{if(!value)setVisible(false);},[value]); + return
+ onChange(event.target.value)} disabled={disabled} + hint={hint} placeholder={placeholder} autoComplete="off" spellCheck={false}/> + setVisible(current=>!current)}> + + +
; +} diff --git a/plugins/xgrids-k1/frontend/src/networkFailurePresentation.ts b/plugins/xgrids-k1/frontend/src/networkFailurePresentation.ts index b3016d2..5e3c8c6 100644 --- a/plugins/xgrids-k1/frontend/src/networkFailurePresentation.ts +++ b/plugins/xgrids-k1/frontend/src/networkFailurePresentation.ts @@ -5,3 +5,15 @@ export const STATION_WIFI_FAILURE_MESSAGES: Readonly> = { "k1-wifi-credentials-required": "K1 не смог подключиться к сети Wi‑Fi: устройство запросило учётные данные сети. Проверьте название сети и пароль, затем укажите сеть заново.", }; + +/** These failures precede Connect and the Wi-Fi write, on either host. */ +export const BLE_DISCOVERY_FAILURE_MESSAGES: Readonly> = { + 'ble-discovery-busy': + 'Bluetooth-поиск уже выполняется. Дождитесь его завершения и повторите подключение. Настройки Wi-Fi не были отправлены.', + 'ble-adapter-unavailable': + 'Bluetooth недоступен на компьютере, к которому подключается K1. Включите Bluetooth и повторите подключение. Настройки Wi-Fi не были отправлены.', + 'ble-discovery-failed': + 'Служба Bluetooth не смогла выполнить поиск K1. Повторите поиск устройства. Настройки Wi-Fi не были отправлены.', + 'ble-selected-device-unavailable': + 'Выбранный K1 не найден по Bluetooth. Убедитесь, что он включён рядом с компьютером, и найдите K1 ещё раз. Настройки Wi-Fi не были отправлены.', +}; diff --git a/plugins/xgrids-k1/frontend/src/networkProvisioning.ts b/plugins/xgrids-k1/frontend/src/networkProvisioning.ts index 5836f49..365c3af 100644 --- a/plugins/xgrids-k1/frontend/src/networkProvisioning.ts +++ b/plugins/xgrids-k1/frontend/src/networkProvisioning.ts @@ -1,7 +1,7 @@ import { ApiError, type ConnectRequest, type XgridsK1State, type XgridsOperation } from "./api"; import { operationByIdempotencyKey } from "./lifecycle"; import { selectMonotonicXgridsState } from "./stateOrdering"; -import { STATION_WIFI_FAILURE_MESSAGES } from "./networkFailurePresentation"; +import { BLE_DISCOVERY_FAILURE_MESSAGES, STATION_WIFI_FAILURE_MESSAGES } from "./networkFailurePresentation"; const CONTROL_STATE_READ_INTERVAL_MS = 250; const NETWORK_PROVISION_SETTLEMENT_FALLBACK_MS = 30_000; @@ -165,6 +165,7 @@ export function networkProvisionFailureMessage( if (!operation || operation.status !== "failed") return null; const code = operation.error?.code; if (typeof code !== "string") return null; + if (operation.error?.side_effect_status === "none" && BLE_DISCOVERY_FAILURE_MESSAGES[code]) return BLE_DISCOVERY_FAILURE_MESSAGES[code]; if (STATION_WIFI_FAILURE_MESSAGES[code]) return STATION_WIFI_FAILURE_MESSAGES[code]; if (code === "BleakGATTProtocolError") { diff --git a/plugins/xgrids-k1/frontend/src/sensors/DeviceEnrollmentWindow.tsx b/plugins/xgrids-k1/frontend/src/sensors/DeviceEnrollmentWindow.tsx index e45576d..32c5bed 100644 --- a/plugins/xgrids-k1/frontend/src/sensors/DeviceEnrollmentWindow.tsx +++ b/plugins/xgrids-k1/frontend/src/sensors/DeviceEnrollmentWindow.tsx @@ -2,6 +2,7 @@ import {useEffect,useRef,useState} from 'react'; import {ActivityIndicator,Button,ResourceList,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack} from '@nodedc/ui-react'; import type {SensorEnrollmentProps} from '@mission-core/sensor-sdk'; import {bridgeFormValid,enroll,enrollmentAllowed,connectionAttempt,enrollmentNotice,enrollmentBluetoothFailure,mergeEnrollmentState,enrollmentProof,enrollmentProofCurrent,type EnrollmentProof,type EnrollmentState} from './enrollment'; +import {K1WifiPasswordField} from '../components/K1WifiPasswordField'; import {revealEnrollment} from './revealEnrollment'; export function DeviceEnrollmentWindow({transport,onChange,onComplete,renderWindow}:SensorEnrollmentProps){ @@ -157,7 +158,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onComplete,renderWind onChange={value=>{setNetwork(value);if(value!=='manual')setSSID(networks[Number(value)].ssid);setPassword('');}} disabled={pending}/>}
{setSSID(event.target.value);setNetwork('manual');}} disabled={pending} autoComplete="off"/> - setPassword(event.target.value)} disabled={pending} autoComplete="new-password"/> + diff --git a/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts b/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts index 8bee3cc..687c398 100644 --- a/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts +++ b/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts @@ -1,6 +1,6 @@ import type {EnrollmentState,EnrollmentCommand,EnrollmentOperation,EnrollmentTransport} from '@mission-core/sensor-sdk'; import type {XgridsConnectionAttemptSummary} from '../connectionAttempt'; -import {STATION_WIFI_FAILURE_MESSAGES} from '../networkFailurePresentation'; +import {BLE_DISCOVERY_FAILURE_MESSAGES,STATION_WIFI_FAILURE_MESSAGES} from '../networkFailurePresentation'; export type {EnrollmentState,EnrollmentTransport} from '@mission-core/sensor-sdk'; export function connectionAttempt(state:EnrollmentState|null):XgridsConnectionAttemptSummary|null { @@ -115,6 +115,7 @@ export function enrollmentNotice(state:EnrollmentState):string { return ''; // The pending action is already shown inside its button. } const code=attempt?.public_error_code??state.command_result?.error_code; + if(attempt?.side_effect_status==='none'&&code&&BLE_DISCOVERY_FAILURE_MESSAGES[code])return BLE_DISCOVERY_FAILURE_MESSAGES[code]; if(code==='network-provision-candidate-not-fresh')return 'Результат Bluetooth-поиска больше недоступен. Найдите K1 ещё раз и выберите его из списка. Настройки Wi-Fi не были отправлены.'; if(enrollmentBluetoothFailure(state))return 'Не удалось установить связь с K1 по Bluetooth. Настройки Wi-Fi не были отправлены. Найдите K1 ещё раз и выберите его из списка.'; if(state.command_result?.status==='rejected')return 'Выбранное устройство или сеанс изменились. Обновите сведения и выберите K1 заново.'; @@ -123,6 +124,7 @@ export function enrollmentNotice(state:EnrollmentState):string { if(state.connected)return 'K1 подключён к БК.'; if(attempt?.phase==='network_applied')return 'K1 подключился к Wi-Fi. Связь с БК пока не подтверждена. Нажмите «Проверить состояние K1»; повторно вводить сеть и пароль не нужно.'; if(attempt?.phase==='network_outcome_unknown')return unknownResult; + if(attempt?.side_effect_status==='none'&&attempt.stage==='resolution-failed')return 'Не удалось подготовить Bluetooth-соединение с K1. Настройки Wi-Fi не были отправлены. Найдите K1 ещё раз и повторите подключение.'; if(attempt?.status==='failed'||state.command_result?.status==='failed')return 'Подключение не завершено. Проверьте состояние K1 и выбранную сеть.'; return ''; } @@ -131,7 +133,8 @@ export function enrollmentBluetoothFailure(state:EnrollmentState|null):boolean { const attempt=connectionAttempt(state); return !!attempt&&attempt.status==='failed'&&attempt.side_effect_status==='none'&& (attempt.public_error_code==='network-provision-candidate-not-fresh'|| - (attempt.stage==='connect-failed'&&['BleakError','BleakDBusError','BleakDeviceNotFoundError','TimeoutError'].includes(attempt.public_error_code??''))); + attempt.public_error_code==='ble-selected-device-unavailable'|| + (['resolution-failed','connect-failed','gatt-contract-failed','baseline-read-failed'].includes(attempt.stage)&&['BleakError','BleakDBusError','BleakDeviceNotFoundError','TimeoutError'].includes(attempt.public_error_code??''))); } export interface EnrollmentProof { diff --git a/plugins/xgrids-k1/frontend/src/styles.css b/plugins/xgrids-k1/frontend/src/styles.css index b268907..f54c8f1 100644 --- a/plugins/xgrids-k1/frontend/src/styles.css +++ b/plugins/xgrids-k1/frontend/src/styles.css @@ -502,14 +502,6 @@ box-sizing: border-box; gap: 0.85rem; } -.password-field-row { - display: grid; - min-width: 0; - grid-template-columns: minmax(0, 1fr) auto; - align-items: end; - gap: 0.55rem; -} - .connection-recovery-choice { display: grid; min-width: 0; diff --git a/plugins/xgrids-k1/packaging/build_deb.py b/plugins/xgrids-k1/packaging/build_deb.py index 430a869..1bca640 100644 --- a/plugins/xgrids-k1/packaging/build_deb.py +++ b/plugins/xgrids-k1/packaging/build_deb.py @@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402 from debian import package # noqa: E402 from runtime_payload import files as runtime_files # noqa: E402 -VERSION = "0.1.11" +VERSION = "0.1.12" RESOURCES = ( "plugins/xgrids-k1/profile_loader.py", "plugins/xgrids-k1/plugin.manifest.json", @@ -135,7 +135,7 @@ Architecture: amd64 Maintainer: NODE.DC local build Section: admin Priority: optional -Depends: mission-core-node (>= 0.8.11), mission-core-node (<< 0.9.0), +Depends: mission-core-node (>= 0.8.12), mission-core-node (<< 0.9.0), systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg Breaks: mission-core-node (<< 0.8.0) Replaces: mission-core-node (<< 0.8.0) diff --git a/pyproject.toml b/pyproject.toml index 4d9e48d..7c7a51b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ missioncore-sim = "k1link.simulation.cli:app" [dependency-groups] dev = [ + "dbus-fast==5.0.22", "mypy>=1.15,<2", "pytest>=8.3,<9", "ruff>=0.11,<1", diff --git a/src/k1link/device_plugins/xgrids_k1/ble/ap_activation.py b/src/k1link/device_plugins/xgrids_k1/ble/ap_activation.py index a5653f4..acf3fb1 100644 --- a/src/k1link/device_plugins/xgrids_k1/ble/ap_activation.py +++ b/src/k1link/device_plugins/xgrids_k1/ble/ap_activation.py @@ -23,6 +23,7 @@ from k1link.device_plugins.xgrids_k1.ble.scanner import ( discovered_device_selection, mark_captured_device_gatt_validated, retrieve_connected_device_capture, + selected_device_discovery, ) from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import ( AP_FALLBACK_IPV4, @@ -157,6 +158,7 @@ async def _device_ap_activation_session_impl( max_without_response: int | None = None active_captured_device: CapturedDiscoveredDevice | None = None gatt_baseline_validated = False + connect_attempted = False progress.operation_stage = operation_stage try: @@ -228,17 +230,22 @@ async def _device_ap_activation_session_impl( raise async with AsyncExitStack() as client_stack: - operation_stage = "connect" - progress.operation_stage = operation_stage try: - client = await client_stack.enter_async_context( - BleakClient(device, timeout=connect_timeout_seconds, pair=False) - ) - except Exception as exc: - if active_captured_device is not None: - demote_connected_device_handle_after_gatt_failure( - active_captured_device + async with selected_device_discovery( + device, timeout_seconds=connect_timeout_seconds, + ): + if (active_captured_device is not None + and captured_device_handle(active_captured_device) is not device): + raise BleakDeviceNotFoundError( + device_macos_uuid, "BLE selection invalidated", + ) + operation_stage = "connect" + progress.operation_stage = operation_stage + connect_attempted = True + client = await client_stack.enter_async_context( + BleakClient(device, timeout=connect_timeout_seconds, pair=False) ) + except Exception as exc: _annotate_ble_operation_error( exc, operation_stage=operation_stage, @@ -411,10 +418,6 @@ async def _device_ap_activation_session_impl( "outcome": _outcome(baseline, observations, disconnected), } except Exception as exc: - if active_captured_device is not None and not gatt_baseline_validated: - demote_connected_device_handle_after_gatt_failure( - active_captured_device - ) _annotate_ble_operation_error( exc, operation_stage=operation_stage, @@ -438,7 +441,7 @@ async def _device_ap_activation_session_impl( # when that bypasses the normal exception annotator, a captured # recovery object that never passed baseline GATT validation must be # discarded. - if active_captured_device is not None and not gatt_baseline_validated: + if connect_attempted and active_captured_device is not None and not gatt_baseline_validated: demote_connected_device_handle_after_gatt_failure(active_captured_device) frame[:] = b"\x00" * len(frame) diff --git a/src/k1link/device_plugins/xgrids_k1/ble/scanner.py b/src/k1link/device_plugins/xgrids_k1/ble/scanner.py index d81b99a..4466b33 100644 --- a/src/k1link/device_plugins/xgrids_k1/ble/scanner.py +++ b/src/k1link/device_plugins/xgrids_k1/ble/scanner.py @@ -17,7 +17,7 @@ from uuid import UUID from bleak import BleakScanner from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData -from bleak.exc import BleakDeviceNotFoundError +from bleak.exc import BleakDBusError, BleakDeviceNotFoundError from k1link.artifacts import utc_now_iso from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import ( @@ -840,47 +840,21 @@ async def _retrieve_bluez_device(address: str, details: object = None) -> BLEDev @asynccontextmanager -async def hold_bluez_device_for_connect(device: BLEDevice): - """Keep discovery alive across the cache-check / D-Bus-connect gap. +async def selected_device_discovery(device: BLEDevice, *, timeout_seconds: float): + """One BlueZ discovery session from exact-path resolution through Connect. - BlueZ can discard an unconnected path after StopDiscovery. Holding one - scanner reference until BleakClient connects avoids consuming a path just - removed by that cleanup. This neither selects another device nor repeats - Connect/GATT writes, and is released before any characteristic is read. + All Bleak scanners on this owner loop share a D-Bus client. BlueZ permits + one discovery session per client/adapter, so resolution must consume this + scanner's observations, never start a nested find_device_by_address scan. + The original owner capture remains authoritative; discovery only restores + its exact address/path and stays alive until the consuming client connects. + CoreBluetooth already carries the native object and needs no extra scan. """ details = getattr(device, "details", None) if not sys.platform.startswith("linux") or not isinstance(details, dict): yield return path = details.get("path", "") - match = re.fullmatch(r"/org/bluez/(hci[0-9]+)/dev_[0-9A-F_]+", path) - if (match is None - or not re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", device.address) - or path.rsplit("/", 1)[-1] != "dev_" + device.address.upper().replace(":", "_")): - raise BleakDeviceNotFoundError(device.address, "Invalid selected BlueZ transport") - runtime = ble_runtime_snapshot() - owner = ble_runtime_owner_epoch_for_current_loop() - if (owner is None or runtime["owner_epoch"] != owner - or runtime["poisoned"] or not runtime["owner_loop_bound"] - or runtime["active_operation_kind"] not in {"status-read", "wifi-provision"}): - raise BleakDeviceNotFoundError(device.address, "BLE operation owner changed") - async with BleakScanner(bluez={"adapter": match[1]}): - yield - - -async def ensure_device_for_gatt(device: BLEDevice, *, timeout_seconds: float) -> None: - """Restore a vanished BlueZ path before the one admitted GATT connection. - - A BLEDevice stores a D-Bus path, not a native object lease. After the form - has been filled in, BlueZ may have removed that path. Observe the same - address on the same adapter once, then require that exact path to exist. - Keep the original selection/capture; no session pin, public scan generation, - GATT connection or write is created here. CoreBluetooth needs no refresh. - """ - details = getattr(device, "details", None) - if not sys.platform.startswith("linux") or not isinstance(details, dict): - return - path = details.get("path", "") address = device.address match = re.fullmatch(r"/org/bluez/(hci[0-9]+)/dev_[0-9A-F_]+", path) if (match is None @@ -898,30 +872,45 @@ async def ensure_device_for_gatt(device: BLEDevice, *, timeout_seconds: float) - or ble_runtime_owner_epoch_for_current_loop() != owner_epoch or runtime["owner_epoch"] != owner_epoch or not runtime["owner_loop_bound"] or runtime["poisoned"] - or operation_kind not in {"status-read", "wifi-provision"} + or operation_kind not in {"status-read", "wifi-provision", "ap-enable"} or runtime["active_operation_kind"] != operation_kind): raise BleakDeviceNotFoundError(address, "BLE operation owner changed") - require_owner() - current = await _retrieve_bluez_device(address, details) - require_owner() - if current is not None: - return + observed = asyncio.Event() + + def observe(candidate: BLEDevice, _advertisement: AdvertisementData) -> None: + if (candidate.address.casefold() == address.casefold() + and isinstance(candidate.details, dict) + and candidate.details.get("path") == path): + observed.set() - candidate = await BleakScanner.find_device_by_address( - address, - timeout=min(timeout_seconds, 8.0), - bluez={"adapter": match[1]}, - ) require_owner() - if (candidate is None or candidate.address.casefold() != address.casefold() - or not isinstance(candidate.details, dict) - or candidate.details.get("path") != path): - raise BleakDeviceNotFoundError(address, "Selected BlueZ transport unavailable") - current = await _retrieve_bluez_device(address, details) - require_owner() - if current is None: - raise BleakDeviceNotFoundError(address, "Selected BlueZ transport disappeared") + admitted = False + try: + async with BleakScanner( + detection_callback=observe, bluez={"adapter": match[1]}, + ): + require_owner() + if await _retrieve_bluez_device(address, details) is None: + await asyncio.wait_for(observed.wait(), timeout=min(timeout_seconds, 8.0)) + require_owner() + if await _retrieve_bluez_device(address, details) is None: + raise BleakDeviceNotFoundError(address, "Selected BlueZ transport disappeared") + require_owner() + admitted = True + yield + except Exception as exc: + # Describe only resolution failures here. Connect/GATT failures keep + # their own stage, and exception text (possibly a payload) stays private. + if not admitted: + if isinstance(exc, BleakDBusError): + exc.reason_code = { + "org.bluez.Error.InProgress": "ble-discovery-busy", + "org.bluez.Error.NotReady": "ble-adapter-unavailable", + }.get(exc.dbus_error, "ble-discovery-failed") + elif isinstance(exc, (TimeoutError, BleakDeviceNotFoundError)): + exc.reason_code = "ble-selected-device-unavailable" + raise async def _retrieve_corebluetooth_device( diff --git a/src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py b/src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py index fdf6f6a..db4a5b1 100644 --- a/src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py +++ b/src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py @@ -24,11 +24,10 @@ from k1link.device_plugins.xgrids_k1.ble.scanner import ( demote_connected_device_handle_after_gatt_failure, discover_known_device_capture_for_status_read, discovered_device_selection, - ensure_device_for_gatt, - hold_bluez_device_for_connect, mark_captured_device_gatt_validated, retrieve_connected_device_capture, retrieve_known_device_capture_for_status_read, + selected_device_discovery, ) PROFILE_ID = "xgrids-k1-fw3-wifi-v1" @@ -308,6 +307,7 @@ async def _read_wifi_status_impl( ) -> WifiStatusReadResult: active_captured_device: CapturedDiscoveredDevice | None = None gatt_baseline_validated = False + connect_attempted = False try: progress.operation_stage = "resolution" if captured_device is not None: @@ -394,12 +394,12 @@ async def _read_wifi_status_impl( ) async with AsyncExitStack() as gatt_session: - async with hold_bluez_device_for_connect(device): - await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds) + async with selected_device_discovery(device, timeout_seconds=timeout_seconds): if (active_captured_device is not None and captured_device_handle(active_captured_device) is not device): raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated") progress.operation_stage = "connect" + connect_attempted = True client = await gatt_session.enter_async_context( BleakClient(device, timeout=timeout_seconds, pair=False) ) @@ -478,7 +478,7 @@ async def _read_wifi_status_impl( # Connection/contract/read failure revokes only this transport lease; # the process-scoped UUID/session token remains available for another # explicit CoreBluetooth retrieval attempt. - if active_captured_device is not None and not gatt_baseline_validated: + if connect_attempted and active_captured_device is not None and not gatt_baseline_validated: demote_connected_device_handle_after_gatt_failure(active_captured_device) @@ -566,6 +566,7 @@ async def _provision_wifi_impl( max_without_response: int | None = None active_captured_device: CapturedDiscoveredDevice | None = None gatt_baseline_validated = False + connect_attempted = False try: progress.operation_stage = operation_stage @@ -626,13 +627,13 @@ async def _provision_wifi_impl( ) async with AsyncExitStack() as gatt_session: - async with hold_bluez_device_for_connect(device): - await ensure_device_for_gatt(device, timeout_seconds=timeout_seconds) + async with selected_device_discovery(device, timeout_seconds=timeout_seconds): if (active_captured_device is not None and captured_device_handle(active_captured_device) is not device): raise BleakDeviceNotFoundError(device_macos_uuid, "BLE selection invalidated") operation_stage = "connect" progress.operation_stage = operation_stage + connect_attempted = True client = await gatt_session.enter_async_context( BleakClient(device, timeout=timeout_seconds, pair=False) ) @@ -767,8 +768,6 @@ async def _provision_wifi_impl( "outcome": _outcome(baseline, observations, disconnected), } except Exception as exc: - if active_captured_device is not None and not gatt_baseline_validated: - demote_connected_device_handle_after_gatt_failure(active_captured_device) _annotate_ble_operation_error( exc, operation_stage=operation_stage, @@ -781,12 +780,14 @@ async def _provision_wifi_impl( ) raise finally: + # Only an attempted Connect/GATT exchange can revoke this capture. + # Discovery failures have not tested the selected transport. # Hard-timeout cancellation may bypass ``except Exception``. The # failed live transport lease must still be demoted when the exact # captured object never completed the reviewed baseline read; the # UUID/session recovery token itself remains available for a later # explicit attempt. - if active_captured_device is not None and not gatt_baseline_validated: + if connect_attempted and active_captured_device is not None and not gatt_baseline_validated: demote_connected_device_handle_after_gatt_failure(active_captured_device) frame[:] = b"\x00" * len(frame) diff --git a/src/k1link/device_plugins/xgrids_k1/node_bridge.py b/src/k1link/device_plugins/xgrids_k1/node_bridge.py index 6848eb1..aa3cf27 100644 --- a/src/k1link/device_plugins/xgrids_k1/node_bridge.py +++ b/src/k1link/device_plugins/xgrids_k1/node_bridge.py @@ -38,6 +38,35 @@ ATTESTATION = { } +def verification_parameters(state, operation_id, *, requested_device_id=None): + """Use the admitted persisted Bridge target before asking for BLE again. + + The facade still checks its ledger, exact identity, route, DeviceInfo and + physical reconciliation fences. A saved address alone never grants START. + """ + parameters = { + "expected_snapshot_runtime_id": state["snapshot_runtime_id"], + "operation_id": operation_id, + } + policy = (state.get("connection_policy") or {}).get("actions", {}) + decision = policy.get("observe-configured-device-network") or {} + target = decision.get("required_transport_ref") + selected = state.get("selected_device_id") + if (decision.get("allowed") is True + and decision.get("requires_live_gatt_validation") is False + and decision.get("required_connection_mode") == "bridge" + and isinstance(target, str) and isinstance(selected, str) + and target.casefold() == selected.casefold() + and (requested_device_id is None + or target.casefold() == requested_device_id.casefold())): + parameters.update( + device_id=target, source="durable-configured-state", + compatibility_attestation=ATTESTATION, + expected_mode_revision=state.get("desired_connection_mode_revision"), + ) + return parameters + + def failure_locations(error: BaseException) -> str: """Bounded causal stack, excluding messages, source lines and frame locals. @@ -61,6 +90,37 @@ def failure_locations(error: BaseException) -> str: return " <- ".join(chain) +def failure_transport_facts(error: BaseException) -> str: + """Journal finite native codes and dispatch facts, never error text/payloads.""" + facts: dict[str, object] = {} + current: BaseException | None = error + seen: set[int] = set() + while current is not None and id(current) not in seen and len(seen) < 6: + seen.add(id(current)) + stage = getattr(current, "operation_stage", None) + if isinstance(stage, str) and stage in { + "resolution", "exact-uuid-scan", "connect", "gatt-contract", + "baseline-read", "gatt-write", "status-poll", "status-read", + }: + facts["stage"] = stage + bluez = getattr(current, "dbus_error", None) + if isinstance(bluez, str) and bluez in { + "org.bluez.Error.InProgress", "org.bluez.Error.NotReady", + "org.bluez.Error.Failed", "org.bluez.Error.NotAuthorized", + "org.bluez.Error.NotSupported", "org.bluez.Error.DoesNotExist", + "org.freedesktop.DBus.Error.UnknownObject", + }: + facts["bluez"] = bluez + for field in ("device_write_attempted", "device_write_confirmed"): + value = getattr(current, field, None) + if isinstance(value, bool): + facts[field] = value + current = current.__cause__ or ( + None if current.__suppress_context__ else current.__context__ + ) + return " ".join(f"{key}={value}" for key, value in facts.items()) or "unavailable" + + class NodeEnrollmentRejected(ValueError): """A host admission failure proven to precede any device invocation.""" @@ -186,12 +246,15 @@ class NodeBridge: except Exception as error: # Journalled device failures are returned as normal operation # results, so the HTTP exception logger never sees them. Record - # only source locations here; exception text may contain a frame. + # only source locations and finite transport facts; exception text + # may contain a frame. logging.getLogger(__name__).warning( - "K1 journalled invocation failed: action=%s operation=%s exception=%s chain=%s", + "K1 journalled invocation failed: action=%s operation=%s " + "exception=%s facts=%s chain=%s", action, identifier, type(error).__name__, + failure_transport_facts(error), failure_locations(error), ) # Read the exact result after a failure; never repeat an invocation @@ -234,7 +297,8 @@ class NodeBridge: async with self.lock: if datetime.fromisoformat(command["deadline_at"]) <= datetime.now(UTC): raise NodeEnrollmentRejected("command-expired") - state = await self.state() + snapshot = await self.invoke("state.read", {}, identifier + "-admission") + state = self.project(snapshot) if command.get("runtime_id") != state["runtime_id"]: raise NodeEnrollmentRejected("runtime-changed") if action == "networks": @@ -274,6 +338,10 @@ class NodeBridge: ssid=parameters.get("ssid"), password=parameters.get("password"), ) + else: + payload.update(verification_parameters( + snapshot, plugin_identifier, requested_device_id=parameters["device_id"], + )) try: result = await self.invoke_journalled( "network.provision" if action == "connect" else "connection.verify", diff --git a/src/k1link/device_plugins/xgrids_k1/node_sensor.py b/src/k1link/device_plugins/xgrids_k1/node_sensor.py index 4205a35..a705771 100644 --- a/src/k1link/device_plugins/xgrids_k1/node_sensor.py +++ b/src/k1link/device_plugins/xgrids_k1/node_sensor.py @@ -12,7 +12,7 @@ from .facade import ( ViewerSettingsRequest, normalize_project_name, ) -from .node_bridge import ATTESTATION, plugin_operation_id +from .node_bridge import ATTESTATION, plugin_operation_id, verification_parameters PHYSICAL_ACCEPTANCE_KEYS = ( "operator_present", @@ -107,33 +107,6 @@ def project_sensor(snapshot, node_id): } -def verification_parameters(state, operation_id): - """Use the admitted persisted Bridge target before asking for BLE again. - - The facade still checks its ledger, exact identity, route, DeviceInfo and - physical reconciliation fences. A saved address alone never grants START. - """ - parameters = { - "expected_snapshot_runtime_id": state["snapshot_runtime_id"], - "operation_id": operation_id, - } - policy = (state.get("connection_policy") or {}).get("actions", {}) - decision = policy.get("observe-configured-device-network") or {} - target = decision.get("required_transport_ref") - selected = state.get("selected_device_id") - if (decision.get("allowed") is True - and decision.get("requires_live_gatt_validation") is False - and decision.get("required_connection_mode") == "bridge" - and isinstance(target, str) and isinstance(selected, str) - and target.casefold() == selected.casefold()): - parameters.update( - device_id=target, source="durable-configured-state", - compatibility_attestation=ATTESTATION, - expected_mode_revision=state.get("desired_connection_mode_revision"), - ) - return parameters - - class NodeK1Sensor: def __init__(self, bridge, peers): self.bridge, self.peers = bridge, peers diff --git a/tests/test_bluez_discovery_session.py b/tests/test_bluez_discovery_session.py new file mode 100644 index 0000000..bb8868c --- /dev/null +++ b/tests/test_bluez_discovery_session.py @@ -0,0 +1,140 @@ +"""Exercise the installed Bleak manager against BlueZ's single-session contract. + +Only the D-Bus wire is synthetic. No Bluetooth adapter or device is accessed. +""" + +import asyncio +from types import SimpleNamespace + +import pytest +from bleak import BleakScanner +from bleak.backends.bluezdbus import defs +from bleak.backends.bluezdbus import manager as manager_module +from bleak.backends.bluezdbus import scanner as backend_module +from bleak.backends.bluezdbus.manager import BlueZManager +from bleak.backends.bluezdbus.scanner import BleakScannerBlueZDBus +from bleak.backends.device import BLEDevice +from bleak.exc import BleakDBusError +from dbus_fast import Message, MessageType + +from k1link.device_plugins.xgrids_k1.ble import scanner +from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import ( + bind_ble_runtime_owner_loop, + configure_ble_runtime_process_lease, + reset_ble_runtime_arbiter_for_tests, + run_ble_operation, +) + + +@pytest.mark.parametrize("kind", ["status-read", "wifi-provision", "ap-enable"]) +@pytest.mark.parametrize( + "mode", ["present", "missing", "busy", "absent", "cancelled", "connect-failed"] +) +def test_one_discovery_session_retains_exact_path_until_connect(monkeypatch, tmp_path, kind, mode): + async def scenario(): + reset_ble_runtime_arbiter_for_tests() + configure_ble_runtime_process_lease(tmp_path) + bind_ble_runtime_owner_loop() + manager = BlueZManager() + adapter = "/org/bluez/hci7" + address = "AA:BB:CC:DD:EE:FF" + path = adapter + "/dev_AA_BB_CC_DD_EE_FF" + device = BLEDevice(address, "synthetic", {"path": path}) + manager._properties[adapter] = {defs.ADAPTER_INTERFACE: {"Powered": True}} + calls = [] + discovery_active = False + + def advertise(): + props = { + "Address": address, + "Alias": "synthetic", + "Name": "synthetic", + "Adapter": adapter, + "RSSI": -45, + } + manager._properties[path] = {defs.DEVICE_INTERFACE: props} + for callback in tuple(manager._advertisement_callbacks[adapter]): + callback(path, props) + + class Bus: + async def call(self, message): + nonlocal discovery_active + calls.append(message.member) + if message.member == "StartDiscovery": + if discovery_active or mode == "busy": + return Message( + message_type=MessageType.ERROR, + reply_serial=1, + error_name="org.bluez.Error.InProgress", + signature="s", + body=["Operation already in progress"], + ) + discovery_active = True + if mode != "absent": + asyncio.get_running_loop().call_later(0.01, advertise) + elif message.member == "StopDiscovery": + discovery_active = False + manager._properties.pop(path, None) + return Message(message_type=MessageType.METHOD_RETURN, reply_serial=1) + + manager._bus = Bus() + if mode == "present": + advertise() + + async def get_manager(): + return manager + + monkeypatch.setattr(manager_module, "get_global_bluez_manager", get_manager) + monkeypatch.setattr(backend_module, "get_global_bluez_manager", get_manager) + monkeypatch.setattr(scanner, "sys", SimpleNamespace(platform="linux")) + + class LinuxScanner(BleakScanner): + def __init__(self, *args, **kwargs): + super().__init__(*args, backend=BleakScannerBlueZDBus, **kwargs) + + monkeypatch.setattr(scanner, "BleakScanner", LinuxScanner) + + async def connect(): + async with scanner.selected_device_discovery(device, timeout_seconds=1): + await asyncio.sleep(0) + assert manager.get_device_address(path) == address + assert discovery_active + calls.append("Connect") + if mode == "cancelled": + raise asyncio.CancelledError() + if mode == "connect-failed": + raise ConnectionError("synthetic connection failure") + + try: + action = run_ble_operation( + kind, operation=lambda _progress: connect(), hard_timeout_seconds=2 + ) + expected_error = { + "busy": BleakDBusError, + "absent": TimeoutError, + "cancelled": asyncio.CancelledError, + "connect-failed": ConnectionError, + }.get(mode) + if expected_error: + with pytest.raises(expected_error) as raised: + await action + if mode in {"busy", "absent"}: + assert "Connect" not in calls + assert raised.value.reason_code == ( + "ble-discovery-busy" + if mode == "busy" + else "ble-selected-device-unavailable" + ) + else: + await action + assert calls.count("StartDiscovery") == 1 + assert calls.count("StopDiscovery") == (mode != "busy") + if "Connect" in calls: + assert calls.index("Connect") < calls.index("StopDiscovery") + assert not discovery_active + assert not manager._advertisement_callbacks[adapter] + assert not manager._device_removed_callbacks + finally: + reset_ble_runtime_arbiter_for_tests() + + asyncio.run(scenario()) diff --git a/tests/test_bluez_gatt_selection.py b/tests/test_bluez_gatt_selection.py index eb1a075..1827c29 100644 --- a/tests/test_bluez_gatt_selection.py +++ b/tests/test_bluez_gatt_selection.py @@ -49,10 +49,8 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n ) return device if present else None - async def find(selected_address, *, timeout, bluez): - assert selected_address == address - assert 0 < timeout <= 8 - assert bluez == {"adapter": "hci7"} + async def advertise(callback): + await asyncio.sleep(0) events.append("scan") assert events.count("scan") == 1 if native == "absent": @@ -61,11 +59,11 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n monkeypatch.setattr(scanner, "ble_runtime_owner_epoch_for_current_loop", lambda: -1) if native == "invalidated": scanner.demote_connected_device_handle_after_gatt_failure(capture) - return BLEDevice( + callback(BLEDevice( "AA:BB:CC:DD:EE:00" if native == "wrong-address" else address, "synthetic", {"path": path.replace("hci7", "hci8") if native == "wrong-adapter" else path}, - ) + ), None) service = SimpleNamespace(uuid=wifi.SERVICE_UUID) write = SimpleNamespace( @@ -129,17 +127,21 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n )) monkeypatch.setattr(scanner, "_retrieve_bluez_device", retrieve) class Scanner: - find_device_by_address = staticmethod(find) - - def __init__(self, *, bluez): + def __init__(self, *, detection_callback, bluez): assert bluez == {"adapter": "hci7"} + self.callback = detection_callback + self.task = None async def __aenter__(self): events.append("hold-discovery") + if native not in {"present", "cache-cleanup-race"}: + self.task = asyncio.create_task(advertise(self.callback)) return self async def __aexit__(self, *_args): events.append("release-discovery") + if self.task: + await self.task monkeypatch.setattr(scanner, "BleakScanner", Scanner) monkeypatch.setattr(wifi, "BleakClient", Client) @@ -172,14 +174,17 @@ def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, n elif fails_before_connect or native == "connect-failed" or ( native == "write-failed" and operation == "provision" ): - with pytest.raises(BleakError) as raised: + with pytest.raises((BleakError, TimeoutError)) as raised: await action if fails_before_connect: - assert isinstance(raised.value, BleakDeviceNotFoundError) + assert isinstance(raised.value, (BleakDeviceNotFoundError, TimeoutError)) assert "connect" not in events if operation == "provision": assert raised.value.device_write_attempted is (native == "write-failed") assert events.count("write") == (native == "write-failed") + if fails_before_connect and native not in {"owner-changed", "invalidated"}: + # A failed discovery has not tested/revoked the original capture. + assert scanner.captured_device_handle(capture) is device else: result = await action assert result.get("outcome", "lan_address_observed") == "lan_address_observed" diff --git a/tests/test_k1_installer.py b/tests/test_k1_installer.py index 2df01a9..ea6462c 100644 --- a/tests/test_k1_installer.py +++ b/tests/test_k1_installer.py @@ -158,7 +158,7 @@ def test_private_release_contains_material_only_in_root_private_member( position += 60 + length + length % 2 with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive: control = archive.extractfile("control").read().decode() - assert "Depends: mission-core-node (>= 0.8.11)" in control + assert "Depends: mission-core-node (>= 0.8.12)" in control assert "Replaces: mission-core-node (<< 0.8.0)" in control diff --git a/tests/test_node_k1_bridge.py b/tests/test_node_k1_bridge.py index 2325a9c..e15acc7 100644 --- a/tests/test_node_k1_bridge.py +++ b/tests/test_node_k1_bridge.py @@ -198,6 +198,33 @@ def test_failure_locations_respects_suppression_and_bounds_cyclic_chains(): assert failure_locations(first) == "RuntimeError[]" +def test_native_failure_facts_keep_stage_and_write_boundary_without_messages(): + from bleak.exc import BleakDBusError + + from k1link.device_plugins.xgrids_k1.node_bridge import failure_transport_facts + + secret = secrets.token_hex(20) + native = BleakDBusError("org.bluez.Error.InProgress", [secret]) + native.operation_stage = "resolution" + native.device_write_attempted = False + native.device_write_confirmed = False + wrapper = RuntimeError(secret) + wrapper.__cause__ = native + facts = failure_transport_facts(wrapper) + assert facts == ( + "stage=resolution bluez=org.bluez.Error.InProgress " + "device_write_attempted=False device_write_confirmed=False" + ) + assert secret not in facts + native = BleakDBusError(secret, [secret]) + native.operation_stage = secret + wrapper.__cause__ = native + assert secret not in failure_transport_facts(wrapper) + wrapper.__suppress_context__ = True + wrapper.__cause__ = None + assert failure_transport_facts(wrapper) == "unavailable" + + def test_node_scan_reaches_service_through_real_facade_with_runtime_fence(): """Exercise the actual admission boundary, replacing only the BLE service.""" from k1link.device_plugins.xgrids_k1.facade import SnapshotRuntimeConflict @@ -640,6 +667,9 @@ def test_recheck_uses_only_exact_admitted_durable_bridge_target(): assert result["source"] == "durable-configured-state" assert result["device_id"] == current["selected_device_id"] assert result["expected_mode_revision"] == current["desired_connection_mode_revision"] + assert "source" not in verification_parameters( + current, "synthetic-operation", requested_device_id="other-device", + ) for patch in [{"allowed": False}, {"requires_live_gatt_validation": True}, {"required_connection_mode": "quick-connect"}, {"required_transport_ref": "other"}]: @@ -648,3 +678,30 @@ def test_recheck_uses_only_exact_admitted_durable_bridge_target(): decision.update(allowed=True, requires_live_gatt_validation=False, required_transport_ref=current["selected_device_id"], required_connection_mode="bridge") + + +@pytest.mark.parametrize("requires_gatt", [False, True]) +def test_enrollment_verify_and_detail_share_durable_target_admission(requires_gatt): + async def run(): + device = bridge() + current = device.facade.current + current["connection_policy"] = {"actions": {"observe-configured-device-network": { + "allowed": True, "requires_live_gatt_validation": requires_gatt, + "required_connection_mode": "bridge", + "required_transport_ref": current["selected_device_id"], + }}} + command = { + "operation_id": "op_" + "d" * 32, "action": "verify", "runtime_id": "runtime-one", + "deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(), + "parameters": {"device_id": current["selected_device_id"], + "discovery_generation": 1, "mode_revision": 0}, + } + await device.execute(command) + actions = [(a, p) for a, p in device.facade.actions if a != "state.read"] + assert len(actions) == 1 and actions[0][0] == "connection.verify" + payload = actions[0][1] + assert (payload.get("source") == "durable-configured-state") is not requires_gatt + assert payload["device_id"] == current["selected_device_id"] + assert payload["expected_discovery_generation"] == 1 + assert "password" not in payload + asyncio.run(run()) diff --git a/uv.lock b/uv.lock index 8b6c463..d7c8303 100644 --- a/uv.lock +++ b/uv.lock @@ -210,6 +210,7 @@ version = "5.0.22" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/aa/db/b621610e50b1bc46ff63534d75239553c1bf33256de6096b58214fd9808a/dbus_fast-5.0.22.tar.gz", hash = "sha256:34dc67d7d21a12399828dd13e63b352750580beea54ea7c729e708f2d2905fef", size = 83224, upload-time = "2026-06-05T18:47:59.171Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/52/81/ffc155f700c45191673e7f7620a28cbbbf5f116ff74a99f765895baa6f9c/dbus_fast-5.0.22-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f72b77be63f7bb24cf42936ad10994d40f43fed691f857f7854b5882d6a5227c", size = 690171, upload-time = "2026-06-05T18:56:01.151Z" }, { url = "https://files.pythonhosted.org/packages/24/06/233b0bc13919474f70320bf389cb81ce02811956d6cf86c45e84679b63c3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f0bcad7f71d2304a68a5b0bc0d24c3fcc14710a2ffcf5f2a27521e3aece71ca", size = 799464, upload-time = "2026-06-05T18:56:02.617Z" }, { url = "https://files.pythonhosted.org/packages/68/e9/77bc23a6f5aebfb8f2c34489795e8517aed7eca31738438e1a4c4a4891d3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ffcf16034f71a801bd2108aeffb6337d104c9459e8b1a218d16a917c8a2d2e9", size = 852687, upload-time = "2026-06-05T18:56:04.433Z" }, { url = "https://files.pythonhosted.org/packages/ae/04/56769d0936d1273d1801ef574ec426ccb3f61f4b0a7a0eeb9eb2b8ccafa5/dbus_fast-5.0.22-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98de6d2c200d8182e1fd0bdde3206fa556b8fa14ebb752a044cd8daa87b4658c", size = 833814, upload-time = "2026-06-05T18:56:05.87Z" }, @@ -507,6 +508,7 @@ perception-stream = [ [package.dev-dependencies] dev = [ + { name = "dbus-fast" }, { name = "mypy" }, { name = "pytest" }, { name = "ruff" }, @@ -536,6 +538,7 @@ provides-extras = ["perception-stream", "node-device-media"] [package.metadata.requires-dev] dev = [ + { name = "dbus-fast", specifier = "==5.0.22" }, { name = "mypy", specifier = ">=1.15,<2" }, { name = "pytest", specifier = ">=8.3,<9" }, { name = "ruff", specifier = ">=0.11,<1" },