mirror of https://github.com/home-assistant/core
1663 lines
54 KiB
Python
1663 lines
54 KiB
Python
"""Tests for the Bluetooth integration manager."""
|
|
|
|
from collections.abc import Generator
|
|
from datetime import timedelta
|
|
import time
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
from bleak.backends.scanner import AdvertisementData, BLEDevice
|
|
from bluetooth_adapters import AdvertisementHistory
|
|
|
|
# pylint: disable-next=no-name-in-module
|
|
from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS
|
|
import pytest
|
|
|
|
from homeassistant import config_entries
|
|
from homeassistant.components import bluetooth
|
|
from homeassistant.components.bluetooth import (
|
|
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
|
|
MONOTONIC_TIME,
|
|
BaseHaRemoteScanner,
|
|
BluetoothChange,
|
|
BluetoothScanningMode,
|
|
BluetoothServiceInfo,
|
|
BluetoothServiceInfoBleak,
|
|
HaBluetoothConnector,
|
|
async_ble_device_from_address,
|
|
async_get_fallback_availability_interval,
|
|
async_get_learned_advertising_interval,
|
|
async_scanner_count,
|
|
async_set_fallback_availability_interval,
|
|
async_track_unavailable,
|
|
storage,
|
|
)
|
|
from homeassistant.components.bluetooth.const import (
|
|
SOURCE_LOCAL,
|
|
UNAVAILABLE_TRACK_SECONDS,
|
|
)
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.helpers.discovery_flow import DiscoveryKey
|
|
from homeassistant.setup import async_setup_component
|
|
from homeassistant.util import dt as dt_util
|
|
from homeassistant.util.json import json_loads
|
|
|
|
from . import (
|
|
FakeScanner,
|
|
MockBleakClient,
|
|
_get_manager,
|
|
generate_advertisement_data,
|
|
generate_ble_device,
|
|
inject_advertisement_with_source,
|
|
inject_advertisement_with_time_and_source,
|
|
inject_advertisement_with_time_and_source_connectable,
|
|
patch_bluetooth_time,
|
|
)
|
|
|
|
from tests.common import (
|
|
MockConfigEntry,
|
|
MockModule,
|
|
async_fire_time_changed,
|
|
load_fixture,
|
|
mock_integration,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]:
|
|
"""Register an hci0 scanner."""
|
|
hci0_scanner = FakeScanner("hci0", "hci0")
|
|
cancel = bluetooth.async_register_scanner(hass, hci0_scanner)
|
|
yield
|
|
cancel()
|
|
|
|
|
|
@pytest.fixture
|
|
def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]:
|
|
"""Register an hci1 scanner."""
|
|
hci1_scanner = FakeScanner("hci1", "hci1")
|
|
cancel = bluetooth.async_register_scanner(hass, hci1_scanner)
|
|
yield
|
|
cancel()
|
|
|
|
|
|
@pytest.mark.usefixtures("enable_bluetooth")
|
|
async def test_advertisements_do_not_switch_adapters_for_no_reason(
|
|
hass: HomeAssistant,
|
|
register_hci0_scanner: None,
|
|
register_hci1_scanner: None,
|
|
) -> None:
|
|
"""Test we only switch adapters when needed."""
|
|
|
|
address = "44:44:33:11:23:12"
|
|
|
|
switchbot_device_signal_100 = generate_ble_device(
|
|
address, "wohand_signal_100", rssi=-100
|
|
)
|
|
switchbot_adv_signal_100 = generate_advertisement_data(
|
|
local_name="wohand_signal_100", service_uuids=[]
|
|
)
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0"
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_signal_100
|
|
)
|
|
|
|
switchbot_device_signal_99 = generate_ble_device(
|
|
address, "wohand_signal_99", rssi=-99
|
|
)
|
|
switchbot_adv_signal_99 = generate_advertisement_data(
|
|
local_name="wohand_signal_99", service_uuids=[]
|
|
)
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_signal_99, switchbot_adv_signal_99, "hci0"
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_signal_99
|
|
)
|
|
|
|
switchbot_device_signal_98 = generate_ble_device(
|
|
address, "wohand_good_signal", rssi=-98
|
|
)
|
|
switchbot_adv_signal_98 = generate_advertisement_data(
|
|
local_name="wohand_good_signal", service_uuids=[]
|
|
)
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_signal_98, switchbot_adv_signal_98, "hci1"
|
|
)
|
|
|
|
# should not switch to hci1
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_signal_99
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("enable_bluetooth")
|
|
async def test_switching_adapters_based_on_rssi(
|
|
hass: HomeAssistant,
|
|
register_hci0_scanner: None,
|
|
register_hci1_scanner: None,
|
|
) -> None:
|
|
"""Test switching adapters based on rssi."""
|
|
|
|
address = "44:44:33:11:23:45"
|
|
|
|
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
|
|
switchbot_adv_poor_signal = generate_advertisement_data(
|
|
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
|
|
)
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0"
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_poor_signal
|
|
)
|
|
|
|
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
|
|
switchbot_adv_good_signal = generate_advertisement_data(
|
|
local_name="wohand_good_signal", service_uuids=[], rssi=-60
|
|
)
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1"
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_good_signal
|
|
)
|
|
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_good_signal, switchbot_adv_poor_signal, "hci0"
|
|
)
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_good_signal
|
|
)
|
|
|
|
# We should not switch adapters unless the signal hits the threshold
|
|
switchbot_device_similar_signal = generate_ble_device(
|
|
address, "wohand_similar_signal"
|
|
)
|
|
switchbot_adv_similar_signal = generate_advertisement_data(
|
|
local_name="wohand_similar_signal", service_uuids=[], rssi=-62
|
|
)
|
|
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_similar_signal, switchbot_adv_similar_signal, "hci0"
|
|
)
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_good_signal
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("enable_bluetooth")
|
|
async def test_switching_adapters_based_on_zero_rssi(
|
|
hass: HomeAssistant,
|
|
register_hci0_scanner: None,
|
|
register_hci1_scanner: None,
|
|
) -> None:
|
|
"""Test switching adapters based on zero rssi."""
|
|
|
|
address = "44:44:33:11:23:45"
|
|
|
|
switchbot_device_no_rssi = generate_ble_device(address, "wohand_poor_signal")
|
|
switchbot_adv_no_rssi = generate_advertisement_data(
|
|
local_name="wohand_no_rssi", service_uuids=[], rssi=0
|
|
)
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_no_rssi, switchbot_adv_no_rssi, "hci0"
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_no_rssi
|
|
)
|
|
|
|
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
|
|
switchbot_adv_good_signal = generate_advertisement_data(
|
|
local_name="wohand_good_signal", service_uuids=[], rssi=-60
|
|
)
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1"
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_good_signal
|
|
)
|
|
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_good_signal, switchbot_adv_no_rssi, "hci0"
|
|
)
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_good_signal
|
|
)
|
|
|
|
# We should not switch adapters unless the signal hits the threshold
|
|
switchbot_device_similar_signal = generate_ble_device(
|
|
address, "wohand_similar_signal"
|
|
)
|
|
switchbot_adv_similar_signal = generate_advertisement_data(
|
|
local_name="wohand_similar_signal", service_uuids=[], rssi=-62
|
|
)
|
|
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_similar_signal, switchbot_adv_similar_signal, "hci0"
|
|
)
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_good_signal
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("enable_bluetooth")
|
|
async def test_switching_adapters_based_on_stale(
|
|
hass: HomeAssistant,
|
|
register_hci0_scanner: None,
|
|
register_hci1_scanner: None,
|
|
) -> None:
|
|
"""Test switching adapters based on the previous advertisement being stale."""
|
|
|
|
address = "44:44:33:11:23:41"
|
|
start_time_monotonic = 50.0
|
|
|
|
switchbot_device_poor_signal_hci0 = generate_ble_device(
|
|
address, "wohand_poor_signal_hci0"
|
|
)
|
|
switchbot_adv_poor_signal_hci0 = generate_advertisement_data(
|
|
local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100
|
|
)
|
|
inject_advertisement_with_time_and_source(
|
|
hass,
|
|
switchbot_device_poor_signal_hci0,
|
|
switchbot_adv_poor_signal_hci0,
|
|
start_time_monotonic,
|
|
"hci0",
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_poor_signal_hci0
|
|
)
|
|
|
|
switchbot_device_poor_signal_hci1 = generate_ble_device(
|
|
address, "wohand_poor_signal_hci1"
|
|
)
|
|
switchbot_adv_poor_signal_hci1 = generate_advertisement_data(
|
|
local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99
|
|
)
|
|
inject_advertisement_with_time_and_source(
|
|
hass,
|
|
switchbot_device_poor_signal_hci1,
|
|
switchbot_adv_poor_signal_hci1,
|
|
start_time_monotonic,
|
|
"hci1",
|
|
)
|
|
|
|
# Should not switch adapters until the advertisement is stale
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_poor_signal_hci0
|
|
)
|
|
|
|
# Should switch to hci1 since the previous advertisement is stale
|
|
# even though the signal is poor because the device is now
|
|
# likely unreachable via hci0
|
|
inject_advertisement_with_time_and_source(
|
|
hass,
|
|
switchbot_device_poor_signal_hci1,
|
|
switchbot_adv_poor_signal_hci1,
|
|
start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1,
|
|
"hci1",
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_poor_signal_hci1
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("enable_bluetooth")
|
|
async def test_switching_adapters_based_on_stale_with_discovered_interval(
|
|
hass: HomeAssistant,
|
|
register_hci0_scanner: None,
|
|
register_hci1_scanner: None,
|
|
) -> None:
|
|
"""Test switching with discovered interval."""
|
|
|
|
address = "44:44:33:11:23:41"
|
|
start_time_monotonic = 50.0
|
|
|
|
switchbot_device_poor_signal_hci0 = generate_ble_device(
|
|
address, "wohand_poor_signal_hci0"
|
|
)
|
|
switchbot_adv_poor_signal_hci0 = generate_advertisement_data(
|
|
local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100
|
|
)
|
|
inject_advertisement_with_time_and_source(
|
|
hass,
|
|
switchbot_device_poor_signal_hci0,
|
|
switchbot_adv_poor_signal_hci0,
|
|
start_time_monotonic,
|
|
"hci0",
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_poor_signal_hci0
|
|
)
|
|
|
|
bluetooth.async_set_fallback_availability_interval(hass, address, 10)
|
|
|
|
switchbot_device_poor_signal_hci1 = generate_ble_device(
|
|
address, "wohand_poor_signal_hci1"
|
|
)
|
|
switchbot_adv_poor_signal_hci1 = generate_advertisement_data(
|
|
local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99
|
|
)
|
|
inject_advertisement_with_time_and_source(
|
|
hass,
|
|
switchbot_device_poor_signal_hci1,
|
|
switchbot_adv_poor_signal_hci1,
|
|
start_time_monotonic,
|
|
"hci1",
|
|
)
|
|
|
|
# Should not switch adapters until the advertisement is stale
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_poor_signal_hci0
|
|
)
|
|
|
|
inject_advertisement_with_time_and_source(
|
|
hass,
|
|
switchbot_device_poor_signal_hci1,
|
|
switchbot_adv_poor_signal_hci1,
|
|
start_time_monotonic + 10 + 1,
|
|
"hci1",
|
|
)
|
|
|
|
# Should not switch yet since we are not within the
|
|
# wobble period
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_poor_signal_hci0
|
|
)
|
|
|
|
inject_advertisement_with_time_and_source(
|
|
hass,
|
|
switchbot_device_poor_signal_hci1,
|
|
switchbot_adv_poor_signal_hci1,
|
|
start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1,
|
|
"hci1",
|
|
)
|
|
# Should switch to hci1 since the previous advertisement is stale
|
|
# even though the signal is poor because the device is now
|
|
# likely unreachable via hci0
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_poor_signal_hci1
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("one_adapter")
|
|
async def test_restore_history_from_dbus(
|
|
hass: HomeAssistant, disable_new_discovery_flows
|
|
) -> None:
|
|
"""Test we can restore history from dbus."""
|
|
address = "AA:BB:CC:CC:CC:FF"
|
|
|
|
ble_device = generate_ble_device(address, "name")
|
|
history = {
|
|
address: AdvertisementHistory(
|
|
ble_device, generate_advertisement_data(local_name="name"), "hci0"
|
|
)
|
|
}
|
|
|
|
with patch(
|
|
"bluetooth_adapters.systems.linux.LinuxAdapters.history",
|
|
history,
|
|
):
|
|
assert await async_setup_component(hass, bluetooth.DOMAIN, {})
|
|
await hass.async_block_till_done()
|
|
|
|
assert bluetooth.async_ble_device_from_address(hass, address) is ble_device
|
|
|
|
|
|
@pytest.mark.usefixtures("one_adapter")
|
|
async def test_restore_history_from_dbus_and_remote_adapters(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
disable_new_discovery_flows,
|
|
) -> None:
|
|
"""Test we can restore history from dbus along with remote adapters."""
|
|
address = "AA:BB:CC:CC:CC:FF"
|
|
|
|
data = hass_storage[storage.REMOTE_SCANNER_STORAGE_KEY] = json_loads(
|
|
load_fixture("bluetooth.remote_scanners", bluetooth.DOMAIN)
|
|
)
|
|
now = time.time()
|
|
timestamps = data["data"]["atom-bluetooth-proxy-ceaac4"][
|
|
"discovered_device_timestamps"
|
|
]
|
|
for address in timestamps:
|
|
timestamps[address] = now
|
|
|
|
ble_device = generate_ble_device(address, "name")
|
|
history = {
|
|
address: AdvertisementHistory(
|
|
ble_device, generate_advertisement_data(local_name="name"), "hci0"
|
|
)
|
|
}
|
|
|
|
with patch(
|
|
"bluetooth_adapters.systems.linux.LinuxAdapters.history",
|
|
history,
|
|
):
|
|
assert await async_setup_component(hass, bluetooth.DOMAIN, {})
|
|
await hass.async_block_till_done()
|
|
|
|
assert bluetooth.async_ble_device_from_address(hass, address) is not None
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, "EB:0B:36:35:6F:A4") is not None
|
|
)
|
|
assert disable_new_discovery_flows.call_count > 1
|
|
|
|
|
|
@pytest.mark.usefixtures("one_adapter")
|
|
async def test_restore_history_from_dbus_and_corrupted_remote_adapters(
|
|
hass: HomeAssistant,
|
|
hass_storage: dict[str, Any],
|
|
disable_new_discovery_flows,
|
|
) -> None:
|
|
"""Test we can restore history from dbus when the remote adapters data is corrupted."""
|
|
address = "AA:BB:CC:CC:CC:FF"
|
|
|
|
data = hass_storage[storage.REMOTE_SCANNER_STORAGE_KEY] = json_loads(
|
|
load_fixture("bluetooth.remote_scanners.corrupt", bluetooth.DOMAIN)
|
|
)
|
|
now = time.time()
|
|
timestamps = data["data"]["atom-bluetooth-proxy-ceaac4"][
|
|
"discovered_device_timestamps"
|
|
]
|
|
for address in timestamps:
|
|
timestamps[address] = now
|
|
|
|
ble_device = generate_ble_device(address, "name")
|
|
history = {
|
|
address: AdvertisementHistory(
|
|
ble_device, generate_advertisement_data(local_name="name"), "hci0"
|
|
)
|
|
}
|
|
|
|
with patch(
|
|
"bluetooth_adapters.systems.linux.LinuxAdapters.history",
|
|
history,
|
|
):
|
|
assert await async_setup_component(hass, bluetooth.DOMAIN, {})
|
|
await hass.async_block_till_done()
|
|
|
|
assert bluetooth.async_ble_device_from_address(hass, address) is not None
|
|
assert bluetooth.async_ble_device_from_address(hass, "EB:0B:36:35:6F:A4") is None
|
|
assert disable_new_discovery_flows.call_count >= 1
|
|
|
|
|
|
@pytest.mark.usefixtures("enable_bluetooth")
|
|
async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable(
|
|
hass: HomeAssistant,
|
|
register_hci0_scanner: None,
|
|
register_hci1_scanner: None,
|
|
) -> None:
|
|
"""Test switching adapters based on rssi from connectable to non connectable."""
|
|
|
|
address = "44:44:33:11:23:45"
|
|
now = time.monotonic()
|
|
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
|
|
switchbot_adv_poor_signal = generate_advertisement_data(
|
|
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
|
|
)
|
|
inject_advertisement_with_time_and_source_connectable(
|
|
hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address, False)
|
|
is switchbot_device_poor_signal
|
|
)
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address, True)
|
|
is switchbot_device_poor_signal
|
|
)
|
|
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
|
|
switchbot_adv_good_signal = generate_advertisement_data(
|
|
local_name="wohand_good_signal", service_uuids=[], rssi=-60
|
|
)
|
|
inject_advertisement_with_time_and_source_connectable(
|
|
hass,
|
|
switchbot_device_good_signal,
|
|
switchbot_adv_good_signal,
|
|
now,
|
|
"hci1",
|
|
False,
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address, False)
|
|
is switchbot_device_good_signal
|
|
)
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address, True)
|
|
is switchbot_device_poor_signal
|
|
)
|
|
inject_advertisement_with_time_and_source_connectable(
|
|
hass,
|
|
switchbot_device_good_signal,
|
|
switchbot_adv_poor_signal,
|
|
now,
|
|
"hci0",
|
|
False,
|
|
)
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address, False)
|
|
is switchbot_device_good_signal
|
|
)
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address, True)
|
|
is switchbot_device_poor_signal
|
|
)
|
|
switchbot_device_excellent_signal = generate_ble_device(
|
|
address, "wohand_excellent_signal"
|
|
)
|
|
switchbot_adv_excellent_signal = generate_advertisement_data(
|
|
local_name="wohand_excellent_signal", service_uuids=[], rssi=-25
|
|
)
|
|
|
|
inject_advertisement_with_time_and_source_connectable(
|
|
hass,
|
|
switchbot_device_excellent_signal,
|
|
switchbot_adv_excellent_signal,
|
|
now,
|
|
"hci2",
|
|
False,
|
|
)
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address, False)
|
|
is switchbot_device_excellent_signal
|
|
)
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address, True)
|
|
is switchbot_device_poor_signal
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("enable_bluetooth")
|
|
async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_connectable(
|
|
hass: HomeAssistant,
|
|
register_hci0_scanner: None,
|
|
register_hci1_scanner: None,
|
|
) -> None:
|
|
"""Test we can still get a connectable BLEDevice when the best path is non-connectable.
|
|
|
|
In this case the device is closer to a non-connectable scanner, but the
|
|
at least one connectable scanner has the device in range.
|
|
"""
|
|
|
|
address = "44:44:33:11:23:45"
|
|
now = time.monotonic()
|
|
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
|
|
switchbot_adv_good_signal = generate_advertisement_data(
|
|
local_name="wohand_good_signal", service_uuids=[], rssi=-60
|
|
)
|
|
inject_advertisement_with_time_and_source_connectable(
|
|
hass,
|
|
switchbot_device_good_signal,
|
|
switchbot_adv_good_signal,
|
|
now,
|
|
"hci1",
|
|
False,
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address, False)
|
|
is switchbot_device_good_signal
|
|
)
|
|
assert bluetooth.async_ble_device_from_address(hass, address, True) is None
|
|
|
|
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
|
|
switchbot_adv_poor_signal = generate_advertisement_data(
|
|
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
|
|
)
|
|
inject_advertisement_with_time_and_source_connectable(
|
|
hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address, False)
|
|
is switchbot_device_good_signal
|
|
)
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address, True)
|
|
is switchbot_device_poor_signal
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("enable_bluetooth")
|
|
async def test_switching_adapters_when_one_goes_away(
|
|
hass: HomeAssistant, register_hci0_scanner: None
|
|
) -> None:
|
|
"""Test switching adapters when one goes away."""
|
|
cancel_hci2 = bluetooth.async_register_scanner(hass, FakeScanner("hci2", "hci2"))
|
|
|
|
address = "44:44:33:11:23:45"
|
|
|
|
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
|
|
switchbot_adv_good_signal = generate_advertisement_data(
|
|
local_name="wohand_good_signal", service_uuids=[], rssi=-60
|
|
)
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci2"
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_good_signal
|
|
)
|
|
|
|
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
|
|
switchbot_adv_poor_signal = generate_advertisement_data(
|
|
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
|
|
)
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0"
|
|
)
|
|
|
|
# We want to prefer the good signal when we have options
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_good_signal
|
|
)
|
|
|
|
cancel_hci2()
|
|
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0"
|
|
)
|
|
|
|
# Now that hci2 is gone, we should prefer the poor signal
|
|
# since no poor signal is better than no signal
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_poor_signal
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("enable_bluetooth")
|
|
async def test_switching_adapters_when_one_stop_scanning(
|
|
hass: HomeAssistant, register_hci0_scanner: None
|
|
) -> None:
|
|
"""Test switching adapters when stops scanning."""
|
|
hci2_scanner = FakeScanner("hci2", "hci2")
|
|
cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner)
|
|
|
|
address = "44:44:33:11:23:45"
|
|
|
|
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
|
|
switchbot_adv_good_signal = generate_advertisement_data(
|
|
local_name="wohand_good_signal", service_uuids=[], rssi=-60
|
|
)
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci2"
|
|
)
|
|
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_good_signal
|
|
)
|
|
|
|
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
|
|
switchbot_adv_poor_signal = generate_advertisement_data(
|
|
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
|
|
)
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0"
|
|
)
|
|
|
|
# We want to prefer the good signal when we have options
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_good_signal
|
|
)
|
|
|
|
hci2_scanner.scanning = False
|
|
|
|
inject_advertisement_with_source(
|
|
hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0"
|
|
)
|
|
|
|
# Now that hci2 has stopped scanning, we should prefer the poor signal
|
|
# since poor signal is better than no signal
|
|
assert (
|
|
bluetooth.async_ble_device_from_address(hass, address)
|
|
is switchbot_device_poor_signal
|
|
)
|
|
|
|
cancel_hci2()
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_bluetooth_adapters")
|
|
async def test_goes_unavailable_connectable_only_and_recovers(
|
|
hass: HomeAssistant,
|
|
) -> None:
|
|
"""Test all connectable scanners go unavailable, and than recover when there is a non-connectable scanner."""
|
|
assert await async_setup_component(hass, bluetooth.DOMAIN, {})
|
|
await hass.async_block_till_done()
|
|
|
|
assert async_scanner_count(hass, connectable=True) == 0
|
|
assert async_scanner_count(hass, connectable=False) == 0
|
|
switchbot_device_connectable = generate_ble_device(
|
|
"44:44:33:11:23:45",
|
|
"wohand",
|
|
{},
|
|
rssi=-100,
|
|
)
|
|
switchbot_device_non_connectable = generate_ble_device(
|
|
"44:44:33:11:23:45",
|
|
"wohand",
|
|
{},
|
|
rssi=-100,
|
|
)
|
|
switchbot_device_adv = generate_advertisement_data(
|
|
local_name="wohand",
|
|
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
|
|
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
|
|
manufacturer_data={1: b"\x01"},
|
|
rssi=-100,
|
|
)
|
|
callbacks = []
|
|
|
|
def _fake_subscriber(
|
|
service_info: BluetoothServiceInfo,
|
|
change: BluetoothChange,
|
|
) -> None:
|
|
"""Fake subscriber for the BleakScanner."""
|
|
callbacks.append((service_info, change))
|
|
|
|
cancel = bluetooth.async_register_callback(
|
|
hass,
|
|
_fake_subscriber,
|
|
{"address": "44:44:33:11:23:45", "connectable": True},
|
|
BluetoothScanningMode.ACTIVE,
|
|
)
|
|
|
|
class FakeScanner(BaseHaRemoteScanner):
|
|
def inject_advertisement(
|
|
self, device: BLEDevice, advertisement_data: AdvertisementData
|
|
) -> None:
|
|
"""Inject an advertisement."""
|
|
self._async_on_advertisement(
|
|
device.address,
|
|
advertisement_data.rssi,
|
|
device.name,
|
|
advertisement_data.service_uuids,
|
|
advertisement_data.service_data,
|
|
advertisement_data.manufacturer_data,
|
|
advertisement_data.tx_power,
|
|
{"scanner_specific_data": "test"},
|
|
MONOTONIC_TIME(),
|
|
)
|
|
|
|
connector = (
|
|
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
|
|
)
|
|
connectable_scanner = FakeScanner(
|
|
"connectable",
|
|
"connectable",
|
|
connector,
|
|
True,
|
|
)
|
|
unsetup_connectable_scanner = connectable_scanner.async_setup()
|
|
cancel_connectable_scanner = _get_manager().async_register_scanner(
|
|
connectable_scanner
|
|
)
|
|
connectable_scanner.inject_advertisement(
|
|
switchbot_device_connectable, switchbot_device_adv
|
|
)
|
|
assert async_ble_device_from_address(hass, "44:44:33:11:23:45") is not None
|
|
assert async_scanner_count(hass, connectable=True) == 1
|
|
assert len(callbacks) == 1
|
|
|
|
assert (
|
|
"44:44:33:11:23:45"
|
|
in connectable_scanner.discovered_devices_and_advertisement_data
|
|
)
|
|
|
|
not_connectable_scanner = FakeScanner(
|
|
"not_connectable",
|
|
"not_connectable",
|
|
connector,
|
|
False,
|
|
)
|
|
unsetup_not_connectable_scanner = not_connectable_scanner.async_setup()
|
|
cancel_not_connectable_scanner = _get_manager().async_register_scanner(
|
|
not_connectable_scanner
|
|
)
|
|
not_connectable_scanner.inject_advertisement(
|
|
switchbot_device_non_connectable, switchbot_device_adv
|
|
)
|
|
assert async_scanner_count(hass, connectable=True) == 1
|
|
assert async_scanner_count(hass, connectable=False) == 2
|
|
|
|
assert (
|
|
"44:44:33:11:23:45"
|
|
in not_connectable_scanner.discovered_devices_and_advertisement_data
|
|
)
|
|
|
|
unavailable_callbacks: list[BluetoothServiceInfoBleak] = []
|
|
|
|
@callback
|
|
def _unavailable_callback(service_info: BluetoothServiceInfoBleak) -> None:
|
|
"""Wrong device unavailable callback."""
|
|
nonlocal unavailable_callbacks
|
|
unavailable_callbacks.append(service_info.address)
|
|
|
|
cancel_unavailable = async_track_unavailable(
|
|
hass,
|
|
_unavailable_callback,
|
|
switchbot_device_connectable.address,
|
|
connectable=True,
|
|
)
|
|
|
|
assert async_scanner_count(hass, connectable=True) == 1
|
|
cancel_connectable_scanner()
|
|
unsetup_connectable_scanner()
|
|
assert async_scanner_count(hass, connectable=True) == 0
|
|
assert async_scanner_count(hass, connectable=False) == 1
|
|
|
|
async_fire_time_changed(
|
|
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)
|
|
)
|
|
await hass.async_block_till_done()
|
|
assert "44:44:33:11:23:45" in unavailable_callbacks
|
|
cancel_unavailable()
|
|
|
|
connectable_scanner_2 = FakeScanner(
|
|
"connectable",
|
|
"connectable",
|
|
connector,
|
|
True,
|
|
)
|
|
unsetup_connectable_scanner_2 = connectable_scanner_2.async_setup()
|
|
cancel_connectable_scanner_2 = _get_manager().async_register_scanner(
|
|
connectable_scanner
|
|
)
|
|
connectable_scanner_2.inject_advertisement(
|
|
switchbot_device_connectable, switchbot_device_adv
|
|
)
|
|
assert (
|
|
"44:44:33:11:23:45"
|
|
in connectable_scanner_2.discovered_devices_and_advertisement_data
|
|
)
|
|
|
|
# We should get another callback to make the device available again
|
|
assert len(callbacks) == 2
|
|
|
|
cancel()
|
|
cancel_connectable_scanner_2()
|
|
unsetup_connectable_scanner_2()
|
|
cancel_not_connectable_scanner()
|
|
unsetup_not_connectable_scanner()
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_bluetooth_adapters")
|
|
async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable(
|
|
hass: HomeAssistant,
|
|
) -> None:
|
|
"""Test that unavailable will dismiss any active discoveries and make device discoverable again."""
|
|
mock_bt = [
|
|
{
|
|
"domain": "switchbot",
|
|
"service_data_uuid": "050a021a-0000-1000-8000-00805f9b34fb",
|
|
"connectable": False,
|
|
},
|
|
]
|
|
with patch(
|
|
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
|
|
):
|
|
assert await async_setup_component(hass, bluetooth.DOMAIN, {})
|
|
await hass.async_block_till_done()
|
|
|
|
assert async_scanner_count(hass, connectable=False) == 0
|
|
switchbot_device_non_connectable = generate_ble_device(
|
|
"44:44:33:11:23:45",
|
|
"wohand",
|
|
{},
|
|
rssi=-100,
|
|
)
|
|
switchbot_device_adv = generate_advertisement_data(
|
|
local_name="wohand",
|
|
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
|
|
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
|
|
manufacturer_data={1: b"\x01"},
|
|
rssi=-100,
|
|
)
|
|
callbacks = []
|
|
|
|
def _fake_subscriber(
|
|
service_info: BluetoothServiceInfo,
|
|
change: BluetoothChange,
|
|
) -> None:
|
|
"""Fake subscriber for the BleakScanner."""
|
|
callbacks.append((service_info, change))
|
|
|
|
cancel = bluetooth.async_register_callback(
|
|
hass,
|
|
_fake_subscriber,
|
|
{"address": "44:44:33:11:23:45", "connectable": False},
|
|
BluetoothScanningMode.ACTIVE,
|
|
)
|
|
|
|
class FakeScanner(BaseHaRemoteScanner):
|
|
def inject_advertisement(
|
|
self, device: BLEDevice, advertisement_data: AdvertisementData
|
|
) -> None:
|
|
"""Inject an advertisement."""
|
|
self._async_on_advertisement(
|
|
device.address,
|
|
advertisement_data.rssi,
|
|
device.name,
|
|
advertisement_data.service_uuids,
|
|
advertisement_data.service_data,
|
|
advertisement_data.manufacturer_data,
|
|
advertisement_data.tx_power,
|
|
{"scanner_specific_data": "test"},
|
|
MONOTONIC_TIME(),
|
|
)
|
|
|
|
def clear_all_devices(self) -> None:
|
|
"""Clear all devices."""
|
|
self._discovered_device_advertisement_datas.clear()
|
|
self._discovered_device_timestamps.clear()
|
|
self._previous_service_info.clear()
|
|
|
|
connector = (
|
|
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
|
|
)
|
|
non_connectable_scanner = FakeScanner(
|
|
"connectable",
|
|
"connectable",
|
|
connector,
|
|
False,
|
|
)
|
|
unsetup_connectable_scanner = non_connectable_scanner.async_setup()
|
|
cancel_connectable_scanner = _get_manager().async_register_scanner(
|
|
non_connectable_scanner
|
|
)
|
|
with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow:
|
|
non_connectable_scanner.inject_advertisement(
|
|
switchbot_device_non_connectable, switchbot_device_adv
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(mock_config_flow.mock_calls) == 1
|
|
assert mock_config_flow.mock_calls[0][1][0] == "switchbot"
|
|
assert mock_config_flow.mock_calls[0][2]["context"] == {
|
|
"discovery_key": DiscoveryKey(
|
|
domain="bluetooth", key="44:44:33:11:23:45", version=1
|
|
),
|
|
"source": "bluetooth",
|
|
}
|
|
|
|
assert async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None
|
|
assert async_scanner_count(hass, connectable=False) == 1
|
|
assert len(callbacks) == 1
|
|
|
|
assert (
|
|
"44:44:33:11:23:45"
|
|
in non_connectable_scanner.discovered_devices_and_advertisement_data
|
|
)
|
|
|
|
unavailable_callbacks: list[BluetoothServiceInfoBleak] = []
|
|
|
|
@callback
|
|
def _unavailable_callback(service_info: BluetoothServiceInfoBleak) -> None:
|
|
"""Wrong device unavailable callback."""
|
|
nonlocal unavailable_callbacks
|
|
unavailable_callbacks.append(service_info.address)
|
|
|
|
cancel_unavailable = async_track_unavailable(
|
|
hass,
|
|
_unavailable_callback,
|
|
switchbot_device_non_connectable.address,
|
|
connectable=False,
|
|
)
|
|
|
|
assert async_scanner_count(hass, connectable=False) == 1
|
|
|
|
non_connectable_scanner.clear_all_devices()
|
|
assert (
|
|
"44:44:33:11:23:45"
|
|
not in non_connectable_scanner.discovered_devices_and_advertisement_data
|
|
)
|
|
monotonic_now = time.monotonic()
|
|
with (
|
|
patch.object(
|
|
hass.config_entries.flow,
|
|
"async_progress_by_init_data_type",
|
|
return_value=[{"flow_id": "mock_flow_id"}],
|
|
) as mock_async_progress_by_init_data_type,
|
|
patch.object(hass.config_entries.flow, "async_abort") as mock_async_abort,
|
|
patch_bluetooth_time(
|
|
monotonic_now + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
|
|
),
|
|
):
|
|
async_fire_time_changed(
|
|
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)
|
|
)
|
|
await hass.async_block_till_done()
|
|
assert "44:44:33:11:23:45" in unavailable_callbacks
|
|
|
|
assert len(mock_async_progress_by_init_data_type.mock_calls) == 1
|
|
assert mock_async_abort.mock_calls[0][1][0] == "mock_flow_id"
|
|
|
|
# Test that if the device comes back online, it can be discovered again
|
|
with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow:
|
|
new_switchbot_device_adv = generate_advertisement_data(
|
|
local_name="wohand",
|
|
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
|
|
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
|
|
manufacturer_data={1: b"\x01"},
|
|
rssi=-60,
|
|
)
|
|
non_connectable_scanner.inject_advertisement(
|
|
switchbot_device_non_connectable, new_switchbot_device_adv
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
assert (
|
|
"44:44:33:11:23:45"
|
|
in non_connectable_scanner.discovered_devices_and_advertisement_data
|
|
)
|
|
assert len(mock_config_flow.mock_calls) == 1
|
|
assert mock_config_flow.mock_calls[0][1][0] == "switchbot"
|
|
assert mock_config_flow.mock_calls[0][2]["context"] == {
|
|
"discovery_key": DiscoveryKey(
|
|
domain="bluetooth", key="44:44:33:11:23:45", version=1
|
|
),
|
|
"source": "bluetooth",
|
|
}
|
|
|
|
cancel_unavailable()
|
|
|
|
cancel()
|
|
unsetup_connectable_scanner()
|
|
cancel_connectable_scanner()
|
|
|
|
|
|
@pytest.mark.usefixtures("enable_bluetooth")
|
|
async def test_debug_logging(
|
|
hass: HomeAssistant,
|
|
register_hci0_scanner: None,
|
|
register_hci1_scanner: None,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test debug logging."""
|
|
assert await async_setup_component(hass, "logger", {"logger": {}})
|
|
await hass.services.async_call(
|
|
"logger",
|
|
"set_level",
|
|
{"homeassistant.components.bluetooth": "DEBUG"},
|
|
blocking=True,
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
address = "44:44:33:11:23:41"
|
|
start_time_monotonic = 50.0
|
|
|
|
switchbot_device_poor_signal_hci0 = generate_ble_device(
|
|
address, "wohand_poor_signal_hci0"
|
|
)
|
|
switchbot_adv_poor_signal_hci0 = generate_advertisement_data(
|
|
local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100
|
|
)
|
|
inject_advertisement_with_time_and_source(
|
|
hass,
|
|
switchbot_device_poor_signal_hci0,
|
|
switchbot_adv_poor_signal_hci0,
|
|
start_time_monotonic,
|
|
"hci0",
|
|
)
|
|
assert "wohand_poor_signal_hci0" in caplog.text
|
|
caplog.clear()
|
|
|
|
await hass.services.async_call(
|
|
"logger",
|
|
"set_level",
|
|
{"homeassistant.components.bluetooth": "WARNING"},
|
|
blocking=True,
|
|
)
|
|
|
|
switchbot_device_good_signal_hci0 = generate_ble_device(
|
|
address, "wohand_good_signal_hci0"
|
|
)
|
|
switchbot_adv_good_signal_hci0 = generate_advertisement_data(
|
|
local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33
|
|
)
|
|
inject_advertisement_with_time_and_source(
|
|
hass,
|
|
switchbot_device_good_signal_hci0,
|
|
switchbot_adv_good_signal_hci0,
|
|
start_time_monotonic,
|
|
"hci0",
|
|
)
|
|
assert "wohand_good_signal_hci0" not in caplog.text
|
|
|
|
|
|
@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter")
|
|
async def test_set_fallback_interval_small(hass: HomeAssistant) -> None:
|
|
"""Test we can set the fallback advertisement interval."""
|
|
assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None
|
|
|
|
async_set_fallback_availability_interval(hass, "44:44:33:11:23:12", 2.0)
|
|
assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") == 2.0
|
|
|
|
start_monotonic_time = time.monotonic()
|
|
switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand")
|
|
switchbot_adv = generate_advertisement_data(
|
|
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
|
|
)
|
|
switchbot_device_went_unavailable = False
|
|
|
|
inject_advertisement_with_time_and_source(
|
|
hass,
|
|
switchbot_device,
|
|
switchbot_adv,
|
|
start_monotonic_time,
|
|
SOURCE_LOCAL,
|
|
)
|
|
|
|
@callback
|
|
def _switchbot_device_unavailable_callback(_address: str) -> None:
|
|
"""Switchbot device unavailable callback."""
|
|
nonlocal switchbot_device_went_unavailable
|
|
switchbot_device_went_unavailable = True
|
|
|
|
assert async_get_learned_advertising_interval(hass, "44:44:33:11:23:12") is None
|
|
|
|
switchbot_device_unavailable_cancel = async_track_unavailable(
|
|
hass,
|
|
_switchbot_device_unavailable_callback,
|
|
switchbot_device.address,
|
|
connectable=False,
|
|
)
|
|
|
|
monotonic_now = start_monotonic_time + 2
|
|
with patch_bluetooth_time(
|
|
monotonic_now + UNAVAILABLE_TRACK_SECONDS,
|
|
):
|
|
async_fire_time_changed(
|
|
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
assert switchbot_device_went_unavailable is True
|
|
switchbot_device_unavailable_cancel()
|
|
|
|
# We should forget fallback interval after it expires
|
|
assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None
|
|
|
|
|
|
@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter")
|
|
async def test_set_fallback_interval_big(hass: HomeAssistant) -> None:
|
|
"""Test we can set the fallback advertisement interval."""
|
|
assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None
|
|
|
|
# Force the interval to be really big and check it doesn't expire using the default timeout (900)
|
|
|
|
async_set_fallback_availability_interval(hass, "44:44:33:11:23:12", 604800.0)
|
|
assert (
|
|
async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") == 604800.0
|
|
)
|
|
|
|
start_monotonic_time = time.monotonic()
|
|
switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand")
|
|
switchbot_adv = generate_advertisement_data(
|
|
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
|
|
)
|
|
switchbot_device_went_unavailable = False
|
|
|
|
inject_advertisement_with_time_and_source(
|
|
hass,
|
|
switchbot_device,
|
|
switchbot_adv,
|
|
start_monotonic_time,
|
|
SOURCE_LOCAL,
|
|
)
|
|
|
|
@callback
|
|
def _switchbot_device_unavailable_callback(_address: str) -> None:
|
|
"""Switchbot device unavailable callback."""
|
|
nonlocal switchbot_device_went_unavailable
|
|
switchbot_device_went_unavailable = True
|
|
|
|
assert async_get_learned_advertising_interval(hass, "44:44:33:11:23:12") is None
|
|
|
|
switchbot_device_unavailable_cancel = async_track_unavailable(
|
|
hass,
|
|
_switchbot_device_unavailable_callback,
|
|
switchbot_device.address,
|
|
connectable=False,
|
|
)
|
|
|
|
# Check that device hasn't expired after a day
|
|
|
|
monotonic_now = start_monotonic_time + 86400
|
|
with patch_bluetooth_time(
|
|
monotonic_now + UNAVAILABLE_TRACK_SECONDS,
|
|
):
|
|
async_fire_time_changed(
|
|
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
assert switchbot_device_went_unavailable is False
|
|
|
|
# Try again after it has expired
|
|
|
|
monotonic_now = start_monotonic_time + 604800
|
|
with patch_bluetooth_time(
|
|
monotonic_now + UNAVAILABLE_TRACK_SECONDS,
|
|
):
|
|
async_fire_time_changed(
|
|
hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
assert switchbot_device_went_unavailable is True
|
|
|
|
switchbot_device_unavailable_cancel()
|
|
|
|
# We should forget fallback interval after it expires
|
|
assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_bluetooth_adapters")
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"entry_domain",
|
|
"entry_discovery_keys",
|
|
),
|
|
[
|
|
# Matching discovery key
|
|
(
|
|
"switchbot",
|
|
{
|
|
"bluetooth": (
|
|
DiscoveryKey(
|
|
domain="bluetooth", key="44:44:33:11:23:45", version=1
|
|
),
|
|
)
|
|
},
|
|
),
|
|
# Matching discovery key
|
|
(
|
|
"switchbot",
|
|
{
|
|
"bluetooth": (
|
|
DiscoveryKey(
|
|
domain="bluetooth", key="44:44:33:11:23:45", version=1
|
|
),
|
|
),
|
|
"other": (DiscoveryKey(domain="other", key="blah", version=1),),
|
|
},
|
|
),
|
|
# Matching discovery key, other domain
|
|
# Note: Rediscovery is not currently restricted to the domain of the removed
|
|
# entry. Such a check can be added if needed.
|
|
(
|
|
"comp",
|
|
{
|
|
"bluetooth": (
|
|
DiscoveryKey(
|
|
domain="bluetooth", key="44:44:33:11:23:45", version=1
|
|
),
|
|
)
|
|
},
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"entry_source",
|
|
[
|
|
config_entries.SOURCE_BLUETOOTH,
|
|
config_entries.SOURCE_IGNORE,
|
|
config_entries.SOURCE_USER,
|
|
],
|
|
)
|
|
async def test_bluetooth_rediscover(
|
|
hass: HomeAssistant,
|
|
entry_domain: str,
|
|
entry_discovery_keys: dict[str, tuple[DiscoveryKey, ...]],
|
|
entry_source: str,
|
|
) -> None:
|
|
"""Test we reinitiate flows when an ignored config entry is removed."""
|
|
mock_bt = [
|
|
{
|
|
"domain": "switchbot",
|
|
"service_data_uuid": "050a021a-0000-1000-8000-00805f9b34fb",
|
|
"connectable": False,
|
|
},
|
|
]
|
|
with patch(
|
|
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
|
|
):
|
|
assert await async_setup_component(hass, bluetooth.DOMAIN, {})
|
|
await hass.async_block_till_done()
|
|
|
|
assert async_scanner_count(hass, connectable=False) == 0
|
|
switchbot_device_non_connectable = generate_ble_device(
|
|
"44:44:33:11:23:45",
|
|
"wohand",
|
|
{},
|
|
rssi=-100,
|
|
)
|
|
switchbot_device_adv = generate_advertisement_data(
|
|
local_name="wohand",
|
|
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
|
|
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
|
|
manufacturer_data={1: b"\x01"},
|
|
rssi=-100,
|
|
)
|
|
callbacks = []
|
|
|
|
def _fake_subscriber(
|
|
service_info: BluetoothServiceInfo,
|
|
change: BluetoothChange,
|
|
) -> None:
|
|
"""Fake subscriber for the BleakScanner."""
|
|
callbacks.append((service_info, change))
|
|
|
|
cancel = bluetooth.async_register_callback(
|
|
hass,
|
|
_fake_subscriber,
|
|
{"address": "44:44:33:11:23:45", "connectable": False},
|
|
BluetoothScanningMode.ACTIVE,
|
|
)
|
|
|
|
class FakeScanner(BaseHaRemoteScanner):
|
|
def inject_advertisement(
|
|
self, device: BLEDevice, advertisement_data: AdvertisementData
|
|
) -> None:
|
|
"""Inject an advertisement."""
|
|
self._async_on_advertisement(
|
|
device.address,
|
|
advertisement_data.rssi,
|
|
device.name,
|
|
advertisement_data.service_uuids,
|
|
advertisement_data.service_data,
|
|
advertisement_data.manufacturer_data,
|
|
advertisement_data.tx_power,
|
|
{"scanner_specific_data": "test"},
|
|
MONOTONIC_TIME(),
|
|
)
|
|
|
|
def clear_all_devices(self) -> None:
|
|
"""Clear all devices."""
|
|
self._discovered_device_advertisement_datas.clear()
|
|
self._discovered_device_timestamps.clear()
|
|
self._previous_service_info.clear()
|
|
|
|
connector = (
|
|
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
|
|
)
|
|
non_connectable_scanner = FakeScanner(
|
|
"connectable",
|
|
"connectable",
|
|
connector,
|
|
False,
|
|
)
|
|
unsetup_connectable_scanner = non_connectable_scanner.async_setup()
|
|
cancel_connectable_scanner = _get_manager().async_register_scanner(
|
|
non_connectable_scanner
|
|
)
|
|
with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow:
|
|
non_connectable_scanner.inject_advertisement(
|
|
switchbot_device_non_connectable, switchbot_device_adv
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
expected_context = {
|
|
"discovery_key": DiscoveryKey(
|
|
domain="bluetooth", key="44:44:33:11:23:45", version=1
|
|
),
|
|
"source": "bluetooth",
|
|
}
|
|
assert len(mock_config_flow.mock_calls) == 1
|
|
assert mock_config_flow.mock_calls[0][1][0] == "switchbot"
|
|
assert mock_config_flow.mock_calls[0][2]["context"] == expected_context
|
|
|
|
hass.config.components.add(entry_domain)
|
|
mock_integration(hass, MockModule(entry_domain))
|
|
|
|
entry = MockConfigEntry(
|
|
domain=entry_domain,
|
|
discovery_keys=entry_discovery_keys,
|
|
unique_id="mock-unique-id",
|
|
state=config_entries.ConfigEntryState.LOADED,
|
|
source=entry_source,
|
|
)
|
|
entry.add_to_hass(hass)
|
|
|
|
assert (
|
|
async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None
|
|
)
|
|
assert async_scanner_count(hass, connectable=False) == 1
|
|
assert len(callbacks) == 1
|
|
|
|
assert (
|
|
"44:44:33:11:23:45"
|
|
in non_connectable_scanner.discovered_devices_and_advertisement_data
|
|
)
|
|
|
|
await hass.config_entries.async_remove(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert (
|
|
async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None
|
|
)
|
|
assert async_scanner_count(hass, connectable=False) == 1
|
|
assert len(callbacks) == 1
|
|
|
|
assert len(mock_config_flow.mock_calls) == 2
|
|
assert mock_config_flow.mock_calls[1][1][0] == "switchbot"
|
|
assert mock_config_flow.mock_calls[1][2]["context"] == expected_context
|
|
|
|
cancel()
|
|
unsetup_connectable_scanner()
|
|
cancel_connectable_scanner()
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_bluetooth_adapters")
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"entry_domain",
|
|
"entry_discovery_keys",
|
|
"entry_source",
|
|
"entry_unique_id",
|
|
),
|
|
[
|
|
# Discovery key from other domain
|
|
(
|
|
"switchbot",
|
|
{
|
|
"zeroconf": (
|
|
DiscoveryKey(domain="zeroconf", key="44:44:33:11:23:45", version=1),
|
|
)
|
|
},
|
|
config_entries.SOURCE_IGNORE,
|
|
"mock-unique-id",
|
|
),
|
|
# Discovery key from the future
|
|
(
|
|
"switchbot",
|
|
{
|
|
"bluetooth": (
|
|
DiscoveryKey(
|
|
domain="bluetooth", key="44:44:33:11:23:45", version=2
|
|
),
|
|
)
|
|
},
|
|
config_entries.SOURCE_IGNORE,
|
|
"mock-unique-id",
|
|
),
|
|
],
|
|
)
|
|
async def test_bluetooth_rediscover_no_match(
|
|
hass: HomeAssistant,
|
|
entry_domain: str,
|
|
entry_discovery_keys: dict[str, tuple[DiscoveryKey, ...]],
|
|
entry_source: str,
|
|
entry_unique_id: str,
|
|
) -> None:
|
|
"""Test we don't reinitiate flows when a non matching config entry is removed."""
|
|
mock_bt = [
|
|
{
|
|
"domain": "switchbot",
|
|
"service_data_uuid": "050a021a-0000-1000-8000-00805f9b34fb",
|
|
"connectable": False,
|
|
},
|
|
]
|
|
with patch(
|
|
"homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt
|
|
):
|
|
assert await async_setup_component(hass, bluetooth.DOMAIN, {})
|
|
await hass.async_block_till_done()
|
|
|
|
assert async_scanner_count(hass, connectable=False) == 0
|
|
switchbot_device_non_connectable = generate_ble_device(
|
|
"44:44:33:11:23:45",
|
|
"wohand",
|
|
{},
|
|
rssi=-100,
|
|
)
|
|
switchbot_device_adv = generate_advertisement_data(
|
|
local_name="wohand",
|
|
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
|
|
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
|
|
manufacturer_data={1: b"\x01"},
|
|
rssi=-100,
|
|
)
|
|
callbacks = []
|
|
|
|
def _fake_subscriber(
|
|
service_info: BluetoothServiceInfo,
|
|
change: BluetoothChange,
|
|
) -> None:
|
|
"""Fake subscriber for the BleakScanner."""
|
|
callbacks.append((service_info, change))
|
|
|
|
cancel = bluetooth.async_register_callback(
|
|
hass,
|
|
_fake_subscriber,
|
|
{"address": "44:44:33:11:23:45", "connectable": False},
|
|
BluetoothScanningMode.ACTIVE,
|
|
)
|
|
|
|
class FakeScanner(BaseHaRemoteScanner):
|
|
def inject_advertisement(
|
|
self, device: BLEDevice, advertisement_data: AdvertisementData
|
|
) -> None:
|
|
"""Inject an advertisement."""
|
|
self._async_on_advertisement(
|
|
device.address,
|
|
advertisement_data.rssi,
|
|
device.name,
|
|
advertisement_data.service_uuids,
|
|
advertisement_data.service_data,
|
|
advertisement_data.manufacturer_data,
|
|
advertisement_data.tx_power,
|
|
{"scanner_specific_data": "test"},
|
|
MONOTONIC_TIME(),
|
|
)
|
|
|
|
def clear_all_devices(self) -> None:
|
|
"""Clear all devices."""
|
|
self._discovered_device_advertisement_datas.clear()
|
|
self._discovered_device_timestamps.clear()
|
|
self._previous_service_info.clear()
|
|
|
|
connector = (
|
|
HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False),
|
|
)
|
|
non_connectable_scanner = FakeScanner(
|
|
"connectable",
|
|
"connectable",
|
|
connector,
|
|
False,
|
|
)
|
|
unsetup_connectable_scanner = non_connectable_scanner.async_setup()
|
|
cancel_connectable_scanner = _get_manager().async_register_scanner(
|
|
non_connectable_scanner
|
|
)
|
|
with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow:
|
|
non_connectable_scanner.inject_advertisement(
|
|
switchbot_device_non_connectable, switchbot_device_adv
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
expected_context = {
|
|
"discovery_key": DiscoveryKey(
|
|
domain="bluetooth", key="44:44:33:11:23:45", version=1
|
|
),
|
|
"source": "bluetooth",
|
|
}
|
|
assert len(mock_config_flow.mock_calls) == 1
|
|
assert mock_config_flow.mock_calls[0][1][0] == "switchbot"
|
|
assert mock_config_flow.mock_calls[0][2]["context"] == expected_context
|
|
|
|
hass.config.components.add(entry_domain)
|
|
mock_integration(hass, MockModule(entry_domain))
|
|
|
|
entry = MockConfigEntry(
|
|
domain=entry_domain,
|
|
discovery_keys=entry_discovery_keys,
|
|
unique_id=entry_unique_id,
|
|
state=config_entries.ConfigEntryState.LOADED,
|
|
source=entry_source,
|
|
)
|
|
entry.add_to_hass(hass)
|
|
|
|
assert (
|
|
async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None
|
|
)
|
|
assert async_scanner_count(hass, connectable=False) == 1
|
|
assert len(callbacks) == 1
|
|
|
|
assert (
|
|
"44:44:33:11:23:45"
|
|
in non_connectable_scanner.discovered_devices_and_advertisement_data
|
|
)
|
|
|
|
await hass.config_entries.async_remove(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert (
|
|
async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None
|
|
)
|
|
assert async_scanner_count(hass, connectable=False) == 1
|
|
assert len(callbacks) == 1
|
|
assert len(mock_config_flow.mock_calls) == 1
|
|
|
|
cancel()
|
|
unsetup_connectable_scanner()
|
|
cancel_connectable_scanner()
|