Add support for setting up and removing bluetooth in the UI (#75600)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
J. Nick Koston 2022-07-22 13:19:53 -05:00 committed by GitHub
parent 20b6c4c48e
commit 38bccadaa6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 755 additions and 339 deletions

View File

@ -7,6 +7,7 @@ from datetime import datetime, timedelta
from enum import Enum
import fnmatch
import logging
import platform
from typing import Final, TypedDict, Union
from bleak import BleakError
@ -35,7 +36,7 @@ from homeassistant.loader import (
from . import models
from .const import DOMAIN
from .models import HaBleakScanner
from .usage import install_multiple_bleak_catcher
from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher
_LOGGER = logging.getLogger(__name__)
@ -115,6 +116,15 @@ BluetoothCallback = Callable[
]
@hass_callback
def async_get_scanner(hass: HomeAssistant) -> HaBleakScanner:
"""Return a HaBleakScanner."""
if DOMAIN not in hass.data:
raise RuntimeError("Bluetooth integration not loaded")
manager: BluetoothManager = hass.data[DOMAIN]
return manager.async_get_scanner()
@hass_callback
def async_discovered_service_info(
hass: HomeAssistant,
@ -178,14 +188,62 @@ def async_track_unavailable(
return manager.async_track_unavailable(callback, address)
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())
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the bluetooth integration."""
integration_matchers = await async_get_bluetooth(hass)
bluetooth_discovery = BluetoothManager(
hass, integration_matchers, BluetoothScanningMode.PASSIVE
)
await bluetooth_discovery.async_setup()
hass.data[DOMAIN] = bluetooth_discovery
manager = BluetoothManager(hass, integration_matchers)
manager.async_setup()
hass.data[DOMAIN] = manager
# The config entry is responsible for starting the manager
# if its enabled
if hass.config_entries.async_entries(DOMAIN):
return True
if DOMAIN in config:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={}
)
)
elif await _async_has_bluetooth_adapter():
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data={},
)
)
return True
async def async_setup_entry(
hass: HomeAssistant, entry: config_entries.ConfigEntry
) -> bool:
"""Set up the bluetooth integration from a config entry."""
manager: BluetoothManager = hass.data[DOMAIN]
await manager.async_start(BluetoothScanningMode.ACTIVE)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: config_entries.ConfigEntry
) -> bool:
"""Unload a config entry."""
manager: BluetoothManager = hass.data[DOMAIN]
await manager.async_stop()
return True
@ -241,11 +299,9 @@ class BluetoothManager:
self,
hass: HomeAssistant,
integration_matchers: list[BluetoothMatcher],
scanning_mode: BluetoothScanningMode,
) -> None:
"""Init bluetooth discovery."""
self.hass = hass
self.scanning_mode = scanning_mode
self._integration_matchers = integration_matchers
self.scanner: HaBleakScanner | None = None
self._cancel_device_detected: CALLBACK_TYPE | None = None
@ -258,19 +314,27 @@ class BluetoothManager:
# an LRU to avoid memory issues.
self._matched: LRU = LRU(MAX_REMEMBER_ADDRESSES)
async def async_setup(self) -> None:
@hass_callback
def async_setup(self) -> None:
"""Set up the bluetooth manager."""
models.HA_BLEAK_SCANNER = self.scanner = HaBleakScanner()
@hass_callback
def async_get_scanner(self) -> HaBleakScanner:
"""Get the scanner."""
assert self.scanner is not None
return self.scanner
async def async_start(self, scanning_mode: BluetoothScanningMode) -> None:
"""Set up BT Discovery."""
assert self.scanner is not None
try:
self.scanner = HaBleakScanner(
scanning_mode=SCANNING_MODE_TO_BLEAK[self.scanning_mode]
self.scanner.async_setup(
scanning_mode=SCANNING_MODE_TO_BLEAK[scanning_mode]
)
except (FileNotFoundError, BleakError) as ex:
_LOGGER.warning(
"Could not create bluetooth scanner (is bluetooth present and enabled?): %s",
ex,
)
return
install_multiple_bleak_catcher(self.scanner)
raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex
install_multiple_bleak_catcher()
self.async_setup_unavailable_tracking()
# We have to start it right away as some integrations might
# need it straight away.
@ -279,8 +343,11 @@ class BluetoothManager:
self._cancel_device_detected = self.scanner.async_register_callback(
self._device_detected, {}
)
try:
await self.scanner.start()
except (FileNotFoundError, BleakError) as ex:
raise RuntimeError(f"Failed to start Bluetooth: {ex}") from ex
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop)
await self.scanner.start()
@hass_callback
def async_setup_unavailable_tracking(self) -> None:
@ -289,8 +356,8 @@ class BluetoothManager:
@hass_callback
def _async_check_unavailable(now: datetime) -> None:
"""Watch for unavailable devices."""
assert models.HA_BLEAK_SCANNER is not None
scanner = models.HA_BLEAK_SCANNER
scanner = self.scanner
assert scanner is not None
history = set(scanner.history)
active = {device.address for device in scanner.discovered_devices}
disappeared = history.difference(active)
@ -406,8 +473,8 @@ class BluetoothManager:
if (
matcher
and (address := matcher.get(ADDRESS))
and models.HA_BLEAK_SCANNER
and (device_adv_data := models.HA_BLEAK_SCANNER.history.get(address))
and self.scanner
and (device_adv_data := self.scanner.history.get(address))
):
try:
callback(
@ -424,31 +491,25 @@ class BluetoothManager:
@hass_callback
def async_ble_device_from_address(self, address: str) -> BLEDevice | None:
"""Return the BLEDevice if present."""
if models.HA_BLEAK_SCANNER and (
ble_adv := models.HA_BLEAK_SCANNER.history.get(address)
):
if self.scanner and (ble_adv := self.scanner.history.get(address)):
return ble_adv[0]
return None
@hass_callback
def async_address_present(self, address: str) -> bool:
"""Return if the address is present."""
return bool(
models.HA_BLEAK_SCANNER and address in models.HA_BLEAK_SCANNER.history
)
return bool(self.scanner and address in self.scanner.history)
@hass_callback
def async_discovered_service_info(self) -> list[BluetoothServiceInfoBleak]:
"""Return if the address is present."""
if models.HA_BLEAK_SCANNER:
history = models.HA_BLEAK_SCANNER.history
return [
BluetoothServiceInfoBleak.from_advertisement(*device_adv, SOURCE_LOCAL)
for device_adv in history.values()
]
return []
assert self.scanner is not None
return [
BluetoothServiceInfoBleak.from_advertisement(*device_adv, SOURCE_LOCAL)
for device_adv in self.scanner.history.values()
]
async def async_stop(self, event: Event) -> None:
async def async_stop(self, event: Event | None = None) -> None:
"""Stop bluetooth discovery."""
if self._cancel_device_detected:
self._cancel_device_detected()
@ -458,4 +519,4 @@ class BluetoothManager:
self._cancel_unavailable_tracking = None
if self.scanner:
await self.scanner.stop()
models.HA_BLEAK_SCANNER = None
uninstall_multiple_bleak_catcher()

View File

@ -0,0 +1,37 @@
"""Config flow to configure the Bluetooth integration."""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigFlow
from homeassistant.data_entry_flow import FlowResult
from .const import DEFAULT_NAME, DOMAIN
class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Bluetooth."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initialized by the user."""
return await self.async_step_enable_bluetooth()
async def async_step_enable_bluetooth(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initialized by the user or import."""
if self._async_current_entries():
return self.async_abort(reason="already_configured")
if user_input is not None:
return self.async_create_entry(title=DEFAULT_NAME, data={})
return self.async_show_form(step_id="enable_bluetooth")
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)

View File

@ -1,3 +1,4 @@
"""Constants for the Bluetooth integration."""
DOMAIN = "bluetooth"
DEFAULT_NAME = "Bluetooth"

View File

@ -4,7 +4,8 @@
"documentation": "https://www.home-assistant.io/integrations/bluetooth",
"dependencies": ["websocket_api"],
"quality_scale": "internal",
"requirements": ["bleak==0.14.3"],
"requirements": ["bleak==0.14.3", "bluetooth-adapters==0.1.1"],
"codeowners": ["@bdraco"],
"config_flow": true,
"iot_class": "local_push"
}

View File

@ -48,13 +48,24 @@ def _dispatch_callback(
class HaBleakScanner(BleakScanner): # type: ignore[misc]
"""BleakScanner that cannot be stopped."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
def __init__( # pylint: disable=super-init-not-called
self, *args: Any, **kwargs: Any
) -> None:
"""Initialize the BleakScanner."""
self._callbacks: list[
tuple[AdvertisementDataCallback, dict[str, set[str]]]
] = []
self.history: dict[str, tuple[BLEDevice, AdvertisementData]] = {}
super().__init__(*args, **kwargs)
# Init called later in async_setup if we are enabling the scanner
# since init has side effects that can throw exceptions
self._setup = False
@hass_callback
def async_setup(self, *args: Any, **kwargs: Any) -> None:
"""Deferred setup of the BleakScanner since __init__ has side effects."""
if not self._setup:
super().__init__(*args, **kwargs)
self._setup = True
@hass_callback
def async_register_callback(

View File

@ -2,6 +2,9 @@
"config": {
"flow_title": "{name}",
"step": {
"enable_bluetooth": {
"description": "Do you want to setup Bluetooth?"
},
"user": {
"description": "Choose a device to setup",
"data": {
@ -11,6 +14,9 @@
"bluetooth_confirm": {
"description": "Do you want to setup {name}?"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}
}
}

View File

@ -1,10 +1,16 @@
{
"config": {
"abort": {
"already_configured": "Service is already configured"
},
"flow_title": "{name}",
"step": {
"bluetooth_confirm": {
"description": "Do you want to setup {name}?"
},
"enable_bluetooth": {
"description": "Do you want to setup Bluetooth?"
},
"user": {
"data": {
"address": "Device"

View File

@ -3,11 +3,16 @@ from __future__ import annotations
import bleak
from . import models
from .models import HaBleakScanner, HaBleakScannerWrapper
from .models import HaBleakScannerWrapper
ORIGINAL_BLEAK_SCANNER = bleak.BleakScanner
def install_multiple_bleak_catcher(hass_bleak_scanner: HaBleakScanner) -> None:
def install_multiple_bleak_catcher() -> None:
"""Wrap the bleak classes to return the shared instance if multiple instances are detected."""
models.HA_BLEAK_SCANNER = hass_bleak_scanner
bleak.BleakScanner = HaBleakScannerWrapper
def uninstall_multiple_bleak_catcher() -> None:
"""Unwrap the bleak classes."""
bleak.BleakScanner = ORIGINAL_BLEAK_SCANNER

View File

@ -5,6 +5,7 @@
"dependencies": [
"application_credentials",
"automation",
"bluetooth",
"cloud",
"counter",
"dhcp",

View File

@ -47,6 +47,7 @@ FLOWS = {
"balboa",
"blebox",
"blink",
"bluetooth",
"bmw_connected_drive",
"bond",
"bosch_shc",

View File

@ -10,6 +10,8 @@ atomicwrites-homeassistant==1.4.1
attrs==21.2.0
awesomeversion==22.6.0
bcrypt==3.1.7
bleak==0.14.3
bluetooth-adapters==0.1.1
certifi>=2021.5.30
ciso8601==2.2.0
cryptography==36.0.2

View File

@ -424,6 +424,9 @@ blockchain==1.4.4
# homeassistant.components.zengge
# bluepy==1.3.0
# homeassistant.components.bluetooth
bluetooth-adapters==0.1.1
# homeassistant.components.bond
bond-async==0.1.22

View File

@ -334,6 +334,9 @@ blebox_uniapi==2.0.2
# homeassistant.components.blink
blinkpy==0.19.0
# homeassistant.components.bluetooth
bluetooth-adapters==0.1.1
# homeassistant.components.bond
bond-async==0.1.22

View File

@ -0,0 +1,106 @@
"""Test the bluetooth config flow."""
from unittest.mock import patch
from homeassistant import config_entries
from homeassistant.components.bluetooth.const import DOMAIN
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_async_step_user(hass):
"""Test setting up manually."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "enable_bluetooth"
with patch(
"homeassistant.components.bluetooth.async_setup_entry", return_value=True
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "Bluetooth"
assert result2["data"] == {}
assert len(mock_setup_entry.mock_calls) == 1
async def test_async_step_user_only_allows_one(hass):
"""Test setting up manually with an existing entry."""
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_async_step_integration_discovery(hass):
"""Test setting up from integration discovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data={},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "enable_bluetooth"
with patch(
"homeassistant.components.bluetooth.async_setup_entry", return_value=True
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "Bluetooth"
assert result2["data"] == {}
assert len(mock_setup_entry.mock_calls) == 1
async def test_async_step_integration_discovery_already_exists(hass):
"""Test setting up from integration discovery when an entry already exists."""
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data={},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_async_step_import(hass):
"""Test setting up from integration discovery."""
with patch(
"homeassistant.components.bluetooth.async_setup_entry", return_value=True
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "Bluetooth"
assert result["data"] == {}
assert len(mock_setup_entry.mock_calls) == 1
async def test_async_step_import_already_exists(hass):
"""Test setting up from yaml when an entry already exists."""
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@ from homeassistant.components.bluetooth import (
DOMAIN,
UNAVAILABLE_TRACK_SECONDS,
BluetoothChange,
async_get_scanner,
)
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity,
@ -207,12 +208,14 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT)
assert len(mock_add_entities.mock_calls) == 1
assert coordinator.available is True
scanner = async_get_scanner(hass)
with patch(
"homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices",
[MagicMock(address="44:44:33:11:23:45")],
), patch(
"homeassistant.components.bluetooth.models.HA_BLEAK_SCANNER.history",
), patch.object(
scanner,
"history",
{"aa:bb:cc:dd:ee:ff": MagicMock()},
):
async_fire_time_changed(
@ -228,8 +231,9 @@ async def test_unavailable_after_no_data(hass, mock_bleak_scanner_start):
with patch(
"homeassistant.components.bluetooth.models.HaBleakScanner.discovered_devices",
[MagicMock(address="44:44:33:11:23:45")],
), patch(
"homeassistant.components.bluetooth.models.HA_BLEAK_SCANNER.history",
), patch.object(
scanner,
"history",
{"aa:bb:cc:dd:ee:ff": MagicMock()},
):
async_fire_time_changed(

View File

@ -1,22 +1,25 @@
"""Tests for the Bluetooth integration."""
from unittest.mock import MagicMock
import bleak
from homeassistant.components.bluetooth import models
from homeassistant.components.bluetooth.models import HaBleakScannerWrapper
from homeassistant.components.bluetooth.usage import install_multiple_bleak_catcher
from homeassistant.components.bluetooth.usage import (
install_multiple_bleak_catcher,
uninstall_multiple_bleak_catcher,
)
async def test_multiple_bleak_scanner_instances(hass):
"""Test creating multiple zeroconf throws without an integration."""
assert models.HA_BLEAK_SCANNER is None
mock_scanner = MagicMock()
install_multiple_bleak_catcher(mock_scanner)
"""Test creating multiple BleakScanners without an integration."""
install_multiple_bleak_catcher()
instance = bleak.BleakScanner()
assert isinstance(instance, HaBleakScannerWrapper)
assert models.HA_BLEAK_SCANNER is mock_scanner
uninstall_multiple_bleak_catcher()
instance = bleak.BleakScanner()
assert not isinstance(instance, HaBleakScannerWrapper)

View File

@ -1,7 +1,8 @@
"""Tests for the bluetooth_le_tracker component."""
"""Session fixtures."""
import pytest
@pytest.fixture(autouse=True)
def bluetooth_le_tracker_auto_mock_bluetooth(mock_bluetooth):
"""Mock the bluetooth integration scanner."""
def mock_bluetooth(enable_bluetooth):
"""Auto mock bluetooth."""

View File

@ -23,7 +23,7 @@ def recorder_url_mock():
yield
async def test_setup(hass, mock_zeroconf, mock_get_source_ip):
async def test_setup(hass, mock_zeroconf, mock_get_source_ip, mock_bluetooth):
"""Test setup."""
recorder_helper.async_initialize_recorder(hass)
assert await async_setup_component(hass, "default_config", {"foo": "bar"})

View File

@ -4,5 +4,5 @@ import pytest
@pytest.fixture(autouse=True)
def mock_bluetooth(mock_bleak_scanner_start):
def mock_bluetooth(enable_bluetooth):
"""Auto mock bluetooth."""

View File

@ -4,5 +4,5 @@ import pytest
@pytest.fixture(autouse=True)
def auto_mock_bleak_scanner_start(mock_bleak_scanner_start):
"""Auto mock bleak scanner start."""
def mock_bluetooth(enable_bluetooth):
"""Auto mock bluetooth."""

View File

@ -871,6 +871,24 @@ def mock_integration_frame():
yield correct_frame
@pytest.fixture(name="enable_bluetooth")
async def mock_enable_bluetooth(
hass, mock_bleak_scanner_start, mock_bluetooth_adapters
):
"""Fixture to mock starting the bleak scanner."""
entry = MockConfigEntry(domain="bluetooth")
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@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()):
yield
@pytest.fixture(name="mock_bleak_scanner_start")
def mock_bleak_scanner_start():
"""Fixture to mock starting the bleak scanner."""
@ -900,5 +918,5 @@ def mock_bleak_scanner_start():
@pytest.fixture(name="mock_bluetooth")
def mock_bluetooth(mock_bleak_scanner_start):
def mock_bluetooth(mock_bleak_scanner_start, mock_bluetooth_adapters):
"""Mock out bluetooth from starting."""