Unify K1 discovery ownership and verification across enrollment paths

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 20:04:46 +03:00
parent 9c4d70b0e9
commit 762d77ef95
27 changed files with 615 additions and 191 deletions
@@ -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\)\}/,
/<K1WifiPasswordField[\s\S]*?onChange=\{setPassword\}/,
);
});
@@ -16,6 +16,7 @@ let K1ConnectionPipelines;
let physicalRecoveryConnectionDetail;
let shouldRenderK1OperationalPanels;
let K1ProvisioningPipeline;
let K1WifiPasswordField;
let RuntimeActionFenceTestContext;
let LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS;
let emptySearchPresentation;
@@ -102,6 +103,9 @@ before(async () => {
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, /<K1WifiPasswordField[\s\S]*?onChange=\{setPassword\}/);
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
assert.doesNotMatch(
@@ -290,9 +294,13 @@ test("K1 one-intent source contract makes mode reset explicit and keeps device I
source,
/(?:color|background(?:-color)?):\s*(?:#[0-9a-f]{3,8}|rgba?\()/i,
);
const passwordSource = readFileSync(new URL(
"../../../plugins/xgrids-k1/frontend/src/components/K1WifiPasswordField.tsx", import.meta.url,
), "utf8");
for (const sharedControl of ["Button", "IconButton", "TextField", "ActivityIndicator", "StatusBadge"]) {
assert.match(source, new RegExp(`<${sharedControl}\\b`), sharedControl);
assert.match(source + passwordSource, new RegExp(`<${sharedControl}\\b`), sharedControl);
}
assert.doesNotMatch(passwordSource, /<(?:button|input)\b/);
assert.equal((source.match(/buttonLabel:\s*"Применить"/g) ?? []).length, 3);
assert.match(source, /устарел|stale/i);
assert.match(source, /network_outcome_unknown|safe_to_retry/i);
@@ -1181,7 +1189,7 @@ function elementByProp(node, propName, expectedValue) {
return elementByProp(node.props.children, propName, expectedValue);
}
function createStatefulProvisioningHarness(initialProps) {
function createStatefulProvisioningHarness(initialProps, Component = K1ProvisioningPipeline) {
const hookSlots = [];
let currentProps = initialProps;
let capturedTree = null;
@@ -1296,7 +1304,7 @@ function createStatefulProvisioningHarness(initialProps) {
};
try {
capturedTree = K1ProvisioningPipeline(currentProps);
capturedTree = Component(currentProps);
} finally {
dispatcher.useState = originals.useState;
dispatcher.useRef = originals.useRef;
@@ -4669,23 +4677,11 @@ test("post-reset Scan selects the exact prior UUID locally and opens network fie
"local selection must not manufacture a backend Apply attempt",
);
const passwordField = elementByProp(tree, "label", "Пароль WiFi");
const passwordField = elementByProp(tree, "placeholder", "Введите пароль");
assert.ok(passwordField);
assert.equal(passwordField.props.type, "password");
passwordField.props.onChange({ target: { value: "test-only-password" } });
passwordField.props.onChange("test-only-password");
tree = harness.render(props());
harness.flushEffects();
const showPassword = elementByProp(tree, "label", "Показать пароль");
assert.ok(showPassword);
assert.equal(showPassword.props.disabled, false);
showPassword.props.onClick();
tree = harness.render(props());
harness.flushEffects();
assert.equal(
elementByProp(tree, "label", "Пароль WiFi").props.type,
"text",
);
assert.ok(elementByProp(tree, "label", "Скрыть пароль"));
const chooseAnother = actionByLabel(tree, "Выбрать другое");
assert.ok(chooseAnother);
@@ -7333,7 +7329,7 @@ test("Step-2 credentials and Apply share one canonical field stack", () => {
assert.match(
form,
/:\s*\(\s*<div className="field-stack"[\s\S]*?label="Пароль WiFi"[\s\S]*?\{applyAction\}\s*<\/div>/,
/:\s*\(\s*<div className="field-stack"[\s\S]*?<K1WifiPasswordField[\s\S]*?\{applyAction\}\s*<\/div>/,
);
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", "Пароль WiFi").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", "Пароль WiFi").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", "Пароль WiFi").props.type, "password");
assert.equal(elementByProp(tree, "label", "Показать пароль").props.disabled, true);
assert.equal(elementByProp(tree, "label", "Пароль WiFi").props.autoComplete, "off");
} finally { harness.dispose(); }
});
@@ -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 ещё раз/);
});
+1 -1
View File
@@ -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
@@ -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.
@@ -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:0819: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.
@@ -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<string, string> = {
accepted: "Запрос принят",
@@ -97,6 +97,7 @@ function publicConnectionErrorLabel(
structured: ReturnType<typeof hostFailureDiagnosticPresentation>,
): 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];
}
@@ -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<BleDevice | null>(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<CompletedLocalNetworkIntent | null>(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}
/>
<div className="password-field-row">
<TextField
label="Пароль WiFi"
hint={connectionAttemptOwnsDraft
? "Удалён после отправки"
: "Только в оперативной памяти"}
type={passwordVisible ? "text" : "password"}
value={connectionAttemptOwnsDraft ? "" : password}
onChange={(event) => setPassword(event.target.value)}
disabled={provisioningFieldsDisabled}
autoComplete="off"
placeholder={connectionAttemptOwnsDraft
? "Пароль передан"
: "Введите пароль"}
/>
<IconButton
label={passwordVisible ? "Скрыть пароль" : "Показать пароль"}
shape="rounded"
disabled={provisioningFieldsDisabled || password.length === 0}
aria-pressed={passwordVisible}
onClick={() => setPasswordVisible((visible) => !visible)}
>
<Icon name={passwordVisible ? "eye-off" : "eye"} />
</IconButton>
</div>
<K1WifiPasswordField
value={connectionAttemptOwnsDraft ? "" : password}
onChange={setPassword}
disabled={provisioningFieldsDisabled}
hint={connectionAttemptOwnsDraft ? "Удалён после отправки" : "Только в оперативной памяти"}
placeholder={connectionAttemptOwnsDraft ? "Пароль передан" : "Введите пароль"}
/>
{applyAction}
</div>
)}
@@ -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;
}
@@ -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 <div className="password-field-row">
<TextField label="Пароль WiFi" type={visible?'text':'password'} value={value}
onChange={event=>onChange(event.target.value)} disabled={disabled}
hint={hint} placeholder={placeholder} autoComplete="off" spellCheck={false}/>
<IconButton label={visible?'Скрыть пароль':'Показать пароль'} shape="rounded"
disabled={disabled||!value} aria-pressed={visible} onClick={()=>setVisible(current=>!current)}>
<Icon name={visible?'eye-off':'eye'}/>
</IconButton>
</div>;
}
@@ -5,3 +5,15 @@ export const STATION_WIFI_FAILURE_MESSAGES: Readonly<Record<string, string>> = {
"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<Record<string, string>> = {
'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 не были отправлены.',
};
@@ -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") {
@@ -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}/>}
</div>
<TextField label="Название сети Wi-Fi" value={ssid} onChange={event=>{setSSID(event.target.value);setNetwork('manual');}} disabled={pending} autoComplete="off"/>
<TextField label="Пароль Wi-Fi" type="password" value={password} onChange={event=>setPassword(event.target.value)} disabled={pending} autoComplete="new-password"/>
<K1WifiPasswordField value={password} onChange={setPassword} disabled={pending}/>
<Button disabled={pending||!enrollmentAllowed(state,'connect')||!bridgeFormValid(ssid,password)} aria-busy={busy==='connect'}
icon={busy==='connect'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void run('connect')}>{busy==='connect'?'Применяем Wi-Fi и проверяем':'Применить Wi-Fi и проверить'}</Button>
@@ -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 {
@@ -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;
+2 -2
View File
@@ -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 <noreply@example.invalid>
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)
+1
View File
@@ -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",
@@ -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)
@@ -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(
@@ -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)
@@ -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",
@@ -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
+140
View File
@@ -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())
+16 -11
View File
@@ -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"
+1 -1
View File
@@ -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
+57
View File
@@ -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())
Generated
+3
View File
@@ -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" },