diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 46d1e5e8332..3c7f2393257 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta from enum import Enum import fnmatch import logging +import platform from typing import Final, TypedDict, Union from bleak import BleakError @@ -35,7 +36,7 @@ from homeassistant.loader import ( from . import models from .const import DOMAIN from .models import HaBleakScanner -from .usage import install_multiple_bleak_catcher +from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher _LOGGER = logging.getLogger(__name__) @@ -115,6 +116,15 @@ BluetoothCallback = Callable[ ] +@hass_callback +def async_get_scanner(hass: HomeAssistant) -> HaBleakScanner: + """Return a HaBleakScanner.""" + if DOMAIN not in hass.data: + raise RuntimeError("Bluetooth integration not loaded") + manager: BluetoothManager = hass.data[DOMAIN] + return manager.async_get_scanner() + + @hass_callback def async_discovered_service_info( hass: HomeAssistant, @@ -178,14 +188,62 @@ def async_track_unavailable( return manager.async_track_unavailable(callback, address) +async def _async_has_bluetooth_adapter() -> bool: + """Return if the device has a bluetooth adapter.""" + if platform.system() == "Darwin": # CoreBluetooth is built in on MacOS hardware + return True + if platform.system() == "Windows": # We don't have a good way to detect on windows + return False + from bluetooth_adapters import ( # pylint: disable=import-outside-toplevel + get_bluetooth_adapters, + ) + + return bool(await get_bluetooth_adapters()) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the bluetooth integration.""" integration_matchers = await async_get_bluetooth(hass) - bluetooth_discovery = BluetoothManager( - hass, integration_matchers, BluetoothScanningMode.PASSIVE - ) - await bluetooth_discovery.async_setup() - hass.data[DOMAIN] = bluetooth_discovery + manager = BluetoothManager(hass, integration_matchers) + manager.async_setup() + hass.data[DOMAIN] = manager + # The config entry is responsible for starting the manager + # if its enabled + + if hass.config_entries.async_entries(DOMAIN): + return True + if DOMAIN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={} + ) + ) + elif await _async_has_bluetooth_adapter(): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={}, + ) + ) + return True + + +async def async_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Set up the bluetooth integration from a config entry.""" + manager: BluetoothManager = hass.data[DOMAIN] + await manager.async_start(BluetoothScanningMode.ACTIVE) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Unload a config entry.""" + manager: BluetoothManager = hass.data[DOMAIN] + await manager.async_stop() return True @@ -241,11 +299,9 @@ class BluetoothManager: self, hass: HomeAssistant, integration_matchers: list[BluetoothMatcher], - scanning_mode: BluetoothScanningMode, ) -> None: """Init bluetooth discovery.""" self.hass = hass - self.scanning_mode = scanning_mode self._integration_matchers = integration_matchers self.scanner: HaBleakScanner | None = None self._cancel_device_detected: CALLBACK_TYPE | None = None @@ -258,19 +314,27 @@ class BluetoothManager: # an LRU to avoid memory issues. self._matched: LRU = LRU(MAX_REMEMBER_ADDRESSES) - async def async_setup(self) -> None: + @hass_callback + def async_setup(self) -> None: + """Set up the bluetooth manager.""" + models.HA_BLEAK_SCANNER = self.scanner = HaBleakScanner() + + @hass_callback + def async_get_scanner(self) -> HaBleakScanner: + """Get the scanner.""" + assert self.scanner is not None + return self.scanner + + async def async_start(self, scanning_mode: BluetoothScanningMode) -> None: """Set up BT Discovery.""" + assert self.scanner is not None try: - self.scanner = HaBleakScanner( - scanning_mode=SCANNING_MODE_TO_BLEAK[self.scanning_mode] + self.scanner.async_setup( + scanning_mode=SCANNING_MODE_TO_BLEAK[scanning_mode] ) except (FileNotFoundError, BleakError) as ex: - _LOGGER.warning( - "Could not create bluetooth scanner (is bluetooth present and enabled?): %s", - ex, - ) - return - install_multiple_bleak_catcher(self.scanner) + raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex + install_multiple_bleak_catcher() self.async_setup_unavailable_tracking() # We have to start it right away as some integrations might # need it straight away. @@ -279,8 +343,11 @@ class BluetoothManager: self._cancel_device_detected = self.scanner.async_register_callback( self._device_detected, {} ) + try: + await self.scanner.start() + except (FileNotFoundError, BleakError) as ex: + raise RuntimeError(f"Failed to start Bluetooth: {ex}") from ex self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) - await self.scanner.start() @hass_callback def async_setup_unavailable_tracking(self) -> None: @@ -289,8 +356,8 @@ class BluetoothManager: @hass_callback def _async_check_unavailable(now: datetime) -> None: """Watch for unavailable devices.""" - assert models.HA_BLEAK_SCANNER is not None - scanner = models.HA_BLEAK_SCANNER + scanner = self.scanner + assert scanner is not None history = set(scanner.history) active = {device.address for device in scanner.discovered_devices} disappeared = history.difference(active) @@ -406,8 +473,8 @@ class BluetoothManager: if ( matcher and (address := matcher.get(ADDRESS)) - and models.HA_BLEAK_SCANNER - and (device_adv_data := models.HA_BLEAK_SCANNER.history.get(address)) + and self.scanner + and (device_adv_data := self.scanner.history.get(address)) ): try: callback( @@ -424,31 +491,25 @@ class BluetoothManager: @hass_callback def async_ble_device_from_address(self, address: str) -> BLEDevice | None: """Return the BLEDevice if present.""" - if models.HA_BLEAK_SCANNER and ( - ble_adv := models.HA_BLEAK_SCANNER.history.get(address) - ): + if self.scanner and (ble_adv := self.scanner.history.get(address)): return ble_adv[0] return None @hass_callback def async_address_present(self, address: str) -> bool: """Return if the address is present.""" - return bool( - models.HA_BLEAK_SCANNER and address in models.HA_BLEAK_SCANNER.history - ) + return bool(self.scanner and address in self.scanner.history) @hass_callback def async_discovered_service_info(self) -> list[BluetoothServiceInfoBleak]: """Return if the address is present.""" - if models.HA_BLEAK_SCANNER: - history = models.HA_BLEAK_SCANNER.history - return [ - BluetoothServiceInfoBleak.from_advertisement(*device_adv, SOURCE_LOCAL) - for device_adv in history.values() - ] - return [] + assert self.scanner is not None + return [ + BluetoothServiceInfoBleak.from_advertisement(*device_adv, SOURCE_LOCAL) + for device_adv in self.scanner.history.values() + ] - async def async_stop(self, event: Event) -> None: + async def async_stop(self, event: Event | None = None) -> None: """Stop bluetooth discovery.""" if self._cancel_device_detected: self._cancel_device_detected() @@ -458,4 +519,4 @@ class BluetoothManager: self._cancel_unavailable_tracking = None if self.scanner: await self.scanner.stop() - models.HA_BLEAK_SCANNER = None + uninstall_multiple_bleak_catcher() diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py new file mode 100644 index 00000000000..2193170810f --- /dev/null +++ b/homeassistant/components/bluetooth/config_flow.py @@ -0,0 +1,37 @@ +"""Config flow to configure the Bluetooth integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DOMAIN + + +class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for Bluetooth.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + return await self.async_step_enable_bluetooth() + + async def async_step_enable_bluetooth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user or import.""" + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + + if user_input is not None: + return self.async_create_entry(title=DEFAULT_NAME, data={}) + + return self.async_show_form(step_id="enable_bluetooth") + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle import from configuration.yaml.""" + return await self.async_step_enable_bluetooth(user_input) diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index ca5777ccdc2..1e577f6064a 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -1,3 +1,4 @@ """Constants for the Bluetooth integration.""" DOMAIN = "bluetooth" +DEFAULT_NAME = "Bluetooth" diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 0cc11ee14b3..551bb1c3733 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -4,7 +4,8 @@ "documentation": "https://www.home-assistant.io/integrations/bluetooth", "dependencies": ["websocket_api"], "quality_scale": "internal", - "requirements": ["bleak==0.14.3"], + "requirements": ["bleak==0.14.3", "bluetooth-adapters==0.1.1"], "codeowners": ["@bdraco"], + "config_flow": true, "iot_class": "local_push" } diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index ffb0ad107ec..e1d15c27243 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -48,13 +48,24 @@ def _dispatch_callback( class HaBleakScanner(BleakScanner): # type: ignore[misc] """BleakScanner that cannot be stopped.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: + def __init__( # pylint: disable=super-init-not-called + self, *args: Any, **kwargs: Any + ) -> None: """Initialize the BleakScanner.""" self._callbacks: list[ tuple[AdvertisementDataCallback, dict[str, set[str]]] ] = [] self.history: dict[str, tuple[BLEDevice, AdvertisementData]] = {} - super().__init__(*args, **kwargs) + # Init called later in async_setup if we are enabling the scanner + # since init has side effects that can throw exceptions + self._setup = False + + @hass_callback + def async_setup(self, *args: Any, **kwargs: Any) -> None: + """Deferred setup of the BleakScanner since __init__ has side effects.""" + if not self._setup: + super().__init__(*args, **kwargs) + self._setup = True @hass_callback def async_register_callback( diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 925e9c512cc..328a001ad96 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -2,6 +2,9 @@ "config": { "flow_title": "{name}", "step": { + "enable_bluetooth": { + "description": "Do you want to setup Bluetooth?" + }, "user": { "description": "Choose a device to setup", "data": { @@ -11,6 +14,9 @@ "bluetooth_confirm": { "description": "Do you want to setup {name}?" } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } } } diff --git a/homeassistant/components/bluetooth/translations/en.json b/homeassistant/components/bluetooth/translations/en.json index f75dc2603db..85019bdd689 100644 --- a/homeassistant/components/bluetooth/translations/en.json +++ b/homeassistant/components/bluetooth/translations/en.json @@ -1,10 +1,16 @@ { "config": { + "abort": { + "already_configured": "Service is already configured" + }, "flow_title": "{name}", "step": { "bluetooth_confirm": { "description": "Do you want to setup {name}?" }, + "enable_bluetooth": { + "description": "Do you want to setup Bluetooth?" + }, "user": { "data": { "address": "Device" diff --git a/homeassistant/components/bluetooth/usage.py b/homeassistant/components/bluetooth/usage.py index e305576f97f..da5d062a36f 100644 --- a/homeassistant/components/bluetooth/usage.py +++ b/homeassistant/components/bluetooth/usage.py @@ -3,11 +3,16 @@ from __future__ import annotations import bleak -from . import models -from .models import HaBleakScanner, HaBleakScannerWrapper +from .models import HaBleakScannerWrapper + +ORIGINAL_BLEAK_SCANNER = bleak.BleakScanner -def install_multiple_bleak_catcher(hass_bleak_scanner: HaBleakScanner) -> None: +def install_multiple_bleak_catcher() -> None: """Wrap the bleak classes to return the shared instance if multiple instances are detected.""" - models.HA_BLEAK_SCANNER = hass_bleak_scanner bleak.BleakScanner = HaBleakScannerWrapper + + +def uninstall_multiple_bleak_catcher() -> None: + """Unwrap the bleak classes.""" + bleak.BleakScanner = ORIGINAL_BLEAK_SCANNER diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 1742092cc70..3cb9e60a278 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -5,6 +5,7 @@ "dependencies": [ "application_credentials", "automation", + "bluetooth", "cloud", "counter", "dhcp", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 33e34035b34..2c7732fee8d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -47,6 +47,7 @@ FLOWS = { "balboa", "blebox", "blink", + "bluetooth", "bmw_connected_drive", "bond", "bosch_shc", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d787cec62f2..ad640ff596b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,6 +10,8 @@ atomicwrites-homeassistant==1.4.1 attrs==21.2.0 awesomeversion==22.6.0 bcrypt==3.1.7 +bleak==0.14.3 +bluetooth-adapters==0.1.1 certifi>=2021.5.30 ciso8601==2.2.0 cryptography==36.0.2 diff --git a/requirements_all.txt b/requirements_all.txt index c17e031f07a..15c62c63c2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -424,6 +424,9 @@ blockchain==1.4.4 # homeassistant.components.zengge # bluepy==1.3.0 +# homeassistant.components.bluetooth +bluetooth-adapters==0.1.1 + # homeassistant.components.bond bond-async==0.1.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8745afe1e64..742d6eb0f3a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -334,6 +334,9 @@ blebox_uniapi==2.0.2 # homeassistant.components.blink blinkpy==0.19.0 +# homeassistant.components.bluetooth +bluetooth-adapters==0.1.1 + # homeassistant.components.bond bond-async==0.1.22 diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py new file mode 100644 index 00000000000..550f5a583d7 --- /dev/null +++ b/tests/components/bluetooth/test_config_flow.py @@ -0,0 +1,106 @@ +"""Test the bluetooth config flow.""" + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.bluetooth.const import DOMAIN +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_async_step_user(hass): + """Test setting up manually.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "enable_bluetooth" + with patch( + "homeassistant.components.bluetooth.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Bluetooth" + assert result2["data"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_async_step_user_only_allows_one(hass): + """Test setting up manually with an existing entry.""" + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_async_step_integration_discovery(hass): + """Test setting up from integration discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "enable_bluetooth" + with patch( + "homeassistant.components.bluetooth.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Bluetooth" + assert result2["data"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_async_step_integration_discovery_already_exists(hass): + """Test setting up from integration discovery when an entry already exists.""" + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_async_step_import(hass): + """Test setting up from integration discovery.""" + with patch( + "homeassistant.components.bluetooth.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Bluetooth" + assert result["data"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_async_step_import_already_exists(hass): + """Test setting up from yaml when an entry already exists.""" + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index e76ad559305..8aef5f3ddbb 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice +import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -11,6 +12,7 @@ from homeassistant.components.bluetooth import ( UNAVAILABLE_TRACK_SECONDS, BluetoothChange, BluetoothServiceInfo, + async_get_scanner, async_track_unavailable, models, ) @@ -19,10 +21,10 @@ from homeassistant.core import callback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed -async def test_setup_and_stop(hass, mock_bleak_scanner_start): +async def test_setup_and_stop(hass, mock_bleak_scanner_start, enable_bluetooth): """Test we and setup and stop the scanner.""" mock_bt = [ {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} @@ -47,33 +49,57 @@ async def test_setup_and_stop_no_bluetooth(hass, caplog): {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} ] with patch( - "homeassistant.components.bluetooth.HaBleakScanner", side_effect=BleakError + "homeassistant.components.bluetooth.HaBleakScanner.async_setup", + side_effect=BleakError, ) as mock_ha_bleak_scanner, patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt - ), patch.object( - hass.config_entries.flow, "async_init" ): 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() hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() assert len(mock_ha_bleak_scanner.mock_calls) == 1 - assert "Could not create bluetooth scanner" in caplog.text + assert "Failed to initialize Bluetooth" in caplog.text + + +async def test_setup_and_stop_broken_bluetooth(hass, caplog): + """Test we fail gracefully when bluetooth/dbus is broken.""" + mock_bt = [ + {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} + ] + + 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() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert "Failed to start Bluetooth" in caplog.text + assert len(bluetooth.async_discovered_service_info(hass)) == 0 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 = [] with patch( - "homeassistant.components.bluetooth.HaBleakScanner", side_effect=BleakError - ) as mock_ha_bleak_scanner, patch( + "homeassistant.components.bluetooth.HaBleakScanner.async_setup", + side_effect=FileNotFoundError, + ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt - ), patch.object( - hass.config_entries.flow, "async_init" ): assert await async_setup_component( hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} @@ -83,13 +109,14 @@ async def test_calling_async_discovered_devices_no_bluetooth(hass, caplog): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - assert len(mock_ha_bleak_scanner.mock_calls) == 1 - assert "Could not create bluetooth scanner" in caplog.text + assert "Failed to initialize Bluetooth" in caplog.text assert not bluetooth.async_discovered_service_info(hass) assert not bluetooth.async_address_present(hass, "aa:bb:bb:dd:ee:ff") -async def test_discovery_match_by_service_uuid(hass, mock_bleak_scanner_start): +async def test_discovery_match_by_service_uuid( + hass, mock_bleak_scanner_start, enable_bluetooth +): """Test bluetooth discovery match by service_uuid.""" mock_bt = [ {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} @@ -108,7 +135,7 @@ async def test_discovery_match_by_service_uuid(hass, mock_bleak_scanner_start): wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) - models.HA_BLEAK_SCANNER._callback(wrong_device, wrong_adv) + async_get_scanner(hass)._callback(wrong_device, wrong_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 @@ -118,7 +145,7 @@ async def test_discovery_match_by_service_uuid(hass, mock_bleak_scanner_start): local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) - models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) + async_get_scanner(hass)._callback(switchbot_device, switchbot_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 @@ -130,10 +157,13 @@ async def test_discovery_match_by_local_name(hass, mock_bleak_scanner_start): mock_bt = [{"domain": "switchbot", "local_name": "wohand"}] with patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt - ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: + ): assert await async_setup_component( hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} ) + await hass.async_block_till_done() + + with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -142,7 +172,7 @@ async def test_discovery_match_by_local_name(hass, mock_bleak_scanner_start): wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) - models.HA_BLEAK_SCANNER._callback(wrong_device, wrong_adv) + async_get_scanner(hass)._callback(wrong_device, wrong_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 @@ -150,7 +180,7 @@ async def test_discovery_match_by_local_name(hass, mock_bleak_scanner_start): switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) - models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) + async_get_scanner(hass)._callback(switchbot_device, switchbot_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 @@ -170,10 +200,13 @@ async def test_discovery_match_by_manufacturer_id_and_first_byte( ] with patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt - ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: + ): assert await async_setup_component( hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} ) + await hass.async_block_till_done() + + with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -186,7 +219,7 @@ async def test_discovery_match_by_manufacturer_id_and_first_byte( manufacturer_data={76: b"\x06\x02\x03\x99"}, ) - models.HA_BLEAK_SCANNER._callback(hkc_device, hkc_adv) + async_get_scanner(hass)._callback(hkc_device, hkc_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 @@ -194,7 +227,7 @@ async def test_discovery_match_by_manufacturer_id_and_first_byte( mock_config_flow.reset_mock() # 2nd discovery should not generate another flow - models.HA_BLEAK_SCANNER._callback(hkc_device, hkc_adv) + async_get_scanner(hass)._callback(hkc_device, hkc_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 @@ -205,7 +238,7 @@ async def test_discovery_match_by_manufacturer_id_and_first_byte( local_name="lock", service_uuids=[], manufacturer_data={76: b"\x02"} ) - models.HA_BLEAK_SCANNER._callback(not_hkc_device, not_hkc_adv) + async_get_scanner(hass)._callback(not_hkc_device, not_hkc_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 @@ -214,14 +247,14 @@ async def test_discovery_match_by_manufacturer_id_and_first_byte( local_name="lock", service_uuids=[], manufacturer_data={21: b"\x02"} ) - models.HA_BLEAK_SCANNER._callback(not_apple_device, not_apple_adv) + async_get_scanner(hass)._callback(not_apple_device, not_apple_adv) await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 async def test_async_discovered_device_api(hass, mock_bleak_scanner_start): - """Test the async_discovered_device_api.""" + """Test the async_discovered_device API.""" mock_bt = [] with patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -231,84 +264,86 @@ async def test_async_discovered_device_api(hass, mock_bleak_scanner_start): ): assert not bluetooth.async_discovered_service_info(hass) assert not bluetooth.async_address_present(hass, "44:44:22:22:11:22") - assert await async_setup_component( hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} ) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert len(mock_bleak_scanner_start.mock_calls) == 1 + with patch.object(hass.config_entries.flow, "async_init"): + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() - assert not bluetooth.async_discovered_service_info(hass) + assert len(mock_bleak_scanner_start.mock_calls) == 1 - wrong_device = BLEDevice("44:44:33:11:23:42", "wrong_name") - wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) - models.HA_BLEAK_SCANNER._callback(wrong_device, wrong_adv) - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") - switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) - models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) - wrong_device_went_unavailable = False - switchbot_device_went_unavailable = False + assert not bluetooth.async_discovered_service_info(hass) - @callback - def _wrong_device_unavailable_callback(_address: str) -> None: - """Wrong device unavailable callback.""" - nonlocal wrong_device_went_unavailable - wrong_device_went_unavailable = True - raise ValueError("blow up") + wrong_device = BLEDevice("44:44:33:11:23:42", "wrong_name") + wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) + async_get_scanner(hass)._callback(wrong_device, wrong_adv) + switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) + async_get_scanner(hass)._callback(switchbot_device, switchbot_adv) + wrong_device_went_unavailable = False + switchbot_device_went_unavailable = False - @callback - def _switchbot_device_unavailable_callback(_address: str) -> None: - """Switchbot device unavailable callback.""" - nonlocal switchbot_device_went_unavailable - switchbot_device_went_unavailable = True + @callback + def _wrong_device_unavailable_callback(_address: str) -> None: + """Wrong device unavailable callback.""" + nonlocal wrong_device_went_unavailable + wrong_device_went_unavailable = True + raise ValueError("blow up") - wrong_device_unavailable_cancel = async_track_unavailable( - hass, _wrong_device_unavailable_callback, wrong_device.address - ) - switchbot_device_unavailable_cancel = async_track_unavailable( - hass, _switchbot_device_unavailable_callback, switchbot_device.address - ) + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) - ) - await hass.async_block_till_done() + wrong_device_unavailable_cancel = async_track_unavailable( + hass, _wrong_device_unavailable_callback, wrong_device.address + ) + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, _switchbot_device_unavailable_callback, switchbot_device.address + ) - service_infos = bluetooth.async_discovered_service_info(hass) - assert switchbot_device_went_unavailable is False - assert wrong_device_went_unavailable is True + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() - # See the devices again - models.HA_BLEAK_SCANNER._callback(wrong_device, wrong_adv) - models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) - # Cancel the callbacks - wrong_device_unavailable_cancel() - switchbot_device_unavailable_cancel() - wrong_device_went_unavailable = False - switchbot_device_went_unavailable = False + service_infos = bluetooth.async_discovered_service_info(hass) + assert switchbot_device_went_unavailable is False + assert wrong_device_went_unavailable is True - # Verify the cancel is effective - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) - ) - await hass.async_block_till_done() - assert switchbot_device_went_unavailable is False - assert wrong_device_went_unavailable is False + # See the devices again + async_get_scanner(hass)._callback(wrong_device, wrong_adv) + async_get_scanner(hass)._callback(switchbot_device, switchbot_adv) + # Cancel the callbacks + wrong_device_unavailable_cancel() + switchbot_device_unavailable_cancel() + wrong_device_went_unavailable = False + switchbot_device_went_unavailable = False - assert len(service_infos) == 1 - # wrong_name should not appear because bleak no longer sees it - assert service_infos[0].name == "wohand" - assert service_infos[0].source == SOURCE_LOCAL - assert isinstance(service_infos[0].device, BLEDevice) - assert isinstance(service_infos[0].advertisement, AdvertisementData) + # Verify the cancel is effective + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + assert switchbot_device_went_unavailable is False + assert wrong_device_went_unavailable is False - assert bluetooth.async_address_present(hass, "44:44:33:11:23:42") is False - assert bluetooth.async_address_present(hass, "44:44:33:11:23:45") is True + assert len(service_infos) == 1 + # wrong_name should not appear because bleak no longer sees it + assert service_infos[0].name == "wohand" + assert service_infos[0].source == SOURCE_LOCAL + assert isinstance(service_infos[0].device, BLEDevice) + assert isinstance(service_infos[0].advertisement, AdvertisementData) + + assert bluetooth.async_address_present(hass, "44:44:33:11:23:42") is False + assert bluetooth.async_address_present(hass, "44:44:33:11:23:45") is True -async def test_register_callbacks(hass, mock_bleak_scanner_start): +async def test_register_callbacks(hass, mock_bleak_scanner_start, enable_bluetooth): """Test registering a callback.""" mock_bt = [] callbacks = [] @@ -347,25 +382,25 @@ async def test_register_callbacks(hass, mock_bleak_scanner_start): service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) + async_get_scanner(hass)._callback(switchbot_device, switchbot_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_adv = AdvertisementData(local_name="empty") - models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) + async_get_scanner(hass)._callback(empty_device, empty_adv) await hass.async_block_till_done() empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_adv = AdvertisementData(local_name="empty") # 3rd callback raises ValueError but is still tracked - models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) + async_get_scanner(hass)._callback(empty_device, empty_adv) await hass.async_block_till_done() cancel() # 4th callback should not be tracked since we canceled - models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) + async_get_scanner(hass)._callback(empty_device, empty_adv) await hass.async_block_till_done() assert len(callbacks) == 3 @@ -389,7 +424,9 @@ async def test_register_callbacks(hass, mock_bleak_scanner_start): assert service_info.manufacturer_id is None -async def test_register_callback_by_address(hass, mock_bleak_scanner_start): +async def test_register_callback_by_address( + hass, mock_bleak_scanner_start, enable_bluetooth +): """Test registering a callback by address.""" mock_bt = [] callbacks = [] @@ -404,10 +441,13 @@ async def test_register_callback_by_address(hass, mock_bleak_scanner_start): with patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt - ), patch.object(hass.config_entries.flow, "async_init"): + ): assert await async_setup_component( hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} ) + await hass.async_block_till_done() + + with patch.object(hass.config_entries.flow, "async_init"): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -427,25 +467,25 @@ async def test_register_callback_by_address(hass, mock_bleak_scanner_start): service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) + async_get_scanner(hass)._callback(switchbot_device, switchbot_adv) empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_adv = AdvertisementData(local_name="empty") - models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) + async_get_scanner(hass)._callback(empty_device, empty_adv) await hass.async_block_till_done() empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_adv = AdvertisementData(local_name="empty") # 3rd callback raises ValueError but is still tracked - models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) + async_get_scanner(hass)._callback(empty_device, empty_adv) await hass.async_block_till_done() cancel() # 4th callback should not be tracked since we canceled - models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) + async_get_scanner(hass)._callback(empty_device, empty_adv) await hass.async_block_till_done() # Now register again with a callback that fails to @@ -475,121 +515,133 @@ async def test_register_callback_by_address(hass, mock_bleak_scanner_start): assert service_info.manufacturer_id == 89 -async def test_wrapped_instance_with_filter(hass, mock_bleak_scanner_start): +async def test_wrapped_instance_with_filter( + hass, mock_bleak_scanner_start, enable_bluetooth +): """Test consumers can use the wrapped instance with a filter as if it was normal BleakScanner.""" with patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=[] - ), patch.object(hass.config_entries.flow, "async_init"): + ): assert await async_setup_component( hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} ) + await hass.async_block_till_done() + + with patch.object(hass.config_entries.flow, "async_init"): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - detected = [] + detected = [] - def _device_detected( - device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Handle a detected device.""" - detected.append((device, advertisement_data)) + def _device_detected( + device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Handle a detected device.""" + detected.append((device, advertisement_data)) - 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"}, - ) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + 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"}, + ) + empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_adv = AdvertisementData(local_name="empty") - assert models.HA_BLEAK_SCANNER is not None - scanner = models.HaBleakScannerWrapper( - filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]} - ) - scanner.register_detection_callback(_device_detected) + assert async_get_scanner(hass) is not None + scanner = models.HaBleakScannerWrapper( + filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]} + ) + scanner.register_detection_callback(_device_detected) - mock_discovered = [MagicMock()] - type(models.HA_BLEAK_SCANNER).discovered_devices = mock_discovered - models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) - await hass.async_block_till_done() + mock_discovered = [MagicMock()] + type(async_get_scanner(hass)).discovered_devices = mock_discovered + async_get_scanner(hass)._callback(switchbot_device, switchbot_adv) + await hass.async_block_till_done() - discovered = await scanner.discover(timeout=0) - assert len(discovered) == 1 - assert discovered == mock_discovered - assert len(detected) == 1 + discovered = await scanner.discover(timeout=0) + assert len(discovered) == 1 + assert discovered == mock_discovered + assert len(detected) == 1 - scanner.register_detection_callback(_device_detected) - # We should get a reply from the history when we register again - assert len(detected) == 2 - scanner.register_detection_callback(_device_detected) - # We should get a reply from the history when we register again - assert len(detected) == 3 + scanner.register_detection_callback(_device_detected) + # We should get a reply from the history when we register again + assert len(detected) == 2 + scanner.register_detection_callback(_device_detected) + # We should get a reply from the history when we register again + assert len(detected) == 3 - type(models.HA_BLEAK_SCANNER).discovered_devices = [] - discovered = await scanner.discover(timeout=0) - assert len(discovered) == 0 - assert discovered == [] + type(async_get_scanner(hass)).discovered_devices = [] + discovered = await scanner.discover(timeout=0) + assert len(discovered) == 0 + assert discovered == [] - models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) - assert len(detected) == 4 + async_get_scanner(hass)._callback(switchbot_device, switchbot_adv) + assert len(detected) == 4 - # The filter we created in the wrapped scanner with should be respected - # and we should not get another callback - models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) - assert len(detected) == 4 + # The filter we created in the wrapped scanner with should be respected + # and we should not get another callback + async_get_scanner(hass)._callback(empty_device, empty_adv) + assert len(detected) == 4 -async def test_wrapped_instance_with_service_uuids(hass, mock_bleak_scanner_start): +async def test_wrapped_instance_with_service_uuids( + hass, mock_bleak_scanner_start, enable_bluetooth +): """Test consumers can use the wrapped instance with a service_uuids list as if it was normal BleakScanner.""" with patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=[] - ), patch.object(hass.config_entries.flow, "async_init"): + ): assert await async_setup_component( hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} ) + await hass.async_block_till_done() + + with patch.object(hass.config_entries.flow, "async_init"): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - detected = [] + detected = [] - def _device_detected( - device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Handle a detected device.""" - detected.append((device, advertisement_data)) + def _device_detected( + device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Handle a detected device.""" + detected.append((device, advertisement_data)) - 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"}, - ) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + 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"}, + ) + empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_adv = AdvertisementData(local_name="empty") - assert models.HA_BLEAK_SCANNER is not None - scanner = models.HaBleakScannerWrapper( - service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] - ) - scanner.register_detection_callback(_device_detected) + assert async_get_scanner(hass) is not None + scanner = models.HaBleakScannerWrapper( + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + scanner.register_detection_callback(_device_detected) - type(models.HA_BLEAK_SCANNER).discovered_devices = [MagicMock()] - for _ in range(2): - models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) - await hass.async_block_till_done() + type(async_get_scanner(hass)).discovered_devices = [MagicMock()] + for _ in range(2): + async_get_scanner(hass)._callback(switchbot_device, switchbot_adv) + await hass.async_block_till_done() - assert len(detected) == 2 + assert len(detected) == 2 - # The UUIDs list we created in the wrapped scanner with should be respected - # and we should not get another callback - models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) - assert len(detected) == 2 + # The UUIDs list we created in the wrapped scanner with should be respected + # and we should not get another callback + async_get_scanner(hass)._callback(empty_device, empty_adv) + assert len(detected) == 2 -async def test_wrapped_instance_with_broken_callbacks(hass, mock_bleak_scanner_start): +async def test_wrapped_instance_with_broken_callbacks( + hass, mock_bleak_scanner_start, enable_bluetooth +): """Test broken callbacks do not cause the scanner to fail.""" with patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=[] @@ -597,158 +649,173 @@ async def test_wrapped_instance_with_broken_callbacks(hass, mock_bleak_scanner_s assert await async_setup_component( hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} ) + await hass.async_block_till_done() + + with patch.object(hass.config_entries.flow, "async_init"): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - detected = [] + detected = [] - def _device_detected( - device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Handle a detected device.""" - if detected: - raise ValueError - detected.append((device, advertisement_data)) + def _device_detected( + device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Handle a detected device.""" + if detected: + raise ValueError + detected.append((device, advertisement_data)) - 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"}, - ) + 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"}, + ) - assert models.HA_BLEAK_SCANNER is not None - scanner = models.HaBleakScannerWrapper( - service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] - ) - scanner.register_detection_callback(_device_detected) + assert async_get_scanner(hass) is not None + scanner = models.HaBleakScannerWrapper( + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + scanner.register_detection_callback(_device_detected) - models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) - await hass.async_block_till_done() - models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) - await hass.async_block_till_done() - assert len(detected) == 1 + async_get_scanner(hass)._callback(switchbot_device, switchbot_adv) + await hass.async_block_till_done() + async_get_scanner(hass)._callback(switchbot_device, switchbot_adv) + await hass.async_block_till_done() + assert len(detected) == 1 -async def test_wrapped_instance_changes_uuids(hass, mock_bleak_scanner_start): +async def test_wrapped_instance_changes_uuids( + hass, mock_bleak_scanner_start, enable_bluetooth +): """Test consumers can use the wrapped instance can change the uuids later.""" with patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=[] - ), patch.object(hass.config_entries.flow, "async_init"): + ): assert await async_setup_component( hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} ) + await hass.async_block_till_done() + + with patch.object(hass.config_entries.flow, "async_init"): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() + detected = [] - detected = [] + def _device_detected( + device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Handle a detected device.""" + detected.append((device, advertisement_data)) - def _device_detected( - device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Handle a detected device.""" - detected.append((device, advertisement_data)) + 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"}, + ) + empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_adv = AdvertisementData(local_name="empty") - 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"}, - ) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") - empty_adv = AdvertisementData(local_name="empty") + assert async_get_scanner(hass) is not None + scanner = models.HaBleakScannerWrapper() + scanner.set_scanning_filter( + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + scanner.register_detection_callback(_device_detected) - assert models.HA_BLEAK_SCANNER is not None - scanner = models.HaBleakScannerWrapper() - scanner.set_scanning_filter(service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]) - scanner.register_detection_callback(_device_detected) + type(async_get_scanner(hass)).discovered_devices = [MagicMock()] + for _ in range(2): + async_get_scanner(hass)._callback(switchbot_device, switchbot_adv) + await hass.async_block_till_done() - type(models.HA_BLEAK_SCANNER).discovered_devices = [MagicMock()] - for _ in range(2): - models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) - await hass.async_block_till_done() + assert len(detected) == 2 - assert len(detected) == 2 - - # The UUIDs list we created in the wrapped scanner with should be respected - # and we should not get another callback - models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) - assert len(detected) == 2 + # The UUIDs list we created in the wrapped scanner with should be respected + # and we should not get another callback + async_get_scanner(hass)._callback(empty_device, empty_adv) + assert len(detected) == 2 -async def test_wrapped_instance_changes_filters(hass, mock_bleak_scanner_start): +async def test_wrapped_instance_changes_filters( + hass, mock_bleak_scanner_start, enable_bluetooth +): """Test consumers can use the wrapped instance can change the filter later.""" with patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=[] - ), patch.object(hass.config_entries.flow, "async_init"): + ): assert await async_setup_component( hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} ) + await hass.async_block_till_done() + + with patch.object(hass.config_entries.flow, "async_init"): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() + detected = [] - detected = [] + def _device_detected( + device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Handle a detected device.""" + detected.append((device, advertisement_data)) - def _device_detected( - device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Handle a detected device.""" - detected.append((device, advertisement_data)) + switchbot_device = BLEDevice("44:44:33:11:23:42", "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"}, + ) + empty_device = BLEDevice("11:22:33:44:55:62", "empty") + empty_adv = AdvertisementData(local_name="empty") - switchbot_device = BLEDevice("44:44:33:11:23:42", "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"}, - ) - empty_device = BLEDevice("11:22:33:44:55:62", "empty") - empty_adv = AdvertisementData(local_name="empty") + assert async_get_scanner(hass) is not None + scanner = models.HaBleakScannerWrapper() + scanner.set_scanning_filter( + filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]} + ) + scanner.register_detection_callback(_device_detected) - assert models.HA_BLEAK_SCANNER is not None - scanner = models.HaBleakScannerWrapper() - scanner.set_scanning_filter( - filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]} - ) - scanner.register_detection_callback(_device_detected) + type(async_get_scanner(hass)).discovered_devices = [MagicMock()] + for _ in range(2): + async_get_scanner(hass)._callback(switchbot_device, switchbot_adv) + await hass.async_block_till_done() - type(models.HA_BLEAK_SCANNER).discovered_devices = [MagicMock()] - for _ in range(2): - models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) - await hass.async_block_till_done() + assert len(detected) == 2 - assert len(detected) == 2 - - # The UUIDs list we created in the wrapped scanner with should be respected - # and we should not get another callback - models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) - assert len(detected) == 2 + # The UUIDs list we created in the wrapped scanner with should be respected + # and we should not get another callback + async_get_scanner(hass)._callback(empty_device, empty_adv) + assert len(detected) == 2 async def test_wrapped_instance_unsupported_filter( - hass, mock_bleak_scanner_start, caplog + hass, mock_bleak_scanner_start, caplog, enable_bluetooth ): """Test we want when their filter is ineffective.""" with patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=[] - ), patch.object(hass.config_entries.flow, "async_init"): + ): assert await async_setup_component( hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} ) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert models.HA_BLEAK_SCANNER is not None - scanner = models.HaBleakScannerWrapper() - scanner.set_scanning_filter( - filters={ - "unsupported": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], - "DuplicateData": True, - } - ) - assert "Only UUIDs filters are supported" in caplog.text + with patch.object(hass.config_entries.flow, "async_init"): + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert async_get_scanner(hass) is not None + scanner = models.HaBleakScannerWrapper() + scanner.set_scanning_filter( + filters={ + "unsupported": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + "DuplicateData": True, + } + ) + assert "Only UUIDs filters are supported" in caplog.text async def test_async_ble_device_from_address(hass, mock_bleak_scanner_start): @@ -778,7 +845,7 @@ async def test_async_ble_device_from_address(hass, mock_bleak_scanner_start): switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) - models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) + async_get_scanner(hass)._callback(switchbot_device, switchbot_adv) await hass.async_block_till_done() assert ( @@ -789,3 +856,82 @@ async def test_async_ble_device_from_address(hass, mock_bleak_scanner_start): assert ( bluetooth.async_ble_device_from_address(hass, "00:66:33:22:11:22") is None ) + + +async def test_setup_without_bluetooth_in_configuration_yaml(hass, mock_bluetooth): + """Test setting up without bluetooth in configuration.yaml does not create the config entry.""" + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(bluetooth.DOMAIN) + + +async def test_setup_with_bluetooth_in_configuration_yaml(hass, mock_bluetooth): + """Test setting up with bluetooth in configuration.yaml creates the config entry.""" + assert await async_setup_component(hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}) + await hass.async_block_till_done() + assert hass.config_entries.async_entries(bluetooth.DOMAIN) + + +async def test_can_unsetup_bluetooth(hass, mock_bleak_scanner_start, enable_bluetooth): + """Test we can setup and unsetup bluetooth.""" + entry = MockConfigEntry(domain=bluetooth.DOMAIN, data={}) + entry.add_to_hass(hass) + for _ in range(2): + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_auto_detect_bluetooth_adapters_linux(hass): + """Test we auto detect bluetooth adapters on linux.""" + with patch( + "bluetooth_adapters.get_bluetooth_adapters", return_value={"hci0"} + ), patch( + "homeassistant.components.bluetooth.platform.system", return_value="Linux" + ): + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(bluetooth.DOMAIN) + assert len(hass.config_entries.flow.async_progress(bluetooth.DOMAIN)) == 1 + + +async def test_auto_detect_bluetooth_adapters_linux_none_found(hass): + """Test we auto detect bluetooth adapters on linux with no adapters found.""" + with patch("bluetooth_adapters.get_bluetooth_adapters", return_value=set()), patch( + "homeassistant.components.bluetooth.platform.system", return_value="Linux" + ): + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(bluetooth.DOMAIN) + assert len(hass.config_entries.flow.async_progress(bluetooth.DOMAIN)) == 0 + + +async def test_auto_detect_bluetooth_adapters_macos(hass): + """Test we auto detect bluetooth adapters on macos.""" + with patch( + "homeassistant.components.bluetooth.platform.system", return_value="Darwin" + ): + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(bluetooth.DOMAIN) + assert len(hass.config_entries.flow.async_progress(bluetooth.DOMAIN)) == 1 + + +async def test_no_auto_detect_bluetooth_adapters_windows(hass): + """Test we auto detect bluetooth adapters on windows.""" + with patch( + "homeassistant.components.bluetooth.platform.system", return_value="Windows" + ): + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(bluetooth.DOMAIN) + assert len(hass.config_entries.flow.async_progress(bluetooth.DOMAIN)) == 0 + + +async def test_raising_runtime_error_when_no_bluetooth(hass): + """Test we raise an exception if we try to get the scanner when its not there.""" + with pytest.raises(RuntimeError): + bluetooth.async_get_scanner(hass) diff --git a/tests/components/bluetooth/test_passive_update_coordinator.py b/tests/components/bluetooth/test_passive_update_coordinator.py index 755b2ec07f8..010989628e1 100644 --- a/tests/components/bluetooth/test_passive_update_coordinator.py +++ b/tests/components/bluetooth/test_passive_update_coordinator.py @@ -12,6 +12,7 @@ from homeassistant.components.bluetooth import ( DOMAIN, UNAVAILABLE_TRACK_SECONDS, BluetoothChange, + async_get_scanner, ) from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothCoordinatorEntity, @@ -207,12 +208,14 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start): saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) assert len(mock_add_entities.mock_calls) == 1 assert coordinator.available is True + scanner = async_get_scanner(hass) with patch( "homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices", [MagicMock(address="44:44:33:11:23:45")], - ), patch( - "homeassistant.components.bluetooth.models.HA_BLEAK_SCANNER.history", + ), patch.object( + scanner, + "history", {"aa:bb:cc:dd:ee:ff": MagicMock()}, ): async_fire_time_changed( @@ -228,8 +231,9 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start): with patch( "homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices", [MagicMock(address="44:44:33:11:23:45")], - ), patch( - "homeassistant.components.bluetooth.models.HA_BLEAK_SCANNER.history", + ), patch.object( + scanner, + "history", {"aa:bb:cc:dd:ee:ff": MagicMock()}, ): async_fire_time_changed( diff --git a/tests/components/bluetooth/test_usage.py b/tests/components/bluetooth/test_usage.py index 92339735340..8e566a7ce5a 100644 --- a/tests/components/bluetooth/test_usage.py +++ b/tests/components/bluetooth/test_usage.py @@ -1,22 +1,25 @@ """Tests for the Bluetooth integration.""" -from unittest.mock import MagicMock import bleak -from homeassistant.components.bluetooth import models from homeassistant.components.bluetooth.models import HaBleakScannerWrapper -from homeassistant.components.bluetooth.usage import install_multiple_bleak_catcher +from homeassistant.components.bluetooth.usage import ( + install_multiple_bleak_catcher, + uninstall_multiple_bleak_catcher, +) async def test_multiple_bleak_scanner_instances(hass): - """Test creating multiple zeroconf throws without an integration.""" - assert models.HA_BLEAK_SCANNER is None - mock_scanner = MagicMock() - - install_multiple_bleak_catcher(mock_scanner) + """Test creating multiple BleakScanners without an integration.""" + install_multiple_bleak_catcher() instance = bleak.BleakScanner() assert isinstance(instance, HaBleakScannerWrapper) - assert models.HA_BLEAK_SCANNER is mock_scanner + + uninstall_multiple_bleak_catcher() + + instance = bleak.BleakScanner() + + assert not isinstance(instance, HaBleakScannerWrapper) diff --git a/tests/components/bluetooth_le_tracker/conftest.py b/tests/components/bluetooth_le_tracker/conftest.py index 30b2d5a44fb..9fce8e85ea8 100644 --- a/tests/components/bluetooth_le_tracker/conftest.py +++ b/tests/components/bluetooth_le_tracker/conftest.py @@ -1,7 +1,8 @@ -"""Tests for the bluetooth_le_tracker component.""" +"""Session fixtures.""" + import pytest @pytest.fixture(autouse=True) -def bluetooth_le_tracker_auto_mock_bluetooth(mock_bluetooth): - """Mock the bluetooth integration scanner.""" +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py index d82b4109839..f8f8c20dbb2 100644 --- a/tests/components/default_config/test_init.py +++ b/tests/components/default_config/test_init.py @@ -23,7 +23,7 @@ def recorder_url_mock(): yield -async def test_setup(hass, mock_zeroconf, mock_get_source_ip): +async def test_setup(hass, mock_zeroconf, mock_get_source_ip, mock_bluetooth): """Test setup.""" recorder_helper.async_initialize_recorder(hass) assert await async_setup_component(hass, "default_config", {"foo": "bar"}) diff --git a/tests/components/inkbird/conftest.py b/tests/components/inkbird/conftest.py index c44e9f7929a..3450cb933fe 100644 --- a/tests/components/inkbird/conftest.py +++ b/tests/components/inkbird/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(mock_bleak_scanner_start): +def mock_bluetooth(enable_bluetooth): """Auto mock bluetooth.""" diff --git a/tests/components/sensorpush/conftest.py b/tests/components/sensorpush/conftest.py index c6497a4e76d..2a983a7a4ed 100644 --- a/tests/components/sensorpush/conftest.py +++ b/tests/components/sensorpush/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def auto_mock_bleak_scanner_start(mock_bleak_scanner_start): - """Auto mock bleak scanner start.""" +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/conftest.py b/tests/conftest.py index 9ca29c60658..e0f4fb5ab90 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -871,6 +871,24 @@ def mock_integration_frame(): yield correct_frame +@pytest.fixture(name="enable_bluetooth") +async def mock_enable_bluetooth( + hass, mock_bleak_scanner_start, mock_bluetooth_adapters +): + """Fixture to mock starting the bleak scanner.""" + entry = MockConfigEntry(domain="bluetooth") + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +@pytest.fixture(name="mock_bluetooth_adapters") +def mock_bluetooth_adapters(): + """Fixture to mock bluetooth adapters.""" + with patch("bluetooth_adapters.get_bluetooth_adapters", return_value=set()): + yield + + @pytest.fixture(name="mock_bleak_scanner_start") def mock_bleak_scanner_start(): """Fixture to mock starting the bleak scanner.""" @@ -900,5 +918,5 @@ def mock_bleak_scanner_start(): @pytest.fixture(name="mock_bluetooth") -def mock_bluetooth(mock_bleak_scanner_start): +def mock_bluetooth(mock_bleak_scanner_start, mock_bluetooth_adapters): """Mock out bluetooth from starting."""