Unify K1 discovery ownership and verification across enrollment paths
This commit is contained in:
@@ -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", "Пароль Wi‑Fi");
|
||||
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", "Пароль Wi‑Fi").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="Пароль Wi‑Fi"[\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", "Пароль 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(); }
|
||||
});
|
||||
|
||||
@@ -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 ещё раз/);
|
||||
});
|
||||
|
||||
@@ -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: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.
|
||||
|
||||
@@ -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="Пароль Wi‑Fi"
|
||||
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="Пароль Wi‑Fi" 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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user