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

View File

@ -4,7 +4,8 @@
"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"], "requirements": ["bleak==0.14.3", "bluetooth-adapters==0.1.1"],
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],
"config_flow": true,
"iot_class": "local_push" "iot_class": "local_push"
} }

View File

@ -48,13 +48,24 @@ def _dispatch_callback(
class HaBleakScanner(BleakScanner): # type: ignore[misc] class HaBleakScanner(BleakScanner): # type: ignore[misc]
"""BleakScanner that cannot be stopped.""" """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.""" """Initialize the BleakScanner."""
self._callbacks: list[ self._callbacks: list[
tuple[AdvertisementDataCallback, dict[str, set[str]]] tuple[AdvertisementDataCallback, dict[str, set[str]]]
] = [] ] = []
self.history: dict[str, tuple[BLEDevice, AdvertisementData]] = {} self.history: dict[str, tuple[BLEDevice, AdvertisementData]] = {}
# 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) super().__init__(*args, **kwargs)
self._setup = True
@hass_callback @hass_callback
def async_register_callback( def async_register_callback(

View File

@ -2,6 +2,9 @@
"config": { "config": {
"flow_title": "{name}", "flow_title": "{name}",
"step": { "step": {
"enable_bluetooth": {
"description": "Do you want to setup Bluetooth?"
},
"user": { "user": {
"description": "Choose a device to setup", "description": "Choose a device to setup",
"data": { "data": {
@ -11,6 +14,9 @@
"bluetooth_confirm": { "bluetooth_confirm": {
"description": "Do you want to setup {name}?" "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": { "config": {
"abort": {
"already_configured": "Service is already configured"
},
"flow_title": "{name}", "flow_title": "{name}",
"step": { "step": {
"bluetooth_confirm": { "bluetooth_confirm": {
"description": "Do you want to setup {name}?" "description": "Do you want to setup {name}?"
}, },
"enable_bluetooth": {
"description": "Do you want to setup Bluetooth?"
},
"user": { "user": {
"data": { "data": {
"address": "Device" "address": "Device"

View File

@ -3,11 +3,16 @@ from __future__ import annotations
import bleak import bleak
from . import models from .models import HaBleakScannerWrapper
from .models import HaBleakScanner, 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.""" """Wrap the bleak classes to return the shared instance if multiple instances are detected."""
models.HA_BLEAK_SCANNER = hass_bleak_scanner
bleak.BleakScanner = HaBleakScannerWrapper 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": [ "dependencies": [
"application_credentials", "application_credentials",
"automation", "automation",
"bluetooth",
"cloud", "cloud",
"counter", "counter",
"dhcp", "dhcp",

View File

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

View File

@ -10,6 +10,8 @@ atomicwrites-homeassistant==1.4.1
attrs==21.2.0 attrs==21.2.0
awesomeversion==22.6.0 awesomeversion==22.6.0
bcrypt==3.1.7 bcrypt==3.1.7
bleak==0.14.3
bluetooth-adapters==0.1.1
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

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

View File

@ -334,6 +334,9 @@ blebox_uniapi==2.0.2
# homeassistant.components.blink # homeassistant.components.blink
blinkpy==0.19.0 blinkpy==0.19.0
# homeassistant.components.bluetooth
bluetooth-adapters==0.1.1
# homeassistant.components.bond # homeassistant.components.bond
bond-async==0.1.22 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"

View File

@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch
from bleak import BleakError from bleak import BleakError
from bleak.backends.scanner import AdvertisementData, BLEDevice from bleak.backends.scanner import AdvertisementData, BLEDevice
import pytest
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth import (
@ -11,6 +12,7 @@ from homeassistant.components.bluetooth import (
UNAVAILABLE_TRACK_SECONDS, UNAVAILABLE_TRACK_SECONDS,
BluetoothChange, BluetoothChange,
BluetoothServiceInfo, BluetoothServiceInfo,
async_get_scanner,
async_track_unavailable, async_track_unavailable,
models, models,
) )
@ -19,10 +21,10 @@ from homeassistant.core import callback
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from tests.common import async_fire_time_changed from tests.common import MockConfigEntry, async_fire_time_changed
async def test_setup_and_stop(hass, mock_bleak_scanner_start): async def test_setup_and_stop(hass, mock_bleak_scanner_start, enable_bluetooth):
"""Test we and setup and stop the scanner.""" """Test we and setup and stop the scanner."""
mock_bt = [ mock_bt = [
{"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"}
@ -47,33 +49,57 @@ async def test_setup_and_stop_no_bluetooth(hass, caplog):
{"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"}
] ]
with patch( with patch(
"homeassistant.components.bluetooth.HaBleakScanner", side_effect=BleakError "homeassistant.components.bluetooth.HaBleakScanner.async_setup",
side_effect=BleakError,
) as mock_ha_bleak_scanner, patch( ) as mock_ha_bleak_scanner, patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
), patch.object(
hass.config_entries.flow, "async_init"
): ):
assert await async_setup_component( assert await async_setup_component(
hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}
) )
await hass.async_block_till_done()
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done() await hass.async_block_till_done()
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_ha_bleak_scanner.mock_calls) == 1 assert len(mock_ha_bleak_scanner.mock_calls) == 1
assert "Could not create bluetooth scanner" in caplog.text assert "Failed to initialize Bluetooth" in caplog.text
async def test_setup_and_stop_broken_bluetooth(hass, caplog):
"""Test we fail gracefully when bluetooth/dbus is broken."""
mock_bt = [
{"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"}
]
with patch("homeassistant.components.bluetooth.HaBleakScanner.async_setup"), patch(
"homeassistant.components.bluetooth.HaBleakScanner.start",
side_effect=BleakError,
), patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
):
assert await async_setup_component(
hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}
)
await hass.async_block_till_done()
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 "Failed to start Bluetooth" in caplog.text
assert len(bluetooth.async_discovered_service_info(hass)) == 0
async def test_calling_async_discovered_devices_no_bluetooth(hass, caplog): async def test_calling_async_discovered_devices_no_bluetooth(hass, caplog):
"""Test we fail gracefully when asking for discovered devices and there is no blueooth.""" """Test we fail gracefully when asking for discovered devices and there is no blueooth."""
mock_bt = [] mock_bt = []
with patch( with patch(
"homeassistant.components.bluetooth.HaBleakScanner", side_effect=BleakError "homeassistant.components.bluetooth.HaBleakScanner.async_setup",
) as mock_ha_bleak_scanner, patch( side_effect=FileNotFoundError,
), patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
), patch.object(
hass.config_entries.flow, "async_init"
): ):
assert await async_setup_component( assert await async_setup_component(
hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}
@ -83,13 +109,14 @@ async def test_calling_async_discovered_devices_no_bluetooth(hass, caplog):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_ha_bleak_scanner.mock_calls) == 1 assert "Failed to initialize Bluetooth" in caplog.text
assert "Could not create bluetooth scanner" in caplog.text
assert not bluetooth.async_discovered_service_info(hass) assert not bluetooth.async_discovered_service_info(hass)
assert not bluetooth.async_address_present(hass, "aa:bb:bb:dd:ee:ff") assert not bluetooth.async_address_present(hass, "aa:bb:bb:dd:ee:ff")
async def test_discovery_match_by_service_uuid(hass, mock_bleak_scanner_start): async def test_discovery_match_by_service_uuid(
hass, mock_bleak_scanner_start, enable_bluetooth
):
"""Test bluetooth discovery match by service_uuid.""" """Test bluetooth discovery match by service_uuid."""
mock_bt = [ mock_bt = [
{"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"}
@ -108,7 +135,7 @@ async def test_discovery_match_by_service_uuid(hass, mock_bleak_scanner_start):
wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name")
wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[])
models.HA_BLEAK_SCANNER._callback(wrong_device, wrong_adv) async_get_scanner(hass)._callback(wrong_device, wrong_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
@ -118,7 +145,7 @@ async def test_discovery_match_by_service_uuid(hass, mock_bleak_scanner_start):
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
) )
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) async_get_scanner(hass)._callback(switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
@ -130,10 +157,13 @@ async def test_discovery_match_by_local_name(hass, mock_bleak_scanner_start):
mock_bt = [{"domain": "switchbot", "local_name": "wohand"}] mock_bt = [{"domain": "switchbot", "local_name": "wohand"}]
with patch( with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: ):
assert await async_setup_component( assert await async_setup_component(
hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}
) )
await hass.async_block_till_done()
with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow:
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -142,7 +172,7 @@ async def test_discovery_match_by_local_name(hass, mock_bleak_scanner_start):
wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name")
wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[])
models.HA_BLEAK_SCANNER._callback(wrong_device, wrong_adv) async_get_scanner(hass)._callback(wrong_device, wrong_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
@ -150,7 +180,7 @@ async def test_discovery_match_by_local_name(hass, mock_bleak_scanner_start):
switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand")
switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[])
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) async_get_scanner(hass)._callback(switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
@ -170,10 +200,13 @@ async def test_discovery_match_by_manufacturer_id_and_first_byte(
] ]
with patch( with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: ):
assert await async_setup_component( assert await async_setup_component(
hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}
) )
await hass.async_block_till_done()
with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow:
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -186,7 +219,7 @@ async def test_discovery_match_by_manufacturer_id_and_first_byte(
manufacturer_data={76: b"\x06\x02\x03\x99"}, manufacturer_data={76: b"\x06\x02\x03\x99"},
) )
models.HA_BLEAK_SCANNER._callback(hkc_device, hkc_adv) async_get_scanner(hass)._callback(hkc_device, hkc_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1
@ -194,7 +227,7 @@ async def test_discovery_match_by_manufacturer_id_and_first_byte(
mock_config_flow.reset_mock() mock_config_flow.reset_mock()
# 2nd discovery should not generate another flow # 2nd discovery should not generate another flow
models.HA_BLEAK_SCANNER._callback(hkc_device, hkc_adv) async_get_scanner(hass)._callback(hkc_device, hkc_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
@ -205,7 +238,7 @@ async def test_discovery_match_by_manufacturer_id_and_first_byte(
local_name="lock", service_uuids=[], manufacturer_data={76: b"\x02"} local_name="lock", service_uuids=[], manufacturer_data={76: b"\x02"}
) )
models.HA_BLEAK_SCANNER._callback(not_hkc_device, not_hkc_adv) async_get_scanner(hass)._callback(not_hkc_device, not_hkc_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
@ -214,14 +247,14 @@ async def test_discovery_match_by_manufacturer_id_and_first_byte(
local_name="lock", service_uuids=[], manufacturer_data={21: b"\x02"} local_name="lock", service_uuids=[], manufacturer_data={21: b"\x02"}
) )
models.HA_BLEAK_SCANNER._callback(not_apple_device, not_apple_adv) async_get_scanner(hass)._callback(not_apple_device, not_apple_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_config_flow.mock_calls) == 0 assert len(mock_config_flow.mock_calls) == 0
async def test_async_discovered_device_api(hass, mock_bleak_scanner_start): async def test_async_discovered_device_api(hass, mock_bleak_scanner_start):
"""Test the async_discovered_device_api.""" """Test the async_discovered_device API."""
mock_bt = [] mock_bt = []
with patch( with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
@ -231,10 +264,12 @@ async def test_async_discovered_device_api(hass, mock_bleak_scanner_start):
): ):
assert not bluetooth.async_discovered_service_info(hass) assert not bluetooth.async_discovered_service_info(hass)
assert not bluetooth.async_address_present(hass, "44:44:22:22:11:22") assert not bluetooth.async_address_present(hass, "44:44:22:22:11:22")
assert await async_setup_component( assert await async_setup_component(
hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}
) )
await hass.async_block_till_done()
with patch.object(hass.config_entries.flow, "async_init"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -244,10 +279,10 @@ async def test_async_discovered_device_api(hass, mock_bleak_scanner_start):
wrong_device = BLEDevice("44:44:33:11:23:42", "wrong_name") wrong_device = BLEDevice("44:44:33:11:23:42", "wrong_name")
wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[]) wrong_adv = AdvertisementData(local_name="wrong_name", service_uuids=[])
models.HA_BLEAK_SCANNER._callback(wrong_device, wrong_adv) async_get_scanner(hass)._callback(wrong_device, wrong_adv)
switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand")
switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[])
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) async_get_scanner(hass)._callback(switchbot_device, switchbot_adv)
wrong_device_went_unavailable = False wrong_device_went_unavailable = False
switchbot_device_went_unavailable = False switchbot_device_went_unavailable = False
@ -281,8 +316,8 @@ async def test_async_discovered_device_api(hass, mock_bleak_scanner_start):
assert wrong_device_went_unavailable is True assert wrong_device_went_unavailable is True
# See the devices again # See the devices again
models.HA_BLEAK_SCANNER._callback(wrong_device, wrong_adv) async_get_scanner(hass)._callback(wrong_device, wrong_adv)
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) async_get_scanner(hass)._callback(switchbot_device, switchbot_adv)
# Cancel the callbacks # Cancel the callbacks
wrong_device_unavailable_cancel() wrong_device_unavailable_cancel()
switchbot_device_unavailable_cancel() switchbot_device_unavailable_cancel()
@ -308,7 +343,7 @@ async def test_async_discovered_device_api(hass, mock_bleak_scanner_start):
assert bluetooth.async_address_present(hass, "44:44:33:11:23:45") is True assert bluetooth.async_address_present(hass, "44:44:33:11:23:45") is True
async def test_register_callbacks(hass, mock_bleak_scanner_start): async def test_register_callbacks(hass, mock_bleak_scanner_start, enable_bluetooth):
"""Test registering a callback.""" """Test registering a callback."""
mock_bt = [] mock_bt = []
callbacks = [] callbacks = []
@ -347,25 +382,25 @@ async def test_register_callbacks(hass, mock_bleak_scanner_start):
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
) )
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) async_get_scanner(hass)._callback(switchbot_device, switchbot_adv)
empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_device = BLEDevice("11:22:33:44:55:66", "empty")
empty_adv = AdvertisementData(local_name="empty") empty_adv = AdvertisementData(local_name="empty")
models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) async_get_scanner(hass)._callback(empty_device, empty_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_device = BLEDevice("11:22:33:44:55:66", "empty")
empty_adv = AdvertisementData(local_name="empty") empty_adv = AdvertisementData(local_name="empty")
# 3rd callback raises ValueError but is still tracked # 3rd callback raises ValueError but is still tracked
models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) async_get_scanner(hass)._callback(empty_device, empty_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
cancel() cancel()
# 4th callback should not be tracked since we canceled # 4th callback should not be tracked since we canceled
models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) async_get_scanner(hass)._callback(empty_device, empty_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(callbacks) == 3 assert len(callbacks) == 3
@ -389,7 +424,9 @@ async def test_register_callbacks(hass, mock_bleak_scanner_start):
assert service_info.manufacturer_id is None assert service_info.manufacturer_id is None
async def test_register_callback_by_address(hass, mock_bleak_scanner_start): async def test_register_callback_by_address(
hass, mock_bleak_scanner_start, enable_bluetooth
):
"""Test registering a callback by address.""" """Test registering a callback by address."""
mock_bt = [] mock_bt = []
callbacks = [] callbacks = []
@ -404,10 +441,13 @@ async def test_register_callback_by_address(hass, mock_bleak_scanner_start):
with patch( with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
), patch.object(hass.config_entries.flow, "async_init"): ):
assert await async_setup_component( assert await async_setup_component(
hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}
) )
await hass.async_block_till_done()
with patch.object(hass.config_entries.flow, "async_init"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -427,25 +467,25 @@ async def test_register_callback_by_address(hass, mock_bleak_scanner_start):
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
) )
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) async_get_scanner(hass)._callback(switchbot_device, switchbot_adv)
empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_device = BLEDevice("11:22:33:44:55:66", "empty")
empty_adv = AdvertisementData(local_name="empty") empty_adv = AdvertisementData(local_name="empty")
models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) async_get_scanner(hass)._callback(empty_device, empty_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_device = BLEDevice("11:22:33:44:55:66", "empty")
empty_adv = AdvertisementData(local_name="empty") empty_adv = AdvertisementData(local_name="empty")
# 3rd callback raises ValueError but is still tracked # 3rd callback raises ValueError but is still tracked
models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) async_get_scanner(hass)._callback(empty_device, empty_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
cancel() cancel()
# 4th callback should not be tracked since we canceled # 4th callback should not be tracked since we canceled
models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) async_get_scanner(hass)._callback(empty_device, empty_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
# Now register again with a callback that fails to # Now register again with a callback that fails to
@ -475,14 +515,19 @@ async def test_register_callback_by_address(hass, mock_bleak_scanner_start):
assert service_info.manufacturer_id == 89 assert service_info.manufacturer_id == 89
async def test_wrapped_instance_with_filter(hass, mock_bleak_scanner_start): async def test_wrapped_instance_with_filter(
hass, mock_bleak_scanner_start, enable_bluetooth
):
"""Test consumers can use the wrapped instance with a filter as if it was normal BleakScanner.""" """Test consumers can use the wrapped instance with a filter as if it was normal BleakScanner."""
with patch( with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=[] "homeassistant.components.bluetooth.async_get_bluetooth", return_value=[]
), patch.object(hass.config_entries.flow, "async_init"): ):
assert await async_setup_component( assert await async_setup_component(
hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}
) )
await hass.async_block_till_done()
with patch.object(hass.config_entries.flow, "async_init"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -504,15 +549,15 @@ async def test_wrapped_instance_with_filter(hass, mock_bleak_scanner_start):
empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_device = BLEDevice("11:22:33:44:55:66", "empty")
empty_adv = AdvertisementData(local_name="empty") empty_adv = AdvertisementData(local_name="empty")
assert models.HA_BLEAK_SCANNER is not None assert async_get_scanner(hass) is not None
scanner = models.HaBleakScannerWrapper( scanner = models.HaBleakScannerWrapper(
filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]} filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]}
) )
scanner.register_detection_callback(_device_detected) scanner.register_detection_callback(_device_detected)
mock_discovered = [MagicMock()] mock_discovered = [MagicMock()]
type(models.HA_BLEAK_SCANNER).discovered_devices = mock_discovered type(async_get_scanner(hass)).discovered_devices = mock_discovered
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) async_get_scanner(hass)._callback(switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
discovered = await scanner.discover(timeout=0) discovered = await scanner.discover(timeout=0)
@ -527,28 +572,33 @@ async def test_wrapped_instance_with_filter(hass, mock_bleak_scanner_start):
# We should get a reply from the history when we register again # We should get a reply from the history when we register again
assert len(detected) == 3 assert len(detected) == 3
type(models.HA_BLEAK_SCANNER).discovered_devices = [] type(async_get_scanner(hass)).discovered_devices = []
discovered = await scanner.discover(timeout=0) discovered = await scanner.discover(timeout=0)
assert len(discovered) == 0 assert len(discovered) == 0
assert discovered == [] assert discovered == []
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) async_get_scanner(hass)._callback(switchbot_device, switchbot_adv)
assert len(detected) == 4 assert len(detected) == 4
# The filter we created in the wrapped scanner with should be respected # The filter we created in the wrapped scanner with should be respected
# and we should not get another callback # and we should not get another callback
models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) async_get_scanner(hass)._callback(empty_device, empty_adv)
assert len(detected) == 4 assert len(detected) == 4
async def test_wrapped_instance_with_service_uuids(hass, mock_bleak_scanner_start): async def test_wrapped_instance_with_service_uuids(
hass, mock_bleak_scanner_start, enable_bluetooth
):
"""Test consumers can use the wrapped instance with a service_uuids list as if it was normal BleakScanner.""" """Test consumers can use the wrapped instance with a service_uuids list as if it was normal BleakScanner."""
with patch( with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=[] "homeassistant.components.bluetooth.async_get_bluetooth", return_value=[]
), patch.object(hass.config_entries.flow, "async_init"): ):
assert await async_setup_component( assert await async_setup_component(
hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}
) )
await hass.async_block_till_done()
with patch.object(hass.config_entries.flow, "async_init"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -570,26 +620,28 @@ async def test_wrapped_instance_with_service_uuids(hass, mock_bleak_scanner_star
empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_device = BLEDevice("11:22:33:44:55:66", "empty")
empty_adv = AdvertisementData(local_name="empty") empty_adv = AdvertisementData(local_name="empty")
assert models.HA_BLEAK_SCANNER is not None assert async_get_scanner(hass) is not None
scanner = models.HaBleakScannerWrapper( scanner = models.HaBleakScannerWrapper(
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
) )
scanner.register_detection_callback(_device_detected) scanner.register_detection_callback(_device_detected)
type(models.HA_BLEAK_SCANNER).discovered_devices = [MagicMock()] type(async_get_scanner(hass)).discovered_devices = [MagicMock()]
for _ in range(2): for _ in range(2):
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) async_get_scanner(hass)._callback(switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(detected) == 2 assert len(detected) == 2
# The UUIDs list we created in the wrapped scanner with should be respected # The UUIDs list we created in the wrapped scanner with should be respected
# and we should not get another callback # and we should not get another callback
models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) async_get_scanner(hass)._callback(empty_device, empty_adv)
assert len(detected) == 2 assert len(detected) == 2
async def test_wrapped_instance_with_broken_callbacks(hass, mock_bleak_scanner_start): async def test_wrapped_instance_with_broken_callbacks(
hass, mock_bleak_scanner_start, enable_bluetooth
):
"""Test broken callbacks do not cause the scanner to fail.""" """Test broken callbacks do not cause the scanner to fail."""
with patch( with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=[] "homeassistant.components.bluetooth.async_get_bluetooth", return_value=[]
@ -597,6 +649,9 @@ async def test_wrapped_instance_with_broken_callbacks(hass, mock_bleak_scanner_s
assert await async_setup_component( assert await async_setup_component(
hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}
) )
await hass.async_block_till_done()
with patch.object(hass.config_entries.flow, "async_init"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -618,30 +673,34 @@ async def test_wrapped_instance_with_broken_callbacks(hass, mock_bleak_scanner_s
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
) )
assert models.HA_BLEAK_SCANNER is not None assert async_get_scanner(hass) is not None
scanner = models.HaBleakScannerWrapper( scanner = models.HaBleakScannerWrapper(
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
) )
scanner.register_detection_callback(_device_detected) scanner.register_detection_callback(_device_detected)
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) async_get_scanner(hass)._callback(switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) async_get_scanner(hass)._callback(switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(detected) == 1 assert len(detected) == 1
async def test_wrapped_instance_changes_uuids(hass, mock_bleak_scanner_start): async def test_wrapped_instance_changes_uuids(
hass, mock_bleak_scanner_start, enable_bluetooth
):
"""Test consumers can use the wrapped instance can change the uuids later.""" """Test consumers can use the wrapped instance can change the uuids later."""
with patch( with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=[] "homeassistant.components.bluetooth.async_get_bluetooth", return_value=[]
), patch.object(hass.config_entries.flow, "async_init"): ):
assert await async_setup_component( assert await async_setup_component(
hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}
) )
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done() await hass.async_block_till_done()
with patch.object(hass.config_entries.flow, "async_init"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
detected = [] detected = []
def _device_detected( def _device_detected(
@ -660,35 +719,41 @@ async def test_wrapped_instance_changes_uuids(hass, mock_bleak_scanner_start):
empty_device = BLEDevice("11:22:33:44:55:66", "empty") empty_device = BLEDevice("11:22:33:44:55:66", "empty")
empty_adv = AdvertisementData(local_name="empty") empty_adv = AdvertisementData(local_name="empty")
assert models.HA_BLEAK_SCANNER is not None assert async_get_scanner(hass) is not None
scanner = models.HaBleakScannerWrapper() scanner = models.HaBleakScannerWrapper()
scanner.set_scanning_filter(service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]) scanner.set_scanning_filter(
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
scanner.register_detection_callback(_device_detected) scanner.register_detection_callback(_device_detected)
type(models.HA_BLEAK_SCANNER).discovered_devices = [MagicMock()] type(async_get_scanner(hass)).discovered_devices = [MagicMock()]
for _ in range(2): for _ in range(2):
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) async_get_scanner(hass)._callback(switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(detected) == 2 assert len(detected) == 2
# The UUIDs list we created in the wrapped scanner with should be respected # The UUIDs list we created in the wrapped scanner with should be respected
# and we should not get another callback # and we should not get another callback
models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) async_get_scanner(hass)._callback(empty_device, empty_adv)
assert len(detected) == 2 assert len(detected) == 2
async def test_wrapped_instance_changes_filters(hass, mock_bleak_scanner_start): async def test_wrapped_instance_changes_filters(
hass, mock_bleak_scanner_start, enable_bluetooth
):
"""Test consumers can use the wrapped instance can change the filter later.""" """Test consumers can use the wrapped instance can change the filter later."""
with patch( with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=[] "homeassistant.components.bluetooth.async_get_bluetooth", return_value=[]
), patch.object(hass.config_entries.flow, "async_init"): ):
assert await async_setup_component( assert await async_setup_component(
hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}
) )
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done() await hass.async_block_till_done()
with patch.object(hass.config_entries.flow, "async_init"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
detected = [] detected = []
def _device_detected( def _device_detected(
@ -707,40 +772,42 @@ async def test_wrapped_instance_changes_filters(hass, mock_bleak_scanner_start):
empty_device = BLEDevice("11:22:33:44:55:62", "empty") empty_device = BLEDevice("11:22:33:44:55:62", "empty")
empty_adv = AdvertisementData(local_name="empty") empty_adv = AdvertisementData(local_name="empty")
assert models.HA_BLEAK_SCANNER is not None assert async_get_scanner(hass) is not None
scanner = models.HaBleakScannerWrapper() scanner = models.HaBleakScannerWrapper()
scanner.set_scanning_filter( scanner.set_scanning_filter(
filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]} filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]}
) )
scanner.register_detection_callback(_device_detected) scanner.register_detection_callback(_device_detected)
type(models.HA_BLEAK_SCANNER).discovered_devices = [MagicMock()] type(async_get_scanner(hass)).discovered_devices = [MagicMock()]
for _ in range(2): for _ in range(2):
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) async_get_scanner(hass)._callback(switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(detected) == 2 assert len(detected) == 2
# The UUIDs list we created in the wrapped scanner with should be respected # The UUIDs list we created in the wrapped scanner with should be respected
# and we should not get another callback # and we should not get another callback
models.HA_BLEAK_SCANNER._callback(empty_device, empty_adv) async_get_scanner(hass)._callback(empty_device, empty_adv)
assert len(detected) == 2 assert len(detected) == 2
async def test_wrapped_instance_unsupported_filter( async def test_wrapped_instance_unsupported_filter(
hass, mock_bleak_scanner_start, caplog hass, mock_bleak_scanner_start, caplog, enable_bluetooth
): ):
"""Test we want when their filter is ineffective.""" """Test we want when their filter is ineffective."""
with patch( with patch(
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=[] "homeassistant.components.bluetooth.async_get_bluetooth", return_value=[]
), patch.object(hass.config_entries.flow, "async_init"): ):
assert await async_setup_component( assert await async_setup_component(
hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}}
) )
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done() await hass.async_block_till_done()
assert models.HA_BLEAK_SCANNER is not None with patch.object(hass.config_entries.flow, "async_init"):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert async_get_scanner(hass) is not None
scanner = models.HaBleakScannerWrapper() scanner = models.HaBleakScannerWrapper()
scanner.set_scanning_filter( scanner.set_scanning_filter(
filters={ filters={
@ -778,7 +845,7 @@ async def test_async_ble_device_from_address(hass, mock_bleak_scanner_start):
switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand")
switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[]) switchbot_adv = AdvertisementData(local_name="wohand", service_uuids=[])
models.HA_BLEAK_SCANNER._callback(switchbot_device, switchbot_adv) async_get_scanner(hass)._callback(switchbot_device, switchbot_adv)
await hass.async_block_till_done() await hass.async_block_till_done()
assert ( assert (
@ -789,3 +856,82 @@ async def test_async_ble_device_from_address(hass, mock_bleak_scanner_start):
assert ( assert (
bluetooth.async_ble_device_from_address(hass, "00:66:33:22:11:22") is None bluetooth.async_ble_device_from_address(hass, "00:66:33:22:11:22") is None
) )
async def test_setup_without_bluetooth_in_configuration_yaml(hass, mock_bluetooth):
"""Test setting up without bluetooth in configuration.yaml does not create the config entry."""
assert await async_setup_component(hass, bluetooth.DOMAIN, {})
await hass.async_block_till_done()
assert not hass.config_entries.async_entries(bluetooth.DOMAIN)
async def test_setup_with_bluetooth_in_configuration_yaml(hass, mock_bluetooth):
"""Test setting up with bluetooth in configuration.yaml creates the config entry."""
assert await async_setup_component(hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}})
await hass.async_block_till_done()
assert hass.config_entries.async_entries(bluetooth.DOMAIN)
async def test_can_unsetup_bluetooth(hass, mock_bleak_scanner_start, enable_bluetooth):
"""Test we can setup and unsetup bluetooth."""
entry = MockConfigEntry(domain=bluetooth.DOMAIN, data={})
entry.add_to_hass(hass)
for _ in range(2):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_auto_detect_bluetooth_adapters_linux(hass):
"""Test we auto detect bluetooth adapters on linux."""
with patch(
"bluetooth_adapters.get_bluetooth_adapters", return_value={"hci0"}
), patch(
"homeassistant.components.bluetooth.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_none_found(hass):
"""Test we auto detect bluetooth adapters on linux with no adapters found."""
with patch("bluetooth_adapters.get_bluetooth_adapters", return_value=set()), patch(
"homeassistant.components.bluetooth.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)) == 0
async def test_auto_detect_bluetooth_adapters_macos(hass):
"""Test we auto detect bluetooth adapters on macos."""
with patch(
"homeassistant.components.bluetooth.platform.system", return_value="Darwin"
):
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_no_auto_detect_bluetooth_adapters_windows(hass):
"""Test we auto detect bluetooth adapters on windows."""
with patch(
"homeassistant.components.bluetooth.platform.system", return_value="Windows"
):
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)) == 0
async def test_raising_runtime_error_when_no_bluetooth(hass):
"""Test we raise an exception if we try to get the scanner when its not there."""
with pytest.raises(RuntimeError):
bluetooth.async_get_scanner(hass)

View File

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

View File

@ -1,22 +1,25 @@
"""Tests for the Bluetooth integration.""" """Tests for the Bluetooth integration."""
from unittest.mock import MagicMock
import bleak import bleak
from homeassistant.components.bluetooth import models
from homeassistant.components.bluetooth.models import HaBleakScannerWrapper 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): async def test_multiple_bleak_scanner_instances(hass):
"""Test creating multiple zeroconf throws without an integration.""" """Test creating multiple BleakScanners without an integration."""
assert models.HA_BLEAK_SCANNER is None install_multiple_bleak_catcher()
mock_scanner = MagicMock()
install_multiple_bleak_catcher(mock_scanner)
instance = bleak.BleakScanner() instance = bleak.BleakScanner()
assert isinstance(instance, HaBleakScannerWrapper) 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 import pytest
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def bluetooth_le_tracker_auto_mock_bluetooth(mock_bluetooth): def mock_bluetooth(enable_bluetooth):
"""Mock the bluetooth integration scanner.""" """Auto mock bluetooth."""

View File

@ -23,7 +23,7 @@ def recorder_url_mock():
yield 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.""" """Test setup."""
recorder_helper.async_initialize_recorder(hass) recorder_helper.async_initialize_recorder(hass)
assert await async_setup_component(hass, "default_config", {"foo": "bar"}) assert await async_setup_component(hass, "default_config", {"foo": "bar"})

View File

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

View File

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

View File

@ -871,6 +871,24 @@ def mock_integration_frame():
yield correct_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") @pytest.fixture(name="mock_bleak_scanner_start")
def mock_bleak_scanner_start(): def mock_bleak_scanner_start():
"""Fixture to mock starting the bleak scanner.""" """Fixture to mock starting the bleak scanner."""
@ -900,5 +918,5 @@ def mock_bleak_scanner_start():
@pytest.fixture(name="mock_bluetooth") @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.""" """Mock out bluetooth from starting."""