Files
NODEDC_MISSION_CORE/tests/test_wifi_provisioning.py
T

490 lines
17 KiB
Python

import asyncio
from collections.abc import Iterator
from types import SimpleNamespace
from typing import Any
import pytest
from bleak.exc import BleakDeviceNotFoundError, BleakGATTProtocolError
import k1link.device_plugins.xgrids_k1.ble.scanner as scanner_module
import k1link.device_plugins.xgrids_k1.ble.wifi_provisioning as wifi_module
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
FRAME_LENGTH,
build_wifi_provisioning_frame,
parse_wifi_status,
provision_wifi_once,
read_wifi_status_once,
)
@pytest.fixture(autouse=True)
def reset_runtime_handle_lease() -> Iterator[None]:
with scanner_module._runtime_handle_lock: # noqa: SLF001
scanner_module._runtime_handles.clear() # noqa: SLF001
scanner_module._runtime_handle_observed_at_monotonic = None # noqa: SLF001
scanner_module._runtime_handle_generation = 0 # noqa: SLF001
yield
with scanner_module._runtime_handle_lock: # noqa: SLF001
scanner_module._runtime_handles.clear() # noqa: SLF001
scanner_module._runtime_handle_observed_at_monotonic = None # noqa: SLF001
scanner_module._runtime_handle_generation = 0 # noqa: SLF001
def _seed_scan_lease(handles: dict[str, object], *, observed_at: float) -> None:
with scanner_module._runtime_handle_lock: # noqa: SLF001
scanner_module._runtime_handles.clear() # noqa: SLF001
scanner_module._runtime_handles.update(handles) # type: ignore[arg-type] # noqa: SLF001
scanner_module._runtime_handle_observed_at_monotonic = observed_at # noqa: SLF001
scanner_module._runtime_handle_generation += 1 # noqa: SLF001
def test_build_wifi_provisioning_frame_layout() -> None:
credential = "x" * 13
frame = build_wifi_provisioning_frame("LabNet", credential)
assert len(frame) == FRAME_LENGTH
assert frame[0] == 6
assert frame[1:7] == b"LabNet"
assert frame[7:33] == bytes(26)
assert frame[33] == 13
assert frame[34:47] == b"x" * 13
assert frame[47:98] == bytes(51)
assert frame[98] == 0
def test_build_wifi_provisioning_frame_uses_utf8_byte_lengths() -> None:
ssid = "Ж" * 4
credential = "я" * 6
frame = build_wifi_provisioning_frame(ssid, credential)
assert frame[0] == len(ssid.encode())
assert frame[33] == len(credential.encode())
@pytest.mark.parametrize(
("ssid", "password", "message"),
[
("", "x" * 8, "SSID must not be empty"),
("network", "", "password must not be empty"),
("x" * 33, "y" * 8, "at most 32 UTF-8 bytes"),
("network", "x" * 65, "at most 64 UTF-8 bytes"),
],
)
def test_build_wifi_provisioning_frame_rejects_invalid_lengths(
ssid: str,
password: str,
message: str,
) -> None:
with pytest.raises(ValueError, match=message):
build_wifi_provisioning_frame(ssid, password)
def test_parse_wifi_status_ap_baseline() -> None:
value = bytearray(54)
value[0] = 7
value[1:8] = b"WIFI_AP"
value[33] = 4
value[34:38] = bytes((192, 168, 56, 1))
value[50] = 1
value[52:54] = b"XX"
assert parse_wifi_status(bytes(value)) == {
"value_length": 54,
"mode": "WIFI_AP",
"ipv4": "192.168.56.1",
"status_code": 1,
"reserved": 0,
"trailer_hex": "5858",
}
def test_parse_wifi_status_rejects_short_frame() -> None:
with pytest.raises(ValueError, match="at least 51 bytes"):
parse_wifi_status(bytes(50))
def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
monkeypatch: pytest.MonkeyPatch,
) -> None:
value = bytearray(54)
value[0] = 11
value[1:12] = b"WIFI_CLIENT"
value[33] = 4
value[34:38] = bytes((10, 255, 254, 77))
value[50] = 1
characteristic = SimpleNamespace(
uuid=wifi_module.STATUS_CHARACTERISTIC_UUID,
service_uuid=wifi_module.SERVICE_UUID,
properties=["read"],
)
service = SimpleNamespace(uuid=wifi_module.SERVICE_UUID)
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:
return characteristic if uuid == wifi_module.STATUS_CHARACTERISTIC_UUID else None
class FakeClient:
def __init__(self, _device: object, **_kwargs: object) -> None:
self.services = FakeServices()
self.name = "XGR-K1"
self.write_calls = 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:
return bytes(value)
async def write_gatt_char(self, *_args: object, **_kwargs: object) -> None:
self.write_calls += 1
raise AssertionError("status refresh must not write a BLE characteristic")
retained_handle = object()
monkeypatch.setattr(
wifi_module,
"discovered_device_selection",
lambda _uuid: SimpleNamespace(
device=retained_handle,
from_fresh_scan=True,
),
)
monkeypatch.setattr(wifi_module, "BleakClient", FakeClient)
result = asyncio.run(read_wifi_status_once("synthetic-corebluetooth-uuid"))
assert result["operation"] == "single_reviewed_wifi_status_read"
assert result["write_performed"] is False
assert result["status"]["ipv4"] == "10.255.254.77"
def test_read_wifi_status_recovery_keeps_fresh_retained_handle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
value = bytearray(54)
value[0] = 11
value[1:12] = b"WIFI_CLIENT"
value[33] = 4
value[34:38] = bytes((10, 255, 254, 77))
value[50] = 1
retained_handle = object()
characteristic = SimpleNamespace(
uuid=wifi_module.STATUS_CHARACTERISTIC_UUID,
service_uuid=wifi_module.SERVICE_UUID,
properties=["read"],
)
service = SimpleNamespace(uuid=wifi_module.SERVICE_UUID)
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:
return characteristic if uuid == wifi_module.STATUS_CHARACTERISTIC_UUID else None
class FakeClient:
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:
return bytes(value)
async def rediscover(*_args: object, **_kwargs: object) -> object:
raise AssertionError("a fresh explicit scan handle must not be discarded")
monkeypatch.setattr(
wifi_module,
"discovered_device_selection",
lambda _uuid: SimpleNamespace(
device=retained_handle,
from_fresh_scan=True,
),
)
monkeypatch.setattr(wifi_module.BleakScanner, "find_device_by_address", rediscover)
monkeypatch.setattr(wifi_module, "BleakClient", FakeClient)
result = asyncio.run(
read_wifi_status_once(
"synthetic-corebluetooth-uuid",
rediscover=True,
)
)
assert result["status"]["ipv4"] == "10.255.254.77"
def test_provisioning_write_uses_retained_handle_without_rediscovery(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device_id = "synthetic-corebluetooth-uuid"
retained_handle = object()
rediscovery_calls: list[tuple[str, float]] = []
client_calls: list[object] = []
class SelectedHandleObserved(RuntimeError):
pass
async def forbidden_rediscovery(address: str, *, timeout: float) -> None:
rediscovery_calls.append((address, timeout))
raise AssertionError("a fresh explicit scan handle must be used directly")
class CapturingClient:
def __init__(self, device: object, **_kwargs: object) -> None:
client_calls.append(device)
raise SelectedHandleObserved
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
_seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
monkeypatch.setattr(
wifi_module.BleakScanner,
"find_device_by_address",
forbidden_rediscovery,
)
monkeypatch.setattr(wifi_module, "BleakClient", CapturingClient)
with pytest.raises(SelectedHandleObserved) as caught:
asyncio.run(
provision_wifi_once(
device_id,
"LabNet",
"synthetic-password",
timeout_seconds=1.0,
)
)
assert rediscovery_calls == []
assert client_calls == [retained_handle]
assert caught.value.operation_stage == "connect" # type: ignore[attr-defined]
assert caught.value.device_write_attempted is False # type: ignore[attr-defined]
assert caught.value.device_write_confirmed is False # type: ignore[attr-defined]
def test_provisioning_write_does_not_fallback_when_fresh_scan_omits_device(
monkeypatch: pytest.MonkeyPatch,
) -> None:
rediscovery_calls: list[tuple[str, float]] = []
async def forbidden_rediscovery(address: str, *, timeout: float) -> None:
rediscovery_calls.append((address, timeout))
return None
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
_seed_scan_lease({}, observed_at=100.0)
monkeypatch.setattr(
wifi_module.BleakScanner,
"find_device_by_address",
forbidden_rediscovery,
)
with pytest.raises(BleakDeviceNotFoundError) as caught:
asyncio.run(
provision_wifi_once(
"not-in-fresh-scan",
"LabNet",
"synthetic-password",
timeout_seconds=1.0,
)
)
assert rediscovery_calls == []
assert caught.value.operation_stage == "resolution" # type: ignore[attr-defined]
assert caught.value.device_write_attempted is False # type: ignore[attr-defined]
assert caught.value.device_write_confirmed is False # type: ignore[attr-defined]
def test_provisioning_write_rediscovery_fallback_after_scan_lease_expires(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device_id = "synthetic-corebluetooth-uuid"
expired_handle = object()
rediscovered_handle = object()
rediscovery_calls: list[tuple[str, float]] = []
client_calls: list[object] = []
clock = [100.0]
class RediscoveredHandleObserved(RuntimeError):
pass
async def rediscover(address: str, *, timeout: float) -> object:
rediscovery_calls.append((address, timeout))
return rediscovered_handle
class CapturingClient:
def __init__(self, device: object, **_kwargs: object) -> None:
client_calls.append(device)
raise RediscoveredHandleObserved
monkeypatch.setattr(scanner_module, "monotonic", lambda: clock[0])
_seed_scan_lease({device_id: expired_handle}, observed_at=clock[0])
clock[0] += scanner_module.BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS + 0.001
monkeypatch.setattr(wifi_module.BleakScanner, "find_device_by_address", rediscover)
monkeypatch.setattr(wifi_module, "BleakClient", CapturingClient)
with pytest.raises(RediscoveredHandleObserved):
asyncio.run(
provision_wifi_once(
device_id,
"LabNet",
"synthetic-password",
timeout_seconds=1.0,
)
)
assert rediscovery_calls == [(device_id, 1.0)]
assert client_calls == [rediscovered_handle]
assert scanner_module.discovered_device(device_id) is None
def test_provisioning_baseline_error_keeps_type_and_adds_safe_gatt_facts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device_id = "synthetic-corebluetooth-uuid"
retained_handle = object()
service = SimpleNamespace(uuid=wifi_module.SERVICE_UUID)
write_characteristic = SimpleNamespace(
uuid=wifi_module.WRITE_CHARACTERISTIC_UUID,
service_uuid=wifi_module.SERVICE_UUID,
properties=["write"],
max_write_without_response_size=512,
)
status_characteristic = SimpleNamespace(
uuid=wifi_module.STATUS_CHARACTERISTIC_UUID,
service_uuid=wifi_module.SERVICE_UUID,
properties=["read"],
)
class FakeServices:
def get_service(self, uuid: str) -> object | None:
return service if uuid == wifi_module.SERVICE_UUID else None
def get_characteristic(self, uuid: str) -> object | None:
if uuid == wifi_module.WRITE_CHARACTERISTIC_UUID:
return write_characteristic
if uuid == wifi_module.STATUS_CHARACTERISTIC_UUID:
return status_characteristic
return None
class FailingBaselineClient:
def __init__(self, device: object, **_kwargs: object) -> None:
assert device is retained_handle
self.services = FakeServices()
self.name = "XGR-K1"
async def __aenter__(self) -> Any:
return self
async def __aexit__(self, *_args: object) -> None:
return None
async def read_gatt_char(self, _characteristic: object) -> bytes:
raise BleakGATTProtocolError(0x0E)
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
_seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
monkeypatch.setattr(wifi_module, "BleakClient", FailingBaselineClient)
with pytest.raises(BleakGATTProtocolError) as caught:
asyncio.run(
provision_wifi_once(
device_id,
"LabNet",
"synthetic-password",
timeout_seconds=1.0,
)
)
error = caught.value
assert error.operation_stage == "baseline-read" # type: ignore[attr-defined]
assert error.device_write_attempted is False # type: ignore[attr-defined]
assert error.device_write_confirmed is False # type: ignore[attr-defined]
assert error.att_error_code == 0x0E # type: ignore[attr-defined]
assert error.att_error_name == "UNLIKELY_ERROR" # type: ignore[attr-defined]
def test_provisioning_status_poll_error_reports_confirmed_write(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device_id = "synthetic-corebluetooth-uuid"
retained_handle = object()
service = SimpleNamespace(uuid=wifi_module.SERVICE_UUID)
write_characteristic = SimpleNamespace(
uuid=wifi_module.WRITE_CHARACTERISTIC_UUID,
service_uuid=wifi_module.SERVICE_UUID,
properties=["write"],
max_write_without_response_size=512,
)
status_characteristic = SimpleNamespace(
uuid=wifi_module.STATUS_CHARACTERISTIC_UUID,
service_uuid=wifi_module.SERVICE_UUID,
properties=["read"],
)
baseline = bytearray(52)
class FakeServices:
def get_service(self, uuid: str) -> object | None:
return service if uuid == wifi_module.SERVICE_UUID else None
def get_characteristic(self, uuid: str) -> object | None:
if uuid == wifi_module.WRITE_CHARACTERISTIC_UUID:
return write_characteristic
if uuid == wifi_module.STATUS_CHARACTERISTIC_UUID:
return status_characteristic
return None
class FailingPollClient:
def __init__(self, device: object, **_kwargs: object) -> None:
assert device is retained_handle
self.services = FakeServices()
self.name = "XGR-K1"
self.is_connected = True
self.read_count = 0
async def __aenter__(self) -> Any:
return self
async def __aexit__(self, *_args: object) -> None:
return None
async def read_gatt_char(self, _characteristic: object) -> bytes:
self.read_count += 1
if self.read_count == 1:
return bytes(baseline)
raise BleakGATTProtocolError(0x12)
async def write_gatt_char(self, *_args: object, **_kwargs: object) -> None:
return None
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
_seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
monkeypatch.setattr(wifi_module, "BleakClient", FailingPollClient)
with pytest.raises(BleakGATTProtocolError) as caught:
asyncio.run(
provision_wifi_once(
device_id,
"LabNet",
"synthetic-password",
timeout_seconds=1.0,
)
)
error = caught.value
assert error.operation_stage == "status-poll" # type: ignore[attr-defined]
assert error.device_write_attempted is True # type: ignore[attr-defined]
assert error.device_write_confirmed is True # type: ignore[attr-defined]
assert error.att_error_code == 0x12 # type: ignore[attr-defined]
assert error.att_error_name == "DATABASE_OUT_OF_SYNC" # type: ignore[attr-defined]