Files
NODEDC_MISSION_CORE/tests/test_xgrids_ap_activation.py
T

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]