fix(k1): restore canonical local connection lifecycle
This commit is contained in:
+284
-22
@@ -1,10 +1,12 @@
|
||||
import asyncio
|
||||
from collections.abc import Iterator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from bleak.exc import BleakDeviceNotFoundError
|
||||
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,
|
||||
@@ -15,6 +17,27 @@ from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
@@ -122,7 +145,15 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
|
||||
self.write_calls += 1
|
||||
raise AssertionError("status refresh must not write a BLE characteristic")
|
||||
|
||||
monkeypatch.setattr(wifi_module, "discovered_device", lambda _uuid: object())
|
||||
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"))
|
||||
@@ -132,7 +163,7 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
|
||||
assert result["status"]["ipv4"] == "10.255.254.77"
|
||||
|
||||
|
||||
def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
def test_read_wifi_status_recovery_keeps_fresh_retained_handle(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
value = bytearray(54)
|
||||
@@ -141,8 +172,7 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
value[33] = 4
|
||||
value[34:38] = bytes((10, 255, 254, 77))
|
||||
value[50] = 1
|
||||
stale_handle = object()
|
||||
recovered_handle = object()
|
||||
retained_handle = object()
|
||||
characteristic = SimpleNamespace(
|
||||
uuid=wifi_module.STATUS_CHARACTERISTIC_UUID,
|
||||
service_uuid=wifi_module.SERVICE_UUID,
|
||||
@@ -159,7 +189,7 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
assert device is recovered_handle
|
||||
assert device is retained_handle
|
||||
self.services = FakeServices()
|
||||
self.name = "XGR-K1"
|
||||
|
||||
@@ -173,9 +203,16 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
return bytes(value)
|
||||
|
||||
async def rediscover(*_args: object, **_kwargs: object) -> object:
|
||||
return recovered_handle
|
||||
raise AssertionError("a fresh explicit scan handle must not be discarded")
|
||||
|
||||
monkeypatch.setattr(wifi_module, "discovered_device", lambda _uuid: stale_handle)
|
||||
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)
|
||||
|
||||
@@ -189,39 +226,264 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
assert result["status"]["ipv4"] == "10.255.254.77"
|
||||
|
||||
|
||||
def test_provisioning_write_requires_fresh_rediscovery_before_connecting(
|
||||
def test_provisioning_write_uses_retained_handle_without_rediscovery(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
stale_handle = object()
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
retained_handle = object()
|
||||
rediscovery_calls: list[tuple[str, float]] = []
|
||||
client_calls: list[object] = []
|
||||
|
||||
async def missing_device(address: str, *, timeout: float) -> None:
|
||||
rediscovery_calls.append((address, timeout))
|
||||
return None
|
||||
class SelectedHandleObserved(RuntimeError):
|
||||
pass
|
||||
|
||||
class ForbiddenClient:
|
||||
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 AssertionError("a failed fresh discovery must stop before the BLE write session")
|
||||
raise SelectedHandleObserved
|
||||
|
||||
monkeypatch.setattr(wifi_module, "discovered_device", lambda _uuid: stale_handle)
|
||||
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",
|
||||
missing_device,
|
||||
forbidden_rediscovery,
|
||||
)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", ForbiddenClient)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", CapturingClient)
|
||||
|
||||
with pytest.raises(BleakDeviceNotFoundError):
|
||||
with pytest.raises(SelectedHandleObserved) as caught:
|
||||
asyncio.run(
|
||||
provision_wifi_once(
|
||||
"synthetic-corebluetooth-uuid",
|
||||
device_id,
|
||||
"LabNet",
|
||||
"synthetic-password",
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert rediscovery_calls == [("synthetic-corebluetooth-uuid", 1.0)]
|
||||
assert client_calls == []
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user