diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index dbb165f7cd9..abdf73f0659 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -123,7 +123,6 @@ class BluetoothManager: tuple[AdvertisementDataCallback, dict[str, set[str]]] ] = [] self._all_history: dict[str, BluetoothServiceInfoBleak] = {} - self._non_connectable_history: dict[str, BluetoothServiceInfoBleak] = {} self._connectable_history: dict[str, BluetoothServiceInfoBleak] = {} self._non_connectable_scanners: list[BaseHaScanner] = [] self._connectable_scanners: list[BaseHaScanner] = [] @@ -157,9 +156,8 @@ class BluetoothManager: service_info.as_dict() for service_info in self._connectable_history.values() ], - "non_connectable_history": [ - service_info.as_dict() - for service_info in self._non_connectable_history.values() + "all_history": [ + service_info.as_dict() for service_info in self._all_history.values() ], "advertisement_tracker": self._advertisement_tracker.async_diagnostics(), } @@ -256,7 +254,6 @@ class BluetoothManager: """Watch for unavailable devices and cleanup state history.""" monotonic_now = MONOTONIC_TIME() connectable_history = self._connectable_history - non_connectable_history = self._non_connectable_history all_history = self._all_history tracker = self._advertisement_tracker intervals = tracker.intervals @@ -280,8 +277,6 @@ class BluetoothManager: if time_since_seen <= advertising_interval: continue - non_connectable_history.pop(address, None) - # The second loop (connectable=False) is responsible for removing # the device from all the interval tracking since it is no longer # available for both connectable and non-connectable @@ -363,8 +358,24 @@ class BluetoothManager: device = service_info.device address = device.address all_history = self._all_history + connectable = service_info.connectable + connectable_history = self._connectable_history source = service_info.source + # This logic is complex due to the many combinations of scanners that are supported. + # + # We need to handle multiple connectable and non-connectable scanners + # and we need to handle the case where a device is connectable on one scanner + # but not on another. + # + # The device may also be connectable only by a scanner that has worse signal strength + # than a non-connectable scanner. + # + # all_history - the history of all advertisements from all scanners with the best + # advertisement from each scanner + # connectable_history - the history of all connectable advertisements from all scanners + # with the best advertisement from each connectable scanner + # if ( (old_service_info := all_history.get(address)) and source != old_service_info.source @@ -372,12 +383,35 @@ class BluetoothManager: old_service_info, service_info ) ): + # If we are rejecting the new advertisement and the device is connectable + # but not in the connectable history or the connectable source is the same + # as the new source, we need to add it to the connectable history + if connectable: + old_connectable_service_info = connectable_history.get(address) + if old_connectable_service_info and ( + # If its the same as the preferred source, we are done + # as we know we prefer the old advertisement + # from the check above + (old_connectable_service_info is old_service_info) + # If the old connectable source is different from the preferred + # source, we need to check it as well to see if we prefer + # the old connectable advertisement + or ( + source != old_connectable_service_info.source + and self._prefer_previous_adv_from_different_source( + old_connectable_service_info, service_info + ) + ) + ): + return + + connectable_history[address] = service_info + return - if connectable := service_info.connectable: - self._connectable_history[address] = service_info - else: - self._non_connectable_history[address] = service_info + if connectable: + connectable_history[address] = service_info + all_history[address] = service_info # Track advertisement intervals to determine when we need to diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index b053a439e00..a8d4d7aa142 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -116,7 +116,7 @@ async def test_diagnostics( "timings": {}, }, "connectable_history": [], - "non_connectable_history": [], + "all_history": [], "scanners": [ { "adapter": "hci0", @@ -239,7 +239,30 @@ async def test_diagnostics_macos( "time": ANY, } ], - "non_connectable_history": [], + "all_history": [ + { + "address": "44:44:33:11:23:45", + "advertisement": [ + "wohand", + {"1": {"__type": "", "repr": "b'\\x01'"}}, + {}, + [], + -127, + -127, + [[]], + ], + "connectable": True, + "manufacturer_data": { + "1": {"__type": "", "repr": "b'\\x01'"} + }, + "name": "wohand", + "rssi": -127, + "service_data": {}, + "service_uuids": [], + "source": "local", + "time": ANY, + } + ], "scanners": [ { "adapter": "Core Bluetooth", diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 3f5fb56539c..c6a65046ef9 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -336,3 +336,51 @@ async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( bluetooth.async_ble_device_from_address(hass, address, True) is switchbot_device_poor_signal ) + + +async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_connectable( + hass, enable_bluetooth +): + """Test we can still get a connectable BLEDevice when the best path is non-connectable. + + In this case the the device is closer to a non-connectable scanner, but the + at least one connectable scanner has the device in range. + """ + + address = "44:44:33:11:23:45" + now = time.monotonic() + switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_adv_good_signal = generate_advertisement_data( + local_name="wohand_good_signal", service_uuids=[], rssi=-60 + ) + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device_good_signal, + switchbot_adv_good_signal, + now, + "hci1", + False, + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address, False) + is switchbot_device_good_signal + ) + assert bluetooth.async_ble_device_from_address(hass, address, True) is None + + switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_adv_poor_signal = generate_advertisement_data( + local_name="wohand_poor_signal", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source_connectable( + hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address, False) + is switchbot_device_good_signal + ) + assert ( + bluetooth.async_ble_device_from_address(hass, address, True) + is switchbot_device_poor_signal + )