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.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, FRAME_LENGTH, build_ap_activation_frame, is_ap_ready_status, ) from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import WifiStatus @pytest.fixture(autouse=True) def reset_runtime_handle_lease() -> Iterator[None]: with scanner_module._runtime_handle_lock: # noqa: SLF001 scanner_module._runtime_handles.clear() # noqa: SLF001 scanner_module._runtime_handle_observed_at_monotonic = None # noqa: SLF001 scanner_module._runtime_handle_generation = 0 # noqa: SLF001 yield with scanner_module._runtime_handle_lock: # noqa: SLF001 scanner_module._runtime_handles.clear() # noqa: SLF001 scanner_module._runtime_handle_observed_at_monotonic = None # noqa: SLF001 scanner_module._runtime_handle_generation = 0 # noqa: SLF001 def _seed_scan_lease(handles: dict[str, object], *, observed_at: float) -> None: with scanner_module._runtime_handle_lock: # noqa: SLF001 scanner_module._runtime_handles.clear() # noqa: SLF001 scanner_module._runtime_handles.update(handles) # type: ignore[arg-type] # noqa: SLF001 scanner_module._runtime_handle_observed_at_monotonic = observed_at # noqa: SLF001 scanner_module._runtime_handle_generation += 1 # noqa: SLF001 def test_build_ap_activation_frame_matches_reviewed_lixelgo_layout() -> None: frame = build_ap_activation_frame() assert len(frame) == FRAME_LENGTH == 100 assert frame[COMMAND_OFFSET] == ENABLE_AP_COMMAND == 1 assert frame[:COMMAND_OFFSET] == bytes(COMMAND_OFFSET) def _status(*, reserved: int) -> WifiStatus: return { "value_length": 52, "mode": "WIFI_AP", "ipv4": "192.168.56.1", "status_code": 1, "reserved": reserved, "trailer_hex": "", } def test_ap_control_mode_without_ready_flag_is_not_ready() -> None: assert not is_ap_ready_status(_status(reserved=0)) def test_ap_ready_requires_the_reviewed_byte_51_flag() -> None: assert is_ap_ready_status(_status(reserved=1)) def test_ap_activation_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( ap_module.BleakScanner, "find_device_by_address", forbidden_rediscovery, ) monkeypatch.setattr(ap_module, "BleakClient", CapturingClient) with pytest.raises(SelectedHandleObserved) as caught: asyncio.run( ap_module.activate_device_ap_once( device_id, 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_ap_activation_does_not_fallback_when_fresh_scan_omits_device( monkeypatch: pytest.MonkeyPatch, ) -> None: rediscovery_calls: list[tuple[str, float]] = [] async def forbidden_rediscovery(address: str, *, timeout: float) -> None: rediscovery_calls.append((address, timeout)) return None monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0) _seed_scan_lease({}, observed_at=100.0) monkeypatch.setattr( ap_module.BleakScanner, "find_device_by_address", forbidden_rediscovery, ) with pytest.raises(BleakDeviceNotFoundError) as caught: asyncio.run( ap_module.activate_device_ap_once( "not-in-fresh-scan", timeout_seconds=1.0, ) ) assert rediscovery_calls == [] assert caught.value.operation_stage == "resolution" # type: ignore[attr-defined] assert caught.value.device_write_attempted is False # type: ignore[attr-defined] assert caught.value.device_write_confirmed is False # type: ignore[attr-defined] def test_ap_activation_write_error_keeps_type_and_adds_safe_gatt_facts( monkeypatch: pytest.MonkeyPatch, ) -> None: device_id = "synthetic-corebluetooth-uuid" retained_handle = object() service = SimpleNamespace(uuid=ap_module.SERVICE_UUID) write_characteristic = SimpleNamespace( uuid=ap_module.WRITE_CHARACTERISTIC_UUID, service_uuid=ap_module.SERVICE_UUID, properties=["write"], max_write_without_response_size=512, ) status_characteristic = SimpleNamespace( uuid=ap_module.STATUS_CHARACTERISTIC_UUID, service_uuid=ap_module.SERVICE_UUID, properties=["read"], ) baseline = bytearray(52) class FakeServices: def get_service(self, uuid: str) -> object | None: return service if uuid == ap_module.SERVICE_UUID else None def get_characteristic(self, uuid: str) -> object | None: if uuid == ap_module.WRITE_CHARACTERISTIC_UUID: return write_characteristic if uuid == ap_module.STATUS_CHARACTERISTIC_UUID: return status_characteristic return None class FailingWriteClient: def __init__(self, device: object, **_kwargs: object) -> None: assert device is retained_handle self.services = FakeServices() self.name = "XGR-K1" self.is_connected = True async def __aenter__(self) -> Any: return self async def __aexit__(self, *_args: object) -> None: return None async def read_gatt_char(self, _characteristic: object) -> bytes: return bytes(baseline) async def write_gatt_char(self, *_args: object, **_kwargs: object) -> None: raise BleakGATTProtocolError(0x03) monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0) _seed_scan_lease({device_id: retained_handle}, observed_at=100.0) monkeypatch.setattr(ap_module, "BleakClient", FailingWriteClient) with pytest.raises(BleakGATTProtocolError) as caught: asyncio.run( ap_module.activate_device_ap_once( device_id, timeout_seconds=1.0, ) ) error = caught.value assert error.operation_stage == "gatt-write" # type: ignore[attr-defined] assert error.device_write_attempted is True # type: ignore[attr-defined] assert error.device_write_confirmed is False # type: ignore[attr-defined] assert error.att_error_code == 0x03 # type: ignore[attr-defined] assert error.att_error_name == "WRITE_NOT_PERMITTED" # type: ignore[attr-defined]