fix(k1): restore canonical local connection lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-08-06 13:52:46 +03:00
parent 52da9b75b7
commit aff331082f
19 changed files with 2169 additions and 274 deletions
@@ -635,6 +635,72 @@ test("a provisioned address is not presented as a verified device connection", (
}), "connected"); }), "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", () => { test("provisioning intent keeps one idempotency key and exposes unsafe outcomes", () => {
let created = 0; let created = 0;
const createUuid = () => { const createUuid = () => {
@@ -751,3 +817,52 @@ test("rejected network-profile writes use safe operator copy", () => {
); );
assert.doesNotMatch(message, /Bleak|GATT|ATT/i); 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"), join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
"utf8", "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, /connectionMode === "bridge"/);
assert.match(provisioning, /Подхватить существующее подключение/); assert.match(provisioning, /Подхватить существующее подключение/);
assert.match(provisioning, /pendingAction === "verify"/); assert.match(provisioning, /pendingAction === "verify"/);
@@ -181,9 +175,23 @@ test("K1 Bridge adoption remains one explicit read-only plugin action", () => {
/compatibility_attestation: profileSelectionForConnectionMode\("bridge"\)/, /compatibility_attestation: profileSelectionForConnectionMode\("bridge"\)/,
); );
assert.match(provisioning, /без изменения настроек Wi‑Fi/); assert.match(provisioning, /без изменения настроек Wi‑Fi/);
assert.match(provisioning, /deviceSummary !== undefined/); assert.match(provisioning, /deviceSummary !== null/);
assert.match(selectionEffect, /!state\.devices\.some/); assert.match(provisioning, /device_id: deviceSummary\.device_id/);
assert.match(selectionEffect, /setSelectedDeviceId\(""\)/); });
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", () => { test("generic Control Station has one composition import and no K1 implementation knowledge", () => {
+14
View File
@@ -82,6 +82,19 @@ export interface XgridsConnectionVerification {
reason_code?: string | null; 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 { export interface XgridsCompatibilityState {
profile_id?: string | null; profile_id?: string | null;
decision?: "compatible" | "limited" | "unknown" | "incompatible"; decision?: "compatible" | "limited" | "unknown" | "incompatible";
@@ -352,6 +365,7 @@ export interface XgridsK1State {
device_ref?: XgridsDeviceRef | null; device_ref?: XgridsDeviceRef | null;
device_session?: XgridsDeviceSession | null; device_session?: XgridsDeviceSession | null;
connection_verification?: XgridsConnectionVerification | null; connection_verification?: XgridsConnectionVerification | null;
network_write_reconciliation?: XgridsNetworkWriteReconciliation | null;
acquisition?: XgridsAcquisition | null; acquisition?: XgridsAcquisition | null;
operations?: XgridsOperation[]; operations?: XgridsOperation[];
last_operation?: XgridsOperation | null; last_operation?: XgridsOperation | null;
@@ -17,7 +17,12 @@ import {
connectionModeOptions, connectionModeOptions,
type ConnectionMode, type ConnectionMode,
} from "../configuration"; } from "../configuration";
import { provisioningIntentKey } from "../lifecycle"; import {
canSubmitProvisioningMutation,
isReachableConnectionLease,
provisioningCandidateById,
provisioningIntentKey,
} from "../lifecycle";
import { finiteMetric } from "../presentation"; import { finiteMetric } from "../presentation";
import type { XgridsK1Controller } from "../runtimeContext"; import type { XgridsK1Controller } from "../runtimeContext";
@@ -131,39 +136,34 @@ export function K1ProvisioningPipeline({
const isBusy = pendingAction !== null; const isBusy = pendingAction !== null;
const credentialsReady = connectionMode === "quick-connect" const credentialsReady = connectionMode === "quick-connect"
|| (ssid.trim().length > 0 && password.length > 0); || (ssid.trim().length > 0 && password.length > 0);
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy; const networkWriteReconciliationPending = Boolean(
const modeCopy = connectionCopy[connectionMode]; state?.network_write_reconciliation,
const selectedModeConnected = Boolean(
state?.k1_ip && state.connection_mode === connectionMode,
); );
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( const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId), () => provisioningCandidateById(devices, selectedDeviceId),
[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" const canAdoptExistingBridge = connectionMode === "bridge"
&& powerConfirmed && powerConfirmed
&& selectedDeviceId.length > 0 && deviceSummary !== null
&& deviceSummary !== undefined && deviceSummary.connectable !== false
&& !isBusy; && !isBusy;
const resetProvisioningIntent = () => { const resetProvisioningIntent = () => {
@@ -171,14 +171,14 @@ export function K1ProvisioningPipeline({
}; };
const submitConnect = async () => { const submitConnect = async () => {
if (!canConnect) return; if (!canConnect || !deviceSummary) return;
const idempotencyKey = provisioningIntentKey(provisioningIntentRef.current); const idempotencyKey = provisioningIntentKey(provisioningIntentRef.current);
provisioningIntentRef.current = idempotencyKey; provisioningIntentRef.current = idempotencyKey;
const networkCredentials = connectionMode === "quick-connect" const networkCredentials = connectionMode === "quick-connect"
? {} ? {}
: { ssid: ssid.trim(), password }; : { ssid: ssid.trim(), password };
const succeeded = await connect({ const succeeded = await connect({
device_id: selectedDeviceId, device_id: deviceSummary.device_id,
...networkCredentials, ...networkCredentials,
connection_mode: connectionMode, connection_mode: connectionMode,
compatibility_attestation: profileSelectionForConnectionMode(connectionMode), compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
@@ -187,20 +187,24 @@ export function K1ProvisioningPipeline({
if (succeeded) { if (succeeded) {
provisioningIntentRef.current = null; provisioningIntentRef.current = null;
setPassword(""); setPassword("");
} else if (connectionMode === "quick-connect") { } else {
// The backend has already persisted and reconciled the failed bounded // Every later click is a new explicit operator intent, never an
// attempt. A later click is a new explicit Quick Connect intent, not an // automatic replay of a consumed failed journal entry. If the prior
// automatic replay of the consumed operation key. // write outcome is ambiguous, the backend reconciliation fence blocks
// this new intent before another device write for both modes.
provisioningIntentRef.current = null; provisioningIntentRef.current = null;
} }
}; };
const submitExistingBridgeAdoption = async () => { const submitExistingBridgeAdoption = async () => {
if (!canAdoptExistingBridge) return; if (!canAdoptExistingBridge || !deviceSummary) return;
await verifyConnection({ const succeeded = await verifyConnection({
device_id: selectedDeviceId, device_id: deviceSummary.device_id,
compatibility_attestation: profileSelectionForConnectionMode("bridge"), compatibility_attestation: profileSelectionForConnectionMode("bridge"),
}); });
if (succeeded) {
provisioningIntentRef.current = null;
}
}; };
return ( return (
@@ -241,8 +245,8 @@ export function K1ProvisioningPipeline({
<WizardStep <WizardStep
number="02" number="02"
title="Выберите Bluetooth-устройство" title="Выберите Bluetooth-устройство"
status={pendingAction === "scan" ? "Поиск…" : selectedDeviceId ? "Устройство выбрано" : `Найдено: ${devices.length}`} status={pendingAction === "scan" ? "Поиск…" : deviceSummary ? "Устройство выбрано" : `Найдено: ${devices.length}`}
tone={pendingAction === "scan" ? "accent" : selectedDeviceId ? "success" : "neutral"} tone={pendingAction === "scan" ? "accent" : deviceSummary ? "success" : "neutral"}
> >
<p className="step-copy">Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; точные модель, platform type и прошивка будут проверены по живому DeviceInfo перед START.</p> <p className="step-copy">Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; точные модель, platform type и прошивка будут проверены по живому DeviceInfo перед START.</p>
<Button <Button
@@ -282,7 +286,12 @@ export function K1ProvisioningPipeline({
<TextField label="Пароль WiFi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" /> <TextField label="Пароль WiFi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
</div> </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()}> <Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}>
{pendingAction === "connect" {pendingAction === "connect"
? connectionMode === "quick-connect" ? "Включаем точку и подключаем…" : "Подключаем…" ? connectionMode === "quick-connect" ? "Включаем точку и подключаем…" : "Подключаем…"
@@ -2,6 +2,7 @@ import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "@mission-cor
import type { import type {
AcquisitionState, AcquisitionState,
BleDevice,
XgridsApplicationControlPhase, XgridsApplicationControlPhase,
XgridsAcquisition, XgridsAcquisition,
XgridsK1State, XgridsK1State,
@@ -200,6 +201,50 @@ export function operationNeedsReconciliation(
return operation.error?.safe_to_retry !== true; 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 { function defaultUuid(): string {
const cryptoApi = globalThis.crypto; const cryptoApi = globalThis.crypto;
if (!cryptoApi) { if (!cryptoApi) {
@@ -200,6 +200,21 @@ export function networkProvisionFailureMessage(
const code = operation.error?.code; const code = operation.error?.code;
if (typeof code !== "string") return null; 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> = { const messages: Record<string, string> = {
BleakGATTProtocolError: BleakGATTProtocolError:
"Сканер отклонил запись сетевого профиля. Результат изменения сети неизвестен; автоматический повтор запрещён. Проверьте текущее состояние K1 или подхватите существующее подключение без изменения настроек Wi‑Fi.", "Сканер отклонил запись сетевого профиля. Результат изменения сети неизвестен; автоматический повтор запрещён. Проверьте текущее состояние K1 или подхватите существующее подключение без изменения настроек Wi‑Fi.",
@@ -210,7 +225,7 @@ export function networkProvisionFailureMessage(
"credential-invalid": "credential-invalid":
"Пароль точки доступа K1 имеет недопустимую длину. Получите сохранённый пароль этого K1 в LixelGO/iPhone и повторите подключение.", "Пароль точки доступа K1 имеет недопустимую длину. Получите сохранённый пароль этого K1 в LixelGO/iPhone и повторите подключение.",
"host-wifi-operation-timeout": "host-wifi-operation-timeout":
"Первичное системное подключение к K1 не было завершено вовремя. BLE-команда автоматически не повторялась; получите пароль сохранённой сети этого K1 и запустите подключение заново.", "Локальная операция подготовки Wi‑Fi не завершилась вовремя. Это могло произойти до изменения состояния K1; наличие сохранённого пароля этим кодом не подтверждается и не опровергается. Проверьте состояние K1 и повторите подключение отдельным действием.",
"profile-ssid-mismatch": "profile-ssid-mismatch":
"Сохранённый профиль относится к другому K1. Подключение остановлено без повторной команды сканеру.", "Сохранённый профиль относится к другому K1. Подключение остановлено без повторной команды сканеру.",
"corewlan-error": "corewlan-error":
+1 -1
View File
@@ -25,7 +25,7 @@ WHEEL_NAME = "nodedc_mission_core-0.1.0-py3-none-any.whl"
RUNNER_NAME = RUNNER.name RUNNER_NAME = RUNNER.name
PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$") PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
EXPECTED_BASELINE_SHA256 = "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8" EXPECTED_BASELINE_SHA256 = "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
EXPECTED_WHEEL_SHA256 = "c396a202d5cddc2d22dcc3e8b936519205399d20b0f060c763c02e51ba16c62a" EXPECTED_WHEEL_SHA256 = "ac0ee30446130d3e309cd01e875bec81171a30e057d11d8558a12bb8aec9bf26"
PAYLOAD_FILES = ( PAYLOAD_FILES = (
RUNNER_NAME, RUNNER_NAME,
WHEEL_NAME, WHEEL_NAME,
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import AsyncExitStack, asynccontextmanager
from importlib.metadata import version from importlib.metadata import version
from time import monotonic from time import monotonic
from typing import Literal, TypedDict from typing import Literal, TypedDict
@@ -11,15 +11,18 @@ from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError, BleakError from bleak.exc import BleakDeviceNotFoundError, BleakError
from k1link.artifacts import utc_now_iso 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 ( from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
AP_FALLBACK_IPV4, AP_FALLBACK_IPV4,
SERVICE_UUID, SERVICE_UUID,
STATUS_CHARACTERISTIC_UUID, STATUS_CHARACTERISTIC_UUID,
WRITE_CHARACTERISTIC_UUID, WRITE_CHARACTERISTIC_UUID,
BleOperationStage,
ResolvedWriteMode, ResolvedWriteMode,
StatusObservation, StatusObservation,
WifiStatus, WifiStatus,
WriteMode, WriteMode,
_annotate_ble_operation_error,
parse_wifi_status, parse_wifi_status,
) )
@@ -124,149 +127,198 @@ async def device_ap_activation_session(
started_at = utc_now_iso() started_at = utc_now_iso()
observations: list[StatusObservation] = [] observations: list[StatusObservation] = []
disconnected = False disconnected = False
operation_stage: BleOperationStage = "resolution"
device_write_attempted = False
device_write_confirmed = False
try: try:
async with asyncio.timeout(timeout_seconds + 25.0): try:
# AP activation is a mutation boundary: prove that the selected async with asyncio.timeout(timeout_seconds + 25.0):
# device is advertising now instead of trusting a CoreBluetooth # Keep the explicit scan and AP activation in one CoreBluetooth
# handle retained by an earlier UI scan. # lifecycle. Re-looking up the UUID here lost a physically present
device = await BleakScanner.find_device_by_address( # K1 during acceptance, while the retained BLEDevice connected.
device_macos_uuid, selection = discovered_device_selection(device_macos_uuid)
timeout=min(20.0, timeout_seconds), 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
raise BleakDeviceNotFoundError(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
)
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client: async with AsyncExitStack() as client_stack:
async with asyncio.timeout(timeout_seconds + 10.0): operation_stage = "connect"
device_name = client.name try:
service = client.services.get_service(SERVICE_UUID) client = await client_stack.enter_async_context(
write_characteristic = client.services.get_characteristic( BleakClient(device, timeout=timeout_seconds, pair=False)
WRITE_CHARACTERISTIC_UUID
) )
status_characteristic = client.services.get_characteristic( except Exception as exc:
STATUS_CHARACTERISTIC_UUID _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
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")
properties = set(write_characteristic.properties) try:
max_without_response = write_characteristic.max_write_without_response_size async with asyncio.timeout(timeout_seconds + 10.0):
baseline = parse_wifi_status( device_name = client.name
bytes(await client.read_gatt_char(status_characteristic)) operation_stage = "gatt-contract"
) service = client.services.get_service(SERVICE_UUID)
# WIFI_AP is a control-mode status, not proof that the radio is write_characteristic = client.services.get_characteristic(
# still beaconing. A physical run found the exact SSID shortly WRITE_CHARACTERISTIC_UUID
# after AP-enable, then found no beacon while 7f02 continued to )
# report WIFI_AP. LixelGO emits the reviewed enable frame for status_characteristic = client.services.get_characteristic(
# each explicit Quick Connect action, so Mission Core does the STATUS_CHARACTERISTIC_UUID
# same once per operator action instead of short-circuiting on )
# a stale-ready status. There is still no automatic retry. 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 properties = set(write_characteristic.properties)
if write_mode == "auto": max_without_response = write_characteristic.max_write_without_response_size
if "write-without-response" in properties: resolved_write_mode: ResolvedWriteMode
resolved_write_mode = "without_response" if write_mode == "auto":
elif "write" in properties: 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" resolved_write_mode = "with_response"
else: else:
raise ValueError("Reviewed K1 characteristic is not writable") if len(frame) > max_without_response:
elif write_mode == "with_response": raise ValueError(
if "write" not in properties: "AP activation frame exceeds the negotiated "
raise ValueError( "write-without-response size"
"Reviewed K1 characteristic does not advertise writes with response" )
) resolved_write_mode = "without_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"
await client.write_gatt_char( operation_stage = "baseline-read"
write_characteristic, baseline = parse_wifi_status(
frame, bytes(await client.read_gatt_char(status_characteristic))
response=resolved_write_mode == "with_response", )
) # WIFI_AP is a control-mode status, not proof that the radio is
write_completed = monotonic() # still beaconing. A physical run found the exact SSID shortly
deadline = write_completed + timeout_seconds # 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: operation_stage = "gatt-write"
try: device_write_attempted = True
status = parse_wifi_status( await client.write_gatt_char(
bytes(await client.read_gatt_char(status_characteristic)) write_characteristic,
) frame,
except BleakError: response=resolved_write_mode == "with_response",
if not client.is_connected: )
disconnected = True 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 break
raise await asyncio.sleep(poll_interval_seconds)
observation: StatusObservation = {
"observed_at_utc": utc_now_iso(), result: ApActivationResult = {
"seconds_after_write": round(monotonic() - write_completed, 3), "schema_version": 1,
"status": status, "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"]: except Exception as exc:
observations.append(observation) _annotate_ble_operation_error(
if is_ap_ready_status(status): exc,
break operation_stage=operation_stage,
await asyncio.sleep(poll_interval_seconds) 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 # Keep the same CoreBluetooth session alive while the caller waits
# for and performs the host-side CoreWLAN association. LixelGO does # for and performs the host-side CoreWLAN association. LixelGO does
# not tear down this BLE manager between its AP-ready callback and # 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 yield result
finally: finally:
frame[:] = b"\x00" * len(frame) frame[:] = b"\x00" * len(frame)
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from importlib.metadata import version from importlib.metadata import version
from threading import Lock from threading import Lock
from time import monotonic
from typing import TypedDict from typing import TypedDict
from bleak import BleakScanner from bleak import BleakScanner
@@ -10,8 +12,31 @@ from bleak.backends.scanner import AdvertisementData
from k1link.artifacts import utc_now_iso 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_handle_lock = Lock()
_runtime_handles: dict[str, BLEDevice] = {} _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): class BleDeviceRecord(TypedDict):
@@ -62,11 +87,69 @@ def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) ->
} }
def discovered_device(macos_uuid: str) -> BLEDevice | None: def _invalidate_runtime_handles_locked() -> None:
"""Return the live CoreBluetooth handle retained by the latest explicit scan.""" 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: 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: 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") raise ValueError("duration_seconds must be positive")
started_at = utc_now_iso() started_at = utc_now_iso()
discovered = await BleakScanner.discover(timeout=duration_seconds, return_adv=True) scan_generation = _begin_scan_generation()
with _runtime_handle_lock: try:
_runtime_handles.clear() discovered = await BleakScanner.discover(timeout=duration_seconds, return_adv=True)
_runtime_handles.update( handles = {device.address: device for device, _advertisement in discovered.values()}
{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 = [ result: BleScanResult = {
advertisement_record(device, advertisement) for device, advertisement in discovered.values() "schema_version": 1,
] "started_at_utc": started_at,
devices.sort( "completed_at_utc": utc_now_iso(),
key=lambda item: (not item["k1_name_candidate"], -item["rssi"], item["macos_uuid"]) "duration_seconds": duration_seconds,
) "adapter": "CoreBluetooth",
return { "bleak_version": version("bleak"),
"schema_version": 1, "device_count": len(devices),
"started_at_utc": started_at, "devices": devices,
"completed_at_utc": utc_now_iso(), }
"duration_seconds": duration_seconds, except BaseException:
"adapter": "CoreBluetooth", _finish_failed_scan(scan_generation)
"bleak_version": version("bleak"), raise
"device_count": len(devices),
"devices": devices, # 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 typing import Literal, TypedDict
from bleak import BleakClient, BleakScanner 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.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" PROFILE_ID = "xgrids-k1-fw3-wifi-v1"
SERVICE_UUID = "00007f00-0000-1000-8000-00805f9b34fb" SERVICE_UUID = "00007f00-0000-1000-8000-00805f9b34fb"
@@ -28,6 +30,14 @@ ProvisioningOutcome = Literal[
] ]
WriteMode = Literal["auto", "with_response", "without_response"] WriteMode = Literal["auto", "with_response", "without_response"]
ResolvedWriteMode = Literal["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): class WifiStatus(TypedDict):
@@ -82,6 +92,23 @@ class WifiStatusReadResult(TypedDict):
status: WifiStatus 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: def build_wifi_provisioning_frame(ssid: str, password: str) -> bytearray:
"""Build the deterministic 99-byte frame used by LixelGO for K1 Wi-Fi setup.""" """Build the deterministic 99-byte frame used by LixelGO for K1 Wi-Fi setup."""
ssid_bytes = ssid.encode("utf-8") ssid_bytes = ssid.encode("utf-8")
@@ -170,12 +197,14 @@ async def read_wifi_status_once(
if timeout_seconds <= 0: if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive") raise ValueError("timeout_seconds must be positive")
async with asyncio.timeout(timeout_seconds + 5.0): async with asyncio.timeout(timeout_seconds + 5.0):
# A CoreBluetooth handle retained by an earlier scan is an optimization, # A still-live explicit scan lease is authoritative even for a caller
# not durable connection state. Recovery after sleep, Wi-Fi transition, # requesting recovery. Physical acceptance proved that immediately
# or a completed provisioning GATT session must rediscover the device # looking the same CoreBluetooth UUID up again can lose a present K1.
# instead of repeatedly opening a stale handle. # ``rediscover`` therefore permits fallback only after that short lease
device = None if rediscover else discovered_device(device_macos_uuid) # has expired; it never discards a fresh retained BLEDevice.
if device is None: 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 = await BleakScanner.find_device_by_address(
device_macos_uuid, device_macos_uuid,
timeout=timeout_seconds, timeout=timeout_seconds,
@@ -236,24 +265,36 @@ async def provision_wifi_once(
started_at = utc_now_iso() started_at = utc_now_iso()
observations: list[StatusObservation] = [] observations: list[StatusObservation] = []
disconnected = False disconnected = False
operation_stage: BleOperationStage = "resolution"
device_write_attempted = False
device_write_confirmed = False
try: try:
async with asyncio.timeout(timeout_seconds + 25.0): async with asyncio.timeout(timeout_seconds + 25.0):
# A provisioning write is a mutation boundary: prove that the # The explicit UI scan and its selected network action are one
# selected device is advertising now instead of trusting a # CoreBluetooth lifecycle. Physical acceptance proved that a
# CoreBluetooth handle retained by an earlier UI scan. # second UUID lookup can fail moments after a successful scan, so
device = await BleakScanner.find_device_by_address( # use the exact retained handle while its short lease is fresh.
device_macos_uuid, selection = discovered_device_selection(device_macos_uuid)
timeout=min(20.0, timeout_seconds), 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: if device is None:
raise BleakDeviceNotFoundError( raise BleakDeviceNotFoundError(
device_macos_uuid, device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.", "Device was not rediscovered; keep the K1 powered and nearby.",
) )
operation_stage = "connect"
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client: async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
device_name = client.name device_name = client.name
operation_stage = "gatt-contract"
service = client.services.get_service(SERVICE_UUID) service = client.services.get_service(SERVICE_UUID)
write_characteristic = client.services.get_characteristic(WRITE_CHARACTERISTIC_UUID) write_characteristic = client.services.get_characteristic(WRITE_CHARACTERISTIC_UUID)
status_characteristic = client.services.get_characteristic( status_characteristic = client.services.get_characteristic(
@@ -301,17 +342,22 @@ async def provision_wifi_once(
) )
resolved_write_mode = "without_response" resolved_write_mode = "without_response"
operation_stage = "baseline-read"
baseline_value = bytes(await client.read_gatt_char(status_characteristic)) baseline_value = bytes(await client.read_gatt_char(status_characteristic))
baseline = parse_wifi_status(baseline_value) baseline = parse_wifi_status(baseline_value)
operation_stage = "gatt-write"
device_write_attempted = True
await client.write_gatt_char( await client.write_gatt_char(
write_characteristic, write_characteristic,
frame, frame,
response=resolved_write_mode == "with_response", response=resolved_write_mode == "with_response",
) )
device_write_confirmed = resolved_write_mode == "with_response"
write_completed = monotonic() write_completed = monotonic()
deadline = write_completed + timeout_seconds deadline = write_completed + timeout_seconds
operation_stage = "status-poll"
while monotonic() < deadline: while monotonic() < deadline:
try: try:
value = bytes(await client.read_gatt_char(status_characteristic)) value = bytes(await client.read_gatt_char(status_characteristic))
@@ -353,5 +399,13 @@ async def provision_wifi_once(
"observations": observations, "observations": observations,
"outcome": _outcome(baseline, observations, disconnected), "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: finally:
frame[:] = b"\x00" * len(frame) frame[:] = b"\x00" * len(frame)
+164 -9
View File
@@ -39,7 +39,12 @@ from k1link.compute.live_perception import LivePerceptionIngress
from k1link.device_plugins.xgrids_k1.ble.ap_activation import ( from k1link.device_plugins.xgrids_k1.ble.ap_activation import (
device_ap_activation_session, 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 ( from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
AP_FALLBACK_IPV4, AP_FALLBACK_IPV4,
provision_wifi_once, 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" DEFAULT_ACQUISITION_CAMERA_SOURCE: CameraSourceId = "sensor.camera.right"
CONTROL_MQTT_PORT = 1883 CONTROL_MQTT_PORT = 1883
CONTROL_ENDPOINT_PROBE_TIMEOUT_SECONDS = 1.5 CONTROL_ENDPOINT_PROBE_TIMEOUT_SECONDS = 1.5
BLE_DISCOVERY_LEASE_TTL_SECONDS = 60.0
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ConnectionMode = Literal["bridge", "quick-connect", "direct-connect"] ConnectionMode = Literal["bridge", "quick-connect", "direct-connect"]
@@ -149,6 +152,14 @@ class ConnectionLeaseUnavailable(RuntimeError):
self.reason_code = reason_code 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): class LocalAcquisitionLifecycleError(RuntimeError):
"""A local producer lifecycle invariant failed before scanner authority.""" """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_id: str | None = None
self._device_session_opened_at: str | None = None self._device_session_opened_at: str | None = None
self._connection_lease_generation = 0 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] = { self._connection_verification: dict[str, Any] = {
"status": "not-probed", "status": "not-probed",
"lease_state": "disconnected", "lease_state": "disconnected",
@@ -560,6 +576,11 @@ class XgridsK1CompatibilityService:
device_session_id = self._device_session_id device_session_id = self._device_session_id
device_session_opened_at = self._device_session_opened_at device_session_opened_at = self._device_session_opened_at
connection_lease_generation = self._connection_lease_generation 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) connection_verification = dict(self._connection_verification)
compatibility_attestation = ( compatibility_attestation = (
dict(self._compatibility_attestation) dict(self._compatibility_attestation)
@@ -700,6 +721,7 @@ class XgridsK1CompatibilityService:
**connection_verification, **connection_verification,
"lease_generation": connection_lease_generation, "lease_generation": connection_lease_generation,
}, },
"network_write_reconciliation": network_write_reconciliation,
"sensor_catalog": _sensor_catalog( "sensor_catalog": _sensor_catalog(
active_profile_id, active_profile_id,
device_session_id, device_session_id,
@@ -807,9 +829,36 @@ class XgridsK1CompatibilityService:
with self._lock: with self._lock:
scanned_devices = self._fresh_ble_devices_locked() scanned_devices = self._fresh_ble_devices_locked()
discovery_generation = self._ble_discovery_generation 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} known_ids = {str(item["device_id"]) for item in scanned_devices}
if request.device_id not in known_ids: if request.device_id not in known_ids:
raise ValueError("сначала найдите и выберите устройство через Bluetooth") 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" quick_connect = request.connection_mode == "quick-connect"
selected_device = next( selected_device = next(
item for item in scanned_devices if item["device_id"] == request.device_id item for item in scanned_devices if item["device_id"] == request.device_id
@@ -903,7 +952,9 @@ class XgridsK1CompatibilityService:
raise RuntimeError("другая операция настройки Wi-Fi уже выполняется") raise RuntimeError("другая операция настройки Wi-Fi уже выполняется")
session_dir: Path | None = None 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 retired_ingress_session_id: str | None = None
operation_stage = "device-ap-activation" if quick_connect else "ble-provisioning-write" operation_stage = "device-ap-activation" if quick_connect else "ble-provisioning-write"
try: try:
@@ -1019,7 +1070,6 @@ class XgridsK1CompatibilityService:
stage_code=operation_stage, stage_code=operation_stage,
message_code="network.provision.running", message_code="network.provision.running",
) )
network_change_attempted = True
if quick_connect: if quick_connect:
assert quick_connect_profile_id is not None assert quick_connect_profile_id is not None
operation_stage = "device-ap-activation" operation_stage = "device-ap-activation"
@@ -1028,6 +1078,18 @@ class XgridsK1CompatibilityService:
timeout_seconds=15.0, timeout_seconds=15.0,
write_mode="auto", write_mode="auto",
) as activation: ) 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( write_json_atomic(
session_dir / "ap-activation.redacted.json", session_dir / "ap-activation.redacted.json",
activation, activation,
@@ -1114,6 +1176,19 @@ class XgridsK1CompatibilityService:
timeout_seconds=45.0, timeout_seconds=45.0,
write_mode="auto", 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) write_json_atomic(session_dir / "provisioning.sensitive.json", result)
ipv4 = _provisioned_ipv4(result) ipv4 = _provisioned_ipv4(result)
connection_manifest = { connection_manifest = {
@@ -1155,6 +1230,7 @@ class XgridsK1CompatibilityService:
) )
write_json_atomic(session_dir / "manifest.redacted.json", connection_manifest) write_json_atomic(session_dir / "manifest.redacted.json", connection_manifest)
with self._lock: with self._lock:
self._network_write_reconciliation = None
self._selected_device_id = request.device_id self._selected_device_id = request.device_id
self._k1_ip = ipv4 self._k1_ip = ipv4
self._connection_mode = request.connection_mode self._connection_mode = request.connection_mode
@@ -1226,11 +1302,44 @@ class XgridsK1CompatibilityService:
evidence_refs=(f"evidence-session-{session_dir.name}",), evidence_refs=(f"evidence-session-{session_dir.name}",),
) )
except Exception as exc: 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( operation_error = _operation_error(
exc, exc,
category="transport" if quick_connect else "device", category="transport" if quick_connect else "device",
side_effect_status=("unknown" if network_change_attempted else "none"), side_effect_status=side_effect_status,
safe_to_retry=not network_change_attempted, safe_to_retry=not device_write_attempted,
) )
self._operations.transition_if_pending( self._operations.transition_if_pending(
operation.operation_id, operation.operation_id,
@@ -1257,7 +1366,13 @@ class XgridsK1CompatibilityService:
"error_code": operation_error["code"], "error_code": operation_error["code"],
"safe_to_retry": operation_error["safe_to_retry"], "safe_to_retry": operation_error["safe_to_retry"],
"side_effect_status": operation_error["side_effect_status"], "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, "automatic_retry": False,
}, },
) )
@@ -1391,6 +1506,29 @@ class XgridsK1CompatibilityService:
or status_read.get("write_performed") is not False or status_read.get("write_performed") is not False
): ):
raise RuntimeError("BLE status read не подтвердил read-only операцию") 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"] observed_target = status_read["status"]["ipv4"]
if observed_target is None or observed_target == AP_FALLBACK_IPV4: if observed_target is None or observed_target == AP_FALLBACK_IPV4:
raise RuntimeError("K1 не сообщил актуальный DHCP-адрес общей сети") raise RuntimeError("K1 не сообщил актуальный DHCP-адрес общей сети")
@@ -3659,13 +3797,30 @@ def _operation_error(
safe_to_retry: bool = False, safe_to_retry: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
reason_code = getattr(exc, "reason_code", None) reason_code = getattr(exc, "reason_code", None)
return { error: dict[str, Any] = {
"category": category, "category": category,
"code": reason_code if isinstance(reason_code, str) and reason_code else type(exc).__name__, "code": reason_code if isinstance(reason_code, str) and reason_code else type(exc).__name__,
"retryable": False, "retryable": False,
"safe_to_retry": safe_to_retry, "safe_to_retry": safe_to_retry,
"side_effect_status": side_effect_status, "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( def _validated_requested_streams(
+224 -4
View File
@@ -1,9 +1,15 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import os
import stat
import subprocess import subprocess
import sys 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 pathlib import Path
from typing import Any, Literal, TypedDict from typing import Any, Literal, TypedDict
@@ -70,6 +76,14 @@ _ERROR_MESSAGES = {
"host-wifi-operation-timeout": ( "host-wifi-operation-timeout": (
"оператор не завершил системное подключение Wi-Fi за отведённое время" "оператор не завершил системное подключение 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-интерфейс недоступен", "wifi-interface-unavailable": "системный Wi-Fi-интерфейс недоступен",
"corewlan-error": "системный Wi-Fi не смог подключиться к точке доступа K1", "corewlan-error": "системный Wi-Fi не смог подключиться к точке доступа K1",
} }
@@ -84,15 +98,20 @@ class HostWifiProfileError(RuntimeError):
*, *,
scan_attempt_count: int | None = None, scan_attempt_count: int | None = None,
scan_elapsed_ms: int | None = None, scan_elapsed_ms: int | None = None,
helper_stage: str | None = None,
helper_elapsed_ms: int | None = None,
) -> None: ) -> None:
self.reason_code = reason_code self.reason_code = reason_code
self.scan_attempt_count = scan_attempt_count self.scan_attempt_count = scan_attempt_count
self.scan_elapsed_ms = scan_elapsed_ms 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 завершилась ошибкой") message = _ERROR_MESSAGES.get(reason_code, "операция системного Wi-Fi завершилась ошибкой")
super().__init__(f"{message} ({reason_code})") super().__init__(f"{message} ({reason_code})")
RunProcess = Callable[..., subprocess.CompletedProcess[bytes]] RunProcess = Callable[..., subprocess.CompletedProcess[bytes]]
DEFAULT_HELPER_BUILD_TIMEOUT_SECONDS = 120.0
def _validate_profile_id(profile_id: str) -> None: 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") 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( def _run_macos_helper(
helper_path: Path, helper_path: Path,
request: dict[str, object], request: dict[str, object],
*, *,
timeout_seconds: float, timeout_seconds: float,
runner: RunProcess, runner: RunProcess,
build_timeout_seconds: float = DEFAULT_HELPER_BUILD_TIMEOUT_SECONDS,
) -> dict[str, Any]: ) -> dict[str, Any]:
if sys.platform != "darwin": if sys.platform != "darwin":
raise HostWifiProfileError("unsupported-platform") raise HostWifiProfileError("unsupported-platform")
if not helper_path.is_file():
raise HostWifiProfileError("host-wifi-helper-missing")
if timeout_seconds <= 0: if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive") 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( request_bytes = bytearray(
json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8") json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
) )
try: try:
completed = runner( completed = runner(
["/usr/bin/xcrun", "swift", str(helper_path.resolve())], [str(executable)],
input=request_bytes, input=request_bytes,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
+6
View File
@@ -28,6 +28,12 @@ _EXTRA_FIELDS: Final = (
"safe_to_retry", "safe_to_retry",
"side_effect_status", "side_effect_status",
"network_change_attempted", "network_change_attempted",
"device_write_attempted",
"device_write_confirmed",
"ble_att_error_code",
"ble_att_error_name",
"helper_stage",
"helper_elapsed_ms",
"status_reconciliation", "status_reconciliation",
"network_change_admissible", "network_change_admissible",
"network_change_reconciliation", "network_change_reconciliation",
+150
View File
@@ -1,16 +1,33 @@
import asyncio import asyncio
from collections.abc import Iterator
import pytest
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData from bleak.backends.scanner import AdvertisementData
from pytest import MonkeyPatch from pytest import MonkeyPatch
import k1link.device_plugins.xgrids_k1.ble.scanner as scanner_module
from k1link.device_plugins.xgrids_k1.ble.scanner import ( from k1link.device_plugins.xgrids_k1.ble.scanner import (
advertisement_record, advertisement_record,
discovered_device, discovered_device,
discovered_device_selection,
scan, 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: def test_advertisement_record_marks_k1_candidate() -> None:
device = BLEDevice("TEST-UUID", "Unknown", details=None) device = BLEDevice("TEST-UUID", "Unknown", details=None)
advertisement = AdvertisementData( advertisement = AdvertisementData(
@@ -72,3 +89,136 @@ def test_scan_retains_the_live_corebluetooth_handle(
assert result["devices"][0]["macos_uuid"] == "LIVE-UUID" assert result["devices"][0]["macos_uuid"] == "LIVE-UUID"
assert discovered_device("LIVE-UUID") is device 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
+12
View File
@@ -55,6 +55,12 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
"automatic_retry": False, "automatic_retry": False,
"side_effect_status": "unknown", "side_effect_status": "unknown",
"network_change_attempted": True, "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", "camera_source_id": "sensor.camera.right",
"evidence_session_id": "20260728T163450Z_viewer_live", "evidence_session_id": "20260728T163450Z_viewer_live",
"activation_trigger": "application-control-scanning", "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["automatic_retry"] is False
assert document["side_effect_status"] == "unknown" assert document["side_effect_status"] == "unknown"
assert document["network_change_attempted"] is True 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["camera_source_id"] == "sensor.camera.right"
assert document["evidence_session_id"] == "20260728T163450Z_viewer_live" assert document["evidence_session_id"] == "20260728T163450Z_viewer_live"
assert document["activation_trigger"] == "application-control-scanning" assert document["activation_trigger"] == "application-control-scanning"
+284 -22
View File
@@ -1,10 +1,12 @@
import asyncio import asyncio
from collections.abc import Iterator
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
import pytest 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 import k1link.device_plugins.xgrids_k1.ble.wifi_provisioning as wifi_module
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import ( from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
FRAME_LENGTH, 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: def test_build_wifi_provisioning_frame_layout() -> None:
credential = "x" * 13 credential = "x" * 13
frame = build_wifi_provisioning_frame("LabNet", credential) 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 self.write_calls += 1
raise AssertionError("status refresh must not write a BLE characteristic") 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) monkeypatch.setattr(wifi_module, "BleakClient", FakeClient)
result = asyncio.run(read_wifi_status_once("synthetic-corebluetooth-uuid")) 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" 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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
value = bytearray(54) value = bytearray(54)
@@ -141,8 +172,7 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
value[33] = 4 value[33] = 4
value[34:38] = bytes((10, 255, 254, 77)) value[34:38] = bytes((10, 255, 254, 77))
value[50] = 1 value[50] = 1
stale_handle = object() retained_handle = object()
recovered_handle = object()
characteristic = SimpleNamespace( characteristic = SimpleNamespace(
uuid=wifi_module.STATUS_CHARACTERISTIC_UUID, uuid=wifi_module.STATUS_CHARACTERISTIC_UUID,
service_uuid=wifi_module.SERVICE_UUID, service_uuid=wifi_module.SERVICE_UUID,
@@ -159,7 +189,7 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
class FakeClient: class FakeClient:
def __init__(self, device: object, **_kwargs: object) -> None: def __init__(self, device: object, **_kwargs: object) -> None:
assert device is recovered_handle assert device is retained_handle
self.services = FakeServices() self.services = FakeServices()
self.name = "XGR-K1" self.name = "XGR-K1"
@@ -173,9 +203,16 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
return bytes(value) return bytes(value)
async def rediscover(*_args: object, **_kwargs: object) -> object: 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.BleakScanner, "find_device_by_address", rediscover)
monkeypatch.setattr(wifi_module, "BleakClient", FakeClient) 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" 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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
stale_handle = object() device_id = "synthetic-corebluetooth-uuid"
retained_handle = object()
rediscovery_calls: list[tuple[str, float]] = [] rediscovery_calls: list[tuple[str, float]] = []
client_calls: list[object] = [] client_calls: list[object] = []
async def missing_device(address: str, *, timeout: float) -> None: class SelectedHandleObserved(RuntimeError):
rediscovery_calls.append((address, timeout)) pass
return None
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: def __init__(self, device: object, **_kwargs: object) -> None:
client_calls.append(device) 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( monkeypatch.setattr(
wifi_module.BleakScanner, wifi_module.BleakScanner,
"find_device_by_address", "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( asyncio.run(
provision_wifi_once( provision_wifi_once(
"synthetic-corebluetooth-uuid", device_id,
"LabNet", "LabNet",
"synthetic-password", "synthetic-password",
timeout_seconds=1.0, timeout_seconds=1.0,
) )
) )
assert rediscovery_calls == [("synthetic-corebluetooth-uuid", 1.0)] assert rediscovery_calls == []
assert client_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]
+284 -4
View File
@@ -200,14 +200,18 @@ def service_with_fake_runtime(
return service, 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 { return {
"schema_version": 1, "schema_version": 1,
"profile_id": "xgrids-k1-fw3-wifi-v1", "profile_id": "xgrids-k1-fw3-wifi-v1",
"observed_at_utc": "2026-07-20T12:00:00Z", "observed_at_utc": "2026-07-20T12:00:00Z",
"adapter": "CoreBluetooth", "adapter": "CoreBluetooth",
"bleak_version": "test", "bleak_version": "test",
"device_macos_uuid": "test-ble-transport", "device_macos_uuid": device_id,
"device_name": "XGR-K1", "device_name": "XGR-K1",
"service_uuid": "00007f00-0000-1000-8000-00805f9b34fb", "service_uuid": "00007f00-0000-1000-8000-00805f9b34fb",
"status_characteristic_uuid": "00007f02-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, "ready_observed": True,
"write_performed": True, "write_performed": True,
"write_mode": "with_response", "write_mode": "with_response",
"observations": [{"status": {"mode": "WIFI_AP"}}],
} }
finally: finally:
ble_session_open = False ble_session_open = False
@@ -3090,6 +3095,7 @@ def test_quick_connect_does_not_start_host_wifi_without_ap_ready_flag(
"ready_observed": False, "ready_observed": False,
"write_performed": True, "write_performed": True,
"write_mode": "with_response", "write_mode": "with_response",
"observations": [],
} }
def forbidden_association(*_: object, **__: object) -> dict[str, Any]: 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 len(quick_sessions) == 1
assert (quick_sessions[0] / "ap-activation.redacted.json").exists() assert (quick_sessions[0] / "ap-activation.redacted.json").exists()
assert not (quick_sessions[0] / "manifest.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( 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, "ready_observed": True,
"write_performed": True, "write_performed": True,
"write_mode": "with_response", "write_mode": "with_response",
"observations": [{"status": {"mode": "WIFI_AP"}}],
} }
def failed_association(*_: object, **__: object) -> dict[str, Any]: 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 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( def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, 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") operation = next(item for item in state["operations"] if item["action"] == "network.provision")
assert operation["status"] == "failed" assert operation["status"] == "failed"
assert operation["error"]["safe_to_retry"] is False 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( failure_log = next(
record record
for record in caplog.records 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.connection_mode == "bridge"
assert failure_log.error_code == "RuntimeError" assert failure_log.error_code == "RuntimeError"
assert failure_log.safe_to_retry is False 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.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 PRIMARY_TEST_CREDENTIAL not in caplog.text
assert "lab-network" 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( def test_provisioning_cannot_switch_device_during_active_acquisition(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
+145 -16
View File
@@ -1,7 +1,10 @@
import asyncio import asyncio
from collections.abc import Iterator
from types import SimpleNamespace
from typing import Any
import pytest 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.ap_activation as ap_module
import k1link.device_plugins.xgrids_k1.ble.scanner as scanner_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 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: def test_build_ap_activation_frame_matches_reviewed_lixelgo_layout() -> None:
frame = build_ap_activation_frame() 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)) 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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
device_id = "synthetic-corebluetooth-uuid" device_id = "synthetic-corebluetooth-uuid"
stale_handle = object() retained_handle = object()
rediscovery_calls: list[tuple[str, float]] = [] rediscovery_calls: list[tuple[str, float]] = []
client_calls: list[object] = [] client_calls: list[object] = []
async def missing_device(address: str, *, timeout: float) -> None: class SelectedHandleObserved(RuntimeError):
rediscovery_calls.append((address, timeout)) pass
return None
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: def __init__(self, device: object, **_kwargs: object) -> None:
client_calls.append(device) 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 monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
# path ever regresses to discovered_device(...)-first behavior. _seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
monkeypatch.setitem(scanner_module._runtime_handles, device_id, stale_handle) # noqa: SLF001
monkeypatch.setattr( monkeypatch.setattr(
ap_module.BleakScanner, ap_module.BleakScanner,
"find_device_by_address", "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( asyncio.run(
ap_module.activate_device_ap_once( ap_module.activate_device_ap_once(
device_id, device_id,
@@ -77,5 +103,108 @@ def test_ap_activation_requires_fresh_rediscovery_before_connecting(
) )
) )
assert rediscovery_calls == [(device_id, 1.0)] assert rediscovery_calls == []
assert client_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]
+278 -3
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -13,9 +15,14 @@ TEST_PROFILE_ID = "fixture.quick-connect.v1"
TEST_CREDENTIAL_SOURCE_ID = "fixture.firmware-provider.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 = tmp_path / "associate_wifi.swift"
helper.write_text("// offline fixture\n", encoding="utf-8") 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 return helper
@@ -42,8 +49,9 @@ def test_association_exposes_only_profile_id_and_expected_ssid_to_platform_helpe
stderr=b"", stderr=b"",
) )
helper = _helper(tmp_path)
result = wifi.associate_with_wifi_profile_once( result = wifi.associate_with_wifi_profile_once(
_helper(tmp_path), helper,
TEST_PROFILE_ID, TEST_PROFILE_ID,
"XGR-OFFLINE", "XGR-OFFLINE",
runner=fake_runner, runner=fake_runner,
@@ -61,7 +69,7 @@ def test_association_exposes_only_profile_id_and_expected_ssid_to_platform_helpe
} }
assert len(calls) == 1 assert len(calls) == 1
call = calls[0] 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")) request = json.loads(bytes(call["input"]).decode("utf-8"))
assert request == { assert request == {
"action": "associate", "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.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( 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" 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( def test_association_rejects_an_uninstalled_platform(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,