225 lines
7.9 KiB
Python
225 lines
7.9 KiB
Python
import asyncio
|
|
from collections.abc import Iterator
|
|
|
|
import pytest
|
|
from bleak.backends.device import BLEDevice
|
|
from bleak.backends.scanner import AdvertisementData
|
|
from pytest import MonkeyPatch
|
|
|
|
import k1link.device_plugins.xgrids_k1.ble.scanner as scanner_module
|
|
from k1link.device_plugins.xgrids_k1.ble.scanner import (
|
|
advertisement_record,
|
|
discovered_device,
|
|
discovered_device_selection,
|
|
scan,
|
|
)
|
|
|
|
|
|
@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 test_advertisement_record_marks_k1_candidate() -> None:
|
|
device = BLEDevice("TEST-UUID", "Unknown", details=None)
|
|
advertisement = AdvertisementData(
|
|
local_name="Lixel-K1-1234",
|
|
manufacturer_data={123: b"\x01\x02"},
|
|
service_data={"service": b"\xaa"},
|
|
service_uuids=["B", "a"],
|
|
tx_power=-4,
|
|
rssi=-52,
|
|
platform_data=(),
|
|
)
|
|
record = advertisement_record(device, advertisement)
|
|
assert record["k1_name_candidate"] is True
|
|
assert record["id_kind"] == "corebluetooth_uuid"
|
|
assert record["manufacturer_data_hex"] == {"123": "0102"}
|
|
assert record["service_data_hex"] == {"service": "aa"}
|
|
assert record["service_uuids"] == ["B", "a"]
|
|
|
|
|
|
def test_advertisement_record_marks_synthetic_xgr_name_as_k1_candidate() -> None:
|
|
device = BLEDevice("00000000-0000-0000-0000-000000000001", "XGR-TEST01", None)
|
|
advertisement = AdvertisementData(
|
|
local_name="XGR-TEST01",
|
|
manufacturer_data={},
|
|
service_data={},
|
|
service_uuids=[],
|
|
tx_power=0,
|
|
rssi=-60,
|
|
platform_data=(),
|
|
)
|
|
|
|
record = advertisement_record(device, advertisement)
|
|
assert record["k1_name_candidate"] is True
|
|
|
|
|
|
def test_scan_retains_the_live_corebluetooth_handle(
|
|
monkeypatch: MonkeyPatch,
|
|
) -> None:
|
|
device = BLEDevice("LIVE-UUID", "XGR-LIVE", details=object())
|
|
advertisement = AdvertisementData(
|
|
local_name="XGR-LIVE",
|
|
manufacturer_data={},
|
|
service_data={},
|
|
service_uuids=[],
|
|
tx_power=0,
|
|
rssi=-41,
|
|
platform_data=(),
|
|
)
|
|
|
|
async def fake_discover(**_kwargs: object) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
|
|
return {"LIVE-UUID": (device, advertisement)}
|
|
|
|
monkeypatch.setattr(
|
|
"k1link.device_plugins.xgrids_k1.ble.scanner.BleakScanner.discover",
|
|
fake_discover,
|
|
)
|
|
|
|
result = asyncio.run(scan(1.0))
|
|
|
|
assert result["devices"][0]["macos_uuid"] == "LIVE-UUID"
|
|
assert discovered_device("LIVE-UUID") is device
|
|
assert discovered_device_selection("LIVE-UUID").from_fresh_scan is True
|
|
|
|
|
|
def test_scan_start_invalidates_previous_lease_and_failure_leaves_it_empty(
|
|
monkeypatch: MonkeyPatch,
|
|
) -> None:
|
|
old_device = BLEDevice("OLD-UUID", "XGR-OLD", details=object())
|
|
old_advertisement = AdvertisementData(
|
|
local_name="XGR-OLD",
|
|
manufacturer_data={},
|
|
service_data={},
|
|
service_uuids=[],
|
|
tx_power=0,
|
|
rssi=-41,
|
|
platform_data=(),
|
|
)
|
|
|
|
async def scenario() -> None:
|
|
async def initial_discover(
|
|
**_kwargs: object,
|
|
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
|
|
return {old_device.address: (old_device, old_advertisement)}
|
|
|
|
monkeypatch.setattr(scanner_module.BleakScanner, "discover", initial_discover)
|
|
await scan(1.0)
|
|
assert discovered_device(old_device.address) is old_device
|
|
|
|
failing_scan_started = asyncio.Event()
|
|
release_failing_scan = asyncio.Event()
|
|
|
|
async def failing_discover(**_kwargs: object) -> object:
|
|
failing_scan_started.set()
|
|
await release_failing_scan.wait()
|
|
raise RuntimeError("synthetic BLE scan failure")
|
|
|
|
monkeypatch.setattr(scanner_module.BleakScanner, "discover", failing_discover)
|
|
scan_task = asyncio.create_task(scan(1.0))
|
|
await failing_scan_started.wait()
|
|
|
|
# Starting a new explicit scan revokes the prior generation before I/O.
|
|
assert discovered_device(old_device.address) is None
|
|
assert discovered_device_selection(old_device.address).from_fresh_scan is False
|
|
|
|
release_failing_scan.set()
|
|
with pytest.raises(RuntimeError, match="synthetic BLE scan failure"):
|
|
await scan_task
|
|
|
|
assert discovered_device(old_device.address) is None
|
|
assert discovered_device_selection(old_device.address).from_fresh_scan is False
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
@pytest.mark.parametrize("older_scan_fails", [False, True])
|
|
def test_late_scan_generation_cannot_replace_or_clear_newer_lease(
|
|
monkeypatch: MonkeyPatch,
|
|
older_scan_fails: bool,
|
|
) -> None:
|
|
older_device = BLEDevice("OLDER-UUID", "XGR-OLDER", details=object())
|
|
newer_device = BLEDevice("NEWER-UUID", "XGR-NEWER", details=object())
|
|
older_advertisement = AdvertisementData(
|
|
local_name="XGR-OLDER",
|
|
manufacturer_data={},
|
|
service_data={},
|
|
service_uuids=[],
|
|
tx_power=0,
|
|
rssi=-51,
|
|
platform_data=(),
|
|
)
|
|
newer_advertisement = AdvertisementData(
|
|
local_name="XGR-NEWER",
|
|
manufacturer_data={},
|
|
service_data={},
|
|
service_uuids=[],
|
|
tx_power=0,
|
|
rssi=-31,
|
|
platform_data=(),
|
|
)
|
|
|
|
async def scenario() -> None:
|
|
call_count = 0
|
|
older_scan_started = asyncio.Event()
|
|
release_older_scan = asyncio.Event()
|
|
|
|
async def overlapping_discover(
|
|
**_kwargs: object,
|
|
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
older_scan_started.set()
|
|
await release_older_scan.wait()
|
|
if older_scan_fails:
|
|
raise RuntimeError("late older scan failed")
|
|
return {older_device.address: (older_device, older_advertisement)}
|
|
return {newer_device.address: (newer_device, newer_advertisement)}
|
|
|
|
monkeypatch.setattr(scanner_module.BleakScanner, "discover", overlapping_discover)
|
|
older_task = asyncio.create_task(scan(1.0))
|
|
await older_scan_started.wait()
|
|
|
|
await scan(1.0)
|
|
assert discovered_device(newer_device.address) is newer_device
|
|
assert discovered_device(older_device.address) is None
|
|
|
|
release_older_scan.set()
|
|
if older_scan_fails:
|
|
with pytest.raises(RuntimeError, match="late older scan failed"):
|
|
await older_task
|
|
else:
|
|
await older_task
|
|
|
|
# Neither a late success nor a late failure owns the current lease.
|
|
assert discovered_device(newer_device.address) is newer_device
|
|
assert discovered_device(older_device.address) is None
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_runtime_handle_outlives_operator_candidate_boundary(
|
|
monkeypatch: MonkeyPatch,
|
|
) -> None:
|
|
clock = [100.0]
|
|
handle = BLEDevice("LIVE-UUID", "XGR-K1", details=object())
|
|
monkeypatch.setattr(scanner_module, "monotonic", lambda: clock[0])
|
|
scanner_module._runtime_handles[handle.address] = handle # noqa: SLF001
|
|
scanner_module._runtime_handle_observed_at_monotonic = clock[0] # noqa: SLF001
|
|
|
|
clock[0] += scanner_module.BLE_DISCOVERY_CANDIDATE_LEASE_TTL_SECONDS + 0.001
|
|
assert discovered_device(handle.address) is handle
|
|
|
|
clock[0] = 100.0 + scanner_module.BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS + 0.001
|
|
assert discovered_device(handle.address) is None
|