fix(k1): restore canonical local connection lifecycle
This commit is contained in:
@@ -635,6 +635,72 @@ test("a provisioned address is not presented as a verified device connection", (
|
||||
}), "connected");
|
||||
});
|
||||
|
||||
test("K1 provisioning mutations require the operator's fresh BLE candidate", () => {
|
||||
const backendLease = {
|
||||
selected_device_id: "stale-backend-lease-device",
|
||||
connection_mode: "bridge",
|
||||
devices: [
|
||||
{
|
||||
device_id: "fresh-scan-candidate",
|
||||
name: "K1 candidate",
|
||||
connectable: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
lifecycle.provisioningCandidateById(backendLease.devices, ""),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
lifecycle.provisioningCandidateById(
|
||||
backendLease.devices,
|
||||
backendLease.selected_device_id,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(lifecycle.canSubmitProvisioningMutation({
|
||||
devices: backendLease.devices,
|
||||
selectedDeviceId: "expired-scan-candidate",
|
||||
powerConfirmed: true,
|
||||
credentialsReady: true,
|
||||
isBusy: false,
|
||||
}), false);
|
||||
assert.equal(lifecycle.canSubmitProvisioningMutation({
|
||||
devices: backendLease.devices,
|
||||
selectedDeviceId: "fresh-scan-candidate",
|
||||
powerConfirmed: true,
|
||||
credentialsReady: true,
|
||||
isBusy: false,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("K1 connection status is green only for a reachable matching lease", () => {
|
||||
const unreachableLease = {
|
||||
k1_ip: "192.168.68.50",
|
||||
connection_mode: "bridge",
|
||||
connection_verification: {
|
||||
lease_state: "disconnected",
|
||||
network_reachability: "unreachable",
|
||||
},
|
||||
};
|
||||
const reachableLease = {
|
||||
...unreachableLease,
|
||||
connection_verification: {
|
||||
lease_state: "reachable",
|
||||
network_reachability: "reachable",
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(lifecycle.isReachableConnectionLease(unreachableLease, "bridge"), false);
|
||||
assert.equal(lifecycle.isReachableConnectionLease(reachableLease, "bridge"), true);
|
||||
assert.equal(lifecycle.isReachableConnectionLease(reachableLease, "quick-connect"), false);
|
||||
assert.equal(lifecycle.isReachableConnectionLease({
|
||||
k1_ip: "192.168.68.50",
|
||||
connection_mode: "bridge",
|
||||
}, "bridge"), false);
|
||||
});
|
||||
|
||||
test("provisioning intent keeps one idempotency key and exposes unsafe outcomes", () => {
|
||||
let created = 0;
|
||||
const createUuid = () => {
|
||||
@@ -751,3 +817,52 @@ test("rejected network-profile writes use safe operator copy", () => {
|
||||
);
|
||||
assert.doesNotMatch(message, /Bleak|GATT|ATT/i);
|
||||
});
|
||||
|
||||
test("host Wi-Fi helper failures do not fabricate a missing-password diagnosis", () => {
|
||||
const operationTimeout = networkProvisionFailureMessage({
|
||||
status: "failed",
|
||||
error: { code: "host-wifi-operation-timeout" },
|
||||
});
|
||||
const buildTimeout = networkProvisionFailureMessage({
|
||||
status: "failed",
|
||||
error: {
|
||||
code: "host-wifi-helper-build-timeout",
|
||||
side_effect_status: "none",
|
||||
safe_to_retry: true,
|
||||
},
|
||||
});
|
||||
const buildFailed = networkProvisionFailureMessage({
|
||||
status: "failed",
|
||||
error: {
|
||||
code: "host-wifi-helper-build-failed",
|
||||
side_effect_status: "none",
|
||||
safe_to_retry: true,
|
||||
},
|
||||
});
|
||||
const postWriteBuildFailed = networkProvisionFailureMessage({
|
||||
status: "failed",
|
||||
error: {
|
||||
code: "host-wifi-helper-build-failed",
|
||||
side_effect_status: "confirmed",
|
||||
safe_to_retry: false,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
operationTimeout,
|
||||
"Локальная операция подготовки Wi‑Fi не завершилась вовремя. Это могло произойти до изменения состояния K1; наличие сохранённого пароля этим кодом не подтверждается и не опровергается. Проверьте состояние K1 и повторите подключение отдельным действием.",
|
||||
);
|
||||
assert.doesNotMatch(operationTimeout, /получите пароль|пароль отсутствует/i);
|
||||
assert.equal(
|
||||
buildTimeout,
|
||||
"Локальный компонент Wi‑Fi не успел собраться за отведённое время. Команда K1 не отправлялась; подготовьте локальный компонент и повторите подключение отдельным действием.",
|
||||
);
|
||||
assert.equal(
|
||||
buildFailed,
|
||||
"Локальный компонент Wi‑Fi не удалось собрать. Команда K1 не отправлялась; подготовьте локальный компонент и повторите подключение отдельным действием.",
|
||||
);
|
||||
assert.equal(
|
||||
postWriteBuildFailed,
|
||||
"Локальный компонент Wi‑Fi не удалось собрать уже после начала операции с K1. Состояние устройства нельзя выводить из этой локальной ошибки; автоматического повтора команды не было. Выполните read-only проверку K1 перед новым подключением.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -167,12 +167,6 @@ test("K1 Bridge adoption remains one explicit read-only plugin action", () => {
|
||||
join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const selectionEffectStart = provisioning.indexOf("useEffect(() => {");
|
||||
const selectionEffectEnd = provisioning.indexOf(
|
||||
"useEffect(() => {",
|
||||
selectionEffectStart + 1,
|
||||
);
|
||||
const selectionEffect = provisioning.slice(selectionEffectStart, selectionEffectEnd);
|
||||
assert.match(provisioning, /connectionMode === "bridge"/);
|
||||
assert.match(provisioning, /Подхватить существующее подключение/);
|
||||
assert.match(provisioning, /pendingAction === "verify"/);
|
||||
@@ -181,9 +175,23 @@ test("K1 Bridge adoption remains one explicit read-only plugin action", () => {
|
||||
/compatibility_attestation: profileSelectionForConnectionMode\("bridge"\)/,
|
||||
);
|
||||
assert.match(provisioning, /без изменения настроек Wi‑Fi/);
|
||||
assert.match(provisioning, /deviceSummary !== undefined/);
|
||||
assert.match(selectionEffect, /!state\.devices\.some/);
|
||||
assert.match(selectionEffect, /setSelectedDeviceId\(""\)/);
|
||||
assert.match(provisioning, /deviceSummary !== null/);
|
||||
assert.match(provisioning, /device_id: deviceSummary\.device_id/);
|
||||
});
|
||||
|
||||
test("K1 provisioning keeps the operator draft separate from the backend lease", () => {
|
||||
const provisioning = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.doesNotMatch(provisioning, /setSelectedDeviceId\(state\.selected_device_id\)/);
|
||||
assert.doesNotMatch(provisioning, /setConnectionMode\(state\.connection_mode\)/);
|
||||
assert.match(provisioning, /canSubmitProvisioningMutation\(\{/);
|
||||
assert.match(provisioning, /isReachableConnectionLease\(state, connectionMode\)/);
|
||||
assert.doesNotMatch(provisioning, /else if \(connectionMode === "quick-connect"\)/);
|
||||
assert.match(provisioning, /const succeeded = await verifyConnection\(\{/);
|
||||
assert.match(provisioning, /if \(succeeded\) \{\s*provisioningIntentRef\.current = null;/);
|
||||
});
|
||||
|
||||
test("generic Control Station has one composition import and no K1 implementation knowledge", () => {
|
||||
|
||||
@@ -82,6 +82,19 @@ export interface XgridsConnectionVerification {
|
||||
reason_code?: string | null;
|
||||
}
|
||||
|
||||
export interface XgridsNetworkWriteReconciliation {
|
||||
status: "device-state-unknown-after-write";
|
||||
operation_id: string;
|
||||
transport_ref: string;
|
||||
connection_mode: "bridge" | "quick-connect" | "direct-connect";
|
||||
operation_stage: string;
|
||||
reason_code: string;
|
||||
device_write_confirmed: boolean;
|
||||
required_action: "explicit-read-only-ble-status-observation";
|
||||
scope: "process-runtime";
|
||||
observed_at: string;
|
||||
}
|
||||
|
||||
export interface XgridsCompatibilityState {
|
||||
profile_id?: string | null;
|
||||
decision?: "compatible" | "limited" | "unknown" | "incompatible";
|
||||
@@ -352,6 +365,7 @@ export interface XgridsK1State {
|
||||
device_ref?: XgridsDeviceRef | null;
|
||||
device_session?: XgridsDeviceSession | null;
|
||||
connection_verification?: XgridsConnectionVerification | null;
|
||||
network_write_reconciliation?: XgridsNetworkWriteReconciliation | null;
|
||||
acquisition?: XgridsAcquisition | null;
|
||||
operations?: XgridsOperation[];
|
||||
last_operation?: XgridsOperation | null;
|
||||
|
||||
@@ -17,7 +17,12 @@ import {
|
||||
connectionModeOptions,
|
||||
type ConnectionMode,
|
||||
} from "../configuration";
|
||||
import { provisioningIntentKey } from "../lifecycle";
|
||||
import {
|
||||
canSubmitProvisioningMutation,
|
||||
isReachableConnectionLease,
|
||||
provisioningCandidateById,
|
||||
provisioningIntentKey,
|
||||
} from "../lifecycle";
|
||||
import { finiteMetric } from "../presentation";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
|
||||
@@ -131,39 +136,34 @@ export function K1ProvisioningPipeline({
|
||||
const isBusy = pendingAction !== null;
|
||||
const credentialsReady = connectionMode === "quick-connect"
|
||||
|| (ssid.trim().length > 0 && password.length > 0);
|
||||
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||
const modeCopy = connectionCopy[connectionMode];
|
||||
const selectedModeConnected = Boolean(
|
||||
state?.k1_ip && state.connection_mode === connectionMode,
|
||||
const networkWriteReconciliationPending = Boolean(
|
||||
state?.network_write_reconciliation,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.selected_device_id) {
|
||||
if (state.selected_device_id !== selectedDeviceId) {
|
||||
provisioningIntentRef.current = null;
|
||||
}
|
||||
setSelectedDeviceId(state.selected_device_id);
|
||||
return;
|
||||
}
|
||||
if (selectedDeviceId && state?.devices && !state.devices.some((device) => device.device_id === selectedDeviceId)) {
|
||||
setSelectedDeviceId("");
|
||||
}
|
||||
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.connection_mode) {
|
||||
setConnectionMode(state.connection_mode);
|
||||
}
|
||||
}, [state?.connection_mode]);
|
||||
|
||||
const deviceSummary = useMemo(
|
||||
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||
() => provisioningCandidateById(devices, selectedDeviceId),
|
||||
[devices, selectedDeviceId],
|
||||
);
|
||||
const canConnect = !networkWriteReconciliationPending && canSubmitProvisioningMutation({
|
||||
devices,
|
||||
selectedDeviceId,
|
||||
powerConfirmed,
|
||||
credentialsReady,
|
||||
isBusy,
|
||||
});
|
||||
const modeCopy = connectionCopy[connectionMode];
|
||||
const selectedModeConnected = isReachableConnectionLease(state, connectionMode);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId && !deviceSummary) {
|
||||
provisioningIntentRef.current = null;
|
||||
setSelectedDeviceId("");
|
||||
}
|
||||
}, [deviceSummary, selectedDeviceId]);
|
||||
|
||||
const canAdoptExistingBridge = connectionMode === "bridge"
|
||||
&& powerConfirmed
|
||||
&& selectedDeviceId.length > 0
|
||||
&& deviceSummary !== undefined
|
||||
&& deviceSummary !== null
|
||||
&& deviceSummary.connectable !== false
|
||||
&& !isBusy;
|
||||
|
||||
const resetProvisioningIntent = () => {
|
||||
@@ -171,14 +171,14 @@ export function K1ProvisioningPipeline({
|
||||
};
|
||||
|
||||
const submitConnect = async () => {
|
||||
if (!canConnect) return;
|
||||
if (!canConnect || !deviceSummary) return;
|
||||
const idempotencyKey = provisioningIntentKey(provisioningIntentRef.current);
|
||||
provisioningIntentRef.current = idempotencyKey;
|
||||
const networkCredentials = connectionMode === "quick-connect"
|
||||
? {}
|
||||
: { ssid: ssid.trim(), password };
|
||||
const succeeded = await connect({
|
||||
device_id: selectedDeviceId,
|
||||
device_id: deviceSummary.device_id,
|
||||
...networkCredentials,
|
||||
connection_mode: connectionMode,
|
||||
compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
|
||||
@@ -187,20 +187,24 @@ export function K1ProvisioningPipeline({
|
||||
if (succeeded) {
|
||||
provisioningIntentRef.current = null;
|
||||
setPassword("");
|
||||
} else if (connectionMode === "quick-connect") {
|
||||
// The backend has already persisted and reconciled the failed bounded
|
||||
// attempt. A later click is a new explicit Quick Connect intent, not an
|
||||
// automatic replay of the consumed operation key.
|
||||
} else {
|
||||
// Every later click is a new explicit operator intent, never an
|
||||
// automatic replay of a consumed failed journal entry. If the prior
|
||||
// write outcome is ambiguous, the backend reconciliation fence blocks
|
||||
// this new intent before another device write for both modes.
|
||||
provisioningIntentRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const submitExistingBridgeAdoption = async () => {
|
||||
if (!canAdoptExistingBridge) return;
|
||||
await verifyConnection({
|
||||
device_id: selectedDeviceId,
|
||||
if (!canAdoptExistingBridge || !deviceSummary) return;
|
||||
const succeeded = await verifyConnection({
|
||||
device_id: deviceSummary.device_id,
|
||||
compatibility_attestation: profileSelectionForConnectionMode("bridge"),
|
||||
});
|
||||
if (succeeded) {
|
||||
provisioningIntentRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -241,8 +245,8 @@ export function K1ProvisioningPipeline({
|
||||
<WizardStep
|
||||
number="02"
|
||||
title="Выберите Bluetooth-устройство"
|
||||
status={pendingAction === "scan" ? "Поиск…" : selectedDeviceId ? "Устройство выбрано" : `Найдено: ${devices.length}`}
|
||||
tone={pendingAction === "scan" ? "accent" : selectedDeviceId ? "success" : "neutral"}
|
||||
status={pendingAction === "scan" ? "Поиск…" : deviceSummary ? "Устройство выбрано" : `Найдено: ${devices.length}`}
|
||||
tone={pendingAction === "scan" ? "accent" : deviceSummary ? "success" : "neutral"}
|
||||
>
|
||||
<p className="step-copy">Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; точные модель, platform type и прошивка будут проверены по живому DeviceInfo перед START.</p>
|
||||
<Button
|
||||
@@ -282,7 +286,12 @@ export function K1ProvisioningPipeline({
|
||||
<TextField label="Пароль Wi‑Fi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
|
||||
</div>
|
||||
)}
|
||||
<div className="connection-summary"><span>Устройство</span><strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong></div>
|
||||
<div className="connection-summary"><span>Устройство</span><strong>{deviceSummary?.name || deviceSummary?.device_id || "Сначала выберите устройство"}</strong></div>
|
||||
{networkWriteReconciliationPending ? (
|
||||
<p className="safety-note">
|
||||
Предыдущая BLE-запись завершилась до подтверждения актуального состояния K1. Новая запись заблокирована: выберите Bridge и выполните read-only подхват существующего подключения.
|
||||
</p>
|
||||
) : null}
|
||||
<Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}>
|
||||
{pendingAction === "connect"
|
||||
? connectionMode === "quick-connect" ? "Включаем точку и подключаем…" : "Подключаем…"
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "@mission-cor
|
||||
|
||||
import type {
|
||||
AcquisitionState,
|
||||
BleDevice,
|
||||
XgridsApplicationControlPhase,
|
||||
XgridsAcquisition,
|
||||
XgridsK1State,
|
||||
@@ -200,6 +201,50 @@ export function operationNeedsReconciliation(
|
||||
return operation.error?.safe_to_retry !== true;
|
||||
}
|
||||
|
||||
export function provisioningCandidateById(
|
||||
devices: readonly BleDevice[],
|
||||
selectedDeviceId: string,
|
||||
): BleDevice | null {
|
||||
if (!selectedDeviceId) return null;
|
||||
return devices.find((device) => device.device_id === selectedDeviceId) ?? null;
|
||||
}
|
||||
|
||||
export function canSubmitProvisioningMutation({
|
||||
devices,
|
||||
selectedDeviceId,
|
||||
powerConfirmed,
|
||||
credentialsReady,
|
||||
isBusy,
|
||||
}: {
|
||||
devices: readonly BleDevice[];
|
||||
selectedDeviceId: string;
|
||||
powerConfirmed: boolean;
|
||||
credentialsReady: boolean;
|
||||
isBusy: boolean;
|
||||
}): boolean {
|
||||
const candidate = provisioningCandidateById(devices, selectedDeviceId);
|
||||
return Boolean(
|
||||
powerConfirmed &&
|
||||
credentialsReady &&
|
||||
!isBusy &&
|
||||
candidate &&
|
||||
candidate.connectable !== false,
|
||||
);
|
||||
}
|
||||
|
||||
export function isReachableConnectionLease(
|
||||
state: XgridsK1State | null | undefined,
|
||||
connectionMode: NonNullable<XgridsK1State["connection_mode"]>,
|
||||
): boolean {
|
||||
const verification = state?.connection_verification;
|
||||
return Boolean(
|
||||
state?.k1_ip &&
|
||||
state.connection_mode === connectionMode &&
|
||||
verification?.lease_state === "reachable" &&
|
||||
verification.network_reachability === "reachable",
|
||||
);
|
||||
}
|
||||
|
||||
function defaultUuid(): string {
|
||||
const cryptoApi = globalThis.crypto;
|
||||
if (!cryptoApi) {
|
||||
|
||||
@@ -200,6 +200,21 @@ export function networkProvisionFailureMessage(
|
||||
const code = operation.error?.code;
|
||||
if (typeof code !== "string") return null;
|
||||
|
||||
if (
|
||||
code === "host-wifi-helper-build-timeout"
|
||||
|| code === "host-wifi-helper-build-failed"
|
||||
) {
|
||||
const failedBeforeDeviceWrite = operation.error?.side_effect_status === "none"
|
||||
&& operation.error?.safe_to_retry === true;
|
||||
const failure = code === "host-wifi-helper-build-timeout"
|
||||
? "не успел собраться за отведённое время"
|
||||
: "не удалось собрать";
|
||||
if (failedBeforeDeviceWrite) {
|
||||
return `Локальный компонент Wi‑Fi ${failure}. Команда K1 не отправлялась; подготовьте локальный компонент и повторите подключение отдельным действием.`;
|
||||
}
|
||||
return `Локальный компонент Wi‑Fi ${failure} уже после начала операции с K1. Состояние устройства нельзя выводить из этой локальной ошибки; автоматического повтора команды не было. Выполните read-only проверку K1 перед новым подключением.`;
|
||||
}
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
BleakGATTProtocolError:
|
||||
"Сканер отклонил запись сетевого профиля. Результат изменения сети неизвестен; автоматический повтор запрещён. Проверьте текущее состояние K1 или подхватите существующее подключение без изменения настроек Wi‑Fi.",
|
||||
@@ -210,7 +225,7 @@ export function networkProvisionFailureMessage(
|
||||
"credential-invalid":
|
||||
"Пароль точки доступа K1 имеет недопустимую длину. Получите сохранённый пароль этого K1 в LixelGO/iPhone и повторите подключение.",
|
||||
"host-wifi-operation-timeout":
|
||||
"Первичное системное подключение к K1 не было завершено вовремя. BLE-команда автоматически не повторялась; получите пароль сохранённой сети этого K1 и запустите подключение заново.",
|
||||
"Локальная операция подготовки Wi‑Fi не завершилась вовремя. Это могло произойти до изменения состояния K1; наличие сохранённого пароля этим кодом не подтверждается и не опровергается. Проверьте состояние K1 и повторите подключение отдельным действием.",
|
||||
"profile-ssid-mismatch":
|
||||
"Сохранённый профиль относится к другому K1. Подключение остановлено без повторной команды сканеру.",
|
||||
"corewlan-error":
|
||||
|
||||
@@ -25,7 +25,7 @@ WHEEL_NAME = "nodedc_mission_core-0.1.0-py3-none-any.whl"
|
||||
RUNNER_NAME = RUNNER.name
|
||||
PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
|
||||
EXPECTED_BASELINE_SHA256 = "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
|
||||
EXPECTED_WHEEL_SHA256 = "c396a202d5cddc2d22dcc3e8b936519205399d20b0f060c763c02e51ba16c62a"
|
||||
EXPECTED_WHEEL_SHA256 = "ac0ee30446130d3e309cd01e875bec81171a30e057d11d8558a12bb8aec9bf26"
|
||||
PAYLOAD_FILES = (
|
||||
RUNNER_NAME,
|
||||
WHEEL_NAME,
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from importlib.metadata import version
|
||||
from time import monotonic
|
||||
from typing import Literal, TypedDict
|
||||
@@ -11,15 +11,18 @@ from bleak import BleakClient, BleakScanner
|
||||
from bleak.exc import BleakDeviceNotFoundError, BleakError
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import discovered_device_selection
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
AP_FALLBACK_IPV4,
|
||||
SERVICE_UUID,
|
||||
STATUS_CHARACTERISTIC_UUID,
|
||||
WRITE_CHARACTERISTIC_UUID,
|
||||
BleOperationStage,
|
||||
ResolvedWriteMode,
|
||||
StatusObservation,
|
||||
WifiStatus,
|
||||
WriteMode,
|
||||
_annotate_ble_operation_error,
|
||||
parse_wifi_status,
|
||||
)
|
||||
|
||||
@@ -124,149 +127,198 @@ async def device_ap_activation_session(
|
||||
started_at = utc_now_iso()
|
||||
observations: list[StatusObservation] = []
|
||||
disconnected = False
|
||||
operation_stage: BleOperationStage = "resolution"
|
||||
device_write_attempted = False
|
||||
device_write_confirmed = False
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds + 25.0):
|
||||
# AP activation is a mutation boundary: prove that the selected
|
||||
# device is advertising now instead of trusting a CoreBluetooth
|
||||
# handle retained by an earlier UI scan.
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=min(20.0, timeout_seconds),
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds + 25.0):
|
||||
# Keep the explicit scan and AP activation in one CoreBluetooth
|
||||
# lifecycle. Re-looking up the UUID here lost a physically present
|
||||
# K1 during acceptance, while the retained BLEDevice connected.
|
||||
selection = discovered_device_selection(device_macos_uuid)
|
||||
device = selection.device
|
||||
if device is None and not selection.from_fresh_scan:
|
||||
# Preserve a bounded fallback for non-UI callers that did not
|
||||
# establish a fresh explicit scan lease.
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=min(20.0, timeout_seconds),
|
||||
)
|
||||
if device is None:
|
||||
raise BleakDeviceNotFoundError(
|
||||
device_macos_uuid,
|
||||
"Device was not rediscovered; keep the K1 powered and nearby.",
|
||||
)
|
||||
except Exception as exc:
|
||||
_annotate_ble_operation_error(
|
||||
exc,
|
||||
operation_stage=operation_stage,
|
||||
device_write_attempted=device_write_attempted,
|
||||
device_write_confirmed=device_write_confirmed,
|
||||
)
|
||||
if device is None:
|
||||
raise BleakDeviceNotFoundError(
|
||||
device_macos_uuid,
|
||||
"Device was not rediscovered; keep the K1 powered and nearby.",
|
||||
)
|
||||
raise
|
||||
|
||||
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
|
||||
async with asyncio.timeout(timeout_seconds + 10.0):
|
||||
device_name = client.name
|
||||
service = client.services.get_service(SERVICE_UUID)
|
||||
write_characteristic = client.services.get_characteristic(
|
||||
WRITE_CHARACTERISTIC_UUID
|
||||
async with AsyncExitStack() as client_stack:
|
||||
operation_stage = "connect"
|
||||
try:
|
||||
client = await client_stack.enter_async_context(
|
||||
BleakClient(device, timeout=timeout_seconds, pair=False)
|
||||
)
|
||||
status_characteristic = client.services.get_characteristic(
|
||||
STATUS_CHARACTERISTIC_UUID
|
||||
except Exception as exc:
|
||||
_annotate_ble_operation_error(
|
||||
exc,
|
||||
operation_stage=operation_stage,
|
||||
device_write_attempted=device_write_attempted,
|
||||
device_write_confirmed=device_write_confirmed,
|
||||
)
|
||||
if service is None:
|
||||
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
|
||||
if write_characteristic is None:
|
||||
raise ValueError(
|
||||
"Reviewed K1 AP-control characteristic not found: "
|
||||
f"{WRITE_CHARACTERISTIC_UUID}"
|
||||
)
|
||||
if status_characteristic is None:
|
||||
raise ValueError(
|
||||
f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}"
|
||||
)
|
||||
if write_characteristic.service_uuid != service.uuid:
|
||||
raise ValueError(
|
||||
"K1 AP-control characteristic is attached to an unexpected service"
|
||||
)
|
||||
if status_characteristic.service_uuid != service.uuid:
|
||||
raise ValueError(
|
||||
"K1 status characteristic is attached to an unexpected service"
|
||||
)
|
||||
if "read" not in status_characteristic.properties:
|
||||
raise ValueError("Reviewed K1 status characteristic is not readable")
|
||||
raise
|
||||
|
||||
properties = set(write_characteristic.properties)
|
||||
max_without_response = write_characteristic.max_write_without_response_size
|
||||
baseline = parse_wifi_status(
|
||||
bytes(await client.read_gatt_char(status_characteristic))
|
||||
)
|
||||
# WIFI_AP is a control-mode status, not proof that the radio is
|
||||
# still beaconing. A physical run found the exact SSID shortly
|
||||
# after AP-enable, then found no beacon while 7f02 continued to
|
||||
# report WIFI_AP. LixelGO emits the reviewed enable frame for
|
||||
# each explicit Quick Connect action, so Mission Core does the
|
||||
# same once per operator action instead of short-circuiting on
|
||||
# a stale-ready status. There is still no automatic retry.
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds + 10.0):
|
||||
device_name = client.name
|
||||
operation_stage = "gatt-contract"
|
||||
service = client.services.get_service(SERVICE_UUID)
|
||||
write_characteristic = client.services.get_characteristic(
|
||||
WRITE_CHARACTERISTIC_UUID
|
||||
)
|
||||
status_characteristic = client.services.get_characteristic(
|
||||
STATUS_CHARACTERISTIC_UUID
|
||||
)
|
||||
if service is None:
|
||||
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
|
||||
if write_characteristic is None:
|
||||
raise ValueError(
|
||||
"Reviewed K1 AP-control characteristic not found: "
|
||||
f"{WRITE_CHARACTERISTIC_UUID}"
|
||||
)
|
||||
if status_characteristic is None:
|
||||
raise ValueError(
|
||||
"Reviewed K1 status characteristic not found: "
|
||||
f"{STATUS_CHARACTERISTIC_UUID}"
|
||||
)
|
||||
if write_characteristic.service_uuid != service.uuid:
|
||||
raise ValueError(
|
||||
"K1 AP-control characteristic is attached to an unexpected service"
|
||||
)
|
||||
if status_characteristic.service_uuid != service.uuid:
|
||||
raise ValueError(
|
||||
"K1 status characteristic is attached to an unexpected service"
|
||||
)
|
||||
if "read" not in status_characteristic.properties:
|
||||
raise ValueError("Reviewed K1 status characteristic is not readable")
|
||||
|
||||
resolved_write_mode: ResolvedWriteMode
|
||||
if write_mode == "auto":
|
||||
if "write-without-response" in properties:
|
||||
resolved_write_mode = "without_response"
|
||||
elif "write" in properties:
|
||||
properties = set(write_characteristic.properties)
|
||||
max_without_response = write_characteristic.max_write_without_response_size
|
||||
resolved_write_mode: ResolvedWriteMode
|
||||
if write_mode == "auto":
|
||||
if "write-without-response" in properties:
|
||||
resolved_write_mode = "without_response"
|
||||
elif "write" in properties:
|
||||
resolved_write_mode = "with_response"
|
||||
else:
|
||||
raise ValueError("Reviewed K1 characteristic is not writable")
|
||||
elif write_mode == "with_response":
|
||||
if "write" not in properties:
|
||||
raise ValueError(
|
||||
"Reviewed K1 characteristic does not advertise writes with response"
|
||||
)
|
||||
resolved_write_mode = "with_response"
|
||||
else:
|
||||
raise ValueError("Reviewed K1 characteristic is not writable")
|
||||
elif write_mode == "with_response":
|
||||
if "write" not in properties:
|
||||
raise ValueError(
|
||||
"Reviewed K1 characteristic does not advertise writes with response"
|
||||
)
|
||||
resolved_write_mode = "with_response"
|
||||
else:
|
||||
if len(frame) > max_without_response:
|
||||
raise ValueError(
|
||||
"AP activation frame exceeds the negotiated "
|
||||
"write-without-response size"
|
||||
)
|
||||
resolved_write_mode = "without_response"
|
||||
if len(frame) > max_without_response:
|
||||
raise ValueError(
|
||||
"AP activation frame exceeds the negotiated "
|
||||
"write-without-response size"
|
||||
)
|
||||
resolved_write_mode = "without_response"
|
||||
|
||||
await client.write_gatt_char(
|
||||
write_characteristic,
|
||||
frame,
|
||||
response=resolved_write_mode == "with_response",
|
||||
)
|
||||
write_completed = monotonic()
|
||||
deadline = write_completed + timeout_seconds
|
||||
operation_stage = "baseline-read"
|
||||
baseline = parse_wifi_status(
|
||||
bytes(await client.read_gatt_char(status_characteristic))
|
||||
)
|
||||
# WIFI_AP is a control-mode status, not proof that the radio is
|
||||
# still beaconing. A physical run found the exact SSID shortly
|
||||
# after AP-enable, then found no beacon while 7f02 continued to
|
||||
# report WIFI_AP. LixelGO emits the reviewed enable frame for
|
||||
# each explicit Quick Connect action, so Mission Core does the
|
||||
# same once per operator action instead of short-circuiting on
|
||||
# a stale-ready status. There is still no automatic retry.
|
||||
|
||||
while monotonic() < deadline:
|
||||
try:
|
||||
status = parse_wifi_status(
|
||||
bytes(await client.read_gatt_char(status_characteristic))
|
||||
)
|
||||
except BleakError:
|
||||
if not client.is_connected:
|
||||
disconnected = True
|
||||
operation_stage = "gatt-write"
|
||||
device_write_attempted = True
|
||||
await client.write_gatt_char(
|
||||
write_characteristic,
|
||||
frame,
|
||||
response=resolved_write_mode == "with_response",
|
||||
)
|
||||
device_write_confirmed = resolved_write_mode == "with_response"
|
||||
write_completed = monotonic()
|
||||
deadline = write_completed + timeout_seconds
|
||||
|
||||
operation_stage = "status-poll"
|
||||
while monotonic() < deadline:
|
||||
try:
|
||||
status = parse_wifi_status(
|
||||
bytes(await client.read_gatt_char(status_characteristic))
|
||||
)
|
||||
except BleakError:
|
||||
if not client.is_connected:
|
||||
disconnected = True
|
||||
break
|
||||
raise
|
||||
observation: StatusObservation = {
|
||||
"observed_at_utc": utc_now_iso(),
|
||||
"seconds_after_write": round(monotonic() - write_completed, 3),
|
||||
"status": status,
|
||||
}
|
||||
if not observations or status != observations[-1]["status"]:
|
||||
observations.append(observation)
|
||||
if is_ap_ready_status(status):
|
||||
break
|
||||
raise
|
||||
observation: StatusObservation = {
|
||||
"observed_at_utc": utc_now_iso(),
|
||||
"seconds_after_write": round(monotonic() - write_completed, 3),
|
||||
"status": status,
|
||||
await asyncio.sleep(poll_interval_seconds)
|
||||
|
||||
result: ApActivationResult = {
|
||||
"schema_version": 1,
|
||||
"profile_id": PROFILE_ID,
|
||||
"started_at_utc": started_at,
|
||||
"completed_at_utc": utc_now_iso(),
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_macos_uuid": device_macos_uuid,
|
||||
"device_name": device_name,
|
||||
"service_uuid": service.uuid,
|
||||
"write_characteristic_uuid": write_characteristic.uuid,
|
||||
"status_characteristic_uuid": status_characteristic.uuid,
|
||||
"operation": "single_reviewed_quick_connect_ap_activation",
|
||||
"write_performed": True,
|
||||
"write_mode": resolved_write_mode,
|
||||
"write_without_response_advertised": (
|
||||
"write-without-response" in properties
|
||||
),
|
||||
"max_write_without_response_size": max_without_response,
|
||||
"frame_length": len(frame),
|
||||
"baseline_status": baseline,
|
||||
"observations": observations,
|
||||
"ready_observed": bool(
|
||||
observations and is_ap_ready_status(observations[-1]["status"])
|
||||
),
|
||||
"outcome": _outcome(baseline, observations, disconnected),
|
||||
}
|
||||
if not observations or status != observations[-1]["status"]:
|
||||
observations.append(observation)
|
||||
if is_ap_ready_status(status):
|
||||
break
|
||||
await asyncio.sleep(poll_interval_seconds)
|
||||
except Exception as exc:
|
||||
_annotate_ble_operation_error(
|
||||
exc,
|
||||
operation_stage=operation_stage,
|
||||
device_write_attempted=device_write_attempted,
|
||||
device_write_confirmed=device_write_confirmed,
|
||||
)
|
||||
raise
|
||||
|
||||
result: ApActivationResult = {
|
||||
"schema_version": 1,
|
||||
"profile_id": PROFILE_ID,
|
||||
"started_at_utc": started_at,
|
||||
"completed_at_utc": utc_now_iso(),
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_macos_uuid": device_macos_uuid,
|
||||
"device_name": device_name,
|
||||
"service_uuid": service.uuid,
|
||||
"write_characteristic_uuid": write_characteristic.uuid,
|
||||
"status_characteristic_uuid": status_characteristic.uuid,
|
||||
"operation": "single_reviewed_quick_connect_ap_activation",
|
||||
"write_performed": True,
|
||||
"write_mode": resolved_write_mode,
|
||||
"write_without_response_advertised": (
|
||||
"write-without-response" in properties
|
||||
),
|
||||
"max_write_without_response_size": max_without_response,
|
||||
"frame_length": len(frame),
|
||||
"baseline_status": baseline,
|
||||
"observations": observations,
|
||||
"ready_observed": bool(
|
||||
observations and is_ap_ready_status(observations[-1]["status"])
|
||||
),
|
||||
"outcome": _outcome(baseline, observations, disconnected),
|
||||
}
|
||||
# Keep the same CoreBluetooth session alive while the caller waits
|
||||
# for and performs the host-side CoreWLAN association. LixelGO does
|
||||
# not tear down this BLE manager between its AP-ready callback and
|
||||
# native Wi-Fi connect call.
|
||||
# native Wi-Fi connect call. Caller exceptions are intentionally not
|
||||
# annotated as BLE failures when they are thrown back through yield.
|
||||
yield result
|
||||
finally:
|
||||
frame[:] = b"\x00" * len(frame)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from importlib.metadata import version
|
||||
from threading import Lock
|
||||
from time import monotonic
|
||||
from typing import TypedDict
|
||||
|
||||
from bleak import BleakScanner
|
||||
@@ -10,8 +12,31 @@ from bleak.backends.scanner import AdvertisementData
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
# The operator-visible candidate lease is the admission contract. The exact
|
||||
# CoreBluetooth handle gets a small internal grace window because it is
|
||||
# published just before the facade timestamps the same scan result. This
|
||||
# guarantees that a UI-admissible candidate can never fall into a second UUID
|
||||
# lookup at the millisecond boundary.
|
||||
BLE_DISCOVERY_CANDIDATE_LEASE_TTL_SECONDS = 60.0
|
||||
BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS = (
|
||||
BLE_DISCOVERY_CANDIDATE_LEASE_TTL_SECONDS + 5.0
|
||||
)
|
||||
# Compatibility alias for callers that historically treated this as the
|
||||
# low-level retained-handle lifetime.
|
||||
BLE_DISCOVERY_LEASE_TTL_SECONDS = BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS
|
||||
|
||||
_runtime_handle_lock = Lock()
|
||||
_runtime_handles: dict[str, BLEDevice] = {}
|
||||
_runtime_handle_generation = 0
|
||||
_runtime_handle_observed_at_monotonic: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveredDeviceSelection:
|
||||
"""One device selection from the latest still-live explicit BLE scan."""
|
||||
|
||||
device: BLEDevice | None
|
||||
from_fresh_scan: bool
|
||||
|
||||
|
||||
class BleDeviceRecord(TypedDict):
|
||||
@@ -62,11 +87,69 @@ def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) ->
|
||||
}
|
||||
|
||||
|
||||
def discovered_device(macos_uuid: str) -> BLEDevice | None:
|
||||
"""Return the live CoreBluetooth handle retained by the latest explicit scan."""
|
||||
def _invalidate_runtime_handles_locked() -> None:
|
||||
global _runtime_handle_observed_at_monotonic
|
||||
|
||||
_runtime_handles.clear()
|
||||
_runtime_handle_observed_at_monotonic = None
|
||||
|
||||
|
||||
def _begin_scan_generation() -> int:
|
||||
global _runtime_handle_generation
|
||||
|
||||
with _runtime_handle_lock:
|
||||
return _runtime_handles.get(macos_uuid)
|
||||
_runtime_handle_generation += 1
|
||||
_invalidate_runtime_handles_locked()
|
||||
return _runtime_handle_generation
|
||||
|
||||
|
||||
def _finish_failed_scan(scan_generation: int) -> None:
|
||||
with _runtime_handle_lock:
|
||||
if _runtime_handle_generation == scan_generation:
|
||||
_invalidate_runtime_handles_locked()
|
||||
|
||||
|
||||
def _publish_scan_handles(
|
||||
scan_generation: int,
|
||||
handles: dict[str, BLEDevice],
|
||||
) -> None:
|
||||
global _runtime_handle_observed_at_monotonic
|
||||
|
||||
with _runtime_handle_lock:
|
||||
if _runtime_handle_generation != scan_generation:
|
||||
return
|
||||
_runtime_handles.update(handles)
|
||||
_runtime_handle_observed_at_monotonic = monotonic()
|
||||
|
||||
|
||||
def discovered_device_selection(macos_uuid: str) -> DiscoveredDeviceSelection:
|
||||
"""Resolve a device against the latest unexpired explicit scan generation.
|
||||
|
||||
``from_fresh_scan`` distinguishes a fresh scan that did not contain the
|
||||
requested device from a caller that has no usable explicit scan lease. A
|
||||
mutating caller may perform fallback discovery only in the latter case.
|
||||
"""
|
||||
|
||||
with _runtime_handle_lock:
|
||||
observed_at = _runtime_handle_observed_at_monotonic
|
||||
if observed_at is None:
|
||||
return DiscoveredDeviceSelection(device=None, from_fresh_scan=False)
|
||||
|
||||
age_seconds = monotonic() - observed_at
|
||||
if age_seconds < 0.0 or age_seconds > BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS:
|
||||
_invalidate_runtime_handles_locked()
|
||||
return DiscoveredDeviceSelection(device=None, from_fresh_scan=False)
|
||||
|
||||
return DiscoveredDeviceSelection(
|
||||
device=_runtime_handles.get(macos_uuid),
|
||||
from_fresh_scan=True,
|
||||
)
|
||||
|
||||
|
||||
def discovered_device(macos_uuid: str) -> BLEDevice | None:
|
||||
"""Return a CoreBluetooth handle only while its explicit scan lease is fresh."""
|
||||
|
||||
return discovered_device_selection(macos_uuid).device
|
||||
|
||||
|
||||
async def scan(duration_seconds: float) -> BleScanResult:
|
||||
@@ -74,25 +157,36 @@ async def scan(duration_seconds: float) -> BleScanResult:
|
||||
raise ValueError("duration_seconds must be positive")
|
||||
|
||||
started_at = utc_now_iso()
|
||||
discovered = await BleakScanner.discover(timeout=duration_seconds, return_adv=True)
|
||||
with _runtime_handle_lock:
|
||||
_runtime_handles.clear()
|
||||
_runtime_handles.update(
|
||||
{device.address: device for device, _advertisement in discovered.values()}
|
||||
scan_generation = _begin_scan_generation()
|
||||
try:
|
||||
discovered = await BleakScanner.discover(timeout=duration_seconds, return_adv=True)
|
||||
handles = {device.address: device for device, _advertisement in discovered.values()}
|
||||
devices = [
|
||||
advertisement_record(device, advertisement)
|
||||
for device, advertisement in discovered.values()
|
||||
]
|
||||
devices.sort(
|
||||
key=lambda item: (
|
||||
not item["k1_name_candidate"],
|
||||
-item["rssi"],
|
||||
item["macos_uuid"],
|
||||
)
|
||||
)
|
||||
devices = [
|
||||
advertisement_record(device, advertisement) for device, advertisement in discovered.values()
|
||||
]
|
||||
devices.sort(
|
||||
key=lambda item: (not item["k1_name_candidate"], -item["rssi"], item["macos_uuid"])
|
||||
)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"started_at_utc": started_at,
|
||||
"completed_at_utc": utc_now_iso(),
|
||||
"duration_seconds": duration_seconds,
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_count": len(devices),
|
||||
"devices": devices,
|
||||
}
|
||||
result: BleScanResult = {
|
||||
"schema_version": 1,
|
||||
"started_at_utc": started_at,
|
||||
"completed_at_utc": utc_now_iso(),
|
||||
"duration_seconds": duration_seconds,
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_count": len(devices),
|
||||
"devices": devices,
|
||||
}
|
||||
except BaseException:
|
||||
_finish_failed_scan(scan_generation)
|
||||
raise
|
||||
|
||||
# A slower, superseded scan may return useful data to its own caller, but
|
||||
# it must never replace the handle lease published by a newer generation.
|
||||
_publish_scan_handles(scan_generation, handles)
|
||||
return result
|
||||
|
||||
@@ -7,10 +7,12 @@ from time import monotonic
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from bleak import BleakClient, BleakScanner
|
||||
from bleak.exc import BleakDeviceNotFoundError, BleakError
|
||||
from bleak.exc import BleakDeviceNotFoundError, BleakError, BleakGATTProtocolError
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import discovered_device
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import (
|
||||
discovered_device_selection,
|
||||
)
|
||||
|
||||
PROFILE_ID = "xgrids-k1-fw3-wifi-v1"
|
||||
SERVICE_UUID = "00007f00-0000-1000-8000-00805f9b34fb"
|
||||
@@ -28,6 +30,14 @@ ProvisioningOutcome = Literal[
|
||||
]
|
||||
WriteMode = Literal["auto", "with_response", "without_response"]
|
||||
ResolvedWriteMode = Literal["with_response", "without_response"]
|
||||
BleOperationStage = Literal[
|
||||
"resolution",
|
||||
"connect",
|
||||
"gatt-contract",
|
||||
"baseline-read",
|
||||
"gatt-write",
|
||||
"status-poll",
|
||||
]
|
||||
|
||||
|
||||
class WifiStatus(TypedDict):
|
||||
@@ -82,6 +92,23 @@ class WifiStatusReadResult(TypedDict):
|
||||
status: WifiStatus
|
||||
|
||||
|
||||
def _annotate_ble_operation_error(
|
||||
exc: Exception,
|
||||
*,
|
||||
operation_stage: BleOperationStage,
|
||||
device_write_attempted: bool,
|
||||
device_write_confirmed: bool,
|
||||
) -> None:
|
||||
"""Attach non-secret transport facts while preserving the exception type."""
|
||||
|
||||
exc.operation_stage = operation_stage # type: ignore[attr-defined]
|
||||
exc.device_write_attempted = device_write_attempted # type: ignore[attr-defined]
|
||||
exc.device_write_confirmed = device_write_confirmed # type: ignore[attr-defined]
|
||||
if isinstance(exc, BleakGATTProtocolError):
|
||||
exc.att_error_code = int(exc.code) # type: ignore[attr-defined]
|
||||
exc.att_error_name = exc.code.name # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def build_wifi_provisioning_frame(ssid: str, password: str) -> bytearray:
|
||||
"""Build the deterministic 99-byte frame used by LixelGO for K1 Wi-Fi setup."""
|
||||
ssid_bytes = ssid.encode("utf-8")
|
||||
@@ -170,12 +197,14 @@ async def read_wifi_status_once(
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
async with asyncio.timeout(timeout_seconds + 5.0):
|
||||
# A CoreBluetooth handle retained by an earlier scan is an optimization,
|
||||
# not durable connection state. Recovery after sleep, Wi-Fi transition,
|
||||
# or a completed provisioning GATT session must rediscover the device
|
||||
# instead of repeatedly opening a stale handle.
|
||||
device = None if rediscover else discovered_device(device_macos_uuid)
|
||||
if device is None:
|
||||
# A still-live explicit scan lease is authoritative even for a caller
|
||||
# requesting recovery. Physical acceptance proved that immediately
|
||||
# looking the same CoreBluetooth UUID up again can lose a present K1.
|
||||
# ``rediscover`` therefore permits fallback only after that short lease
|
||||
# has expired; it never discards a fresh retained BLEDevice.
|
||||
selection = discovered_device_selection(device_macos_uuid)
|
||||
device = selection.device
|
||||
if device is None and not selection.from_fresh_scan:
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=timeout_seconds,
|
||||
@@ -236,24 +265,36 @@ async def provision_wifi_once(
|
||||
started_at = utc_now_iso()
|
||||
observations: list[StatusObservation] = []
|
||||
disconnected = False
|
||||
operation_stage: BleOperationStage = "resolution"
|
||||
device_write_attempted = False
|
||||
device_write_confirmed = False
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds + 25.0):
|
||||
# A provisioning write is a mutation boundary: prove that the
|
||||
# selected device is advertising now instead of trusting a
|
||||
# CoreBluetooth handle retained by an earlier UI scan.
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=min(20.0, timeout_seconds),
|
||||
)
|
||||
# The explicit UI scan and its selected network action are one
|
||||
# CoreBluetooth lifecycle. Physical acceptance proved that a
|
||||
# second UUID lookup can fail moments after a successful scan, so
|
||||
# use the exact retained handle while its short lease is fresh.
|
||||
selection = discovered_device_selection(device_macos_uuid)
|
||||
device = selection.device
|
||||
if device is None and not selection.from_fresh_scan:
|
||||
# Non-UI callers without a current explicit scan retain the
|
||||
# bounded lookup fallback. A fresh scan missing this device is
|
||||
# authoritative and must not be silently replaced here.
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=min(20.0, timeout_seconds),
|
||||
)
|
||||
if device is None:
|
||||
raise BleakDeviceNotFoundError(
|
||||
device_macos_uuid,
|
||||
"Device was not rediscovered; keep the K1 powered and nearby.",
|
||||
)
|
||||
|
||||
operation_stage = "connect"
|
||||
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
|
||||
device_name = client.name
|
||||
operation_stage = "gatt-contract"
|
||||
service = client.services.get_service(SERVICE_UUID)
|
||||
write_characteristic = client.services.get_characteristic(WRITE_CHARACTERISTIC_UUID)
|
||||
status_characteristic = client.services.get_characteristic(
|
||||
@@ -301,17 +342,22 @@ async def provision_wifi_once(
|
||||
)
|
||||
resolved_write_mode = "without_response"
|
||||
|
||||
operation_stage = "baseline-read"
|
||||
baseline_value = bytes(await client.read_gatt_char(status_characteristic))
|
||||
baseline = parse_wifi_status(baseline_value)
|
||||
|
||||
operation_stage = "gatt-write"
|
||||
device_write_attempted = True
|
||||
await client.write_gatt_char(
|
||||
write_characteristic,
|
||||
frame,
|
||||
response=resolved_write_mode == "with_response",
|
||||
)
|
||||
device_write_confirmed = resolved_write_mode == "with_response"
|
||||
write_completed = monotonic()
|
||||
deadline = write_completed + timeout_seconds
|
||||
|
||||
operation_stage = "status-poll"
|
||||
while monotonic() < deadline:
|
||||
try:
|
||||
value = bytes(await client.read_gatt_char(status_characteristic))
|
||||
@@ -353,5 +399,13 @@ async def provision_wifi_once(
|
||||
"observations": observations,
|
||||
"outcome": _outcome(baseline, observations, disconnected),
|
||||
}
|
||||
except Exception as exc:
|
||||
_annotate_ble_operation_error(
|
||||
exc,
|
||||
operation_stage=operation_stage,
|
||||
device_write_attempted=device_write_attempted,
|
||||
device_write_confirmed=device_write_confirmed,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
frame[:] = b"\x00" * len(frame)
|
||||
|
||||
@@ -39,7 +39,12 @@ from k1link.compute.live_perception import LivePerceptionIngress
|
||||
from k1link.device_plugins.xgrids_k1.ble.ap_activation import (
|
||||
device_ap_activation_session,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import scan
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import (
|
||||
BLE_DISCOVERY_CANDIDATE_LEASE_TTL_SECONDS as BLE_DISCOVERY_LEASE_TTL_SECONDS,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import (
|
||||
scan,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
AP_FALLBACK_IPV4,
|
||||
provision_wifi_once,
|
||||
@@ -128,8 +133,6 @@ XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.local-network
|
||||
DEFAULT_ACQUISITION_CAMERA_SOURCE: CameraSourceId = "sensor.camera.right"
|
||||
CONTROL_MQTT_PORT = 1883
|
||||
CONTROL_ENDPOINT_PROBE_TIMEOUT_SECONDS = 1.5
|
||||
BLE_DISCOVERY_LEASE_TTL_SECONDS = 60.0
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ConnectionMode = Literal["bridge", "quick-connect", "direct-connect"]
|
||||
@@ -149,6 +152,14 @@ class ConnectionLeaseUnavailable(RuntimeError):
|
||||
self.reason_code = reason_code
|
||||
|
||||
|
||||
class NetworkWriteReconciliationRequired(RuntimeError):
|
||||
"""A prior K1 network write must be observed before another mutation."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.reason_code = "network-write-reconciliation-required"
|
||||
|
||||
|
||||
class LocalAcquisitionLifecycleError(RuntimeError):
|
||||
"""A local producer lifecycle invariant failed before scanner authority."""
|
||||
|
||||
@@ -455,6 +466,11 @@ class XgridsK1CompatibilityService:
|
||||
self._device_session_id: str | None = None
|
||||
self._device_session_opened_at: str | None = None
|
||||
self._connection_lease_generation = 0
|
||||
# This is deliberately process-owned and cannot be cleared by a
|
||||
# browser refresh, a new idempotency key, or another discovery scan.
|
||||
# It is raised only when a BLE write may have left device state
|
||||
# unknown; a reviewed read-only status observation clears it.
|
||||
self._network_write_reconciliation: dict[str, Any] | None = None
|
||||
self._connection_verification: dict[str, Any] = {
|
||||
"status": "not-probed",
|
||||
"lease_state": "disconnected",
|
||||
@@ -560,6 +576,11 @@ class XgridsK1CompatibilityService:
|
||||
device_session_id = self._device_session_id
|
||||
device_session_opened_at = self._device_session_opened_at
|
||||
connection_lease_generation = self._connection_lease_generation
|
||||
network_write_reconciliation = (
|
||||
dict(self._network_write_reconciliation)
|
||||
if self._network_write_reconciliation is not None
|
||||
else None
|
||||
)
|
||||
connection_verification = dict(self._connection_verification)
|
||||
compatibility_attestation = (
|
||||
dict(self._compatibility_attestation)
|
||||
@@ -700,6 +721,7 @@ class XgridsK1CompatibilityService:
|
||||
**connection_verification,
|
||||
"lease_generation": connection_lease_generation,
|
||||
},
|
||||
"network_write_reconciliation": network_write_reconciliation,
|
||||
"sensor_catalog": _sensor_catalog(
|
||||
active_profile_id,
|
||||
device_session_id,
|
||||
@@ -807,9 +829,36 @@ class XgridsK1CompatibilityService:
|
||||
with self._lock:
|
||||
scanned_devices = self._fresh_ble_devices_locked()
|
||||
discovery_generation = self._ble_discovery_generation
|
||||
network_write_reconciliation = (
|
||||
dict(self._network_write_reconciliation)
|
||||
if self._network_write_reconciliation is not None
|
||||
else None
|
||||
)
|
||||
known_ids = {str(item["device_id"]) for item in scanned_devices}
|
||||
if request.device_id not in known_ids:
|
||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
||||
if network_write_reconciliation is not None:
|
||||
logger.warning(
|
||||
"K1 network mutation blocked pending read-only reconciliation",
|
||||
extra={
|
||||
"event_code": "k1_network_write_reconciliation_required",
|
||||
"operation_id": network_write_reconciliation.get("operation_id"),
|
||||
"operation_stage": network_write_reconciliation.get("operation_stage"),
|
||||
"connection_mode": network_write_reconciliation.get("connection_mode"),
|
||||
"reason_code": "network-write-reconciliation-required",
|
||||
"device_write_attempted": True,
|
||||
"device_write_confirmed": network_write_reconciliation.get(
|
||||
"device_write_confirmed",
|
||||
False,
|
||||
),
|
||||
"automatic_retry": False,
|
||||
},
|
||||
)
|
||||
raise NetworkWriteReconciliationRequired(
|
||||
"предыдущая BLE-запись завершилась до подтверждения актуального "
|
||||
"состояния K1; новая запись заблокирована. Выполните свежий "
|
||||
"Bluetooth-поиск и read-only проверку существующего Bridge-подключения"
|
||||
)
|
||||
quick_connect = request.connection_mode == "quick-connect"
|
||||
selected_device = next(
|
||||
item for item in scanned_devices if item["device_id"] == request.device_id
|
||||
@@ -903,7 +952,9 @@ class XgridsK1CompatibilityService:
|
||||
raise RuntimeError("другая операция настройки Wi-Fi уже выполняется")
|
||||
|
||||
session_dir: Path | None = None
|
||||
network_change_attempted = False
|
||||
device_write_attempted = False
|
||||
device_write_confirmed = False
|
||||
device_state_reconciled = False
|
||||
retired_ingress_session_id: str | None = None
|
||||
operation_stage = "device-ap-activation" if quick_connect else "ble-provisioning-write"
|
||||
try:
|
||||
@@ -1019,7 +1070,6 @@ class XgridsK1CompatibilityService:
|
||||
stage_code=operation_stage,
|
||||
message_code="network.provision.running",
|
||||
)
|
||||
network_change_attempted = True
|
||||
if quick_connect:
|
||||
assert quick_connect_profile_id is not None
|
||||
operation_stage = "device-ap-activation"
|
||||
@@ -1028,6 +1078,18 @@ class XgridsK1CompatibilityService:
|
||||
timeout_seconds=15.0,
|
||||
write_mode="auto",
|
||||
) as activation:
|
||||
device_write_attempted = bool(activation["write_performed"])
|
||||
device_write_confirmed = bool(
|
||||
activation["write_performed"]
|
||||
and (
|
||||
activation["write_mode"] == "with_response"
|
||||
or activation["ready_observed"]
|
||||
)
|
||||
)
|
||||
# Only an actual post-write 7f02 observation reconciles
|
||||
# device state. A GATT acknowledgement alone confirms the
|
||||
# transport write, not the network state K1 retained.
|
||||
device_state_reconciled = bool(activation.get("observations"))
|
||||
write_json_atomic(
|
||||
session_dir / "ap-activation.redacted.json",
|
||||
activation,
|
||||
@@ -1114,6 +1176,19 @@ class XgridsK1CompatibilityService:
|
||||
timeout_seconds=45.0,
|
||||
write_mode="auto",
|
||||
)
|
||||
device_write_attempted = True
|
||||
# A successful return may still describe a disconnect before
|
||||
# any post-write status was read. Keep the ambiguity fence in
|
||||
# that case even when write-with-response was acknowledged.
|
||||
observations = result.get("observations") or []
|
||||
device_state_reconciled = bool(observations)
|
||||
device_write_confirmed = bool(
|
||||
result.get("write_mode") == "with_response"
|
||||
or (
|
||||
observations
|
||||
and observations[-1].get("status") != result.get("baseline_status")
|
||||
)
|
||||
)
|
||||
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
|
||||
ipv4 = _provisioned_ipv4(result)
|
||||
connection_manifest = {
|
||||
@@ -1155,6 +1230,7 @@ class XgridsK1CompatibilityService:
|
||||
)
|
||||
write_json_atomic(session_dir / "manifest.redacted.json", connection_manifest)
|
||||
with self._lock:
|
||||
self._network_write_reconciliation = None
|
||||
self._selected_device_id = request.device_id
|
||||
self._k1_ip = ipv4
|
||||
self._connection_mode = request.connection_mode
|
||||
@@ -1226,11 +1302,44 @@ class XgridsK1CompatibilityService:
|
||||
evidence_refs=(f"evidence-session-{session_dir.name}",),
|
||||
)
|
||||
except Exception as exc:
|
||||
annotated_stage = getattr(exc, "operation_stage", None)
|
||||
if isinstance(annotated_stage, str) and annotated_stage:
|
||||
operation_stage = annotated_stage
|
||||
device_write_attempted = bool(
|
||||
getattr(exc, "device_write_attempted", device_write_attempted)
|
||||
)
|
||||
device_write_confirmed = bool(
|
||||
getattr(exc, "device_write_confirmed", device_write_confirmed)
|
||||
)
|
||||
if device_write_confirmed:
|
||||
device_write_attempted = True
|
||||
if device_write_attempted and not device_state_reconciled:
|
||||
with self._lock:
|
||||
self._network_write_reconciliation = {
|
||||
"status": "device-state-unknown-after-write",
|
||||
"operation_id": operation.operation_id,
|
||||
"transport_ref": request.device_id,
|
||||
"connection_mode": request.connection_mode,
|
||||
"operation_stage": operation_stage,
|
||||
"reason_code": getattr(exc, "reason_code", None)
|
||||
or type(exc).__name__,
|
||||
"device_write_confirmed": device_write_confirmed,
|
||||
"required_action": "explicit-read-only-ble-status-observation",
|
||||
"scope": "process-runtime",
|
||||
"observed_at": _utc_now_iso(),
|
||||
}
|
||||
side_effect_status: Literal["none", "confirmed", "unknown"] = (
|
||||
"confirmed"
|
||||
if device_write_confirmed and device_state_reconciled
|
||||
else "unknown"
|
||||
if device_write_attempted
|
||||
else "none"
|
||||
)
|
||||
operation_error = _operation_error(
|
||||
exc,
|
||||
category="transport" if quick_connect else "device",
|
||||
side_effect_status=("unknown" if network_change_attempted else "none"),
|
||||
safe_to_retry=not network_change_attempted,
|
||||
side_effect_status=side_effect_status,
|
||||
safe_to_retry=not device_write_attempted,
|
||||
)
|
||||
self._operations.transition_if_pending(
|
||||
operation.operation_id,
|
||||
@@ -1257,7 +1366,13 @@ class XgridsK1CompatibilityService:
|
||||
"error_code": operation_error["code"],
|
||||
"safe_to_retry": operation_error["safe_to_retry"],
|
||||
"side_effect_status": operation_error["side_effect_status"],
|
||||
"network_change_attempted": network_change_attempted,
|
||||
"network_change_attempted": device_write_attempted,
|
||||
"device_write_attempted": device_write_attempted,
|
||||
"device_write_confirmed": device_write_confirmed,
|
||||
"ble_att_error_code": operation_error.get("ble_att_error_code"),
|
||||
"ble_att_error_name": operation_error.get("ble_att_error_name"),
|
||||
"helper_stage": operation_error.get("helper_stage"),
|
||||
"helper_elapsed_ms": operation_error.get("helper_elapsed_ms"),
|
||||
"automatic_retry": False,
|
||||
},
|
||||
)
|
||||
@@ -1391,6 +1506,29 @@ class XgridsK1CompatibilityService:
|
||||
or status_read.get("write_performed") is not False
|
||||
):
|
||||
raise RuntimeError("BLE status read не подтвердил read-only операцию")
|
||||
if status_read.get("device_macos_uuid") != device_id:
|
||||
raise RuntimeError("BLE status read вернул состояние другого устройства")
|
||||
# The exact current 7f02 state has now been observed without a
|
||||
# device write. Clear only the process-owned ambiguity fence; all
|
||||
# route/admission checks below still have to pass independently.
|
||||
with self._lock:
|
||||
reconciliation = self._network_write_reconciliation
|
||||
reconciliation_cleared = bool(
|
||||
reconciliation is not None
|
||||
and reconciliation.get("transport_ref") == device_id
|
||||
)
|
||||
if reconciliation_cleared:
|
||||
self._network_write_reconciliation = None
|
||||
if reconciliation_cleared:
|
||||
logger.info(
|
||||
"K1 network write ambiguity reconciled by read-only status",
|
||||
extra={
|
||||
"event_code": "k1_network_write_reconciled",
|
||||
"reason_code": "read-only-ble-status-observed",
|
||||
"device_write_performed": False,
|
||||
"automatic_retry": False,
|
||||
},
|
||||
)
|
||||
observed_target = status_read["status"]["ipv4"]
|
||||
if observed_target is None or observed_target == AP_FALLBACK_IPV4:
|
||||
raise RuntimeError("K1 не сообщил актуальный DHCP-адрес общей сети")
|
||||
@@ -3659,13 +3797,30 @@ def _operation_error(
|
||||
safe_to_retry: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
reason_code = getattr(exc, "reason_code", None)
|
||||
return {
|
||||
error: dict[str, Any] = {
|
||||
"category": category,
|
||||
"code": reason_code if isinstance(reason_code, str) and reason_code else type(exc).__name__,
|
||||
"retryable": False,
|
||||
"safe_to_retry": safe_to_retry,
|
||||
"side_effect_status": side_effect_status,
|
||||
}
|
||||
safe_fields = {
|
||||
"operation_stage": getattr(exc, "operation_stage", None),
|
||||
"device_write_attempted": getattr(exc, "device_write_attempted", None),
|
||||
"device_write_confirmed": getattr(exc, "device_write_confirmed", None),
|
||||
"ble_att_error_code": getattr(exc, "att_error_code", None),
|
||||
"ble_att_error_name": getattr(exc, "att_error_name", None),
|
||||
"helper_stage": getattr(exc, "helper_stage", None),
|
||||
"helper_elapsed_ms": getattr(exc, "helper_elapsed_ms", None),
|
||||
}
|
||||
error.update(
|
||||
{
|
||||
field: value
|
||||
for field, value in safe_fields.items()
|
||||
if isinstance(value, (str, int, bool))
|
||||
}
|
||||
)
|
||||
return error
|
||||
|
||||
|
||||
def _validated_requested_streams(
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager, suppress
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
@@ -70,6 +76,14 @@ _ERROR_MESSAGES = {
|
||||
"host-wifi-operation-timeout": (
|
||||
"оператор не завершил системное подключение Wi-Fi за отведённое время"
|
||||
),
|
||||
"host-wifi-helper-build-timeout": (
|
||||
"локальный Wi-Fi helper не успел скомпилироваться за отведённое время"
|
||||
),
|
||||
"host-wifi-helper-build-failed": "локальный Wi-Fi helper не удалось скомпилировать",
|
||||
"host-wifi-helper-compiler-unavailable": "компилятор локального Wi-Fi helper недоступен",
|
||||
"host-wifi-helper-cache-unavailable": "кэш локального Wi-Fi helper недоступен",
|
||||
"host-wifi-helper-missing": "исходный файл локального Wi-Fi helper не найден",
|
||||
"host-wifi-helper-unavailable": "локальный Wi-Fi helper недоступен",
|
||||
"wifi-interface-unavailable": "системный Wi-Fi-интерфейс недоступен",
|
||||
"corewlan-error": "системный Wi-Fi не смог подключиться к точке доступа K1",
|
||||
}
|
||||
@@ -84,15 +98,20 @@ class HostWifiProfileError(RuntimeError):
|
||||
*,
|
||||
scan_attempt_count: int | None = None,
|
||||
scan_elapsed_ms: int | None = None,
|
||||
helper_stage: str | None = None,
|
||||
helper_elapsed_ms: int | None = None,
|
||||
) -> None:
|
||||
self.reason_code = reason_code
|
||||
self.scan_attempt_count = scan_attempt_count
|
||||
self.scan_elapsed_ms = scan_elapsed_ms
|
||||
self.helper_stage = helper_stage
|
||||
self.helper_elapsed_ms = helper_elapsed_ms
|
||||
message = _ERROR_MESSAGES.get(reason_code, "операция системного Wi-Fi завершилась ошибкой")
|
||||
super().__init__(f"{message} ({reason_code})")
|
||||
|
||||
|
||||
RunProcess = Callable[..., subprocess.CompletedProcess[bytes]]
|
||||
DEFAULT_HELPER_BUILD_TIMEOUT_SECONDS = 120.0
|
||||
|
||||
|
||||
def _validate_profile_id(profile_id: str) -> None:
|
||||
@@ -105,26 +124,227 @@ def _validate_profile_id(profile_id: str) -> None:
|
||||
raise ValueError("host Wi-Fi profile id contains unsupported characters")
|
||||
|
||||
|
||||
def _helper_cache_directory(helper_path: Path) -> Path:
|
||||
"""Resolve the process-local helper cache without an environment override."""
|
||||
|
||||
source = helper_path.expanduser().resolve()
|
||||
for ancestor in source.parents:
|
||||
if ancestor.name != "plugins":
|
||||
continue
|
||||
try:
|
||||
relative = source.relative_to(ancestor)
|
||||
except ValueError: # pragma: no cover - guarded by Path.parents
|
||||
continue
|
||||
if relative.parts[:2] == ("xgrids-k1", "macos"):
|
||||
return ancestor.parent / ".runtime" / "mission-core" / "helpers"
|
||||
# Tests and separately packaged adapters still get a stable cache beside
|
||||
# their source tree. The repository layout above is the production path.
|
||||
return source.parent / ".runtime" / "mission-core" / "helpers"
|
||||
|
||||
|
||||
def _compiled_macos_helper_path(helper_path: Path) -> Path:
|
||||
source = helper_path.expanduser().resolve()
|
||||
source_sha256 = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
return _helper_cache_directory(source) / f"{source.stem}-{source_sha256}"
|
||||
|
||||
|
||||
def _is_ready_executable(path: Path) -> bool:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError:
|
||||
return False
|
||||
return (
|
||||
stat.S_ISREG(metadata.st_mode)
|
||||
and metadata.st_size > 0
|
||||
and metadata.st_mode & 0o111 != 0
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_helper_build_lock(
|
||||
path: Path,
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
) -> Iterator[None]:
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError as exc: # pragma: no cover - production target is macOS
|
||||
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
|
||||
|
||||
flags = (
|
||||
os.O_RDWR
|
||||
| os.O_CREAT
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
try:
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
except OSError as exc:
|
||||
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
|
||||
locked = False
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
||||
raise HostWifiProfileError("host-wifi-helper-cache-unavailable")
|
||||
lock_started = time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError as exc:
|
||||
elapsed_seconds = max(0.0, time.monotonic() - lock_started)
|
||||
if elapsed_seconds >= timeout_seconds:
|
||||
raise HostWifiProfileError(
|
||||
"host-wifi-helper-build-timeout",
|
||||
helper_stage="compile-lock",
|
||||
helper_elapsed_ms=int(elapsed_seconds * 1000),
|
||||
) from exc
|
||||
time.sleep(min(0.05, timeout_seconds - elapsed_seconds))
|
||||
except OSError as exc:
|
||||
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
|
||||
locked = True
|
||||
yield
|
||||
finally:
|
||||
if locked:
|
||||
with suppress(OSError):
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
try:
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _ensure_compiled_macos_helper(
|
||||
helper_path: Path,
|
||||
*,
|
||||
build_timeout_seconds: float,
|
||||
runner: RunProcess,
|
||||
) -> Path:
|
||||
"""Build the source-hash-addressed helper once and reuse it thereafter."""
|
||||
|
||||
if build_timeout_seconds <= 0:
|
||||
raise ValueError("build_timeout_seconds must be positive")
|
||||
source = helper_path.expanduser().resolve()
|
||||
if not source.is_file():
|
||||
raise HostWifiProfileError("host-wifi-helper-missing")
|
||||
try:
|
||||
executable = _compiled_macos_helper_path(source)
|
||||
executable.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
|
||||
if _is_ready_executable(executable):
|
||||
return executable
|
||||
|
||||
build_started_ns = time.monotonic_ns()
|
||||
build_deadline = time.monotonic() + build_timeout_seconds
|
||||
|
||||
def build_error(
|
||||
reason_code: str,
|
||||
*,
|
||||
helper_stage: str = "compile",
|
||||
) -> HostWifiProfileError:
|
||||
elapsed_ms = max(0, (time.monotonic_ns() - build_started_ns) // 1_000_000)
|
||||
return HostWifiProfileError(
|
||||
reason_code,
|
||||
helper_stage=helper_stage,
|
||||
helper_elapsed_ms=elapsed_ms,
|
||||
)
|
||||
|
||||
lock_path = executable.with_name(f".{executable.name}.lock")
|
||||
with _exclusive_helper_build_lock(
|
||||
lock_path,
|
||||
timeout_seconds=max(0.001, build_deadline - time.monotonic()),
|
||||
):
|
||||
if _is_ready_executable(executable):
|
||||
return executable
|
||||
|
||||
try:
|
||||
descriptor, staging_name = tempfile.mkstemp(
|
||||
prefix=f".{executable.name}.",
|
||||
suffix=".tmp",
|
||||
dir=executable.parent,
|
||||
)
|
||||
os.close(descriptor)
|
||||
staging = Path(staging_name)
|
||||
staging.unlink()
|
||||
except OSError as exc:
|
||||
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
|
||||
|
||||
try:
|
||||
remaining_build_seconds = build_deadline - time.monotonic()
|
||||
if remaining_build_seconds <= 0:
|
||||
raise build_error("host-wifi-helper-build-timeout")
|
||||
|
||||
try:
|
||||
completed = runner(
|
||||
[
|
||||
"/usr/bin/xcrun",
|
||||
"swiftc",
|
||||
str(source),
|
||||
"-o",
|
||||
str(staging),
|
||||
],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=remaining_build_seconds,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise build_error("host-wifi-helper-build-timeout") from exc
|
||||
except OSError as exc:
|
||||
raise build_error("host-wifi-helper-compiler-unavailable") from exc
|
||||
|
||||
if completed.returncode != 0 or not _is_ready_executable(staging):
|
||||
raise build_error("host-wifi-helper-build-failed")
|
||||
try:
|
||||
staging.chmod(0o700)
|
||||
with staging.open("rb") as stream:
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(staging, executable)
|
||||
_fsync_directory(executable.parent)
|
||||
except OSError as exc:
|
||||
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
|
||||
finally:
|
||||
with suppress(OSError):
|
||||
staging.unlink(missing_ok=True)
|
||||
return executable
|
||||
|
||||
|
||||
def _run_macos_helper(
|
||||
helper_path: Path,
|
||||
request: dict[str, object],
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
runner: RunProcess,
|
||||
build_timeout_seconds: float = DEFAULT_HELPER_BUILD_TIMEOUT_SECONDS,
|
||||
) -> dict[str, Any]:
|
||||
if sys.platform != "darwin":
|
||||
raise HostWifiProfileError("unsupported-platform")
|
||||
if not helper_path.is_file():
|
||||
raise HostWifiProfileError("host-wifi-helper-missing")
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
|
||||
executable = _ensure_compiled_macos_helper(
|
||||
helper_path,
|
||||
build_timeout_seconds=build_timeout_seconds,
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
request_bytes = bytearray(
|
||||
json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
try:
|
||||
completed = runner(
|
||||
["/usr/bin/xcrun", "swift", str(helper_path.resolve())],
|
||||
[str(executable)],
|
||||
input=request_bytes,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
|
||||
@@ -28,6 +28,12 @@ _EXTRA_FIELDS: Final = (
|
||||
"safe_to_retry",
|
||||
"side_effect_status",
|
||||
"network_change_attempted",
|
||||
"device_write_attempted",
|
||||
"device_write_confirmed",
|
||||
"ble_att_error_code",
|
||||
"ble_att_error_name",
|
||||
"helper_stage",
|
||||
"helper_elapsed_ms",
|
||||
"status_reconciliation",
|
||||
"network_change_admissible",
|
||||
"network_change_reconciliation",
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import asyncio
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
import k1link.device_plugins.xgrids_k1.ble.scanner as scanner_module
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import (
|
||||
advertisement_record,
|
||||
discovered_device,
|
||||
discovered_device_selection,
|
||||
scan,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_runtime_handle_lease() -> Iterator[None]:
|
||||
with scanner_module._runtime_handle_lock: # noqa: SLF001
|
||||
scanner_module._runtime_handles.clear() # noqa: SLF001
|
||||
scanner_module._runtime_handle_observed_at_monotonic = None # noqa: SLF001
|
||||
scanner_module._runtime_handle_generation = 0 # noqa: SLF001
|
||||
yield
|
||||
with scanner_module._runtime_handle_lock: # noqa: SLF001
|
||||
scanner_module._runtime_handles.clear() # noqa: SLF001
|
||||
scanner_module._runtime_handle_observed_at_monotonic = None # noqa: SLF001
|
||||
scanner_module._runtime_handle_generation = 0 # noqa: SLF001
|
||||
|
||||
|
||||
def test_advertisement_record_marks_k1_candidate() -> None:
|
||||
device = BLEDevice("TEST-UUID", "Unknown", details=None)
|
||||
advertisement = AdvertisementData(
|
||||
@@ -72,3 +89,136 @@ def test_scan_retains_the_live_corebluetooth_handle(
|
||||
|
||||
assert result["devices"][0]["macos_uuid"] == "LIVE-UUID"
|
||||
assert discovered_device("LIVE-UUID") is device
|
||||
assert discovered_device_selection("LIVE-UUID").from_fresh_scan is True
|
||||
|
||||
|
||||
def test_scan_start_invalidates_previous_lease_and_failure_leaves_it_empty(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
old_device = BLEDevice("OLD-UUID", "XGR-OLD", details=object())
|
||||
old_advertisement = AdvertisementData(
|
||||
local_name="XGR-OLD",
|
||||
manufacturer_data={},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
tx_power=0,
|
||||
rssi=-41,
|
||||
platform_data=(),
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
async def initial_discover(
|
||||
**_kwargs: object,
|
||||
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
|
||||
return {old_device.address: (old_device, old_advertisement)}
|
||||
|
||||
monkeypatch.setattr(scanner_module.BleakScanner, "discover", initial_discover)
|
||||
await scan(1.0)
|
||||
assert discovered_device(old_device.address) is old_device
|
||||
|
||||
failing_scan_started = asyncio.Event()
|
||||
release_failing_scan = asyncio.Event()
|
||||
|
||||
async def failing_discover(**_kwargs: object) -> object:
|
||||
failing_scan_started.set()
|
||||
await release_failing_scan.wait()
|
||||
raise RuntimeError("synthetic BLE scan failure")
|
||||
|
||||
monkeypatch.setattr(scanner_module.BleakScanner, "discover", failing_discover)
|
||||
scan_task = asyncio.create_task(scan(1.0))
|
||||
await failing_scan_started.wait()
|
||||
|
||||
# Starting a new explicit scan revokes the prior generation before I/O.
|
||||
assert discovered_device(old_device.address) is None
|
||||
assert discovered_device_selection(old_device.address).from_fresh_scan is False
|
||||
|
||||
release_failing_scan.set()
|
||||
with pytest.raises(RuntimeError, match="synthetic BLE scan failure"):
|
||||
await scan_task
|
||||
|
||||
assert discovered_device(old_device.address) is None
|
||||
assert discovered_device_selection(old_device.address).from_fresh_scan is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("older_scan_fails", [False, True])
|
||||
def test_late_scan_generation_cannot_replace_or_clear_newer_lease(
|
||||
monkeypatch: MonkeyPatch,
|
||||
older_scan_fails: bool,
|
||||
) -> None:
|
||||
older_device = BLEDevice("OLDER-UUID", "XGR-OLDER", details=object())
|
||||
newer_device = BLEDevice("NEWER-UUID", "XGR-NEWER", details=object())
|
||||
older_advertisement = AdvertisementData(
|
||||
local_name="XGR-OLDER",
|
||||
manufacturer_data={},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
tx_power=0,
|
||||
rssi=-51,
|
||||
platform_data=(),
|
||||
)
|
||||
newer_advertisement = AdvertisementData(
|
||||
local_name="XGR-NEWER",
|
||||
manufacturer_data={},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
tx_power=0,
|
||||
rssi=-31,
|
||||
platform_data=(),
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
call_count = 0
|
||||
older_scan_started = asyncio.Event()
|
||||
release_older_scan = asyncio.Event()
|
||||
|
||||
async def overlapping_discover(
|
||||
**_kwargs: object,
|
||||
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
older_scan_started.set()
|
||||
await release_older_scan.wait()
|
||||
if older_scan_fails:
|
||||
raise RuntimeError("late older scan failed")
|
||||
return {older_device.address: (older_device, older_advertisement)}
|
||||
return {newer_device.address: (newer_device, newer_advertisement)}
|
||||
|
||||
monkeypatch.setattr(scanner_module.BleakScanner, "discover", overlapping_discover)
|
||||
older_task = asyncio.create_task(scan(1.0))
|
||||
await older_scan_started.wait()
|
||||
|
||||
await scan(1.0)
|
||||
assert discovered_device(newer_device.address) is newer_device
|
||||
assert discovered_device(older_device.address) is None
|
||||
|
||||
release_older_scan.set()
|
||||
if older_scan_fails:
|
||||
with pytest.raises(RuntimeError, match="late older scan failed"):
|
||||
await older_task
|
||||
else:
|
||||
await older_task
|
||||
|
||||
# Neither a late success nor a late failure owns the current lease.
|
||||
assert discovered_device(newer_device.address) is newer_device
|
||||
assert discovered_device(older_device.address) is None
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_runtime_handle_outlives_operator_candidate_boundary(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
clock = [100.0]
|
||||
handle = BLEDevice("LIVE-UUID", "XGR-K1", details=object())
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: clock[0])
|
||||
scanner_module._runtime_handles[handle.address] = handle # noqa: SLF001
|
||||
scanner_module._runtime_handle_observed_at_monotonic = clock[0] # noqa: SLF001
|
||||
|
||||
clock[0] += scanner_module.BLE_DISCOVERY_CANDIDATE_LEASE_TTL_SECONDS + 0.001
|
||||
assert discovered_device(handle.address) is handle
|
||||
|
||||
clock[0] = 100.0 + scanner_module.BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS + 0.001
|
||||
assert discovered_device(handle.address) is None
|
||||
|
||||
@@ -55,6 +55,12 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
|
||||
"automatic_retry": False,
|
||||
"side_effect_status": "unknown",
|
||||
"network_change_attempted": True,
|
||||
"device_write_attempted": True,
|
||||
"device_write_confirmed": False,
|
||||
"ble_att_error_code": 4,
|
||||
"ble_att_error_name": "INVALID_PDU",
|
||||
"helper_stage": "compile",
|
||||
"helper_elapsed_ms": 34720,
|
||||
"camera_source_id": "sensor.camera.right",
|
||||
"evidence_session_id": "20260728T163450Z_viewer_live",
|
||||
"activation_trigger": "application-control-scanning",
|
||||
@@ -91,6 +97,12 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
|
||||
assert document["automatic_retry"] is False
|
||||
assert document["side_effect_status"] == "unknown"
|
||||
assert document["network_change_attempted"] is True
|
||||
assert document["device_write_attempted"] is True
|
||||
assert document["device_write_confirmed"] is False
|
||||
assert document["ble_att_error_code"] == 4
|
||||
assert document["ble_att_error_name"] == "INVALID_PDU"
|
||||
assert document["helper_stage"] == "compile"
|
||||
assert document["helper_elapsed_ms"] == 34720
|
||||
assert document["camera_source_id"] == "sensor.camera.right"
|
||||
assert document["evidence_session_id"] == "20260728T163450Z_viewer_live"
|
||||
assert document["activation_trigger"] == "application-control-scanning"
|
||||
|
||||
+284
-22
@@ -1,10 +1,12 @@
|
||||
import asyncio
|
||||
from collections.abc import Iterator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from bleak.exc import BleakDeviceNotFoundError
|
||||
from bleak.exc import BleakDeviceNotFoundError, BleakGATTProtocolError
|
||||
|
||||
import k1link.device_plugins.xgrids_k1.ble.scanner as scanner_module
|
||||
import k1link.device_plugins.xgrids_k1.ble.wifi_provisioning as wifi_module
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
FRAME_LENGTH,
|
||||
@@ -15,6 +17,27 @@ from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_runtime_handle_lease() -> Iterator[None]:
|
||||
with scanner_module._runtime_handle_lock: # noqa: SLF001
|
||||
scanner_module._runtime_handles.clear() # noqa: SLF001
|
||||
scanner_module._runtime_handle_observed_at_monotonic = None # noqa: SLF001
|
||||
scanner_module._runtime_handle_generation = 0 # noqa: SLF001
|
||||
yield
|
||||
with scanner_module._runtime_handle_lock: # noqa: SLF001
|
||||
scanner_module._runtime_handles.clear() # noqa: SLF001
|
||||
scanner_module._runtime_handle_observed_at_monotonic = None # noqa: SLF001
|
||||
scanner_module._runtime_handle_generation = 0 # noqa: SLF001
|
||||
|
||||
|
||||
def _seed_scan_lease(handles: dict[str, object], *, observed_at: float) -> None:
|
||||
with scanner_module._runtime_handle_lock: # noqa: SLF001
|
||||
scanner_module._runtime_handles.clear() # noqa: SLF001
|
||||
scanner_module._runtime_handles.update(handles) # type: ignore[arg-type] # noqa: SLF001
|
||||
scanner_module._runtime_handle_observed_at_monotonic = observed_at # noqa: SLF001
|
||||
scanner_module._runtime_handle_generation += 1 # noqa: SLF001
|
||||
|
||||
|
||||
def test_build_wifi_provisioning_frame_layout() -> None:
|
||||
credential = "x" * 13
|
||||
frame = build_wifi_provisioning_frame("LabNet", credential)
|
||||
@@ -122,7 +145,15 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
|
||||
self.write_calls += 1
|
||||
raise AssertionError("status refresh must not write a BLE characteristic")
|
||||
|
||||
monkeypatch.setattr(wifi_module, "discovered_device", lambda _uuid: object())
|
||||
retained_handle = object()
|
||||
monkeypatch.setattr(
|
||||
wifi_module,
|
||||
"discovered_device_selection",
|
||||
lambda _uuid: SimpleNamespace(
|
||||
device=retained_handle,
|
||||
from_fresh_scan=True,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", FakeClient)
|
||||
|
||||
result = asyncio.run(read_wifi_status_once("synthetic-corebluetooth-uuid"))
|
||||
@@ -132,7 +163,7 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
|
||||
assert result["status"]["ipv4"] == "10.255.254.77"
|
||||
|
||||
|
||||
def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
def test_read_wifi_status_recovery_keeps_fresh_retained_handle(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
value = bytearray(54)
|
||||
@@ -141,8 +172,7 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
value[33] = 4
|
||||
value[34:38] = bytes((10, 255, 254, 77))
|
||||
value[50] = 1
|
||||
stale_handle = object()
|
||||
recovered_handle = object()
|
||||
retained_handle = object()
|
||||
characteristic = SimpleNamespace(
|
||||
uuid=wifi_module.STATUS_CHARACTERISTIC_UUID,
|
||||
service_uuid=wifi_module.SERVICE_UUID,
|
||||
@@ -159,7 +189,7 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
assert device is recovered_handle
|
||||
assert device is retained_handle
|
||||
self.services = FakeServices()
|
||||
self.name = "XGR-K1"
|
||||
|
||||
@@ -173,9 +203,16 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
return bytes(value)
|
||||
|
||||
async def rediscover(*_args: object, **_kwargs: object) -> object:
|
||||
return recovered_handle
|
||||
raise AssertionError("a fresh explicit scan handle must not be discarded")
|
||||
|
||||
monkeypatch.setattr(wifi_module, "discovered_device", lambda _uuid: stale_handle)
|
||||
monkeypatch.setattr(
|
||||
wifi_module,
|
||||
"discovered_device_selection",
|
||||
lambda _uuid: SimpleNamespace(
|
||||
device=retained_handle,
|
||||
from_fresh_scan=True,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(wifi_module.BleakScanner, "find_device_by_address", rediscover)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", FakeClient)
|
||||
|
||||
@@ -189,39 +226,264 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
assert result["status"]["ipv4"] == "10.255.254.77"
|
||||
|
||||
|
||||
def test_provisioning_write_requires_fresh_rediscovery_before_connecting(
|
||||
def test_provisioning_write_uses_retained_handle_without_rediscovery(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
stale_handle = object()
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
retained_handle = object()
|
||||
rediscovery_calls: list[tuple[str, float]] = []
|
||||
client_calls: list[object] = []
|
||||
|
||||
async def missing_device(address: str, *, timeout: float) -> None:
|
||||
rediscovery_calls.append((address, timeout))
|
||||
return None
|
||||
class SelectedHandleObserved(RuntimeError):
|
||||
pass
|
||||
|
||||
class ForbiddenClient:
|
||||
async def forbidden_rediscovery(address: str, *, timeout: float) -> None:
|
||||
rediscovery_calls.append((address, timeout))
|
||||
raise AssertionError("a fresh explicit scan handle must be used directly")
|
||||
|
||||
class CapturingClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
client_calls.append(device)
|
||||
raise AssertionError("a failed fresh discovery must stop before the BLE write session")
|
||||
raise SelectedHandleObserved
|
||||
|
||||
monkeypatch.setattr(wifi_module, "discovered_device", lambda _uuid: stale_handle)
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
|
||||
_seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
|
||||
monkeypatch.setattr(
|
||||
wifi_module.BleakScanner,
|
||||
"find_device_by_address",
|
||||
missing_device,
|
||||
forbidden_rediscovery,
|
||||
)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", ForbiddenClient)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", CapturingClient)
|
||||
|
||||
with pytest.raises(BleakDeviceNotFoundError):
|
||||
with pytest.raises(SelectedHandleObserved) as caught:
|
||||
asyncio.run(
|
||||
provision_wifi_once(
|
||||
"synthetic-corebluetooth-uuid",
|
||||
device_id,
|
||||
"LabNet",
|
||||
"synthetic-password",
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert rediscovery_calls == [("synthetic-corebluetooth-uuid", 1.0)]
|
||||
assert client_calls == []
|
||||
assert rediscovery_calls == []
|
||||
assert client_calls == [retained_handle]
|
||||
assert caught.value.operation_stage == "connect" # type: ignore[attr-defined]
|
||||
assert caught.value.device_write_attempted is False # type: ignore[attr-defined]
|
||||
assert caught.value.device_write_confirmed is False # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_provisioning_write_does_not_fallback_when_fresh_scan_omits_device(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
rediscovery_calls: list[tuple[str, float]] = []
|
||||
|
||||
async def forbidden_rediscovery(address: str, *, timeout: float) -> None:
|
||||
rediscovery_calls.append((address, timeout))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
|
||||
_seed_scan_lease({}, observed_at=100.0)
|
||||
monkeypatch.setattr(
|
||||
wifi_module.BleakScanner,
|
||||
"find_device_by_address",
|
||||
forbidden_rediscovery,
|
||||
)
|
||||
|
||||
with pytest.raises(BleakDeviceNotFoundError) as caught:
|
||||
asyncio.run(
|
||||
provision_wifi_once(
|
||||
"not-in-fresh-scan",
|
||||
"LabNet",
|
||||
"synthetic-password",
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert rediscovery_calls == []
|
||||
assert caught.value.operation_stage == "resolution" # type: ignore[attr-defined]
|
||||
assert caught.value.device_write_attempted is False # type: ignore[attr-defined]
|
||||
assert caught.value.device_write_confirmed is False # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_provisioning_write_rediscovery_fallback_after_scan_lease_expires(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
expired_handle = object()
|
||||
rediscovered_handle = object()
|
||||
rediscovery_calls: list[tuple[str, float]] = []
|
||||
client_calls: list[object] = []
|
||||
clock = [100.0]
|
||||
|
||||
class RediscoveredHandleObserved(RuntimeError):
|
||||
pass
|
||||
|
||||
async def rediscover(address: str, *, timeout: float) -> object:
|
||||
rediscovery_calls.append((address, timeout))
|
||||
return rediscovered_handle
|
||||
|
||||
class CapturingClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
client_calls.append(device)
|
||||
raise RediscoveredHandleObserved
|
||||
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: clock[0])
|
||||
_seed_scan_lease({device_id: expired_handle}, observed_at=clock[0])
|
||||
clock[0] += scanner_module.BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS + 0.001
|
||||
monkeypatch.setattr(wifi_module.BleakScanner, "find_device_by_address", rediscover)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", CapturingClient)
|
||||
|
||||
with pytest.raises(RediscoveredHandleObserved):
|
||||
asyncio.run(
|
||||
provision_wifi_once(
|
||||
device_id,
|
||||
"LabNet",
|
||||
"synthetic-password",
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert rediscovery_calls == [(device_id, 1.0)]
|
||||
assert client_calls == [rediscovered_handle]
|
||||
assert scanner_module.discovered_device(device_id) is None
|
||||
|
||||
|
||||
def test_provisioning_baseline_error_keeps_type_and_adds_safe_gatt_facts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
retained_handle = object()
|
||||
service = SimpleNamespace(uuid=wifi_module.SERVICE_UUID)
|
||||
write_characteristic = SimpleNamespace(
|
||||
uuid=wifi_module.WRITE_CHARACTERISTIC_UUID,
|
||||
service_uuid=wifi_module.SERVICE_UUID,
|
||||
properties=["write"],
|
||||
max_write_without_response_size=512,
|
||||
)
|
||||
status_characteristic = SimpleNamespace(
|
||||
uuid=wifi_module.STATUS_CHARACTERISTIC_UUID,
|
||||
service_uuid=wifi_module.SERVICE_UUID,
|
||||
properties=["read"],
|
||||
)
|
||||
|
||||
class FakeServices:
|
||||
def get_service(self, uuid: str) -> object | None:
|
||||
return service if uuid == wifi_module.SERVICE_UUID else None
|
||||
|
||||
def get_characteristic(self, uuid: str) -> object | None:
|
||||
if uuid == wifi_module.WRITE_CHARACTERISTIC_UUID:
|
||||
return write_characteristic
|
||||
if uuid == wifi_module.STATUS_CHARACTERISTIC_UUID:
|
||||
return status_characteristic
|
||||
return None
|
||||
|
||||
class FailingBaselineClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
assert device is retained_handle
|
||||
self.services = FakeServices()
|
||||
self.name = "XGR-K1"
|
||||
|
||||
async def __aenter__(self) -> Any:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
async def read_gatt_char(self, _characteristic: object) -> bytes:
|
||||
raise BleakGATTProtocolError(0x0E)
|
||||
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
|
||||
_seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", FailingBaselineClient)
|
||||
|
||||
with pytest.raises(BleakGATTProtocolError) as caught:
|
||||
asyncio.run(
|
||||
provision_wifi_once(
|
||||
device_id,
|
||||
"LabNet",
|
||||
"synthetic-password",
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
error = caught.value
|
||||
assert error.operation_stage == "baseline-read" # type: ignore[attr-defined]
|
||||
assert error.device_write_attempted is False # type: ignore[attr-defined]
|
||||
assert error.device_write_confirmed is False # type: ignore[attr-defined]
|
||||
assert error.att_error_code == 0x0E # type: ignore[attr-defined]
|
||||
assert error.att_error_name == "UNLIKELY_ERROR" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_provisioning_status_poll_error_reports_confirmed_write(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
retained_handle = object()
|
||||
service = SimpleNamespace(uuid=wifi_module.SERVICE_UUID)
|
||||
write_characteristic = SimpleNamespace(
|
||||
uuid=wifi_module.WRITE_CHARACTERISTIC_UUID,
|
||||
service_uuid=wifi_module.SERVICE_UUID,
|
||||
properties=["write"],
|
||||
max_write_without_response_size=512,
|
||||
)
|
||||
status_characteristic = SimpleNamespace(
|
||||
uuid=wifi_module.STATUS_CHARACTERISTIC_UUID,
|
||||
service_uuid=wifi_module.SERVICE_UUID,
|
||||
properties=["read"],
|
||||
)
|
||||
baseline = bytearray(52)
|
||||
|
||||
class FakeServices:
|
||||
def get_service(self, uuid: str) -> object | None:
|
||||
return service if uuid == wifi_module.SERVICE_UUID else None
|
||||
|
||||
def get_characteristic(self, uuid: str) -> object | None:
|
||||
if uuid == wifi_module.WRITE_CHARACTERISTIC_UUID:
|
||||
return write_characteristic
|
||||
if uuid == wifi_module.STATUS_CHARACTERISTIC_UUID:
|
||||
return status_characteristic
|
||||
return None
|
||||
|
||||
class FailingPollClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
assert device is retained_handle
|
||||
self.services = FakeServices()
|
||||
self.name = "XGR-K1"
|
||||
self.is_connected = True
|
||||
self.read_count = 0
|
||||
|
||||
async def __aenter__(self) -> Any:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
async def read_gatt_char(self, _characteristic: object) -> bytes:
|
||||
self.read_count += 1
|
||||
if self.read_count == 1:
|
||||
return bytes(baseline)
|
||||
raise BleakGATTProtocolError(0x12)
|
||||
|
||||
async def write_gatt_char(self, *_args: object, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
|
||||
_seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", FailingPollClient)
|
||||
|
||||
with pytest.raises(BleakGATTProtocolError) as caught:
|
||||
asyncio.run(
|
||||
provision_wifi_once(
|
||||
device_id,
|
||||
"LabNet",
|
||||
"synthetic-password",
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
error = caught.value
|
||||
assert error.operation_stage == "status-poll" # type: ignore[attr-defined]
|
||||
assert error.device_write_attempted is True # type: ignore[attr-defined]
|
||||
assert error.device_write_confirmed is True # type: ignore[attr-defined]
|
||||
assert error.att_error_code == 0x12 # type: ignore[attr-defined]
|
||||
assert error.att_error_name == "DATABASE_OUT_OF_SYNC" # type: ignore[attr-defined]
|
||||
|
||||
@@ -200,14 +200,18 @@ def service_with_fake_runtime(
|
||||
return service, runtime
|
||||
|
||||
|
||||
def _wifi_status_read(ipv4: str | None) -> dict[str, Any]:
|
||||
def _wifi_status_read(
|
||||
ipv4: str | None,
|
||||
*,
|
||||
device_id: str = "test-ble-transport",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"profile_id": "xgrids-k1-fw3-wifi-v1",
|
||||
"observed_at_utc": "2026-07-20T12:00:00Z",
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": "test",
|
||||
"device_macos_uuid": "test-ble-transport",
|
||||
"device_macos_uuid": device_id,
|
||||
"device_name": "XGR-K1",
|
||||
"service_uuid": "00007f00-0000-1000-8000-00805f9b34fb",
|
||||
"status_characteristic_uuid": "00007f02-0000-1000-8000-00805f9b34fb",
|
||||
@@ -2867,6 +2871,7 @@ def test_quick_connect_activates_the_device_ap_then_associates_the_host(
|
||||
"ready_observed": True,
|
||||
"write_performed": True,
|
||||
"write_mode": "with_response",
|
||||
"observations": [{"status": {"mode": "WIFI_AP"}}],
|
||||
}
|
||||
finally:
|
||||
ble_session_open = False
|
||||
@@ -3090,6 +3095,7 @@ def test_quick_connect_does_not_start_host_wifi_without_ap_ready_flag(
|
||||
"ready_observed": False,
|
||||
"write_performed": True,
|
||||
"write_mode": "with_response",
|
||||
"observations": [],
|
||||
}
|
||||
|
||||
def forbidden_association(*_: object, **__: object) -> dict[str, Any]:
|
||||
@@ -3121,6 +3127,9 @@ def test_quick_connect_does_not_start_host_wifi_without_ap_ready_flag(
|
||||
assert len(quick_sessions) == 1
|
||||
assert (quick_sessions[0] / "ap-activation.redacted.json").exists()
|
||||
assert not (quick_sessions[0] / "manifest.redacted.json").exists()
|
||||
reconciliation = service.state()["network_write_reconciliation"]
|
||||
assert reconciliation["transport_ref"] == "k1-a"
|
||||
assert reconciliation["status"] == "device-state-unknown-after-write"
|
||||
|
||||
|
||||
def test_failed_connection_change_revokes_the_previous_route(
|
||||
@@ -3154,6 +3163,7 @@ def test_failed_connection_change_revokes_the_previous_route(
|
||||
"ready_observed": True,
|
||||
"write_performed": True,
|
||||
"write_mode": "with_response",
|
||||
"observations": [{"status": {"mode": "WIFI_AP"}}],
|
||||
}
|
||||
|
||||
def failed_association(*_: object, **__: object) -> dict[str, Any]:
|
||||
@@ -3195,6 +3205,75 @@ def test_failed_connection_change_revokes_the_previous_route(
|
||||
assert failure_evidence["scan_elapsed_ms"] == 15014
|
||||
|
||||
|
||||
def test_quick_connect_helper_build_failure_after_ap_write_preserves_side_effect_facts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
|
||||
monkeypatch.setattr(
|
||||
facade_module,
|
||||
"ensure_wifi_profile_from_credential_source",
|
||||
lambda *_args, **_kwargs: {
|
||||
"schema_version": 1,
|
||||
"adapter": "macOS Keychain",
|
||||
"available": True,
|
||||
"profile_enrolled": False,
|
||||
"credential_source": "exact-firmware-profile",
|
||||
},
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def ready_activation(*_: object, **__: object) -> AsyncIterator[dict[str, Any]]:
|
||||
yield {
|
||||
"profile_id": "xgrids-k1-fw3-quick-connect-ap-v1",
|
||||
"started_at_utc": "2026-08-06T10:00:00Z",
|
||||
"completed_at_utc": "2026-08-06T10:00:01Z",
|
||||
"outcome": "ap_ready_observed",
|
||||
"ready_observed": True,
|
||||
"write_performed": True,
|
||||
"write_mode": "with_response",
|
||||
"observations": [{"status": {"mode": "WIFI_AP"}}],
|
||||
}
|
||||
|
||||
def failed_post_write_build(*_: object, **__: object) -> dict[str, Any]:
|
||||
raise facade_module.HostWifiProfileError(
|
||||
"host-wifi-helper-build-failed",
|
||||
helper_stage="compile",
|
||||
helper_elapsed_ms=21,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(facade_module, "device_ap_activation_session", ready_activation)
|
||||
monkeypatch.setattr(
|
||||
facade_module,
|
||||
"associate_with_wifi_profile_once",
|
||||
failed_post_write_build,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
facade_module.HostWifiProfileError,
|
||||
match="host-wifi-helper-build-failed",
|
||||
):
|
||||
asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-a",
|
||||
connection_mode="quick-connect",
|
||||
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
state = service.state()
|
||||
operation = next(item for item in state["operations"] if item["action"] == "network.provision")
|
||||
assert operation["stage_code"] == "host-wifi-association-failed"
|
||||
assert operation["error"]["code"] == "host-wifi-helper-build-failed"
|
||||
assert operation["error"]["side_effect_status"] == "confirmed"
|
||||
assert operation["error"]["safe_to_retry"] is False
|
||||
assert operation["error"]["helper_stage"] == "compile"
|
||||
assert state["network_write_reconciliation"] is None
|
||||
|
||||
|
||||
def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
@@ -3236,7 +3315,8 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||
operation = next(item for item in state["operations"] if item["action"] == "network.provision")
|
||||
assert operation["status"] == "failed"
|
||||
assert operation["error"]["safe_to_retry"] is False
|
||||
assert operation["error"]["side_effect_status"] == "unknown"
|
||||
assert operation["error"]["side_effect_status"] == "confirmed"
|
||||
assert state["network_write_reconciliation"] is None
|
||||
failure_log = next(
|
||||
record
|
||||
for record in caplog.records
|
||||
@@ -3246,12 +3326,212 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||
assert failure_log.connection_mode == "bridge"
|
||||
assert failure_log.error_code == "RuntimeError"
|
||||
assert failure_log.safe_to_retry is False
|
||||
assert failure_log.side_effect_status == "unknown"
|
||||
assert failure_log.side_effect_status == "confirmed"
|
||||
assert failure_log.network_change_attempted is True
|
||||
assert failure_log.device_write_attempted is True
|
||||
assert failure_log.device_write_confirmed is True
|
||||
assert PRIMARY_TEST_CREDENTIAL not in caplog.text
|
||||
assert "lab-network" not in caplog.text
|
||||
|
||||
|
||||
def test_ambiguous_ble_write_blocks_new_network_mutation_until_reconciled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a"}])
|
||||
calls = 0
|
||||
|
||||
async def ambiguous_write(*_: object, **__: object) -> dict[str, Any]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
exc = RuntimeError("synthetic transport failure")
|
||||
exc.operation_stage = "gatt-write" # type: ignore[attr-defined]
|
||||
exc.device_write_attempted = True # type: ignore[attr-defined]
|
||||
exc.device_write_confirmed = False # type: ignore[attr-defined]
|
||||
exc.att_error_code = 4 # type: ignore[attr-defined]
|
||||
exc.att_error_name = "INVALID_PDU" # type: ignore[attr-defined]
|
||||
raise exc
|
||||
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", ambiguous_write)
|
||||
|
||||
with pytest.raises(RuntimeError, match="synthetic transport failure"):
|
||||
asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-a",
|
||||
ssid="lab-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
compatibility_attestation=ATTESTATION,
|
||||
idempotency_key="ambiguous-write-1",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
state = service.state()
|
||||
operation = next(item for item in state["operations"] if item["action"] == "network.provision")
|
||||
assert operation["error"] == {
|
||||
"category": "device",
|
||||
"code": "RuntimeError",
|
||||
"retryable": False,
|
||||
"safe_to_retry": False,
|
||||
"side_effect_status": "unknown",
|
||||
"operation_stage": "gatt-write",
|
||||
"device_write_attempted": True,
|
||||
"device_write_confirmed": False,
|
||||
"ble_att_error_code": 4,
|
||||
"ble_att_error_name": "INVALID_PDU",
|
||||
}
|
||||
assert state["network_write_reconciliation"] == {
|
||||
"status": "device-state-unknown-after-write",
|
||||
"operation_id": operation["operation_id"],
|
||||
"transport_ref": "k1-a",
|
||||
"connection_mode": "bridge",
|
||||
"operation_stage": "gatt-write",
|
||||
"reason_code": "RuntimeError",
|
||||
"device_write_confirmed": False,
|
||||
"required_action": "explicit-read-only-ble-status-observation",
|
||||
"scope": "process-runtime",
|
||||
"observed_at": state["network_write_reconciliation"]["observed_at"],
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
facade_module.NetworkWriteReconciliationRequired,
|
||||
match="новая запись заблокирована",
|
||||
):
|
||||
asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-a",
|
||||
ssid="another-network",
|
||||
password=SecretStr(SECONDARY_TEST_CREDENTIAL),
|
||||
compatibility_attestation=ATTESTATION,
|
||||
idempotency_key="ambiguous-write-2",
|
||||
)
|
||||
)
|
||||
)
|
||||
assert calls == 1
|
||||
|
||||
|
||||
def test_unchanged_status_does_not_confirm_without_response_write(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a"}])
|
||||
unchanged_status = {"ipv4": None, "mode": "WIFI_CLIENT"}
|
||||
|
||||
async def unchanged_write(*_: object, **__: object) -> dict[str, Any]:
|
||||
return {
|
||||
"started_at_utc": "2026-08-06T10:00:00Z",
|
||||
"completed_at_utc": "2026-08-06T10:00:01Z",
|
||||
"profile_id": "xgrids-k1-fw3-wifi-v1",
|
||||
"outcome": "no_status_change_before_timeout",
|
||||
"write_mode": "without_response",
|
||||
"baseline_status": unchanged_status,
|
||||
"observations": [{"status": unchanged_status}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", unchanged_write)
|
||||
|
||||
with (
|
||||
caplog.at_level(logging.ERROR, logger=facade_module.__name__),
|
||||
pytest.raises(RuntimeError, match="не сообщило адрес"),
|
||||
):
|
||||
asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-a",
|
||||
ssid="lab-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
record = next(
|
||||
item
|
||||
for item in caplog.records
|
||||
if getattr(item, "event_code", None) == "k1_network_provision_failed"
|
||||
)
|
||||
assert record.device_write_attempted is True
|
||||
assert record.device_write_confirmed is False
|
||||
assert record.side_effect_status == "unknown"
|
||||
assert service.state()["network_write_reconciliation"] is None
|
||||
|
||||
|
||||
def test_read_only_ble_status_clears_network_write_reconciliation_fence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_k1(service)
|
||||
service._network_write_reconciliation = { # noqa: SLF001
|
||||
"status": "device-state-unknown-after-write",
|
||||
"operation_id": "ambiguous-operation",
|
||||
"transport_ref": "test-ble-transport",
|
||||
"connection_mode": "bridge",
|
||||
"operation_stage": "gatt-write",
|
||||
"reason_code": "BleakGATTProtocolError",
|
||||
"device_write_confirmed": False,
|
||||
"required_action": "explicit-read-only-ble-status-observation",
|
||||
"scope": "process-runtime",
|
||||
"observed_at": "2026-08-06T10:00:00Z",
|
||||
}
|
||||
|
||||
async def read_current_status(*_: object, **__: object) -> dict[str, Any]:
|
||||
return _wifi_status_read(None)
|
||||
|
||||
monkeypatch.setattr(facade_module, "read_wifi_status_once", read_current_status)
|
||||
|
||||
with pytest.raises(RuntimeError, match="не сообщил актуальный DHCP-адрес"):
|
||||
service.verify_connection(
|
||||
ConnectionVerifyRequest(
|
||||
device_id="test-ble-transport",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
|
||||
assert service.state()["network_write_reconciliation"] is None
|
||||
|
||||
|
||||
def test_read_only_ble_status_for_another_transport_keeps_reconciliation_fence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_k1(service, device_id="k1-b")
|
||||
fence = {
|
||||
"status": "device-state-unknown-after-write",
|
||||
"operation_id": "ambiguous-operation",
|
||||
"transport_ref": "k1-a",
|
||||
"connection_mode": "bridge",
|
||||
"operation_stage": "gatt-write",
|
||||
"reason_code": "BleakGATTProtocolError",
|
||||
"device_write_confirmed": False,
|
||||
"required_action": "explicit-read-only-ble-status-observation",
|
||||
"scope": "process-runtime",
|
||||
"observed_at": "2026-08-06T10:00:00Z",
|
||||
}
|
||||
service._network_write_reconciliation = dict(fence) # noqa: SLF001
|
||||
|
||||
async def read_other_status(*_: object, **__: object) -> dict[str, Any]:
|
||||
return _wifi_status_read(None, device_id="k1-b")
|
||||
|
||||
monkeypatch.setattr(facade_module, "read_wifi_status_once", read_other_status)
|
||||
|
||||
with pytest.raises(RuntimeError, match="не сообщил актуальный DHCP-адрес"):
|
||||
service.verify_connection(
|
||||
ConnectionVerifyRequest(
|
||||
device_id="k1-b",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
|
||||
assert service.state()["network_write_reconciliation"] == fence
|
||||
|
||||
|
||||
def test_provisioning_cannot_switch_device_during_active_acquisition(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import asyncio
|
||||
from collections.abc import Iterator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from bleak.exc import BleakDeviceNotFoundError
|
||||
from bleak.exc import BleakDeviceNotFoundError, BleakGATTProtocolError
|
||||
|
||||
import k1link.device_plugins.xgrids_k1.ble.ap_activation as ap_module
|
||||
import k1link.device_plugins.xgrids_k1.ble.scanner as scanner_module
|
||||
@@ -15,6 +18,27 @@ from k1link.device_plugins.xgrids_k1.ble.ap_activation import (
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import WifiStatus
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_runtime_handle_lease() -> Iterator[None]:
|
||||
with scanner_module._runtime_handle_lock: # noqa: SLF001
|
||||
scanner_module._runtime_handles.clear() # noqa: SLF001
|
||||
scanner_module._runtime_handle_observed_at_monotonic = None # noqa: SLF001
|
||||
scanner_module._runtime_handle_generation = 0 # noqa: SLF001
|
||||
yield
|
||||
with scanner_module._runtime_handle_lock: # noqa: SLF001
|
||||
scanner_module._runtime_handles.clear() # noqa: SLF001
|
||||
scanner_module._runtime_handle_observed_at_monotonic = None # noqa: SLF001
|
||||
scanner_module._runtime_handle_generation = 0 # noqa: SLF001
|
||||
|
||||
|
||||
def _seed_scan_lease(handles: dict[str, object], *, observed_at: float) -> None:
|
||||
with scanner_module._runtime_handle_lock: # noqa: SLF001
|
||||
scanner_module._runtime_handles.clear() # noqa: SLF001
|
||||
scanner_module._runtime_handles.update(handles) # type: ignore[arg-type] # noqa: SLF001
|
||||
scanner_module._runtime_handle_observed_at_monotonic = observed_at # noqa: SLF001
|
||||
scanner_module._runtime_handle_generation += 1 # noqa: SLF001
|
||||
|
||||
|
||||
def test_build_ap_activation_frame_matches_reviewed_lixelgo_layout() -> None:
|
||||
frame = build_ap_activation_frame()
|
||||
|
||||
@@ -42,34 +66,36 @@ def test_ap_ready_requires_the_reviewed_byte_51_flag() -> None:
|
||||
assert is_ap_ready_status(_status(reserved=1))
|
||||
|
||||
|
||||
def test_ap_activation_requires_fresh_rediscovery_before_connecting(
|
||||
def test_ap_activation_uses_retained_handle_without_rediscovery(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
stale_handle = object()
|
||||
retained_handle = object()
|
||||
rediscovery_calls: list[tuple[str, float]] = []
|
||||
client_calls: list[object] = []
|
||||
|
||||
async def missing_device(address: str, *, timeout: float) -> None:
|
||||
rediscovery_calls.append((address, timeout))
|
||||
return None
|
||||
class SelectedHandleObserved(RuntimeError):
|
||||
pass
|
||||
|
||||
class ForbiddenClient:
|
||||
async def forbidden_rediscovery(address: str, *, timeout: float) -> None:
|
||||
rediscovery_calls.append((address, timeout))
|
||||
raise AssertionError("a fresh explicit scan handle must be used directly")
|
||||
|
||||
class CapturingClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
client_calls.append(device)
|
||||
raise AssertionError("a failed fresh discovery must stop before the BLE write session")
|
||||
raise SelectedHandleObserved
|
||||
|
||||
# Seed the real retained-handle cache so the test fails if the mutating
|
||||
# path ever regresses to discovered_device(...)-first behavior.
|
||||
monkeypatch.setitem(scanner_module._runtime_handles, device_id, stale_handle) # noqa: SLF001
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
|
||||
_seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
|
||||
monkeypatch.setattr(
|
||||
ap_module.BleakScanner,
|
||||
"find_device_by_address",
|
||||
missing_device,
|
||||
forbidden_rediscovery,
|
||||
)
|
||||
monkeypatch.setattr(ap_module, "BleakClient", ForbiddenClient)
|
||||
monkeypatch.setattr(ap_module, "BleakClient", CapturingClient)
|
||||
|
||||
with pytest.raises(BleakDeviceNotFoundError):
|
||||
with pytest.raises(SelectedHandleObserved) as caught:
|
||||
asyncio.run(
|
||||
ap_module.activate_device_ap_once(
|
||||
device_id,
|
||||
@@ -77,5 +103,108 @@ def test_ap_activation_requires_fresh_rediscovery_before_connecting(
|
||||
)
|
||||
)
|
||||
|
||||
assert rediscovery_calls == [(device_id, 1.0)]
|
||||
assert client_calls == []
|
||||
assert rediscovery_calls == []
|
||||
assert client_calls == [retained_handle]
|
||||
assert caught.value.operation_stage == "connect" # type: ignore[attr-defined]
|
||||
assert caught.value.device_write_attempted is False # type: ignore[attr-defined]
|
||||
assert caught.value.device_write_confirmed is False # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_ap_activation_does_not_fallback_when_fresh_scan_omits_device(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
rediscovery_calls: list[tuple[str, float]] = []
|
||||
|
||||
async def forbidden_rediscovery(address: str, *, timeout: float) -> None:
|
||||
rediscovery_calls.append((address, timeout))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
|
||||
_seed_scan_lease({}, observed_at=100.0)
|
||||
monkeypatch.setattr(
|
||||
ap_module.BleakScanner,
|
||||
"find_device_by_address",
|
||||
forbidden_rediscovery,
|
||||
)
|
||||
|
||||
with pytest.raises(BleakDeviceNotFoundError) as caught:
|
||||
asyncio.run(
|
||||
ap_module.activate_device_ap_once(
|
||||
"not-in-fresh-scan",
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert rediscovery_calls == []
|
||||
assert caught.value.operation_stage == "resolution" # type: ignore[attr-defined]
|
||||
assert caught.value.device_write_attempted is False # type: ignore[attr-defined]
|
||||
assert caught.value.device_write_confirmed is False # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_ap_activation_write_error_keeps_type_and_adds_safe_gatt_facts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
retained_handle = object()
|
||||
service = SimpleNamespace(uuid=ap_module.SERVICE_UUID)
|
||||
write_characteristic = SimpleNamespace(
|
||||
uuid=ap_module.WRITE_CHARACTERISTIC_UUID,
|
||||
service_uuid=ap_module.SERVICE_UUID,
|
||||
properties=["write"],
|
||||
max_write_without_response_size=512,
|
||||
)
|
||||
status_characteristic = SimpleNamespace(
|
||||
uuid=ap_module.STATUS_CHARACTERISTIC_UUID,
|
||||
service_uuid=ap_module.SERVICE_UUID,
|
||||
properties=["read"],
|
||||
)
|
||||
baseline = bytearray(52)
|
||||
|
||||
class FakeServices:
|
||||
def get_service(self, uuid: str) -> object | None:
|
||||
return service if uuid == ap_module.SERVICE_UUID else None
|
||||
|
||||
def get_characteristic(self, uuid: str) -> object | None:
|
||||
if uuid == ap_module.WRITE_CHARACTERISTIC_UUID:
|
||||
return write_characteristic
|
||||
if uuid == ap_module.STATUS_CHARACTERISTIC_UUID:
|
||||
return status_characteristic
|
||||
return None
|
||||
|
||||
class FailingWriteClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
assert device is retained_handle
|
||||
self.services = FakeServices()
|
||||
self.name = "XGR-K1"
|
||||
self.is_connected = True
|
||||
|
||||
async def __aenter__(self) -> Any:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
async def read_gatt_char(self, _characteristic: object) -> bytes:
|
||||
return bytes(baseline)
|
||||
|
||||
async def write_gatt_char(self, *_args: object, **_kwargs: object) -> None:
|
||||
raise BleakGATTProtocolError(0x03)
|
||||
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
|
||||
_seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
|
||||
monkeypatch.setattr(ap_module, "BleakClient", FailingWriteClient)
|
||||
|
||||
with pytest.raises(BleakGATTProtocolError) as caught:
|
||||
asyncio.run(
|
||||
ap_module.activate_device_ap_once(
|
||||
device_id,
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
error = caught.value
|
||||
assert error.operation_stage == "gatt-write" # type: ignore[attr-defined]
|
||||
assert error.device_write_attempted is True # type: ignore[attr-defined]
|
||||
assert error.device_write_confirmed is False # type: ignore[attr-defined]
|
||||
assert error.att_error_code == 0x03 # type: ignore[attr-defined]
|
||||
assert error.att_error_name == "WRITE_NOT_PERMITTED" # type: ignore[attr-defined]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
@@ -13,9 +15,14 @@ TEST_PROFILE_ID = "fixture.quick-connect.v1"
|
||||
TEST_CREDENTIAL_SOURCE_ID = "fixture.firmware-provider.v1"
|
||||
|
||||
|
||||
def _helper(tmp_path: Path) -> Path:
|
||||
def _helper(tmp_path: Path, *, seed_compiled_cache: bool = True) -> Path:
|
||||
helper = tmp_path / "associate_wifi.swift"
|
||||
helper.write_text("// offline fixture\n", encoding="utf-8")
|
||||
if seed_compiled_cache:
|
||||
executable = wifi._compiled_macos_helper_path(helper)
|
||||
executable.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
executable.write_bytes(b"offline compiled fixture\n")
|
||||
executable.chmod(0o700)
|
||||
return helper
|
||||
|
||||
|
||||
@@ -42,8 +49,9 @@ def test_association_exposes_only_profile_id_and_expected_ssid_to_platform_helpe
|
||||
stderr=b"",
|
||||
)
|
||||
|
||||
helper = _helper(tmp_path)
|
||||
result = wifi.associate_with_wifi_profile_once(
|
||||
_helper(tmp_path),
|
||||
helper,
|
||||
TEST_PROFILE_ID,
|
||||
"XGR-OFFLINE",
|
||||
runner=fake_runner,
|
||||
@@ -61,7 +69,7 @@ def test_association_exposes_only_profile_id_and_expected_ssid_to_platform_helpe
|
||||
}
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call["argv"][:2] == ["/usr/bin/xcrun", "swift"]
|
||||
assert call["argv"] == [str(wifi._compiled_macos_helper_path(helper))]
|
||||
request = json.loads(bytes(call["input"]).decode("utf-8"))
|
||||
assert request == {
|
||||
"action": "associate",
|
||||
@@ -290,6 +298,8 @@ def test_association_reports_operator_timeout_separately_from_missing_helper(
|
||||
)
|
||||
|
||||
assert raised.value.reason_code == "host-wifi-operation-timeout"
|
||||
assert raised.value.helper_stage is None
|
||||
assert raised.value.helper_elapsed_ms is None
|
||||
|
||||
|
||||
def test_association_reports_an_unavailable_helper_separately_from_timeout(
|
||||
@@ -317,6 +327,271 @@ def test_association_reports_an_unavailable_helper_separately_from_timeout(
|
||||
assert raised.value.reason_code == "host-wifi-helper-unavailable"
|
||||
|
||||
|
||||
def test_cold_helper_build_uses_source_hash_and_separate_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wifi.sys, "platform", "darwin")
|
||||
helper = _helper(tmp_path, seed_compiled_cache=False)
|
||||
executable = wifi._compiled_macos_helper_path(helper)
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
def fake_runner(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[bytes]:
|
||||
calls.append({"argv": argv, **kwargs})
|
||||
if argv[:2] == ["/usr/bin/xcrun", "swiftc"]:
|
||||
assert argv[2] == str(helper.resolve())
|
||||
assert argv[3] == "-o"
|
||||
assert kwargs["stdin"] == subprocess.DEVNULL
|
||||
assert "input" not in kwargs
|
||||
assert 0 < float(kwargs["timeout"]) <= wifi.DEFAULT_HELPER_BUILD_TIMEOUT_SECONDS
|
||||
staging = Path(argv[4])
|
||||
staging.write_bytes(b"compiled fixture\n")
|
||||
staging.chmod(0o700)
|
||||
return subprocess.CompletedProcess(argv, 0, stdout=b"", stderr=b"")
|
||||
|
||||
assert argv == [str(executable)]
|
||||
assert kwargs["timeout"] == 30.0
|
||||
return subprocess.CompletedProcess(
|
||||
argv,
|
||||
0,
|
||||
stdout=(
|
||||
b'{"ok":true,"adapter":"macOS Keychain",'
|
||||
b'"profile_available":true}'
|
||||
),
|
||||
stderr=b"",
|
||||
)
|
||||
|
||||
result = wifi.check_wifi_profile(
|
||||
helper,
|
||||
TEST_PROFILE_ID,
|
||||
"XGR-OFFLINE",
|
||||
runner=fake_runner,
|
||||
)
|
||||
|
||||
assert result["available"] is True
|
||||
assert len(calls) == 2
|
||||
assert calls[0]["argv"][:2] == ["/usr/bin/xcrun", "swiftc"]
|
||||
assert calls[1]["argv"] == [str(executable)]
|
||||
assert executable.is_file()
|
||||
assert executable.stat().st_mode & 0o111
|
||||
assert executable.name.endswith(hashlib.sha256(helper.read_bytes()).hexdigest())
|
||||
assert not list(executable.parent.glob(f".{executable.name}.*.tmp"))
|
||||
|
||||
|
||||
def test_plugin_helper_cache_is_stable_under_repository_runtime(tmp_path: Path) -> None:
|
||||
helper = tmp_path / "repo" / "plugins" / "xgrids-k1" / "macos" / "associate_wifi.swift"
|
||||
helper.parent.mkdir(parents=True)
|
||||
helper.write_text("// offline fixture\n", encoding="utf-8")
|
||||
|
||||
assert wifi._helper_cache_directory(helper) == (
|
||||
tmp_path / "repo" / ".runtime" / "mission-core" / "helpers"
|
||||
)
|
||||
|
||||
|
||||
def test_helper_lock_and_compile_share_one_build_deadline(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wifi.sys, "platform", "darwin")
|
||||
helper = _helper(tmp_path, seed_compiled_cache=False)
|
||||
monotonic_values = iter((100.0, 100.0, 104.0))
|
||||
monkeypatch.setattr(wifi.time, "monotonic", lambda: next(monotonic_values))
|
||||
monkeypatch.setattr(wifi.time, "monotonic_ns", lambda: 0)
|
||||
lock_timeouts: list[float] = []
|
||||
compile_timeouts: list[float] = []
|
||||
|
||||
class FakeLock:
|
||||
def __enter__(self) -> None:
|
||||
return None
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
def fake_lock(_path: Path, *, timeout_seconds: float) -> FakeLock:
|
||||
lock_timeouts.append(timeout_seconds)
|
||||
return FakeLock()
|
||||
|
||||
def fake_runner(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[bytes]:
|
||||
if argv[:2] == ["/usr/bin/xcrun", "swiftc"]:
|
||||
compile_timeouts.append(float(kwargs["timeout"]))
|
||||
staging = Path(argv[4])
|
||||
staging.write_bytes(b"compiled fixture\n")
|
||||
staging.chmod(0o700)
|
||||
return subprocess.CompletedProcess(argv, 0, stdout=b"", stderr=b"")
|
||||
return subprocess.CompletedProcess(
|
||||
argv,
|
||||
0,
|
||||
stdout=b'{"ok":true,"adapter":"macOS Keychain","profile_available":true}',
|
||||
stderr=b"",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(wifi, "_exclusive_helper_build_lock", fake_lock)
|
||||
result = wifi._run_macos_helper(
|
||||
helper,
|
||||
{"action": "check-profile"},
|
||||
timeout_seconds=3.0,
|
||||
build_timeout_seconds=10.0,
|
||||
runner=fake_runner,
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert lock_timeouts == [10.0]
|
||||
assert compile_timeouts == [6.0]
|
||||
|
||||
|
||||
def test_compiled_helper_is_reused_without_recompiling(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wifi.sys, "platform", "darwin")
|
||||
helper = _helper(tmp_path, seed_compiled_cache=False)
|
||||
executable = wifi._compiled_macos_helper_path(helper)
|
||||
compile_count = 0
|
||||
runtime_count = 0
|
||||
|
||||
def fake_runner(argv: list[str], **_: object) -> subprocess.CompletedProcess[bytes]:
|
||||
nonlocal compile_count, runtime_count
|
||||
if argv[:2] == ["/usr/bin/xcrun", "swiftc"]:
|
||||
compile_count += 1
|
||||
staging = Path(argv[4])
|
||||
staging.write_bytes(b"compiled fixture\n")
|
||||
staging.chmod(0o700)
|
||||
return subprocess.CompletedProcess(argv, 0, stdout=b"", stderr=b"")
|
||||
runtime_count += 1
|
||||
assert argv == [str(executable)]
|
||||
return subprocess.CompletedProcess(
|
||||
argv,
|
||||
0,
|
||||
stdout=(
|
||||
b'{"ok":true,"adapter":"macOS Keychain",'
|
||||
b'"profile_available":true}'
|
||||
),
|
||||
stderr=b"",
|
||||
)
|
||||
|
||||
for _ in range(2):
|
||||
wifi.check_wifi_profile(
|
||||
helper,
|
||||
TEST_PROFILE_ID,
|
||||
"XGR-OFFLINE",
|
||||
runner=fake_runner,
|
||||
)
|
||||
|
||||
assert compile_count == 1
|
||||
assert runtime_count == 2
|
||||
|
||||
|
||||
def test_helper_build_timeout_is_separate_from_operation_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wifi.sys, "platform", "darwin")
|
||||
helper = _helper(tmp_path, seed_compiled_cache=False)
|
||||
seen_timeout: float | None = None
|
||||
monotonic_values = iter((1_000_000_000, 1_123_000_000))
|
||||
monkeypatch.setattr(wifi.time, "monotonic_ns", lambda: next(monotonic_values))
|
||||
|
||||
def timed_out_compiler(
|
||||
argv: list[str], **kwargs: object
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
nonlocal seen_timeout
|
||||
assert argv[:2] == ["/usr/bin/xcrun", "swiftc"]
|
||||
seen_timeout = float(kwargs["timeout"])
|
||||
raise subprocess.TimeoutExpired(argv, timeout=seen_timeout)
|
||||
|
||||
with pytest.raises(
|
||||
wifi.HostWifiProfileError,
|
||||
match="host-wifi-helper-build-timeout",
|
||||
) as raised:
|
||||
wifi._run_macos_helper(
|
||||
helper,
|
||||
{"action": "check-profile"},
|
||||
timeout_seconds=3.0,
|
||||
build_timeout_seconds=7.5,
|
||||
runner=timed_out_compiler,
|
||||
)
|
||||
|
||||
assert raised.value.reason_code == "host-wifi-helper-build-timeout"
|
||||
assert raised.value.helper_stage == "compile"
|
||||
assert raised.value.helper_elapsed_ms == 123
|
||||
assert seen_timeout is not None
|
||||
assert 0 < seen_timeout <= 7.5
|
||||
assert not wifi._compiled_macos_helper_path(helper).exists()
|
||||
|
||||
|
||||
def test_helper_build_lock_contention_is_bounded(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fcntl = pytest.importorskip("fcntl")
|
||||
lock_path = tmp_path / "helper.lock"
|
||||
descriptor = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
monotonic_values = iter((100.0, 100.02))
|
||||
monkeypatch.setattr(wifi.time, "monotonic", lambda: next(monotonic_values))
|
||||
|
||||
try:
|
||||
with (
|
||||
pytest.raises(wifi.HostWifiProfileError) as raised,
|
||||
wifi._exclusive_helper_build_lock(
|
||||
lock_path,
|
||||
timeout_seconds=0.01,
|
||||
),
|
||||
):
|
||||
raise AssertionError("contended lock must not be acquired")
|
||||
finally:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
os.close(descriptor)
|
||||
|
||||
assert raised.value.reason_code == "host-wifi-helper-build-timeout"
|
||||
assert raised.value.helper_stage == "compile-lock"
|
||||
assert raised.value.helper_elapsed_ms == 19
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("compiler_failure", "expected_reason_code"),
|
||||
[
|
||||
("exit", "host-wifi-helper-build-failed"),
|
||||
("unavailable", "host-wifi-helper-compiler-unavailable"),
|
||||
],
|
||||
)
|
||||
def test_helper_build_errors_have_sanitized_build_taxonomy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
compiler_failure: str,
|
||||
expected_reason_code: str,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wifi.sys, "platform", "darwin")
|
||||
helper = _helper(tmp_path, seed_compiled_cache=False)
|
||||
|
||||
def failing_compiler(
|
||||
argv: list[str], **_: object
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
assert argv[:2] == ["/usr/bin/xcrun", "swiftc"]
|
||||
if compiler_failure == "unavailable":
|
||||
raise OSError(f"private diagnostic {TEST_PASSWORD}")
|
||||
return subprocess.CompletedProcess(
|
||||
argv,
|
||||
1,
|
||||
stdout=b"",
|
||||
stderr=f"private diagnostic {TEST_PASSWORD}".encode(),
|
||||
)
|
||||
|
||||
with pytest.raises(wifi.HostWifiProfileError) as raised:
|
||||
wifi.check_wifi_profile(
|
||||
helper,
|
||||
TEST_PROFILE_ID,
|
||||
"XGR-OFFLINE",
|
||||
runner=failing_compiler,
|
||||
)
|
||||
|
||||
assert raised.value.reason_code == expected_reason_code
|
||||
assert raised.value.helper_stage == "compile"
|
||||
assert isinstance(raised.value.helper_elapsed_ms, int)
|
||||
assert raised.value.helper_elapsed_ms >= 0
|
||||
assert TEST_PASSWORD not in str(raised.value)
|
||||
|
||||
|
||||
def test_association_rejects_an_uninstalled_platform(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
Reference in New Issue
Block a user