211 lines
7.6 KiB
Python
211 lines
7.6 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.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]
|