diff --git a/src/habluetooth/storage.py b/src/habluetooth/storage.py index 0080a7fe..f6022342 100644 --- a/src/habluetooth/storage.py +++ b/src/habluetooth/storage.py @@ -95,11 +95,24 @@ def expire_stale_scanner_discovered_device_advertisement_data( expired_scanners: list[str] = [] for scanner, data in data_by_scanner.items(): expire: list[str] = [] - expire_seconds = data[EXPIRE_SECONDS] - timestamps = data[DISCOVERED_DEVICE_TIMESTAMPS] - discovered_device_advertisement_datas = data[ - DISCOVERED_DEVICE_ADVERTISEMENT_DATAS - ] + try: + expire_seconds = data[EXPIRE_SECONDS] + timestamps = data[DISCOVERED_DEVICE_TIMESTAMPS] + discovered_device_advertisement_datas = data[ + DISCOVERED_DEVICE_ADVERTISEMENT_DATAS + ] + except (KeyError, TypeError): + # A corrupt/partial blob for one scanner may be missing required + # top-level keys (or not be a mapping at all). Drop just that + # scanner and keep going rather than aborting expiry for every + # other scanner — this mirrors the discard-and-rebuild strategy + # ``discovered_device_advertisement_data_from_dict`` already uses + # for malformed cache data. + _LOGGER.warning( + "Discarding malformed discovery cache for scanner %s", scanner + ) + expired_scanners.append(scanner) + continue discovered_device_raw = data.get(DISCOVERED_DEVICE_RAW, {}) for address, timestamp in timestamps.items(): time_diff = now - timestamp @@ -116,8 +129,12 @@ def expire_stale_scanner_discovered_device_advertisement_data( ) expire.append(address) for address in expire: + # ``timestamps`` drives expiry, so its key is always present, but a + # divergent/corrupt blob may have an address here that is missing + # from the companion dicts. Use ``pop`` with a default so one stale + # entry can never raise ``KeyError`` and abort the whole load. del timestamps[address] - del discovered_device_advertisement_datas[address] + discovered_device_advertisement_datas.pop(address, None) discovered_device_raw.pop(address, None) if not timestamps: expired_scanners.append(scanner) diff --git a/tests/test_storage.py b/tests/test_storage.py index b5d8cfe1..c19b26ef 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -340,6 +340,112 @@ def test_expire_future_discovered_device_advertisement_data( ) +def test_expire_stale_scanner_with_divergent_dicts(): + """ + Expire a timestamp with no matching ad/raw entry without raising KeyError. + + The timestamps dict drives expiry; if a corrupt blob has an address there + that is missing from the companion dicts, expiry must drop the entry rather + than abort the whole load. + """ + now = time.time() + data = { + "myscanner": DiscoveredDeviceAdvertisementDataDict( + { + "connectable": True, + "discovered_device_advertisement_datas": { + "AA:BB:CC:DD:EE:FF": { + "advertisement_data": { + "local_name": "Test Device", + "manufacturer_data": {"76": "0215aabbccddeeff"}, + "rssi": -50, + "service_data": { + "0000180d-0000-1000-8000-00805f9b34fb": "00000000" + }, + "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], + "tx_power": 50, + "platform_data": ["Test Device", ""], + }, + "device": { + "address": "AA:BB:CC:DD:EE:FF", + "details": {"details": "test"}, + "name": "Test Device", + }, # type: ignore[typeddict-item] + }, + }, + "discovered_device_raw": {}, + # "CC:DD:EE:FF:AA:BB" is stale and present only in timestamps — + # the ad-datas dict above has no matching entry. + "discovered_device_timestamps": { + "AA:BB:CC:DD:EE:FF": now, + "CC:DD:EE:FF:AA:BB": now - 101, + }, + "expire_seconds": 100, + } + ), + } + # Must not raise KeyError despite the divergent dicts. + expire_stale_scanner_discovered_device_advertisement_data(data) + assert "myscanner" in data + timestamps = data["myscanner"]["discovered_device_timestamps"] + assert "CC:DD:EE:FF:AA:BB" not in timestamps + assert "AA:BB:CC:DD:EE:FF" in timestamps + assert len(data["myscanner"]["discovered_device_advertisement_datas"]) == 1 + + +def test_expire_stale_scanner_with_missing_keys(caplog): + """ + A scanner blob missing required top-level keys is dropped, not fatal. + + ``expire_stale...`` runs across every scanner before the cache is handed + to ``..._from_dict``. A single corrupt/partial scanner blob (missing + ``expire_seconds``/``timestamps``/``ad-datas``) must not raise ``KeyError`` + and abort expiry for the healthy scanners; the bad scanner is discarded + and the good one survives. + """ + now = time.time() + good_scanner = DiscoveredDeviceAdvertisementDataDict( + { + "connectable": True, + "discovered_device_advertisement_datas": { + "AA:BB:CC:DD:EE:FF": { + "advertisement_data": { + "local_name": "Test Device", + "manufacturer_data": {"76": "0215aabbccddeeff"}, + "rssi": -50, + "service_data": { + "0000180d-0000-1000-8000-00805f9b34fb": "00000000" + }, + "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], + "tx_power": 50, + "platform_data": ["Test Device", ""], + }, + "device": { + "address": "AA:BB:CC:DD:EE:FF", + "details": {"details": "test"}, + "name": "Test Device", + }, # type: ignore[typeddict-item] + }, + }, + "discovered_device_raw": {}, + "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now}, + "expire_seconds": 100, + } + ) + data: dict[str, DiscoveredDeviceAdvertisementDataDict] = { + # Missing "discovered_device_timestamps" (and others) entirely. + "badscanner": {"connectable": True}, # type: ignore[typeddict-item] + "goodscanner": good_scanner, + } + # Must not raise despite the malformed scanner blob. + expire_stale_scanner_discovered_device_advertisement_data(data) + # The malformed scanner is dropped, the healthy one is preserved intact. + assert "badscanner" not in data + assert "goodscanner" in data + assert "AA:BB:CC:DD:EE:FF" in data["goodscanner"]["discovered_device_timestamps"] + assert "Discarding malformed discovery cache for scanner badscanner" in caplog.text + + def test_discovered_device_advertisement_data_from_dict_corrupt(caplog): """Shape mismatches log a WARNING and discard the cache without a traceback.""" now = time.time()