fix(k1): harden BLE discovery and bridge recovery
This commit is contained in:
@@ -19,6 +19,7 @@ let presentation;
|
||||
let operatorIntentGeneration;
|
||||
let configuration;
|
||||
let compatibility;
|
||||
let networkProvisionFailureMessage;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -62,6 +63,9 @@ before(async () => {
|
||||
({ localizeRuntimeMessage } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/messages.ts",
|
||||
));
|
||||
({ networkProvisionFailureMessage } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/useXgridsK1Runtime.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
@@ -216,6 +220,7 @@ test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions",
|
||||
assert.equal(xgridsK1Actions.acquisitionPrepare, "acquisition.prepare");
|
||||
assert.equal(xgridsK1Actions.acquisitionStart, "acquisition.start");
|
||||
assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop");
|
||||
assert.equal(xgridsK1Actions.connectionVerify, "connection.verify");
|
||||
});
|
||||
|
||||
test("one operator action can open at most one K1 control session", () => {
|
||||
@@ -693,3 +698,56 @@ test("device mutations send explicit nested compatibility attestation", async ()
|
||||
assert.deepEqual(prepare.input.compatibility_attestation, attestation);
|
||||
assert.equal(prepare.input.project_name, "Mission 01");
|
||||
});
|
||||
|
||||
test("connection verification supports refresh and explicit read-only adoption", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls = [];
|
||||
globalThis.fetch = async (path, init) => {
|
||||
calls.push({ path, init });
|
||||
return new Response(JSON.stringify({ state: { source_mode: "idle" } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
const attestation = {
|
||||
firmware_version: "3.0.2",
|
||||
topology: "direct-lan",
|
||||
verification: "live-device-info",
|
||||
};
|
||||
|
||||
try {
|
||||
await xgridsK1Api.verifyConnection();
|
||||
await xgridsK1Api.verifyConnection({
|
||||
device_id: "fresh-ble-device",
|
||||
compatibility_attestation: attestation,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(String(calls[0].path), /actions\/connection\.verify$/);
|
||||
assert.match(String(calls[1].path), /actions\/connection\.verify$/);
|
||||
assert.deepEqual(JSON.parse(calls[0].init.body), { input: {} });
|
||||
const adoption = JSON.parse(calls[1].init.body).input;
|
||||
assert.deepEqual(adoption, {
|
||||
device_id: "fresh-ble-device",
|
||||
compatibility_attestation: attestation,
|
||||
});
|
||||
assert.equal("ssid" in adoption, false);
|
||||
assert.equal("password" in adoption, false);
|
||||
assert.equal("connection_mode" in adoption, false);
|
||||
});
|
||||
|
||||
test("rejected network-profile writes use safe operator copy", () => {
|
||||
const message = networkProvisionFailureMessage({
|
||||
status: "failed",
|
||||
error: { code: "BleakGATTProtocolError" },
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
message,
|
||||
"Сканер отклонил запись сетевого профиля. Результат изменения сети неизвестен; автоматический повтор запрещён. Проверьте текущее состояние K1 или подхватите существующее подключение без изменения настроек Wi‑Fi.",
|
||||
);
|
||||
assert.doesNotMatch(message, /Bleak|GATT|ATT/i);
|
||||
});
|
||||
|
||||
@@ -162,6 +162,30 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
|
||||
assert.match(spatialControls, /Повторить остановку/);
|
||||
});
|
||||
|
||||
test("K1 Bridge adoption remains one explicit read-only plugin action", () => {
|
||||
const provisioning = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const selectionEffectStart = provisioning.indexOf("useEffect(() => {");
|
||||
const selectionEffectEnd = provisioning.indexOf(
|
||||
"useEffect(() => {",
|
||||
selectionEffectStart + 1,
|
||||
);
|
||||
const selectionEffect = provisioning.slice(selectionEffectStart, selectionEffectEnd);
|
||||
assert.match(provisioning, /connectionMode === "bridge"/);
|
||||
assert.match(provisioning, /Подхватить существующее подключение/);
|
||||
assert.match(provisioning, /pendingAction === "verify"/);
|
||||
assert.match(
|
||||
provisioning,
|
||||
/compatibility_attestation: profileSelectionForConnectionMode\("bridge"\)/,
|
||||
);
|
||||
assert.match(provisioning, /без изменения настроек Wi‑Fi/);
|
||||
assert.match(provisioning, /deviceSummary !== undefined/);
|
||||
assert.match(selectionEffect, /!state\.devices\.some/);
|
||||
assert.match(selectionEffect, /setSelectedDeviceId\(""\)/);
|
||||
});
|
||||
|
||||
test("generic Control Station has one composition import and no K1 implementation knowledge", () => {
|
||||
const compositionPath = join(coreSourceRoot, "composition/devicePlugins.ts");
|
||||
const composition = readFileSync(compositionPath, "utf8");
|
||||
|
||||
@@ -386,6 +386,11 @@ export interface ConnectRequest {
|
||||
idempotency_key?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionVerifyRequest {
|
||||
device_id: string;
|
||||
compatibility_attestation: CompatibilityAttestation;
|
||||
}
|
||||
|
||||
export interface PrepareAcquisitionRequest {
|
||||
project_name: string;
|
||||
mount_type: "handheld";
|
||||
@@ -593,6 +598,10 @@ export const xgridsK1Api = {
|
||||
return invokeState(xgridsK1Actions.networkProvision, body);
|
||||
},
|
||||
|
||||
verifyConnection(body?: ConnectionVerifyRequest): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.connectionVerify, body);
|
||||
},
|
||||
|
||||
prepareAcquisition(body: PrepareAcquisitionRequest): Promise<XgridsK1State> {
|
||||
return invokeState(xgridsK1Actions.acquisitionPrepare, body);
|
||||
},
|
||||
|
||||
@@ -118,7 +118,7 @@ export function K1ProvisioningPipeline({
|
||||
phaseLabel: string;
|
||||
phaseTone: StatusTone;
|
||||
}) {
|
||||
const { state, pendingAction, scan, connect } = controller;
|
||||
const { state, pendingAction, scan, connect, verifyConnection } = controller;
|
||||
const [powerConfirmed, setPowerConfirmed] = useState(false);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
||||
const [ssid, setSsid] = useState("");
|
||||
@@ -160,6 +160,11 @@ export function K1ProvisioningPipeline({
|
||||
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||
[devices, selectedDeviceId],
|
||||
);
|
||||
const canAdoptExistingBridge = connectionMode === "bridge"
|
||||
&& powerConfirmed
|
||||
&& selectedDeviceId.length > 0
|
||||
&& deviceSummary !== undefined
|
||||
&& !isBusy;
|
||||
|
||||
const resetProvisioningIntent = () => {
|
||||
provisioningIntentRef.current = null;
|
||||
@@ -190,6 +195,14 @@ export function K1ProvisioningPipeline({
|
||||
}
|
||||
};
|
||||
|
||||
const submitExistingBridgeAdoption = async () => {
|
||||
if (!canAdoptExistingBridge) return;
|
||||
await verifyConnection({
|
||||
device_id: selectedDeviceId,
|
||||
compatibility_attestation: profileSelectionForConnectionMode("bridge"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassSurface className="connection-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
@@ -275,6 +288,24 @@ export function K1ProvisioningPipeline({
|
||||
? connectionMode === "quick-connect" ? "Включаем точку и подключаем…" : "Подключаем…"
|
||||
: modeCopy.buttonLabel}
|
||||
</Button>
|
||||
{connectionMode === "bridge" ? (
|
||||
<>
|
||||
<Button
|
||||
width="full"
|
||||
variant="secondary"
|
||||
icon={<Icon name="refresh" />}
|
||||
disabled={!canAdoptExistingBridge}
|
||||
onClick={() => void submitExistingBridgeAdoption()}
|
||||
>
|
||||
{pendingAction === "verify"
|
||||
? "Проверяем существующее подключение…"
|
||||
: "Подхватить существующее подключение"}
|
||||
</Button>
|
||||
<p className="safety-note">
|
||||
Если K1 уже подключён к этой сети другим способом, Mission Core проверит и примет существующее подключение без изменения настроек Wi‑Fi.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
<p className="safety-note">
|
||||
{modeCopy.safetyNote}
|
||||
{connectionMode === "quick-connect" ? " Это лабораторный путь для уже подготовленного хоста: credential provider должен существовать в системном хранилище заранее. На чистом Mac операция завершится до BLE-записи; браузер, API, журналы и evidence секрета не получают." : " Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха."}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ApiError,
|
||||
xgridsK1Api,
|
||||
openEventSocket,
|
||||
type ConnectionVerifyRequest,
|
||||
type ConnectRequest,
|
||||
type EventSocketStatus,
|
||||
type OpenApplicationControlSessionRequest,
|
||||
@@ -34,6 +35,7 @@ import { selectMonotonicXgridsState } from "./stateOrdering";
|
||||
export type PendingAction =
|
||||
| "scan"
|
||||
| "connect"
|
||||
| "verify"
|
||||
| "control"
|
||||
| "live"
|
||||
| "replay"
|
||||
@@ -191,7 +193,7 @@ function messageFor(error: unknown): string {
|
||||
return "Запрос к локальному сервису устройства завершился ошибкой.";
|
||||
}
|
||||
|
||||
function networkProvisionFailureMessage(
|
||||
export function networkProvisionFailureMessage(
|
||||
operation: XgridsOperation | null | undefined,
|
||||
): string | null {
|
||||
if (!operation || operation.status !== "failed") return null;
|
||||
@@ -199,6 +201,8 @@ function networkProvisionFailureMessage(
|
||||
if (typeof code !== "string") return null;
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
BleakGATTProtocolError:
|
||||
"Сканер отклонил запись сетевого профиля. Результат изменения сети неизвестен; автоматический повтор запрещён. Проверьте текущее состояние K1 или подхватите существующее подключение без изменения настроек Wi‑Fi.",
|
||||
"network-not-found":
|
||||
"Точка доступа выбранного K1 не найдена. Команда включения точки не повторялась; проверьте питание и состояние K1.",
|
||||
"credential-entry-cancelled":
|
||||
@@ -392,6 +396,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
[acceptState, run, state],
|
||||
);
|
||||
|
||||
const verifyConnection = useCallback(
|
||||
(request?: ConnectionVerifyRequest) =>
|
||||
run("verify", () => xgridsK1Api.verifyConnection(request)),
|
||||
[run],
|
||||
);
|
||||
|
||||
const openApplicationControlSession = useCallback(
|
||||
(request: OpenApplicationControlSessionRequest) =>
|
||||
run("control", () => xgridsK1Api.openApplicationControlSession(request)),
|
||||
@@ -773,6 +783,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
clearError: () => setError(null),
|
||||
scan,
|
||||
connect,
|
||||
verifyConnection,
|
||||
openApplicationControlSession,
|
||||
enterApplicationWorkspace,
|
||||
closeApplicationControlSession,
|
||||
|
||||
@@ -11,7 +11,6 @@ from bleak import BleakClient, BleakScanner
|
||||
from bleak.exc import BleakDeviceNotFoundError, BleakError
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import discovered_device
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
AP_FALLBACK_IPV4,
|
||||
SERVICE_UUID,
|
||||
@@ -128,12 +127,13 @@ async def device_ap_activation_session(
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds + 25.0):
|
||||
device = discovered_device(device_macos_uuid)
|
||||
if device is None:
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=min(20.0, timeout_seconds),
|
||||
)
|
||||
# AP activation is a mutation boundary: prove that the selected
|
||||
# device is advertising now instead of trusting a CoreBluetooth
|
||||
# handle retained by an earlier UI scan.
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=min(20.0, timeout_seconds),
|
||||
)
|
||||
if device is None:
|
||||
raise BleakDeviceNotFoundError(
|
||||
device_macos_uuid,
|
||||
|
||||
@@ -239,12 +239,13 @@ async def provision_wifi_once(
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds + 25.0):
|
||||
device = discovered_device(device_macos_uuid)
|
||||
if device is None:
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=min(20.0, timeout_seconds),
|
||||
)
|
||||
# A provisioning write is a mutation boundary: prove that the
|
||||
# selected device is advertising now instead of trusting a
|
||||
# CoreBluetooth handle retained by an earlier UI scan.
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=min(20.0, timeout_seconds),
|
||||
)
|
||||
if device is None:
|
||||
raise BleakDeviceNotFoundError(
|
||||
device_macos_uuid,
|
||||
|
||||
@@ -128,6 +128,7 @@ XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.local-network
|
||||
DEFAULT_ACQUISITION_CAMERA_SOURCE: CameraSourceId = "sensor.camera.right"
|
||||
CONTROL_MQTT_PORT = 1883
|
||||
CONTROL_ENDPOINT_PROBE_TIMEOUT_SECONDS = 1.5
|
||||
BLE_DISCOVERY_LEASE_TTL_SECONDS = 60.0
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -277,6 +278,28 @@ class ConnectRequest(StrictRequest):
|
||||
return self
|
||||
|
||||
|
||||
class ConnectionVerifyRequest(StrictRequest):
|
||||
"""Refresh one lease or adopt a scanned K1 already on the direct LAN."""
|
||||
|
||||
device_id: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
compatibility_attestation: CompatibilityAttestationRequest | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_adoption_request(self) -> Self:
|
||||
has_device = self.device_id is not None
|
||||
has_attestation = self.compatibility_attestation is not None
|
||||
if has_device != has_attestation:
|
||||
raise ValueError(
|
||||
"device_id and compatibility_attestation must be provided together"
|
||||
)
|
||||
if (
|
||||
self.compatibility_attestation is not None
|
||||
and self.compatibility_attestation.topology != "direct-lan"
|
||||
):
|
||||
raise ValueError("connection.verify adoption requires topology=direct-lan")
|
||||
return self
|
||||
|
||||
|
||||
class LiveRequest(StrictRequest):
|
||||
project_name: str = Field(min_length=1, max_length=96)
|
||||
host: str | None = Field(default=None, max_length=15)
|
||||
@@ -422,6 +445,8 @@ class XgridsK1CompatibilityService:
|
||||
self._provisioning_active = False
|
||||
self._fingerprint_key = secrets.token_bytes(32)
|
||||
self._devices: list[dict[str, Any]] = []
|
||||
self._ble_discovery_generation = 0
|
||||
self._ble_device_last_seen_monotonic: dict[str, float] = {}
|
||||
self._selected_device_id: str | None = None
|
||||
self._k1_ip: str | None = None
|
||||
self._connection_mode: ConnectionMode | None = None
|
||||
@@ -494,6 +519,25 @@ class XgridsK1CompatibilityService:
|
||||
allow_device_ap=connection_mode == "quick-connect",
|
||||
)
|
||||
|
||||
def _fresh_ble_devices_locked(
|
||||
self,
|
||||
*,
|
||||
now_monotonic: float | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return only candidates leased by the current in-memory scan generation."""
|
||||
|
||||
now = time.monotonic() if now_monotonic is None else now_monotonic
|
||||
fresh: list[dict[str, Any]] = []
|
||||
for item in self._devices:
|
||||
device_id = str(item.get("device_id") or "")
|
||||
last_seen = self._ble_device_last_seen_monotonic.get(device_id)
|
||||
if last_seen is None:
|
||||
continue
|
||||
age_seconds = now - last_seen
|
||||
if 0.0 <= age_seconds <= BLE_DISCOVERY_LEASE_TTL_SECONDS:
|
||||
fresh.append(dict(item))
|
||||
return fresh
|
||||
|
||||
@_serialized_acquisition_access
|
||||
def state(self) -> dict[str, Any]:
|
||||
application_control = self._application_control.snapshot().as_dict()
|
||||
@@ -507,7 +551,8 @@ class XgridsK1CompatibilityService:
|
||||
with self._lock:
|
||||
operation_phase = self._operation_phase
|
||||
operation_message = self._operation_message
|
||||
devices = list(self._devices)
|
||||
devices = self._fresh_ble_devices_locked()
|
||||
discovery_stale = bool(self._devices) and not devices
|
||||
selected_device_id = self._selected_device_id
|
||||
k1_ip = self._k1_ip
|
||||
connection_mode = self._connection_mode
|
||||
@@ -572,6 +617,9 @@ class XgridsK1CompatibilityService:
|
||||
f"Найдено BLE-устройств: {len(devices)}. "
|
||||
f"Совместимых профилей: {likely_count}. Выберите нужное устройство."
|
||||
)
|
||||
elif discovery_stale:
|
||||
phase = "idle"
|
||||
message = "Результаты Bluetooth-поиска устарели. Выполните поиск ещё раз."
|
||||
else:
|
||||
phase = "idle"
|
||||
message = runtime["message"]
|
||||
@@ -686,6 +734,15 @@ class XgridsK1CompatibilityService:
|
||||
}
|
||||
|
||||
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if self._provisioning_active:
|
||||
raise RuntimeError("нельзя запускать BLE-поиск во время настройки Wi-Fi")
|
||||
self._ble_discovery_generation += 1
|
||||
scan_generation = self._ble_discovery_generation
|
||||
# Discovery results are an in-memory lease, not a durable catalog.
|
||||
# A new explicit scan invalidates the previous generation before I/O.
|
||||
self._devices = []
|
||||
self._ble_device_last_seen_monotonic = {}
|
||||
operation, _ = self._operations.begin(
|
||||
ACTION_DISCOVERY_SCAN,
|
||||
deadline_seconds=duration_seconds + 10.0,
|
||||
@@ -710,9 +767,16 @@ class XgridsK1CompatibilityService:
|
||||
}
|
||||
for item in result["devices"]
|
||||
]
|
||||
observed_monotonic = time.monotonic()
|
||||
with self._lock:
|
||||
self._devices = devices
|
||||
self._operation_message = f"Поиск завершён. Найдено BLE-устройств: {len(devices)}."
|
||||
if self._ble_discovery_generation == scan_generation:
|
||||
self._devices = devices
|
||||
self._ble_device_last_seen_monotonic = {
|
||||
str(item["device_id"]): observed_monotonic for item in devices
|
||||
}
|
||||
self._operation_message = (
|
||||
f"Поиск завершён. Найдено BLE-устройств: {len(devices)}."
|
||||
)
|
||||
self._operations.transition(
|
||||
operation.operation_id,
|
||||
"succeeded",
|
||||
@@ -721,6 +785,10 @@ class XgridsK1CompatibilityService:
|
||||
result={"candidate_count": len(devices)},
|
||||
)
|
||||
except Exception as exc:
|
||||
with self._lock:
|
||||
if self._ble_discovery_generation == scan_generation:
|
||||
self._devices = []
|
||||
self._ble_device_last_seen_monotonic = {}
|
||||
self._operations.transition(
|
||||
operation.operation_id,
|
||||
"failed",
|
||||
@@ -731,16 +799,20 @@ class XgridsK1CompatibilityService:
|
||||
raise
|
||||
finally:
|
||||
with self._lock:
|
||||
self._operation_phase = None
|
||||
if self._ble_discovery_generation == scan_generation:
|
||||
self._operation_phase = None
|
||||
return self.state()
|
||||
|
||||
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
|
||||
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
|
||||
with self._lock:
|
||||
scanned_devices = self._fresh_ble_devices_locked()
|
||||
discovery_generation = self._ble_discovery_generation
|
||||
known_ids = {str(item["device_id"]) for item in scanned_devices}
|
||||
if request.device_id not in known_ids:
|
||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
||||
quick_connect = request.connection_mode == "quick-connect"
|
||||
selected_device = next(
|
||||
item for item in self.state()["devices"] if item["device_id"] == request.device_id
|
||||
item for item in scanned_devices if item["device_id"] == request.device_id
|
||||
)
|
||||
selected_device_name = str(selected_device.get("name") or "").strip()
|
||||
control_snapshot = self._application_control_session.snapshot()
|
||||
@@ -860,6 +932,18 @@ class XgridsK1CompatibilityService:
|
||||
raise HostWifiProfileError("credential-source-unavailable")
|
||||
|
||||
with self._lock:
|
||||
if (
|
||||
self._ble_discovery_generation != discovery_generation
|
||||
or request.device_id
|
||||
not in {
|
||||
str(item["device_id"])
|
||||
for item in self._fresh_ble_devices_locked()
|
||||
}
|
||||
):
|
||||
raise ValueError(
|
||||
"результаты Bluetooth-поиска изменились или устарели; "
|
||||
"выполните поиск ещё раз"
|
||||
)
|
||||
active_acquisition = self._acquisition
|
||||
if (
|
||||
active_acquisition is not None
|
||||
@@ -1142,21 +1226,41 @@ class XgridsK1CompatibilityService:
|
||||
evidence_refs=(f"evidence-session-{session_dir.name}",),
|
||||
)
|
||||
except Exception as exc:
|
||||
operation_error = _operation_error(
|
||||
exc,
|
||||
category="transport" if quick_connect else "device",
|
||||
side_effect_status=("unknown" if network_change_attempted else "none"),
|
||||
safe_to_retry=not network_change_attempted,
|
||||
)
|
||||
self._operations.transition_if_pending(
|
||||
operation.operation_id,
|
||||
"failed",
|
||||
stage_code=f"{operation_stage}-failed",
|
||||
message_code="network.provision.failed",
|
||||
error=_operation_error(
|
||||
exc,
|
||||
category="transport" if quick_connect else "device",
|
||||
side_effect_status=("unknown" if network_change_attempted else "none"),
|
||||
safe_to_retry=not network_change_attempted,
|
||||
),
|
||||
error=operation_error,
|
||||
evidence_refs=(
|
||||
(f"evidence-session-{session_dir.name}",) if session_dir is not None else ()
|
||||
),
|
||||
)
|
||||
# Never log the request, SSID, password, BLE payload, or exception
|
||||
# text here: lower transport errors may embed sensitive frame data.
|
||||
# The structured record is sufficient to correlate the HTTP 502
|
||||
# with its operation stage and retry/side-effect safety contract.
|
||||
logger.error(
|
||||
"K1 network provisioning failed",
|
||||
extra={
|
||||
"event_code": "k1_network_provision_failed",
|
||||
"operation_id": operation.operation_id,
|
||||
"operation_stage": operation_stage,
|
||||
"connection_mode": request.connection_mode,
|
||||
"error_category": operation_error["category"],
|
||||
"error_code": operation_error["code"],
|
||||
"safe_to_retry": operation_error["safe_to_retry"],
|
||||
"side_effect_status": operation_error["side_effect_status"],
|
||||
"network_change_attempted": network_change_attempted,
|
||||
"automatic_retry": False,
|
||||
},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
password = ""
|
||||
@@ -1216,12 +1320,169 @@ class XgridsK1CompatibilityService:
|
||||
payload=segment.payload,
|
||||
)
|
||||
|
||||
def verify_connection(self) -> dict[str, Any]:
|
||||
"""Refresh the session-scoped DHCP address from the read-only BLE status."""
|
||||
def verify_connection(
|
||||
self,
|
||||
request: ConnectionVerifyRequest | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Refresh a lease or adopt an externally configured direct-LAN K1."""
|
||||
|
||||
self._refresh_live_lan_address(rediscover=True)
|
||||
request = request or ConnectionVerifyRequest()
|
||||
if request.device_id is None:
|
||||
self._refresh_live_lan_address(rediscover=True)
|
||||
else:
|
||||
assert request.compatibility_attestation is not None
|
||||
self._adopt_existing_lan_connection(
|
||||
request.device_id,
|
||||
request.compatibility_attestation,
|
||||
)
|
||||
return self.state()
|
||||
|
||||
def _adopt_existing_lan_connection(
|
||||
self,
|
||||
device_id: str,
|
||||
compatibility_attestation: CompatibilityAttestationRequest,
|
||||
) -> str:
|
||||
"""Create a process lease from BLE status without changing K1 Wi-Fi state."""
|
||||
|
||||
if compatibility_attestation.topology != "direct-lan":
|
||||
raise ValueError("read-only K1 adoption supports only direct-lan topology")
|
||||
with self._lock:
|
||||
scanned_device = next(
|
||||
(
|
||||
dict(item)
|
||||
for item in self._fresh_ble_devices_locked()
|
||||
if str(item.get("device_id")) == device_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
discovery_generation = self._ble_discovery_generation
|
||||
acquisition = self._acquisition
|
||||
acquisition_session_lease = self._acquisition_session_lease
|
||||
provisioning_active = self._provisioning_active
|
||||
previous_target = self._k1_ip
|
||||
if scanned_device is None:
|
||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
||||
if scanned_device.get("connectable") is False:
|
||||
raise ValueError("выбранное Bluetooth-устройство сейчас недоступно для подключения")
|
||||
if provisioning_active:
|
||||
raise RuntimeError("нельзя проверять существующую сеть во время настройки Wi-Fi")
|
||||
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||
raise RuntimeError(
|
||||
"нельзя менять connection lease во время активной acquisition-сессии"
|
||||
)
|
||||
if acquisition_session_lease is not None:
|
||||
raise RuntimeError("предыдущая evidence-сессия ещё не остановлена и не запечатана")
|
||||
runtime_state = self.runtime.snapshot()
|
||||
if runtime_state.get("source_mode") != "idle":
|
||||
raise RuntimeError("нельзя менять connection lease при активном live/replay источнике")
|
||||
|
||||
if not self._provisioning_gate.acquire(blocking=False):
|
||||
raise RuntimeError("другая операция настройки или проверки сети уже выполняется")
|
||||
try:
|
||||
status_read = asyncio.run(
|
||||
read_wifi_status_once(
|
||||
device_id,
|
||||
timeout_seconds=20.0,
|
||||
rediscover=True,
|
||||
)
|
||||
)
|
||||
if (
|
||||
status_read.get("operation") != "single_reviewed_wifi_status_read"
|
||||
or status_read.get("write_performed") is not False
|
||||
):
|
||||
raise RuntimeError("BLE status read не подтвердил read-only операцию")
|
||||
observed_target = status_read["status"]["ipv4"]
|
||||
if observed_target is None or observed_target == AP_FALLBACK_IPV4:
|
||||
raise RuntimeError("K1 не сообщил актуальный DHCP-адрес общей сети")
|
||||
target = validate_private_ipv4(observed_target)
|
||||
if _target_is_local_ipv4(target):
|
||||
raise RuntimeError("BLE status сообщил адрес, принадлежащий этому компьютеру")
|
||||
host_route_class = _host_route_class(target)
|
||||
if host_route_class in {"default-route", "tunnel"}:
|
||||
raise RuntimeError(
|
||||
"K1 сообщил адрес другой сети; прямой локальный маршрут отсутствует"
|
||||
)
|
||||
if not _control_endpoint_reachable(target):
|
||||
raise RuntimeError(
|
||||
"K1 сообщил адрес общей сети, но MQTT endpoint 1883 недоступен"
|
||||
)
|
||||
|
||||
# Retire only local, terminal control ownership after every external
|
||||
# observation has passed. No scanner command is sent by this call.
|
||||
self._application_control_session.retire_for_network_change()
|
||||
observed_at = str(status_read["observed_at_utc"])
|
||||
with self._lock:
|
||||
if self._provisioning_active:
|
||||
raise RuntimeError("настройка Wi-Fi началась во время проверки сети")
|
||||
if self._ble_discovery_generation != discovery_generation or not any(
|
||||
str(item.get("device_id")) == device_id for item in self._devices
|
||||
):
|
||||
raise RuntimeError("список найденных Bluetooth-устройств изменился")
|
||||
current_acquisition = self._acquisition
|
||||
if (
|
||||
current_acquisition is not None
|
||||
and current_acquisition.state not in TERMINAL_ACQUISITION_STATES
|
||||
):
|
||||
raise RuntimeError(
|
||||
"acquisition-сессия началась во время проверки существующей сети"
|
||||
)
|
||||
if self._acquisition_session_lease is not None:
|
||||
raise RuntimeError(
|
||||
"evidence-сессия изменилась во время проверки существующей сети"
|
||||
)
|
||||
persistent_device_id = self._device_ids_by_transport_ref.get(device_id)
|
||||
if persistent_device_id is None:
|
||||
persistent_device_id = new_device_id()
|
||||
self._device_ids_by_transport_ref[device_id] = persistent_device_id
|
||||
self._selected_device_id = device_id
|
||||
self._k1_ip = target
|
||||
self._connection_mode = "bridge"
|
||||
self._device_id = persistent_device_id
|
||||
self._device_session_id = new_device_session_id()
|
||||
self._device_session_opened_at = observed_at
|
||||
self._connection_lease_generation += 1
|
||||
self._compatibility_attestation = _attestation_snapshot(
|
||||
compatibility_attestation
|
||||
)
|
||||
self._device_calibration = unavailable_device_calibration_snapshot(
|
||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||
)
|
||||
self._connection_verification = {
|
||||
"status": "adopted",
|
||||
"lease_state": "reachable",
|
||||
"lease_generation": self._connection_lease_generation,
|
||||
"endpoint_validation": "ble-wifi-status-read+mqtt-tcp-connect",
|
||||
"network_reachability": "reachable",
|
||||
"host_route_class": host_route_class,
|
||||
"address_source": "ble-wifi-status-read",
|
||||
"connection_origin": "external-existing-network",
|
||||
"admission_source": ACTION_CONNECTION_VERIFY,
|
||||
"address_changed": previous_target != target,
|
||||
"previous_address_present": previous_target is not None,
|
||||
"write_performed": False,
|
||||
"observed_at": observed_at,
|
||||
}
|
||||
self._operation_message = (
|
||||
"K1 найден в существующей локальной сети без изменения его настроек."
|
||||
)
|
||||
lease_generation = self._connection_lease_generation
|
||||
logger.info(
|
||||
"K1 existing direct-LAN connection adopted without provisioning",
|
||||
extra={
|
||||
"event_code": "k1_existing_lan_connection_adopted",
|
||||
"lease_generation": lease_generation,
|
||||
"connection_origin": "external-existing-network",
|
||||
"address_source": "ble-wifi-status-read",
|
||||
"host_route_class": host_route_class,
|
||||
"endpoint_reachable": True,
|
||||
"device_write_performed": False,
|
||||
"automatic_retry": False,
|
||||
},
|
||||
)
|
||||
return target
|
||||
finally:
|
||||
self._provisioning_gate.release()
|
||||
|
||||
def _refresh_live_lan_address(self, *, rediscover: bool = False) -> str:
|
||||
with self._lock:
|
||||
selected_device_id = self._selected_device_id
|
||||
@@ -3156,7 +3417,10 @@ class XgridsK1ServicePort(Protocol):
|
||||
|
||||
def inspect_device(self) -> dict[str, Any]: ...
|
||||
|
||||
def verify_connection(self) -> dict[str, Any]: ...
|
||||
def verify_connection(
|
||||
self,
|
||||
request: ConnectionVerifyRequest | None = None,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
def read_device_calibration_snapshot(self) -> dict[str, Any]: ...
|
||||
|
||||
@@ -3282,8 +3546,8 @@ class XgridsK1PluginFacade:
|
||||
connect_request = ConnectRequest.model_validate(payload)
|
||||
return await self.service.connect(connect_request)
|
||||
if action_id == ACTION_CONNECTION_VERIFY:
|
||||
EmptyRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(self.service.verify_connection)
|
||||
verify_request = ConnectionVerifyRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(self.service.verify_connection, verify_request)
|
||||
if action_id == ACTION_APPLICATION_CONTROL_SHADOW_STATE:
|
||||
EmptyRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(self.service.state)
|
||||
|
||||
@@ -13,6 +13,11 @@ SCANNER_DIAGNOSTIC_FILE: Final = "scanner-diagnostics.jsonl"
|
||||
_HANDLER_MARKER: Final = "_mission_core_scanner_diagnostics_path"
|
||||
_EXTRA_FIELDS: Final = (
|
||||
"event_code",
|
||||
"operation_id",
|
||||
"operation_stage",
|
||||
"connection_mode",
|
||||
"error_category",
|
||||
"error_code",
|
||||
"reason_code",
|
||||
"failed_phase",
|
||||
"dialogue_stage",
|
||||
@@ -21,6 +26,8 @@ _EXTRA_FIELDS: Final = (
|
||||
"modeling_command_attempted",
|
||||
"outcome_unknown",
|
||||
"safe_to_retry",
|
||||
"side_effect_status",
|
||||
"network_change_attempted",
|
||||
"status_reconciliation",
|
||||
"network_change_admissible",
|
||||
"network_change_reconciliation",
|
||||
|
||||
@@ -20,6 +20,7 @@ from pydantic import ValidationError
|
||||
|
||||
import k1link.web.device_plugin_composition as plugin_composition
|
||||
from k1link.device_plugins.xgrids_k1.facade import (
|
||||
ACTION_CONNECTION_VERIFY,
|
||||
ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ,
|
||||
ACTION_DISCOVERY_SCAN,
|
||||
ACTION_NETWORK_PROVISION,
|
||||
@@ -30,6 +31,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
XGRIDS_K1_PLUGIN_VERSION,
|
||||
CompatibilityAttestationRequest,
|
||||
ConnectionVerifyRequest,
|
||||
ConnectRequest,
|
||||
ViewerSettingsRequest,
|
||||
XgridsK1PluginFacade,
|
||||
@@ -72,6 +74,13 @@ class FakeXgridsService:
|
||||
self.calls.append(("connect", request))
|
||||
return {"phase": "connected", "k1_ip": "192.168.1.20"}
|
||||
|
||||
def verify_connection(
|
||||
self,
|
||||
request: ConnectionVerifyRequest | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(("verify", request))
|
||||
return {"phase": "connected", "k1_ip": "192.168.1.20"}
|
||||
|
||||
def start_live(
|
||||
self,
|
||||
project_name: str,
|
||||
@@ -158,6 +167,58 @@ def test_calibration_snapshot_action_calls_the_read_only_service_method() -> Non
|
||||
assert service.calls == [("calibration", None)]
|
||||
|
||||
|
||||
def test_connection_verify_action_accepts_read_only_adoption_request() -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(service))]
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_CONNECTION_VERIFY,
|
||||
{
|
||||
"device_id": "test-ble-transport",
|
||||
"compatibility_attestation": {
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"verification": "live-device-info",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {"phase": "connected", "k1_ip": "192.168.1.20"}
|
||||
assert len(service.calls) == 1
|
||||
action, request = service.calls[0]
|
||||
assert action == "verify"
|
||||
assert isinstance(request, ConnectionVerifyRequest)
|
||||
assert request.device_id == "test-ble-transport"
|
||||
assert request.compatibility_attestation is not None
|
||||
assert request.compatibility_attestation.topology == "direct-lan"
|
||||
|
||||
|
||||
def test_connection_verify_action_keeps_empty_refresh_request_compatible() -> None:
|
||||
service = FakeXgridsService()
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(service))]
|
||||
)
|
||||
|
||||
asyncio.run(
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_CONNECTION_VERIFY,
|
||||
{},
|
||||
)
|
||||
)
|
||||
|
||||
action, request = service.calls[0]
|
||||
assert action == "verify"
|
||||
assert isinstance(request, ConnectionVerifyRequest)
|
||||
assert request.device_id is None
|
||||
assert request.compatibility_attestation is None
|
||||
|
||||
|
||||
def test_repository_runtime_composition_exactly_matches_catalog() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
environment = load_installed_device_plugins(repository_root)
|
||||
|
||||
@@ -43,11 +43,18 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
|
||||
"field control failure",
|
||||
extra={
|
||||
"event_code": "k1_application_control_session_failed",
|
||||
"operation_id": "operation-test-01",
|
||||
"operation_stage": "ble-provisioning-write",
|
||||
"connection_mode": "bridge",
|
||||
"error_category": "device",
|
||||
"error_code": "BleakGATTProtocolError",
|
||||
"reason_code": "mqtt_network_loop_failed",
|
||||
"mqtt_loop_result_code": 7,
|
||||
"mqtt_loop_result_name": "The connection was lost.",
|
||||
"mqtt_loop_phase": "post-publish-drain",
|
||||
"automatic_retry": False,
|
||||
"side_effect_status": "unknown",
|
||||
"network_change_attempted": True,
|
||||
"camera_source_id": "sensor.camera.right",
|
||||
"evidence_session_id": "20260728T163450Z_viewer_live",
|
||||
"activation_trigger": "application-control-scanning",
|
||||
@@ -73,10 +80,17 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
|
||||
assert stat.S_IMODE(target.parent.stat().st_mode) == 0o700
|
||||
assert stat.S_IMODE(target.stat().st_mode) == 0o600
|
||||
assert document["event_code"] == "k1_application_control_session_failed"
|
||||
assert document["operation_id"] == "operation-test-01"
|
||||
assert document["operation_stage"] == "ble-provisioning-write"
|
||||
assert document["connection_mode"] == "bridge"
|
||||
assert document["error_category"] == "device"
|
||||
assert document["error_code"] == "BleakGATTProtocolError"
|
||||
assert document["reason_code"] == "mqtt_network_loop_failed"
|
||||
assert document["mqtt_loop_result_code"] == 7
|
||||
assert document["mqtt_loop_phase"] == "post-publish-drain"
|
||||
assert document["automatic_retry"] is False
|
||||
assert document["side_effect_status"] == "unknown"
|
||||
assert document["network_change_attempted"] is True
|
||||
assert document["camera_source_id"] == "sensor.camera.right"
|
||||
assert document["evidence_session_id"] == "20260728T163450Z_viewer_live"
|
||||
assert document["activation_trigger"] == "application-control-scanning"
|
||||
|
||||
@@ -3,12 +3,14 @@ from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from bleak.exc import BleakDeviceNotFoundError
|
||||
|
||||
import k1link.device_plugins.xgrids_k1.ble.wifi_provisioning as wifi_module
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
FRAME_LENGTH,
|
||||
build_wifi_provisioning_frame,
|
||||
parse_wifi_status,
|
||||
provision_wifi_once,
|
||||
read_wifi_status_once,
|
||||
)
|
||||
|
||||
@@ -185,3 +187,41 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
)
|
||||
|
||||
assert result["status"]["ipv4"] == "10.255.254.77"
|
||||
|
||||
|
||||
def test_provisioning_write_requires_fresh_rediscovery_before_connecting(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
stale_handle = object()
|
||||
rediscovery_calls: list[tuple[str, float]] = []
|
||||
client_calls: list[object] = []
|
||||
|
||||
async def missing_device(address: str, *, timeout: float) -> None:
|
||||
rediscovery_calls.append((address, timeout))
|
||||
return None
|
||||
|
||||
class ForbiddenClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
client_calls.append(device)
|
||||
raise AssertionError("a failed fresh discovery must stop before the BLE write session")
|
||||
|
||||
monkeypatch.setattr(wifi_module, "discovered_device", lambda _uuid: stale_handle)
|
||||
monkeypatch.setattr(
|
||||
wifi_module.BleakScanner,
|
||||
"find_device_by_address",
|
||||
missing_device,
|
||||
)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", ForbiddenClient)
|
||||
|
||||
with pytest.raises(BleakDeviceNotFoundError):
|
||||
asyncio.run(
|
||||
provision_wifi_once(
|
||||
"synthetic-corebluetooth-uuid",
|
||||
"LabNet",
|
||||
"synthetic-password",
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert rediscovery_calls == [("synthetic-corebluetooth-uuid", 1.0)]
|
||||
assert client_calls == []
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -18,6 +19,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
|
||||
AbortAcquisitionRequest,
|
||||
CameraPreviewSelectRequest,
|
||||
CompatibilityAttestationRequest,
|
||||
ConnectionVerifyRequest,
|
||||
ConnectRequest,
|
||||
OpenApplicationControlSessionRequest,
|
||||
OperatorPresenceRequest,
|
||||
@@ -198,7 +200,7 @@ def service_with_fake_runtime(
|
||||
return service, runtime
|
||||
|
||||
|
||||
def _wifi_status_read(ipv4: str) -> dict[str, Any]:
|
||||
def _wifi_status_read(ipv4: str | None) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"profile_id": "xgrids-k1-fw3-wifi-v1",
|
||||
@@ -222,6 +224,383 @@ def _wifi_status_read(ipv4: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _set_scanned_devices(
|
||||
service: XgridsK1CompatibilityService,
|
||||
devices: list[dict[str, Any]],
|
||||
) -> None:
|
||||
service._devices = devices # noqa: SLF001
|
||||
observed_monotonic = facade_module.time.monotonic()
|
||||
service._ble_device_last_seen_monotonic = { # noqa: SLF001
|
||||
str(item["device_id"]): observed_monotonic for item in devices
|
||||
}
|
||||
|
||||
|
||||
def _set_scanned_k1(
|
||||
service: XgridsK1CompatibilityService,
|
||||
*,
|
||||
device_id: str = "test-ble-transport",
|
||||
) -> None:
|
||||
_set_scanned_devices(service, [
|
||||
{
|
||||
"device_id": device_id,
|
||||
"name": "XGR-K1",
|
||||
"rssi": -44,
|
||||
"address": None,
|
||||
"connectable": True,
|
||||
"likely_k1": True,
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
def test_ble_discovery_lease_expiry_hides_and_rejects_candidate(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_k1(service)
|
||||
service._ble_device_last_seen_monotonic["test-ble-transport"] = ( # noqa: SLF001
|
||||
facade_module.time.monotonic()
|
||||
- facade_module.BLE_DISCOVERY_LEASE_TTL_SECONDS
|
||||
- 0.001
|
||||
)
|
||||
|
||||
async def forbidden_status_read(*_: object, **__: object) -> dict[str, Any]:
|
||||
raise AssertionError("an expired candidate must not be rediscovered by an action")
|
||||
|
||||
async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]:
|
||||
raise AssertionError("an expired candidate must not reach the Wi-Fi write boundary")
|
||||
|
||||
monkeypatch.setattr(facade_module, "read_wifi_status_once", forbidden_status_read)
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision)
|
||||
|
||||
stale_state = service.state()
|
||||
assert stale_state["devices"] == []
|
||||
assert "устарели" in stale_state["message"]
|
||||
with pytest.raises(ValueError, match="найдите и выберите"):
|
||||
service.verify_connection(
|
||||
ConnectionVerifyRequest(
|
||||
device_id="test-ble-transport",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match="найдите и выберите"):
|
||||
asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="test-ble-transport",
|
||||
ssid="lab-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_new_ble_scan_generation_invalidates_old_candidates_before_io_and_on_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_k1(service, device_id="old-scan-device")
|
||||
|
||||
async def scenario() -> None:
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def failing_scan(_duration_seconds: float) -> dict[str, Any]:
|
||||
entered.set()
|
||||
await release.wait()
|
||||
raise RuntimeError("synthetic BLE scan failure")
|
||||
|
||||
monkeypatch.setattr(facade_module, "scan", failing_scan)
|
||||
scan_task = asyncio.create_task(service.scan_ble(6.0))
|
||||
await asyncio.wait_for(entered.wait(), timeout=1.0)
|
||||
assert service.state()["devices"] == []
|
||||
release.set()
|
||||
with pytest.raises(RuntimeError, match="synthetic BLE scan failure"):
|
||||
await asyncio.wait_for(scan_task, timeout=1.0)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert service.state()["devices"] == []
|
||||
assert service._devices == [] # noqa: SLF001
|
||||
assert service._ble_device_last_seen_monotonic == {} # noqa: SLF001
|
||||
|
||||
|
||||
def test_older_ble_scan_cannot_replace_a_newer_generation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
first_entered = asyncio.Event()
|
||||
release_first = asyncio.Event()
|
||||
call_count = 0
|
||||
|
||||
def scan_result(device_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"devices": [
|
||||
{
|
||||
"macos_uuid": device_id,
|
||||
"name": "XGR-K1",
|
||||
"local_name": "XGR-K1",
|
||||
"rssi": -44,
|
||||
"k1_name_candidate": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async def overlapping_scan(_duration_seconds: float) -> dict[str, Any]:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
first_entered.set()
|
||||
await release_first.wait()
|
||||
return scan_result("older-generation")
|
||||
return scan_result("newer-generation")
|
||||
|
||||
async def scenario() -> dict[str, Any]:
|
||||
monkeypatch.setattr(facade_module, "scan", overlapping_scan)
|
||||
older_task = asyncio.create_task(service.scan_ble(6.0))
|
||||
await asyncio.wait_for(first_entered.wait(), timeout=1.0)
|
||||
newer_state = await service.scan_ble(6.0)
|
||||
release_first.set()
|
||||
await asyncio.wait_for(older_task, timeout=1.0)
|
||||
return newer_state
|
||||
|
||||
newer_state = asyncio.run(scenario())
|
||||
|
||||
assert [item["device_id"] for item in newer_state["devices"]] == ["newer-generation"]
|
||||
assert [item["device_id"] for item in service.state()["devices"]] == [
|
||||
"newer-generation"
|
||||
]
|
||||
|
||||
|
||||
def test_connect_stops_before_ble_write_when_a_new_scan_replaces_its_generation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_k1(service)
|
||||
preflight_entered = threading.Event()
|
||||
release_preflight = threading.Event()
|
||||
|
||||
def delayed_preflight(*_: object, **__: object) -> dict[str, Any]:
|
||||
preflight_entered.set()
|
||||
if not release_preflight.wait(timeout=1.0):
|
||||
raise RuntimeError("test preflight release timed out")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"adapter": "macOS Keychain",
|
||||
"available": True,
|
||||
"profile_enrolled": False,
|
||||
"credential_source": "exact-firmware-profile",
|
||||
}
|
||||
|
||||
async def replacement_scan(_duration_seconds: float) -> dict[str, Any]:
|
||||
return {
|
||||
"devices": [
|
||||
{
|
||||
"macos_uuid": "replacement-device",
|
||||
"name": "XGR-NEW",
|
||||
"local_name": "XGR-NEW",
|
||||
"rssi": -40,
|
||||
"k1_name_candidate": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@asynccontextmanager
|
||||
async def forbidden_activation(
|
||||
*_args: object,
|
||||
**_kwargs: object,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
raise AssertionError("changed discovery generation must stop before BLE write")
|
||||
yield {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
facade_module,
|
||||
"ensure_wifi_profile_from_credential_source",
|
||||
delayed_preflight,
|
||||
)
|
||||
monkeypatch.setattr(facade_module, "scan", replacement_scan)
|
||||
monkeypatch.setattr(facade_module, "device_ap_activation_session", forbidden_activation)
|
||||
|
||||
async def scenario() -> None:
|
||||
connect_task = asyncio.create_task(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="test-ble-transport",
|
||||
connection_mode="quick-connect",
|
||||
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
|
||||
)
|
||||
)
|
||||
)
|
||||
entered = await asyncio.wait_for(
|
||||
asyncio.to_thread(preflight_entered.wait, 1.0),
|
||||
timeout=1.5,
|
||||
)
|
||||
assert entered is True
|
||||
replacement_state = await service.scan_ble(6.0)
|
||||
assert [item["device_id"] for item in replacement_state["devices"]] == [
|
||||
"replacement-device"
|
||||
]
|
||||
release_preflight.set()
|
||||
with pytest.raises(ValueError, match="изменились или устарели"):
|
||||
await asyncio.wait_for(connect_task, timeout=1.0)
|
||||
|
||||
try:
|
||||
asyncio.run(scenario())
|
||||
finally:
|
||||
release_preflight.set()
|
||||
|
||||
|
||||
def test_verify_connection_adopts_scanned_existing_lan_without_device_write(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_k1(service)
|
||||
status_reads: list[tuple[str, float, bool]] = []
|
||||
|
||||
async def fake_status_read(
|
||||
device_id: str,
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
rediscover: bool,
|
||||
) -> dict[str, Any]:
|
||||
status_reads.append((device_id, timeout_seconds, rediscover))
|
||||
return _wifi_status_read("10.255.254.77")
|
||||
|
||||
async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]:
|
||||
raise AssertionError("connection.verify must never call Wi-Fi provisioning")
|
||||
|
||||
monkeypatch.setattr(facade_module, "read_wifi_status_once", fake_status_read)
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision)
|
||||
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: False)
|
||||
monkeypatch.setattr(facade_module, "_host_route_class", lambda _target: "direct-or-routed")
|
||||
monkeypatch.setattr(facade_module, "_control_endpoint_reachable", lambda _target: True)
|
||||
|
||||
state = service.verify_connection(
|
||||
ConnectionVerifyRequest(
|
||||
device_id="test-ble-transport",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
|
||||
assert status_reads == [("test-ble-transport", 20.0, True)]
|
||||
assert state["selected_device_id"] == "test-ble-transport"
|
||||
assert state["k1_ip"] == "10.255.254.77"
|
||||
assert state["connection_mode"] == "bridge"
|
||||
assert state["device_ref"]["transport_alias"] == "test-ble-transport"
|
||||
assert state["device_session"]["connectivity"] == "connected"
|
||||
assert state["compatibility"]["attestation"]["topology"] == "direct-lan"
|
||||
assert state["connection_verification"] == {
|
||||
"status": "adopted",
|
||||
"lease_state": "reachable",
|
||||
"lease_generation": 1,
|
||||
"endpoint_validation": "ble-wifi-status-read+mqtt-tcp-connect",
|
||||
"network_reachability": "reachable",
|
||||
"host_route_class": "direct-or-routed",
|
||||
"address_source": "ble-wifi-status-read",
|
||||
"connection_origin": "external-existing-network",
|
||||
"admission_source": "connection.verify",
|
||||
"address_changed": True,
|
||||
"previous_address_present": False,
|
||||
"write_performed": False,
|
||||
"observed_at": "2026-07-20T12:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
def test_verify_connection_adoption_requires_current_scan_candidate(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
|
||||
async def forbidden_status_read(*_: object, **__: object) -> dict[str, Any]:
|
||||
raise AssertionError("an unscanned device must not be probed")
|
||||
|
||||
async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]:
|
||||
raise AssertionError("connection.verify must never call Wi-Fi provisioning")
|
||||
|
||||
monkeypatch.setattr(facade_module, "read_wifi_status_once", forbidden_status_read)
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision)
|
||||
|
||||
with pytest.raises(ValueError, match="найдите и выберите"):
|
||||
service.verify_connection(
|
||||
ConnectionVerifyRequest(
|
||||
device_id="not-in-current-scan",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ipv4", "is_local", "route_class", "endpoint_reachable", "message"),
|
||||
[
|
||||
(None, False, "direct-or-routed", True, "не сообщил актуальный DHCP-адрес"),
|
||||
("192.168.56.1", False, "device-ap", True, "не сообщил актуальный DHCP-адрес"),
|
||||
("10.255.254.77", True, "direct-or-routed", True, "этому компьютеру"),
|
||||
("10.255.254.77", False, "tunnel", True, "прямой локальный маршрут"),
|
||||
("10.255.254.77", False, "default-route", True, "прямой локальный маршрут"),
|
||||
("10.255.254.77", False, "direct-or-routed", False, "1883 недоступен"),
|
||||
],
|
||||
)
|
||||
def test_verify_connection_adoption_fails_closed_before_establishing_lease(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
ipv4: str | None,
|
||||
is_local: bool,
|
||||
route_class: str,
|
||||
endpoint_reachable: bool,
|
||||
message: str,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_k1(service)
|
||||
|
||||
async def fake_status_read(*_: object, **__: object) -> dict[str, Any]:
|
||||
return _wifi_status_read(ipv4)
|
||||
|
||||
async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]:
|
||||
raise AssertionError("connection.verify must never call Wi-Fi provisioning")
|
||||
|
||||
monkeypatch.setattr(facade_module, "read_wifi_status_once", fake_status_read)
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision)
|
||||
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: is_local)
|
||||
monkeypatch.setattr(facade_module, "_host_route_class", lambda _target: route_class)
|
||||
monkeypatch.setattr(
|
||||
facade_module,
|
||||
"_control_endpoint_reachable",
|
||||
lambda _target: endpoint_reachable,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
service.verify_connection(
|
||||
ConnectionVerifyRequest(
|
||||
device_id="test-ble-transport",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
|
||||
state = service.state()
|
||||
assert state["selected_device_id"] is None
|
||||
assert state["k1_ip"] is None
|
||||
assert state["connection_mode"] is None
|
||||
assert state["device_session"] is None
|
||||
assert state["connection_verification"].get("write_performed") is not True
|
||||
|
||||
|
||||
def test_connection_verify_request_requires_exact_bridge_attestation() -> None:
|
||||
with pytest.raises(ValidationError, match="provided together"):
|
||||
ConnectionVerifyRequest(device_id="test-ble-transport")
|
||||
|
||||
with pytest.raises(ValidationError, match="topology=direct-lan"):
|
||||
ConnectionVerifyRequest(
|
||||
device_id="test-ble-transport",
|
||||
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
|
||||
)
|
||||
|
||||
|
||||
def test_verify_connection_refreshes_dynamic_dhcp_address_and_rotates_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
@@ -2273,10 +2652,10 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [ # noqa: SLF001 - deliberate white-box concurrency fixture
|
||||
{"device_id": "k1-a"},
|
||||
{"device_id": "k1-b"},
|
||||
]
|
||||
_set_scanned_devices(
|
||||
service,
|
||||
[{"device_id": "k1-a"}, {"device_id": "k1-b"}],
|
||||
)
|
||||
boundary_calls: list[tuple[str, str, str]] = []
|
||||
|
||||
async def scenario() -> dict[str, Any]:
|
||||
@@ -2346,7 +2725,7 @@ def test_bridge_network_change_retires_terminal_acquisition_and_receiver_error(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
project_name=PROJECT_NAME,
|
||||
@@ -2403,7 +2782,7 @@ def test_bridge_provisioning_reports_host_route_mismatch_without_hiding_device_s
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
|
||||
|
||||
async def fake_provision(*_: object, **__: object) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -2457,7 +2836,7 @@ def test_quick_connect_activates_the_device_ap_then_associates_the_host(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
|
||||
activation_calls: list[str] = []
|
||||
association_calls: list[tuple[Path, str, str]] = []
|
||||
ble_session_open = False
|
||||
@@ -2574,7 +2953,7 @@ def test_direct_connect_reuses_the_reviewed_ble_provisioning_frame(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
|
||||
provisioning_calls: list[tuple[str, str, str]] = []
|
||||
|
||||
async def fake_provision(
|
||||
@@ -2631,7 +3010,7 @@ def test_quick_connect_missing_credential_provider_stops_before_ap_write(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
|
||||
service._selected_device_id = "previous-k1" # noqa: SLF001
|
||||
service._k1_ip = "192.168.1.20" # noqa: SLF001
|
||||
service._connection_mode = "bridge" # noqa: SLF001
|
||||
@@ -2688,7 +3067,7 @@ def test_quick_connect_does_not_start_host_wifi_without_ap_ready_flag(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
|
||||
monkeypatch.setattr(
|
||||
facade_module,
|
||||
"ensure_wifi_profile_from_credential_source",
|
||||
@@ -2749,7 +3128,7 @@ def test_failed_connection_change_revokes_the_previous_route(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
|
||||
service._selected_device_id = "previous-k1" # noqa: SLF001
|
||||
service._k1_ip = "192.168.1.20" # noqa: SLF001
|
||||
service._connection_mode = "bridge" # noqa: SLF001
|
||||
@@ -2819,9 +3198,10 @@ def test_failed_connection_change_revokes_the_previous_route(
|
||||
def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a"}])
|
||||
|
||||
async def fake_provision(*_: object, **__: object) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -2835,7 +3215,10 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision)
|
||||
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: True)
|
||||
|
||||
with pytest.raises(RuntimeError, match="уже принадлежит этому компьютеру"):
|
||||
with (
|
||||
caplog.at_level(logging.ERROR, logger=facade_module.__name__),
|
||||
pytest.raises(RuntimeError, match="уже принадлежит этому компьютеру"),
|
||||
):
|
||||
asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
@@ -2854,6 +3237,19 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||
assert operation["status"] == "failed"
|
||||
assert operation["error"]["safe_to_retry"] is False
|
||||
assert operation["error"]["side_effect_status"] == "unknown"
|
||||
failure_log = next(
|
||||
record
|
||||
for record in caplog.records
|
||||
if getattr(record, "event_code", None) == "k1_network_provision_failed"
|
||||
)
|
||||
assert failure_log.operation_stage == "ble-provisioning-write"
|
||||
assert failure_log.connection_mode == "bridge"
|
||||
assert failure_log.error_code == "RuntimeError"
|
||||
assert failure_log.safe_to_retry is False
|
||||
assert failure_log.side_effect_status == "unknown"
|
||||
assert failure_log.network_change_attempted is True
|
||||
assert PRIMARY_TEST_CREDENTIAL not in caplog.text
|
||||
assert "lab-network" not in caplog.text
|
||||
|
||||
|
||||
def test_provisioning_cannot_switch_device_during_active_acquisition(
|
||||
@@ -2861,7 +3257,7 @@ def test_provisioning_cannot_switch_device_during_active_acquisition(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a"}])
|
||||
service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
project_name=PROJECT_NAME,
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from bleak.exc import BleakDeviceNotFoundError
|
||||
|
||||
import k1link.device_plugins.xgrids_k1.ble.ap_activation as ap_module
|
||||
import k1link.device_plugins.xgrids_k1.ble.scanner as scanner_module
|
||||
from k1link.device_plugins.xgrids_k1.ble.ap_activation import (
|
||||
COMMAND_OFFSET,
|
||||
ENABLE_AP_COMMAND,
|
||||
@@ -33,3 +40,42 @@ def test_ap_control_mode_without_ready_flag_is_not_ready() -> None:
|
||||
|
||||
def test_ap_ready_requires_the_reviewed_byte_51_flag() -> None:
|
||||
assert is_ap_ready_status(_status(reserved=1))
|
||||
|
||||
|
||||
def test_ap_activation_requires_fresh_rediscovery_before_connecting(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
stale_handle = object()
|
||||
rediscovery_calls: list[tuple[str, float]] = []
|
||||
client_calls: list[object] = []
|
||||
|
||||
async def missing_device(address: str, *, timeout: float) -> None:
|
||||
rediscovery_calls.append((address, timeout))
|
||||
return None
|
||||
|
||||
class ForbiddenClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
client_calls.append(device)
|
||||
raise AssertionError("a failed fresh discovery must stop before the BLE write session")
|
||||
|
||||
# Seed the real retained-handle cache so the test fails if the mutating
|
||||
# path ever regresses to discovered_device(...)-first behavior.
|
||||
monkeypatch.setitem(scanner_module._runtime_handles, device_id, stale_handle) # noqa: SLF001
|
||||
monkeypatch.setattr(
|
||||
ap_module.BleakScanner,
|
||||
"find_device_by_address",
|
||||
missing_device,
|
||||
)
|
||||
monkeypatch.setattr(ap_module, "BleakClient", ForbiddenClient)
|
||||
|
||||
with pytest.raises(BleakDeviceNotFoundError):
|
||||
asyncio.run(
|
||||
ap_module.activate_device_ap_once(
|
||||
device_id,
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert rediscovery_calls == [(device_id, 1.0)]
|
||||
assert client_calls == []
|
||||
|
||||
Reference in New Issue
Block a user