From d1486d04d9aca2bf5cd407fce02e64e2fa5452c1 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston" <nick@koston.org>
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 = [
-- 
GitLab