205 lines
8.4 KiB
Python
205 lines
8.4 KiB
Python
"""Native cache loss between owner selection and one admitted GATT operation."""
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from bleak.backends.device import BLEDevice
|
|
from bleak.exc import BleakDeviceNotFoundError, BleakError
|
|
|
|
from k1link.device_plugins.xgrids_k1.ble import scanner
|
|
from k1link.device_plugins.xgrids_k1.ble import wifi_provisioning as wifi
|
|
from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
|
|
bind_ble_runtime_owner_loop,
|
|
configure_ble_runtime_process_lease,
|
|
reset_ble_runtime_arbiter_for_tests,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_owner(tmp_path):
|
|
scanner.reset_runtime_handles_for_tests()
|
|
reset_ble_runtime_arbiter_for_tests()
|
|
configure_ble_runtime_process_lease(tmp_path)
|
|
yield
|
|
scanner.reset_runtime_handles_for_tests()
|
|
reset_ble_runtime_arbiter_for_tests()
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["read", "provision"])
|
|
@pytest.mark.parametrize("native", [
|
|
"macos", "present", "cache-cleanup-race", "vanished", "absent",
|
|
"wrong-address", "wrong-adapter",
|
|
"vanished-again", "owner-changed", "invalidated", "connect-failed",
|
|
"connect-cancelled", "write-failed",
|
|
])
|
|
def test_selected_transport_survives_native_cache_loss(monkeypatch, operation, native):
|
|
address = "AA:BB:CC:DD:EE:FF"
|
|
path = "/org/bluez/hci7/dev_AA_BB_CC_DD_EE_FF"
|
|
device = BLEDevice(address, "synthetic", {"path": path})
|
|
events = []
|
|
capture = None
|
|
validations = []
|
|
|
|
async def retrieve(selected_address, details):
|
|
assert selected_address == address and details["path"] == path
|
|
events.append("cache")
|
|
present = native in {"present", "cache-cleanup-race"} or (
|
|
events.count("cache") == 2 and native != "vanished-again"
|
|
)
|
|
return device if present else None
|
|
|
|
async def advertise(callback):
|
|
await asyncio.sleep(0)
|
|
events.append("scan")
|
|
assert events.count("scan") == 1
|
|
if native == "absent":
|
|
return None
|
|
if native == "owner-changed":
|
|
monkeypatch.setattr(scanner, "ble_runtime_owner_epoch_for_current_loop", lambda: -1)
|
|
if native == "invalidated":
|
|
scanner.demote_connected_device_handle_after_gatt_failure(capture)
|
|
callback(BLEDevice(
|
|
"AA:BB:CC:DD:EE:00" if native == "wrong-address" else address,
|
|
"synthetic",
|
|
{"path": path.replace("hci7", "hci8") if native == "wrong-adapter" else path},
|
|
), None)
|
|
|
|
service = SimpleNamespace(uuid=wifi.SERVICE_UUID)
|
|
write = SimpleNamespace(
|
|
uuid=wifi.WRITE_CHARACTERISTIC_UUID, service_uuid=wifi.SERVICE_UUID,
|
|
properties=["write"],
|
|
)
|
|
status = SimpleNamespace(
|
|
uuid=wifi.STATUS_CHARACTERISTIC_UUID, service_uuid=wifi.SERVICE_UUID,
|
|
properties=["read"],
|
|
)
|
|
services = SimpleNamespace(
|
|
get_service=lambda uuid: service if uuid == service.uuid else None,
|
|
get_characteristic=lambda uuid: {write.uuid: write, status.uuid: status}.get(uuid),
|
|
)
|
|
|
|
class Client:
|
|
name = "synthetic"
|
|
is_connected = True
|
|
|
|
def __init__(self, selected, **_kwargs):
|
|
# Restore the native path without replacing the owner capture.
|
|
assert selected is device
|
|
self.services = services
|
|
|
|
async def __aenter__(self):
|
|
events.append("connect")
|
|
if native == "cache-cleanup-race":
|
|
await asyncio.sleep(0) # The D-Bus connection yields to BlueZ cleanup.
|
|
if events.count("hold-discovery") <= events.count("release-discovery"):
|
|
raise BleakError("selected device not found before watcher registration")
|
|
if native == "connect-cancelled":
|
|
raise asyncio.CancelledError()
|
|
if native == "connect-failed":
|
|
raise BleakError("synthetic native connection failure")
|
|
return self
|
|
|
|
async def __aexit__(self, *_args):
|
|
events.append("disconnect")
|
|
|
|
async def read_gatt_char(self, characteristic):
|
|
assert characteristic is status
|
|
if native != "macos":
|
|
assert "release-discovery" in events
|
|
events.append("read")
|
|
value = bytearray(52)
|
|
value[0] = 11
|
|
value[1:12] = b"WIFI_CLIENT"
|
|
value[33] = 4
|
|
value[34:38] = bytes((10, 255, 254, 77))
|
|
return value
|
|
|
|
async def write_gatt_char(self, characteristic, _frame, *, response):
|
|
assert characteristic is write and response is True
|
|
assert events[-2:] == ["read", "dispatch"]
|
|
events.append("write")
|
|
if native == "write-failed":
|
|
raise BleakError("synthetic unconfirmed write")
|
|
|
|
monkeypatch.setattr(scanner, "sys", SimpleNamespace(
|
|
platform="darwin" if native == "macos" else "linux",
|
|
))
|
|
monkeypatch.setattr(scanner, "_retrieve_bluez_device", retrieve)
|
|
class Scanner:
|
|
def __init__(self, *, detection_callback, bluez):
|
|
assert bluez == {"adapter": "hci7"}
|
|
self.callback = detection_callback
|
|
self.task = None
|
|
|
|
async def __aenter__(self):
|
|
events.append("hold-discovery")
|
|
if native not in {"present", "cache-cleanup-race"}:
|
|
self.task = asyncio.create_task(advertise(self.callback))
|
|
return self
|
|
|
|
async def __aexit__(self, *_args):
|
|
events.append("release-discovery")
|
|
if self.task:
|
|
await self.task
|
|
|
|
monkeypatch.setattr(scanner, "BleakScanner", Scanner)
|
|
monkeypatch.setattr(wifi, "BleakClient", Client)
|
|
|
|
async def scenario():
|
|
nonlocal capture
|
|
owner_epoch = bind_ble_runtime_owner_loop()
|
|
capture = scanner.CapturedDiscoveredDevice(
|
|
device=device, macos_uuid=address, owner_epoch=owner_epoch,
|
|
)
|
|
scanner.pin_connected_device_handle(capture, device_session_id="synthetic-session")
|
|
generation = scanner._runtime_handle_generation # noqa: SLF001
|
|
kwargs = dict(captured_device=capture, timeout_seconds=1)
|
|
action = (
|
|
wifi.read_wifi_status_once(address, on_gatt_validated=validations.append, **kwargs)
|
|
if operation == "read" else
|
|
wifi.provision_wifi_once(
|
|
address, "SyntheticNet", "x" * 13, write_mode="with_response",
|
|
on_write_dispatch=lambda *_args: events.append("dispatch"), **kwargs,
|
|
)
|
|
)
|
|
fails_before_connect = native in {
|
|
"absent", "wrong-address", "wrong-adapter", "vanished-again",
|
|
"owner-changed", "invalidated",
|
|
}
|
|
if native == "connect-cancelled":
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await action
|
|
assert "read" not in events and "write" not in events
|
|
elif fails_before_connect or native == "connect-failed" or (
|
|
native == "write-failed" and operation == "provision"
|
|
):
|
|
with pytest.raises((BleakError, TimeoutError)) as raised:
|
|
await action
|
|
if fails_before_connect:
|
|
assert isinstance(raised.value, (BleakDeviceNotFoundError, TimeoutError))
|
|
assert "connect" not in events
|
|
if operation == "provision":
|
|
assert raised.value.device_write_attempted is (native == "write-failed")
|
|
assert events.count("write") == (native == "write-failed")
|
|
if fails_before_connect and native not in {"owner-changed", "invalidated"}:
|
|
# A failed discovery has not tested/revoked the original capture.
|
|
assert scanner.captured_device_handle(capture) is device
|
|
else:
|
|
result = await action
|
|
assert result.get("outcome", "lan_address_observed") == "lan_address_observed"
|
|
assert scanner.connected_device_capture(
|
|
address, device_session_id="synthetic-session",
|
|
).device is device
|
|
if operation == "read":
|
|
assert validations == [capture]
|
|
assert events.count("write") == (operation == "provision")
|
|
assert scanner._runtime_handle_generation == generation # noqa: SLF001
|
|
|
|
asyncio.run(scenario())
|
|
assert events.count("scan") == (native not in {"macos", "present", "cache-cleanup-race"})
|
|
assert events.count("hold-discovery") == events.count("release-discovery")
|
|
assert events.count("hold-discovery") == (native != "macos")
|
|
assert events.count("connect") <= 1
|
|
assert events.count("write") <= 1
|