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]