diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 9266603839c..551e93d5bd9 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from datetime import datetime, timedelta from enum import Enum import logging -import platform from typing import Final, Union from bleak import BleakError @@ -29,7 +28,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_bluetooth from . import models -from .const import DOMAIN +from .const import CONF_ADAPTER, DEFAULT_ADAPTERS, DOMAIN from .match import ( ADDRESS, BluetoothCallbackMatcher, @@ -38,6 +37,7 @@ from .match import ( ) from .models import HaBleakScanner, HaBleakScannerWrapper from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher +from .util import async_get_bluetooth_adapters _LOGGER = logging.getLogger(__name__) @@ -175,15 +175,7 @@ def async_track_unavailable( 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()) + return bool(await async_get_bluetooth_adapters()) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -219,10 +211,22 @@ async def async_setup_entry( ) -> bool: """Set up the bluetooth integration from a config entry.""" manager: BluetoothManager = hass.data[DOMAIN] - await manager.async_start(BluetoothScanningMode.ACTIVE) + await manager.async_start( + BluetoothScanningMode.ACTIVE, entry.options.get(CONF_ADAPTER) + ) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True +async def _async_update_listener( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> None: + """Handle options update.""" + manager: BluetoothManager = hass.data[DOMAIN] + manager.async_start_reload() + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry ) -> bool: @@ -250,6 +254,7 @@ class BluetoothManager: self._callbacks: list[ tuple[BluetoothCallback, BluetoothCallbackMatcher | None] ] = [] + self._reloading = False @hass_callback def async_setup(self) -> None: @@ -261,13 +266,29 @@ class BluetoothManager: """Get the scanner.""" return HaBleakScannerWrapper() - async def async_start(self, scanning_mode: BluetoothScanningMode) -> None: + @hass_callback + def async_start_reload(self) -> None: + """Start reloading.""" + self._reloading = True + + async def async_start( + self, scanning_mode: BluetoothScanningMode, adapter: str | None + ) -> None: """Set up BT Discovery.""" assert self.scanner is not None + if self._reloading: + # On reload, we need to reset the scanner instance + # since the devices in its history may not be reachable + # anymore. + self.scanner.async_reset() + self._integration_matcher.async_clear_history() + self._reloading = False + scanner_kwargs = {"scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode]} + if adapter and adapter not in DEFAULT_ADAPTERS: + scanner_kwargs["adapter"] = adapter + _LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs) try: - self.scanner.async_setup( - scanning_mode=SCANNING_MODE_TO_BLEAK[scanning_mode] - ) + self.scanner.async_setup(**scanner_kwargs) except (FileNotFoundError, BleakError) as ex: raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex install_multiple_bleak_catcher() diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 8fe01be769d..bbba5f411b2 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -3,11 +3,15 @@ from __future__ import annotations from typing import Any +import voluptuous as vol + from homeassistant.components import onboarding -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from .const import DEFAULT_NAME, DOMAIN +from .const import CONF_ADAPTER, DEFAULT_NAME, DOMAIN +from .util import async_get_bluetooth_adapters class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): @@ -36,3 +40,39 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): 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) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(OptionsFlow): + """Handle the option flow for bluetooth.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + if not (adapters := await async_get_bluetooth_adapters()): + return self.async_abort(reason="no_adapters") + + data_schema = vol.Schema( + { + vol.Required( + CONF_ADAPTER, + default=self.config_entry.options.get(CONF_ADAPTER, adapters[0]), + ): vol.In(adapters), + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index 1e577f6064a..f3f00f581ee 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -2,3 +2,10 @@ DOMAIN = "bluetooth" DEFAULT_NAME = "Bluetooth" + +CONF_ADAPTER = "adapter" + +MACOS_DEFAULT_BLUETOOTH_ADAPTER = "CoreBluetooth" +UNIX_DEFAULT_BLUETOOTH_ADAPTER = "hci0" + +DEFAULT_ADAPTERS = {MACOS_DEFAULT_BLUETOOTH_ADAPTER, UNIX_DEFAULT_BLUETOOTH_ADAPTER} diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 551bb1c3733..c6ca8b11400 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/bluetooth", "dependencies": ["websocket_api"], "quality_scale": "internal", - "requirements": ["bleak==0.14.3", "bluetooth-adapters==0.1.1"], + "requirements": ["bleak==0.14.3", "bluetooth-adapters==0.1.2"], "codeowners": ["@bdraco"], "config_flow": true, "iot_class": "local_push" diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index c4560287feb..000f39eefd4 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -70,6 +70,10 @@ class IntegrationMatcher: MAX_REMEMBER_ADDRESSES ) + def async_clear_history(self) -> None: + """Clear the history.""" + self._matched = {} + def match_domains(self, device: BLEDevice, adv_data: AdvertisementData) -> set[str]: """Return the domains that are matched.""" matched_domains: set[str] = set() diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index e1d15c27243..408e0698879 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -67,6 +67,12 @@ class HaBleakScanner(BleakScanner): # type: ignore[misc] super().__init__(*args, **kwargs) self._setup = True + @hass_callback + def async_reset(self) -> None: + """Reset the scanner so it can be setup again.""" + self.history = {} + self._setup = False + @hass_callback def async_register_callback( self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 328a001ad96..beff2fd8312 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -16,7 +16,17 @@ } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "no_adapters": "No Bluetooth adapters found" + } + }, + "options": { + "step": { + "init": { + "data": { + "adapter": "The Bluetooth Adapter to use for scanning" + } + } } } } diff --git a/homeassistant/components/bluetooth/translations/en.json b/homeassistant/components/bluetooth/translations/en.json index 85019bdd689..4b53822b771 100644 --- a/homeassistant/components/bluetooth/translations/en.json +++ b/homeassistant/components/bluetooth/translations/en.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Service is already configured" + "already_configured": "Service is already configured", + "no_adapters": "No Bluetooth adapters found" }, "flow_title": "{name}", "step": { @@ -18,5 +19,14 @@ "description": "Choose a device to setup" } } + }, + "options": { + "step": { + "init": { + "data": { + "adapter": "The Bluetooth Adapter to use for scanning" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py new file mode 100644 index 00000000000..68920050748 --- /dev/null +++ b/homeassistant/components/bluetooth/util.py @@ -0,0 +1,27 @@ +"""The bluetooth integration utilities.""" +from __future__ import annotations + +import platform + +from .const import MACOS_DEFAULT_BLUETOOTH_ADAPTER, UNIX_DEFAULT_BLUETOOTH_ADAPTER + + +async def async_get_bluetooth_adapters() -> list[str]: + """Return a list of bluetooth adapters.""" + if platform.system() == "Windows": # We don't have a good way to detect on windows + return [] + if platform.system() == "Darwin": # CoreBluetooth is built in on MacOS hardware + return [MACOS_DEFAULT_BLUETOOTH_ADAPTER] + from bluetooth_adapters import ( # pylint: disable=import-outside-toplevel + get_bluetooth_adapters, + ) + + adapters = await get_bluetooth_adapters() + if ( + UNIX_DEFAULT_BLUETOOTH_ADAPTER in adapters + and adapters[0] != UNIX_DEFAULT_BLUETOOTH_ADAPTER + ): + # The default adapter always needs to be the first in the list + # because that is how bleak works. + adapters.insert(0, adapters.pop(adapters.index(UNIX_DEFAULT_BLUETOOTH_ADAPTER))) + return adapters diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 568ba909ba5..3a6ae411c5e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ attrs==21.2.0 awesomeversion==22.6.0 bcrypt==3.1.7 bleak==0.14.3 -bluetooth-adapters==0.1.1 +bluetooth-adapters==0.1.2 certifi>=2021.5.30 ciso8601==2.2.0 cryptography==36.0.2 diff --git a/requirements_all.txt b/requirements_all.txt index 3a39b405c91..64ccf9a0763 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ blockchain==1.4.4 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.1.1 +bluetooth-adapters==0.1.2 # homeassistant.components.bond bond-async==0.1.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9f699d61d8..03538ea10cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -335,7 +335,7 @@ blebox_uniapi==2.0.2 blinkpy==0.19.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.1.1 +bluetooth-adapters==0.1.2 # 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 index 5c6199b9bf0..1053133cac9 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -3,7 +3,11 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components.bluetooth.const import DOMAIN +from homeassistant.components.bluetooth.const import ( + CONF_ADAPTER, + DOMAIN, + MACOS_DEFAULT_BLUETOOTH_ADAPTER, +) from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry @@ -125,3 +129,112 @@ async def test_async_step_import_already_exists(hass): ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@patch("homeassistant.components.bluetooth.util.platform.system", return_value="Linux") +async def test_options_flow_linux(mock_system, hass, mock_bleak_scanner_start): + """Test options on Linux.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + unique_id="DOMAIN", + ) + entry.add_to_hass(hass) + + # Verify we can keep it as hci0 + with patch( + "bluetooth_adapters.get_bluetooth_adapters", return_value=["hci0", "hci1"] + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] is None + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_ADAPTER: "hci0", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_ADAPTER] == "hci0" + + # Verify we can change it to hci1 + with patch( + "bluetooth_adapters.get_bluetooth_adapters", return_value=["hci0", "hci1"] + ): + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] is None + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_ADAPTER: "hci1", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_ADAPTER] == "hci1" + + +@patch("homeassistant.components.bluetooth.util.platform.system", return_value="Darwin") +async def test_options_flow_macos(mock_system, hass, mock_bleak_scanner_start): + """Test options on MacOS.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + unique_id="DOMAIN", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] is None + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_ADAPTER: MACOS_DEFAULT_BLUETOOTH_ADAPTER, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_ADAPTER] == MACOS_DEFAULT_BLUETOOTH_ADAPTER + + +@patch( + "homeassistant.components.bluetooth.util.platform.system", return_value="Windows" +) +async def test_options_flow_windows(mock_system, hass, mock_bleak_scanner_start): + """Test options on Windows.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + unique_id="DOMAIN", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_adapters" diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index aa3a6253b83..bb2c5f49cc9 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -15,6 +15,10 @@ from homeassistant.components.bluetooth import ( async_track_unavailable, models, ) +from homeassistant.components.bluetooth.const import ( + CONF_ADAPTER, + UNIX_DEFAULT_BLUETOOTH_ADAPTER, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback @@ -1160,9 +1164,22 @@ async def test_can_unsetup_bluetooth(hass, mock_bleak_scanner_start, enable_blue 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"} + "bluetooth_adapters.get_bluetooth_adapters", return_value=["hci0"] ), patch( - "homeassistant.components.bluetooth.platform.system", return_value="Linux" + "homeassistant.components.bluetooth.util.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_multiple(hass): + """Test we auto detect bluetooth adapters on linux with multiple adapters.""" + with patch( + "bluetooth_adapters.get_bluetooth_adapters", return_value=["hci1", "hci0"] + ), patch( + "homeassistant.components.bluetooth.util.platform.system", return_value="Linux" ): assert await async_setup_component(hass, bluetooth.DOMAIN, {}) await hass.async_block_till_done() @@ -1173,7 +1190,7 @@ async def test_auto_detect_bluetooth_adapters_linux(hass): 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" + "homeassistant.components.bluetooth.util.platform.system", return_value="Linux" ): assert await async_setup_component(hass, bluetooth.DOMAIN, {}) await hass.async_block_till_done() @@ -1184,7 +1201,7 @@ async def test_auto_detect_bluetooth_adapters_linux_none_found(hass): 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" + "homeassistant.components.bluetooth.util.platform.system", return_value="Darwin" ): assert await async_setup_component(hass, bluetooth.DOMAIN, {}) await hass.async_block_till_done() @@ -1195,7 +1212,8 @@ async def test_auto_detect_bluetooth_adapters_macos(hass): 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" + "homeassistant.components.bluetooth.util.platform.system", + return_value="Windows", ): assert await async_setup_component(hass, bluetooth.DOMAIN, {}) await hass.async_block_till_done() @@ -1230,3 +1248,34 @@ async def test_config_entry_can_be_reloaded_when_stop_raises( assert entry.state == ConfigEntryState.LOADED assert "Error stopping scanner" in caplog.text + + +async def test_changing_the_adapter_at_runtime(hass): + """Test we can change the adapter at runtime.""" + entry = MockConfigEntry( + domain=bluetooth.DOMAIN, + data={}, + options={CONF_ADAPTER: UNIX_DEFAULT_BLUETOOTH_ADAPTER}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.bluetooth.HaBleakScanner.async_setup" + ) as mock_setup, patch( + "homeassistant.components.bluetooth.HaBleakScanner.start" + ), patch( + "homeassistant.components.bluetooth.HaBleakScanner.stop" + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert "adapter" not in mock_setup.mock_calls[0][2] + + entry.options = {CONF_ADAPTER: "hci1"} + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + assert mock_setup.mock_calls[1][2]["adapter"] == "hci1" + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() diff --git a/tests/conftest.py b/tests/conftest.py index e0f4fb5ab90..50c24df8d44 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -885,7 +885,7 @@ async def mock_enable_bluetooth( @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()): + with patch("bluetooth_adapters.get_bluetooth_adapters", return_value=[]): yield