Add bluetooth options flow to pick the adapter (#75701)

This commit is contained in:
J. Nick Koston 2022-07-25 09:52:35 -05:00 committed by GitHub
parent 3df7892454
commit a813cf987b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 318 additions and 31 deletions

View file

@ -6,7 +6,6 @@ from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from enum import Enum
import logging import logging
import platform
from typing import Final, Union from typing import Final, Union
from bleak import BleakError from bleak import BleakError
@ -29,7 +28,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_bluetooth from homeassistant.loader import async_get_bluetooth
from . import models from . import models
from .const import DOMAIN from .const import CONF_ADAPTER, DEFAULT_ADAPTERS, DOMAIN
from .match import ( from .match import (
ADDRESS, ADDRESS,
BluetoothCallbackMatcher, BluetoothCallbackMatcher,
@ -38,6 +37,7 @@ from .match import (
) )
from .models import HaBleakScanner, HaBleakScannerWrapper from .models import HaBleakScanner, HaBleakScannerWrapper
from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher
from .util import async_get_bluetooth_adapters
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -175,15 +175,7 @@ def async_track_unavailable(
async def _async_has_bluetooth_adapter() -> bool: async def _async_has_bluetooth_adapter() -> bool:
"""Return if the device has a bluetooth adapter.""" """Return if the device has a bluetooth adapter."""
if platform.system() == "Darwin": # CoreBluetooth is built in on MacOS hardware return bool(await async_get_bluetooth_adapters())
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: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@ -219,10 +211,22 @@ async def async_setup_entry(
) -> bool: ) -> bool:
"""Set up the bluetooth integration from a config entry.""" """Set up the bluetooth integration from a config entry."""
manager: BluetoothManager = hass.data[DOMAIN] 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 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( async def async_unload_entry(
hass: HomeAssistant, entry: config_entries.ConfigEntry hass: HomeAssistant, entry: config_entries.ConfigEntry
) -> bool: ) -> bool:
@ -250,6 +254,7 @@ class BluetoothManager:
self._callbacks: list[ self._callbacks: list[
tuple[BluetoothCallback, BluetoothCallbackMatcher | None] tuple[BluetoothCallback, BluetoothCallbackMatcher | None]
] = [] ] = []
self._reloading = False
@hass_callback @hass_callback
def async_setup(self) -> None: def async_setup(self) -> None:
@ -261,13 +266,29 @@ class BluetoothManager:
"""Get the scanner.""" """Get the scanner."""
return HaBleakScannerWrapper() 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.""" """Set up BT Discovery."""
assert self.scanner is not None 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: try:
self.scanner.async_setup( self.scanner.async_setup(**scanner_kwargs)
scanning_mode=SCANNING_MODE_TO_BLEAK[scanning_mode]
)
except (FileNotFoundError, BleakError) as ex: except (FileNotFoundError, BleakError) as ex:
raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex
install_multiple_bleak_catcher() install_multiple_bleak_catcher()

View file

@ -3,11 +3,15 @@ from __future__ import annotations
from typing import Any from typing import Any
import voluptuous as vol
from homeassistant.components import onboarding 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 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): 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: async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult:
"""Handle import from configuration.yaml.""" """Handle import from configuration.yaml."""
return await self.async_step_enable_bluetooth(user_input) 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)

View file

@ -2,3 +2,10 @@
DOMAIN = "bluetooth" DOMAIN = "bluetooth"
DEFAULT_NAME = "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}

View file

@ -4,7 +4,7 @@
"documentation": "https://www.home-assistant.io/integrations/bluetooth", "documentation": "https://www.home-assistant.io/integrations/bluetooth",
"dependencies": ["websocket_api"], "dependencies": ["websocket_api"],
"quality_scale": "internal", "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"], "codeowners": ["@bdraco"],
"config_flow": true, "config_flow": true,
"iot_class": "local_push" "iot_class": "local_push"

View file

@ -70,6 +70,10 @@ class IntegrationMatcher:
MAX_REMEMBER_ADDRESSES 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]: def match_domains(self, device: BLEDevice, adv_data: AdvertisementData) -> set[str]:
"""Return the domains that are matched.""" """Return the domains that are matched."""
matched_domains: set[str] = set() matched_domains: set[str] = set()

View file

@ -67,6 +67,12 @@ class HaBleakScanner(BleakScanner): # type: ignore[misc]
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._setup = True 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 @hass_callback
def async_register_callback( def async_register_callback(
self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] self, callback: AdvertisementDataCallback, filters: dict[str, set[str]]

View file

@ -16,7 +16,17 @@
} }
}, },
"abort": { "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"
}
}
} }
} }
} }

View file

@ -1,7 +1,8 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "Service is already configured" "already_configured": "Service is already configured",
"no_adapters": "No Bluetooth adapters found"
}, },
"flow_title": "{name}", "flow_title": "{name}",
"step": { "step": {
@ -18,5 +19,14 @@
"description": "Choose a device to setup" "description": "Choose a device to setup"
} }
} }
},
"options": {
"step": {
"init": {
"data": {
"adapter": "The Bluetooth Adapter to use for scanning"
}
}
}
} }
} }

View file

@ -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

View file

@ -11,7 +11,7 @@ attrs==21.2.0
awesomeversion==22.6.0 awesomeversion==22.6.0
bcrypt==3.1.7 bcrypt==3.1.7
bleak==0.14.3 bleak==0.14.3
bluetooth-adapters==0.1.1 bluetooth-adapters==0.1.2
certifi>=2021.5.30 certifi>=2021.5.30
ciso8601==2.2.0 ciso8601==2.2.0
cryptography==36.0.2 cryptography==36.0.2

View file

@ -425,7 +425,7 @@ blockchain==1.4.4
# bluepy==1.3.0 # bluepy==1.3.0
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-adapters==0.1.1 bluetooth-adapters==0.1.2
# homeassistant.components.bond # homeassistant.components.bond
bond-async==0.1.22 bond-async==0.1.22

View file

@ -335,7 +335,7 @@ blebox_uniapi==2.0.2
blinkpy==0.19.0 blinkpy==0.19.0
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
bluetooth-adapters==0.1.1 bluetooth-adapters==0.1.2
# homeassistant.components.bond # homeassistant.components.bond
bond-async==0.1.22 bond-async==0.1.22

View file

@ -3,7 +3,11 @@
from unittest.mock import patch from unittest.mock import patch
from homeassistant import config_entries 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 homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry 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["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured" 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"

View file

@ -15,6 +15,10 @@ from homeassistant.components.bluetooth import (
async_track_unavailable, async_track_unavailable,
models, models,
) )
from homeassistant.components.bluetooth.const import (
CONF_ADAPTER,
UNIX_DEFAULT_BLUETOOTH_ADAPTER,
)
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import callback 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): async def test_auto_detect_bluetooth_adapters_linux(hass):
"""Test we auto detect bluetooth adapters on linux.""" """Test we auto detect bluetooth adapters on linux."""
with patch( with patch(
"bluetooth_adapters.get_bluetooth_adapters", return_value={"hci0"} "bluetooth_adapters.get_bluetooth_adapters", return_value=["hci0"]
), patch( ), 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, {}) assert await async_setup_component(hass, bluetooth.DOMAIN, {})
await hass.async_block_till_done() 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): async def test_auto_detect_bluetooth_adapters_linux_none_found(hass):
"""Test we auto detect bluetooth adapters on linux with no adapters found.""" """Test we auto detect bluetooth adapters on linux with no adapters found."""
with patch("bluetooth_adapters.get_bluetooth_adapters", return_value=set()), patch( 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, {}) assert await async_setup_component(hass, bluetooth.DOMAIN, {})
await hass.async_block_till_done() 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): async def test_auto_detect_bluetooth_adapters_macos(hass):
"""Test we auto detect bluetooth adapters on macos.""" """Test we auto detect bluetooth adapters on macos."""
with patch( 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, {}) assert await async_setup_component(hass, bluetooth.DOMAIN, {})
await hass.async_block_till_done() 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): async def test_no_auto_detect_bluetooth_adapters_windows(hass):
"""Test we auto detect bluetooth adapters on windows.""" """Test we auto detect bluetooth adapters on windows."""
with patch( 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, {}) assert await async_setup_component(hass, bluetooth.DOMAIN, {})
await hass.async_block_till_done() 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 entry.state == ConfigEntryState.LOADED
assert "Error stopping scanner" in caplog.text 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()

View file

@ -885,7 +885,7 @@ async def mock_enable_bluetooth(
@pytest.fixture(name="mock_bluetooth_adapters") @pytest.fixture(name="mock_bluetooth_adapters")
def mock_bluetooth_adapters(): def mock_bluetooth_adapters():
"""Fixture to 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 yield