From d1486d04d9aca2bf5cd407fce02e64e2fa5452c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Aug 2022 12:17:28 -0500 Subject: [PATCH] Add support for bleak passive scanning on linux (#75542) --- .../components/bluetooth/__init__.py | 32 +++--- .../components/bluetooth/config_flow.py | 52 ++++++++- homeassistant/components/bluetooth/const.py | 1 + homeassistant/components/bluetooth/scanner.py | 30 ++++- .../components/bluetooth/strings.json | 10 ++ .../components/bluetooth/translations/en.json | 8 +- tests/components/bluetooth/conftest.py | 5 + .../components/bluetooth/test_config_flow.py | 104 ++++++++++++++++++ tests/components/bluetooth/test_init.py | 47 ++++++++ 9 files changed, 260 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 58ca4a6976b..208bbe6952b 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -9,8 +9,8 @@ from typing import TYPE_CHECKING, cast import async_timeout -from homeassistant import config_entries from homeassistant.components import usb +from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.exceptions import ConfigEntryNotReady @@ -25,6 +25,7 @@ from .const import ( ADAPTER_SW_VERSION, CONF_ADAPTER, CONF_DETAILS, + CONF_PASSIVE, DATA_MANAGER, DEFAULT_ADDRESS, DOMAIN, @@ -51,7 +52,6 @@ if TYPE_CHECKING: from homeassistant.helpers.typing import ConfigType - __all__ = [ "async_ble_device_from_address", "async_discovered_service_info", @@ -213,7 +213,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: manager.async_setup() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop) hass.data[DATA_MANAGER] = models.MANAGER = manager - adapters = await manager.async_get_bluetooth_adapters() async_migrate_entries(hass, adapters) @@ -249,8 +248,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @hass_callback def async_migrate_entries( - hass: HomeAssistant, - adapters: dict[str, AdapterDetails], + hass: HomeAssistant, adapters: dict[str, AdapterDetails] ) -> None: """Migrate config entries to support multiple.""" current_entries = hass.config_entries.async_entries(DOMAIN) @@ -284,15 +282,13 @@ async def async_discover_adapters( discovery_flow.async_create_flow( hass, DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, data={CONF_ADAPTER: adapter, CONF_DETAILS: details}, ) async def async_update_device( - hass: HomeAssistant, - entry: config_entries.ConfigEntry, - adapter: str, + hass: HomeAssistant, entry: ConfigEntry, adapter: str ) -> None: """Update device registry entry. @@ -314,9 +310,7 @@ async def async_update_device( ) -async def async_setup_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry for a bluetooth scanner.""" address = entry.unique_id assert address is not None @@ -326,8 +320,10 @@ async def async_setup_entry( f"Bluetooth adapter {adapter} with address {address} not found" ) + passive = entry.options.get(CONF_PASSIVE) + mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE try: - bleak_scanner = create_bleak_scanner(BluetoothScanningMode.ACTIVE, adapter) + bleak_scanner = create_bleak_scanner(mode, adapter) except RuntimeError as err: raise ConfigEntryNotReady( f"{adapter_human_name(adapter, address)}: {err}" @@ -342,12 +338,16 @@ async def async_setup_entry( entry.async_on_unload(async_register_scanner(hass, scanner, True)) await async_update_device(hass, entry, adapter) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = scanner + entry.async_on_unload(entry.add_update_listener(async_update_listener)) return True -async def async_unload_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry -) -> bool: +async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" scanner: HaScanner = hass.data[DOMAIN].pop(entry.entry_id) await scanner.async_stop() diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 2435a1e39ed..0fa2304468f 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -1,15 +1,24 @@ """Config flow to configure the Bluetooth integration.""" from __future__ import annotations +import platform from typing import TYPE_CHECKING, Any, cast 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.helpers.typing import DiscoveryInfoType -from .const import ADAPTER_ADDRESS, CONF_ADAPTER, CONF_DETAILS, DOMAIN, AdapterDetails +from .const import ( + ADAPTER_ADDRESS, + CONF_ADAPTER, + CONF_DETAILS, + CONF_PASSIVE, + DOMAIN, + AdapterDetails, +) from .util import adapter_human_name, adapter_unique_name, async_get_bluetooth_adapters if TYPE_CHECKING: @@ -112,3 +121,42 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle a flow initialized by the user.""" return await self.async_step_multiple_adapters() + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + @classmethod + @callback + def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: + """Return options flow support for this handler.""" + return platform.system() == "Linux" + + +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) + + data_schema = vol.Schema( + { + vol.Required( + CONF_PASSIVE, + default=self.config_entry.options.get(CONF_PASSIVE, False), + ): bool, + } + ) + 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 d6f7b515532..540310e9747 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -8,6 +8,7 @@ DOMAIN = "bluetooth" CONF_ADAPTER = "adapter" CONF_DETAILS = "details" +CONF_PASSIVE = "passive" WINDOWS_DEFAULT_BLUETOOTH_ADAPTER = "bluetooth" MACOS_DEFAULT_BLUETOOTH_ADAPTER = "Core Bluetooth" diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index 8805b0adaf2..d186f613c94 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -7,10 +7,14 @@ from datetime import datetime import logging import platform import time +from typing import Any import async_timeout import bleak from bleak import BleakError +from bleak.assigned_numbers import AdvertisementDataType +from bleak.backends.bluezdbus.advertisement_monitor import OrPattern +from bleak.backends.bluezdbus.scanner import BlueZScannerArgs from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from dbus_next import InvalidMessageError @@ -38,7 +42,15 @@ from .util import adapter_human_name, async_reset_adapter OriginalBleakScanner = bleak.BleakScanner MONOTONIC_TIME = time.monotonic - +# or_patterns is a workaround for the fact that passive scanning +# needs at least one matcher to be set. The below matcher +# will match all devices. +PASSIVE_SCANNER_ARGS = BlueZScannerArgs( + or_patterns=[ + OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"), + OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"), + ] +) _LOGGER = logging.getLogger(__name__) @@ -81,13 +93,19 @@ def create_bleak_scanner( scanning_mode: BluetoothScanningMode, adapter: str | None ) -> bleak.BleakScanner: """Create a Bleak scanner.""" - scanner_kwargs = {"scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode]} - # Only Linux supports multiple adapters - if adapter and platform.system() == "Linux": - scanner_kwargs["adapter"] = adapter + scanner_kwargs: dict[str, Any] = { + "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode] + } + if platform.system() == "Linux": + # Only Linux supports multiple adapters + if adapter: + scanner_kwargs["adapter"] = adapter + if scanning_mode == BluetoothScanningMode.PASSIVE: + scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS _LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs) + try: - return OriginalBleakScanner(**scanner_kwargs) # type: ignore[arg-type] + return OriginalBleakScanner(**scanner_kwargs) except (FileNotFoundError, BleakError) as ex: raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 269995192a8..1912242ea6a 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -25,5 +25,15 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "no_adapters": "No unconfigured Bluetooth adapters found" } + }, + "options": { + "step": { + "init": { + "description": "Passive listening requires BlueZ 5.63 or later with experimental features enabled.", + "data": { + "passive": "Passive listening" + } + } + } } } diff --git a/homeassistant/components/bluetooth/translations/en.json b/homeassistant/components/bluetooth/translations/en.json index 5b40308cd3c..940cf5ebf88 100644 --- a/homeassistant/components/bluetooth/translations/en.json +++ b/homeassistant/components/bluetooth/translations/en.json @@ -9,9 +9,6 @@ "bluetooth_confirm": { "description": "Do you want to setup {name}?" }, - "enable_bluetooth": { - "description": "Do you want to setup Bluetooth?" - }, "multiple_adapters": { "data": { "adapter": "Adapter" @@ -33,8 +30,9 @@ "step": { "init": { "data": { - "adapter": "The Bluetooth Adapter to use for scanning" - } + "passive": "Passive listening" + }, + "description": "Passive listening requires BlueZ 5.63 or later with experimental features enabled." } } } diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 5ddd0fbc15f..1ea9b8706d4 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -28,6 +28,11 @@ def windows_adapter(): def one_adapter_fixture(): """Fixture that mocks one adapter on Linux.""" with patch( + "homeassistant.components.bluetooth.platform.system", return_value="Linux" + ), patch( + "homeassistant.components.bluetooth.scanner.platform.system", + return_value="Linux", + ), patch( "homeassistant.components.bluetooth.util.platform.system", return_value="Linux" ), patch( "bluetooth_adapters.get_bluetooth_adapter_details", diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index e16208b3d70..61763eef257 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -6,11 +6,13 @@ from homeassistant import config_entries from homeassistant.components.bluetooth.const import ( CONF_ADAPTER, CONF_DETAILS, + CONF_PASSIVE, DEFAULT_ADDRESS, DOMAIN, AdapterDetails, ) from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -235,3 +237,105 @@ async def test_async_step_integration_discovery_already_exists(hass): ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_options_flow_linux(hass, mock_bleak_scanner_start, one_adapter): + """Test options on Linux.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + unique_id="00:00:00:00:00:01", + ) + 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_PASSIVE: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_PASSIVE] is True + + # Verify we can change it to False + + 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_PASSIVE: False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_PASSIVE] is False + + +@patch( + "homeassistant.components.bluetooth.config_flow.platform.system", + return_value="Darwin", +) +async def test_options_flow_disabled_macos(mock_system, hass, hass_ws_client): + """Test options are disabled on MacOS.""" + await async_setup_component(hass, "config", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + ) + entry.add_to_hass(hass) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + "id": 5, + "type": "config_entries/get", + "domain": "bluetooth", + "type_filter": "integration", + } + ) + response = await ws_client.receive_json() + assert response["result"][0]["supports_options"] is False + + +@patch( + "homeassistant.components.bluetooth.config_flow.platform.system", + return_value="Linux", +) +async def test_options_flow_enabled_linux(mock_system, hass, hass_ws_client): + """Test options are enabled on Linux.""" + await async_setup_component(hass, "config", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + ) + entry.add_to_hass(hass) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + "id": 5, + "type": "config_entries/get", + "domain": "bluetooth", + "type_filter": "integration", + } + ) + response = await ws_client.receive_json() + assert response["result"][0]["supports_options"] is True diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 81b25a6c0dd..bfd327bfc03 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -20,6 +20,7 @@ from homeassistant.components.bluetooth import ( scanner, ) from homeassistant.components.bluetooth.const import ( + CONF_PASSIVE, DEFAULT_ADDRESS, DOMAIN, SOURCE_LOCAL, @@ -61,6 +62,52 @@ async def test_setup_and_stop(hass, mock_bleak_scanner_start, enable_bluetooth): assert len(mock_bleak_scanner_start.mock_calls) == 1 +async def test_setup_and_stop_passive(hass, mock_bleak_scanner_start, one_adapter): + """Test we and setup and stop the scanner the passive scanner.""" + entry = MockConfigEntry( + domain=bluetooth.DOMAIN, + data={}, + options={CONF_PASSIVE: True}, + unique_id="00:00:00:00:00:01", + ) + entry.add_to_hass(hass) + init_kwargs = None + + class MockPassiveBleakScanner: + def __init__(self, *args, **kwargs): + """Init the scanner.""" + nonlocal init_kwargs + init_kwargs = kwargs + + async def start(self, *args, **kwargs): + """Start the scanner.""" + + async def stop(self, *args, **kwargs): + """Stop the scanner.""" + + def register_detection_callback(self, *args, **kwargs): + """Register a callback.""" + + with patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + MockPassiveBleakScanner, + ): + assert await async_setup_component( + hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} + ) + 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 init_kwargs == { + "adapter": "hci0", + "bluez": scanner.PASSIVE_SCANNER_ARGS, + "scanning_mode": "passive", + } + + async def test_setup_and_stop_no_bluetooth(hass, caplog, macos_adapter): """Test we fail gracefully when bluetooth is not available.""" mock_bt = [