diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index fa92ecdba95..e05598242d5 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -369,6 +369,7 @@ class BluetoothManager: all_history = self._all_history connectable = service_info.connectable connectable_history = self._connectable_history + old_connectable_service_info = connectable and connectable_history.get(address) source = service_info.source debug = _LOGGER.isEnabledFor(logging.DEBUG) @@ -399,7 +400,6 @@ class BluetoothManager: # 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 @@ -442,17 +442,24 @@ class BluetoothManager: tracker.async_collect(service_info) # If the advertisement data is the same as the last time we saw it, we - # don't need to do anything else. - if old_service_info and not ( - service_info.manufacturer_data != old_service_info.manufacturer_data - or service_info.service_data != old_service_info.service_data - or service_info.service_uuids != old_service_info.service_uuids - or service_info.name != old_service_info.name + # don't need to do anything else unless its connectable and we are missing + # connectable history for the device so we can make it available again + # after unavailable callbacks. + if ( + # Ensure its not a connectable device missing from connectable history + not (connectable and not old_connectable_service_info) + # Than check if advertisement data is the same + and old_service_info + and not ( + service_info.manufacturer_data != old_service_info.manufacturer_data + or service_info.service_data != old_service_info.service_data + or service_info.service_uuids != old_service_info.service_uuids + or service_info.name != old_service_info.name + ) ): return - is_connectable_by_any_source = address in self._connectable_history - if not connectable and is_connectable_by_any_source: + if not connectable and old_connectable_service_info: # Since we have a connectable path and our BleakClient will # route any connection attempts to the connectable path, we # mark the service_info as connectable so that the callbacks @@ -481,7 +488,7 @@ class BluetoothManager: matched_domains, ) - if is_connectable_by_any_source: + if connectable or old_connectable_service_info: # Bleak callbacks must get a connectable device for callback_filters in self._bleak_callbacks: _dispatch_bleak_callback(*callback_filters, device, advertisement_data) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 6569d4a148b..d0ae23a5226 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1,30 +1,47 @@ """Tests for the Bluetooth integration manager.""" +from datetime import timedelta import time from unittest.mock import patch -from bleak.backends.scanner import BLEDevice +from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory import pytest from homeassistant.components import bluetooth -from homeassistant.components.bluetooth import storage +from homeassistant.components.bluetooth import ( + BaseHaRemoteScanner, + BluetoothChange, + BluetoothScanningMode, + BluetoothServiceInfo, + BluetoothServiceInfoBleak, + HaBluetoothConnector, + async_ble_device_from_address, + async_get_advertisement_callback, + async_scanner_count, + async_track_unavailable, + storage, +) +from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS from homeassistant.components.bluetooth.manager import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.json import json_loads from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from . import ( FakeScanner, + MockBleakClient, + _get_manager, generate_advertisement_data, inject_advertisement_with_source, inject_advertisement_with_time_and_source, inject_advertisement_with_time_and_source_connectable, ) -from tests.common import load_fixture +from tests.common import async_fire_time_changed, load_fixture @pytest.fixture @@ -588,3 +605,172 @@ async def test_switching_adapters_when_one_stop_scanning( ) cancel_hci2() + + +async def test_goes_unavailable_connectable_only_and_recovers( + hass, mock_bluetooth_adapters +): + """Test all connectable scanners go unavailable, and than recover when there is a non-connectable scanner.""" + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + + assert async_scanner_count(hass, connectable=True) == 0 + assert async_scanner_count(hass, connectable=False) == 0 + switchbot_device_connectable = BLEDevice( + "44:44:33:11:23:45", + "wohand", + {}, + rssi=-100, + ) + switchbot_device_non_connectable = BLEDevice( + "44:44:33:11:23:45", + "wohand", + {}, + rssi=-100, + ) + switchbot_device_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], + service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, + manufacturer_data={1: b"\x01"}, + rssi=-100, + ) + callbacks = [] + + def _fake_subscriber( + service_info: BluetoothServiceInfo, + change: BluetoothChange, + ) -> None: + """Fake subscriber for the BleakScanner.""" + callbacks.append((service_info, change)) + + cancel = bluetooth.async_register_callback( + hass, + _fake_subscriber, + {"address": "44:44:33:11:23:45", "connectable": True}, + BluetoothScanningMode.ACTIVE, + ) + + class FakeScanner(BaseHaRemoteScanner): + def inject_advertisement( + self, device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + ) + + new_info_callback = async_get_advertisement_callback(hass) + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + connectable_scanner = FakeScanner( + hass, + "connectable", + "connectable", + new_info_callback, + connector, + True, + ) + unsetup_connectable_scanner = connectable_scanner.async_setup() + cancel_connectable_scanner = _get_manager().async_register_scanner( + connectable_scanner, True + ) + connectable_scanner.inject_advertisement( + switchbot_device_connectable, switchbot_device_adv + ) + assert async_ble_device_from_address(hass, "44:44:33:11:23:45") is not None + assert async_scanner_count(hass, connectable=True) == 1 + assert len(callbacks) == 1 + + assert ( + "44:44:33:11:23:45" + in connectable_scanner.discovered_devices_and_advertisement_data + ) + + not_connectable_scanner = FakeScanner( + hass, + "not_connectable", + "not_connectable", + new_info_callback, + connector, + False, + ) + unsetup_not_connectable_scanner = not_connectable_scanner.async_setup() + cancel_not_connectable_scanner = _get_manager().async_register_scanner( + not_connectable_scanner, False + ) + not_connectable_scanner.inject_advertisement( + switchbot_device_non_connectable, switchbot_device_adv + ) + assert async_scanner_count(hass, connectable=True) == 1 + assert async_scanner_count(hass, connectable=False) == 2 + + assert ( + "44:44:33:11:23:45" + in not_connectable_scanner.discovered_devices_and_advertisement_data + ) + + unavailable_callbacks: list[BluetoothServiceInfoBleak] = [] + + @callback + def _unavailable_callback(service_info: BluetoothServiceInfoBleak) -> None: + """Wrong device unavailable callback.""" + nonlocal unavailable_callbacks + unavailable_callbacks.append(service_info.address) + + cancel_unavailable = async_track_unavailable( + hass, + _unavailable_callback, + switchbot_device_connectable.address, + connectable=True, + ) + + assert async_scanner_count(hass, connectable=True) == 1 + cancel_connectable_scanner() + unsetup_connectable_scanner() + assert async_scanner_count(hass, connectable=True) == 0 + assert async_scanner_count(hass, connectable=False) == 1 + + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + assert "44:44:33:11:23:45" in unavailable_callbacks + cancel_unavailable() + + connectable_scanner_2 = FakeScanner( + hass, + "connectable", + "connectable", + new_info_callback, + connector, + True, + ) + unsetup_connectable_scanner_2 = connectable_scanner_2.async_setup() + cancel_connectable_scanner_2 = _get_manager().async_register_scanner( + connectable_scanner, True + ) + connectable_scanner_2.inject_advertisement( + switchbot_device_connectable, switchbot_device_adv + ) + assert ( + "44:44:33:11:23:45" + in connectable_scanner_2.discovered_devices_and_advertisement_data + ) + + # We should get another callback to make the device available again + assert len(callbacks) == 2 + + cancel() + cancel_connectable_scanner_2() + unsetup_connectable_scanner_2() + cancel_not_connectable_scanner() + unsetup_not_connectable_scanner()