diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index a5204d50b68..19df484c4e1 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -364,13 +364,13 @@ class BluetoothManager: async with async_timeout.timeout(START_TIMEOUT): await self.scanner.start() # type: ignore[no-untyped-call] except InvalidMessageError as ex: - self._cancel_device_detected() + self._async_cancel_scanner_callback() _LOGGER.debug("Invalid DBus message received: %s", ex, exc_info=True) raise ConfigEntryNotReady( f"Invalid DBus message received: {ex}; try restarting `dbus`" ) from ex except BrokenPipeError as ex: - self._cancel_device_detected() + self._async_cancel_scanner_callback() _LOGGER.debug("DBus connection broken: %s", ex, exc_info=True) if is_docker_env(): raise ConfigEntryNotReady( @@ -380,7 +380,7 @@ class BluetoothManager: f"DBus connection broken: {ex}; try restarting `bluetooth` and `dbus`" ) from ex except FileNotFoundError as ex: - self._cancel_device_detected() + self._async_cancel_scanner_callback() _LOGGER.debug( "FileNotFoundError while starting bluetooth: %s", ex, exc_info=True ) @@ -392,12 +392,12 @@ class BluetoothManager: f"DBus service not found; make sure the DBus socket is available to Home Assistant: {ex}" ) from ex except asyncio.TimeoutError as ex: - self._cancel_device_detected() + self._async_cancel_scanner_callback() raise ConfigEntryNotReady( f"Timed out starting Bluetooth after {START_TIMEOUT} seconds" ) from ex except BleakError as ex: - self._cancel_device_detected() + self._async_cancel_scanner_callback() _LOGGER.debug("BleakError while starting bluetooth: %s", ex, exc_info=True) raise ConfigEntryNotReady(f"Failed to start Bluetooth: {ex}") from ex self.async_setup_unavailable_tracking() @@ -579,15 +579,20 @@ class BluetoothManager: self._cancel_stop = None await self.async_stop() + @hass_callback + def _async_cancel_scanner_callback(self) -> None: + """Cancel the scanner callback.""" + if self._cancel_device_detected: + self._cancel_device_detected() + self._cancel_device_detected = None + async def async_stop(self) -> None: """Stop bluetooth discovery.""" _LOGGER.debug("Stopping bluetooth discovery") if self._cancel_watchdog: self._cancel_watchdog() self._cancel_watchdog = None - if self._cancel_device_detected: - self._cancel_device_detected() - self._cancel_device_detected = None + self._async_cancel_scanner_callback() if self._cancel_unavailable_tracking: self._cancel_unavailable_tracking() self._cancel_unavailable_tracking = None diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 45babd05748..796b3ffb469 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -165,6 +165,43 @@ async def test_setup_and_retry_adapter_not_yet_available(hass, caplog): await hass.async_block_till_done() +async def test_no_race_during_manual_reload_in_retry_state(hass, caplog): + """Test we can successfully reload when the entry is in a retry state.""" + mock_bt = [] + with patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch( + "homeassistant.components.bluetooth.HaBleakScanner.start", + side_effect=BleakError, + ), patch( + "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt + ): + assert await async_setup_component( + hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} + ) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + entry = hass.config_entries.async_entries(bluetooth.DOMAIN)[0] + + assert "Failed to start Bluetooth" in caplog.text + assert len(bluetooth.async_discovered_service_info(hass)) == 0 + assert entry.state == ConfigEntryState.SETUP_RETRY + + with patch( + "homeassistant.components.bluetooth.HaBleakScanner.start", + ): + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + + with patch( + "homeassistant.components.bluetooth.HaBleakScanner.stop", + ): + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + async def test_calling_async_discovered_devices_no_bluetooth(hass, caplog): """Test we fail gracefully when asking for discovered devices and there is no blueooth.""" mock_bt = [] @@ -868,6 +905,66 @@ async def test_register_callback_by_address( assert service_info.manufacturer_id == 89 +async def test_register_callback_survives_reload( + hass, mock_bleak_scanner_start, enable_bluetooth +): + """Test registering a callback by address survives bluetooth being reloaded.""" + mock_bt = [] + callbacks = [] + + def _fake_subscriber( + service_info: BluetoothServiceInfo, change: BluetoothChange + ) -> None: + """Fake subscriber for the BleakScanner.""" + callbacks.append((service_info, change)) + + with patch( + "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt + ): + assert await async_setup_component( + hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} + ) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + bluetooth.async_register_callback( + hass, + _fake_subscriber, + {"address": "44:44:33:11:23:45"}, + BluetoothScanningMode.ACTIVE, + ) + + assert len(mock_bleak_scanner_start.mock_calls) == 1 + + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = AdvertisementData( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, + ) + + _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + assert len(callbacks) == 1 + service_info: BluetoothServiceInfo = callbacks[0][0] + assert service_info.name == "wohand" + assert service_info.manufacturer == "Nordic Semiconductor ASA" + assert service_info.manufacturer_id == 89 + + entry = hass.config_entries.async_entries(bluetooth.DOMAIN)[0] + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + _get_underlying_scanner()._callback(switchbot_device, switchbot_adv) + assert len(callbacks) == 2 + service_info: BluetoothServiceInfo = callbacks[1][0] + assert service_info.name == "wohand" + assert service_info.manufacturer == "Nordic Semiconductor ASA" + assert service_info.manufacturer_id == 89 + + async def test_process_advertisements_bail_on_good_advertisement( hass: HomeAssistant, mock_bleak_scanner_start, enable_bluetooth ):