Files
NODEDC_MISSION_CORE/tests/test_bluez_gatt_selection.py
T

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