490 lines
17 KiB
Python
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]
|