fix(k1): restore canonical local connection lifecycle
This commit is contained in:
@@ -635,6 +635,72 @@ test("a provisioned address is not presented as a verified device connection", (
|
|||||||
}), "connected");
|
}), "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", () => {
|
||||||
|
|||||||
@@ -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="Пароль Wi‑Fi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
|
<TextField label="Пароль Wi‑Fi" 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":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user