Add support for bleak passive scanning on linux (#75542)

pull/77242/head^2
J. Nick Koston 2022-08-24 12:17:28 -05:00 committed by GitHub
parent 7ee47f0f26
commit d1486d04d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 260 additions and 29 deletions

View File

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

View File

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

View File

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

View File

@ -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]}
scanner_kwargs: dict[str, Any] = {
"scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode]
}
if platform.system() == "Linux":
# Only Linux supports multiple adapters
if adapter and platform.system() == "Linux":
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

View File

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

View File

@ -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."
}
}
}

View File

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

View File

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

View File

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