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