mirror of
https://github.com/home-assistant/core.git
synced 2026-06-06 07:26:58 +01:00
347 lines
13 KiB
Python
347 lines
13 KiB
Python
"""Test the ESPHome bluetooth integration."""
|
|
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from aioesphomeapi import (
|
|
BluetoothProxyFeature,
|
|
BluetoothScannerMode,
|
|
BluetoothScannerState,
|
|
BluetoothScannerStateResponse,
|
|
)
|
|
|
|
from homeassistant.components import bluetooth
|
|
from homeassistant.components.bluetooth import BluetoothScanningMode
|
|
from homeassistant.components.esphome.const import CONF_BLUETOOTH_SCANNING_MODE
|
|
from homeassistant.core import HomeAssistant, callback as hass_callback
|
|
from homeassistant.helpers import device_registry as dr
|
|
|
|
from .conftest import MockBluetoothEntryType, MockESPHomeDevice
|
|
|
|
_PROXY_WITH_STATE_AND_MODE = (
|
|
BluetoothProxyFeature.PASSIVE_SCAN
|
|
| BluetoothProxyFeature.ACTIVE_CONNECTIONS
|
|
| BluetoothProxyFeature.RAW_ADVERTISEMENTS
|
|
| BluetoothProxyFeature.FEATURE_STATE_AND_MODE
|
|
)
|
|
|
|
|
|
async def test_bluetooth_connect_with_raw_adv(
|
|
hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice
|
|
) -> None:
|
|
"""Test bluetooth connect with raw advertisements."""
|
|
scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC")
|
|
assert scanner is not None
|
|
assert scanner.connectable is True
|
|
assert scanner.scanning is True
|
|
assert scanner.connector.can_connect() is False # no connection slots
|
|
await mock_bluetooth_entry_with_raw_adv.mock_disconnect(True)
|
|
await hass.async_block_till_done()
|
|
|
|
scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC")
|
|
assert scanner is None
|
|
await mock_bluetooth_entry_with_raw_adv.mock_connect()
|
|
await hass.async_block_till_done()
|
|
scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC")
|
|
assert scanner.scanning is True
|
|
|
|
|
|
async def test_bluetooth_connect_with_legacy_adv(
|
|
hass: HomeAssistant, mock_bluetooth_entry_with_legacy_adv: MockESPHomeDevice
|
|
) -> None:
|
|
"""Test bluetooth connect with legacy advertisements."""
|
|
scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC")
|
|
assert scanner is not None
|
|
assert scanner.connectable is True
|
|
assert scanner.scanning is True
|
|
assert scanner.connector.can_connect() is False # no connection slots
|
|
await mock_bluetooth_entry_with_legacy_adv.mock_disconnect(True)
|
|
await hass.async_block_till_done()
|
|
|
|
scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC")
|
|
assert scanner is None
|
|
await mock_bluetooth_entry_with_legacy_adv.mock_connect()
|
|
await hass.async_block_till_done()
|
|
scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC")
|
|
assert scanner.scanning is True
|
|
|
|
|
|
async def test_bluetooth_device_linked_via_device(
|
|
hass: HomeAssistant,
|
|
mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice,
|
|
device_registry: dr.DeviceRegistry,
|
|
) -> None:
|
|
"""Test the Bluetooth device is linked to the ESPHome device."""
|
|
scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC")
|
|
assert scanner.connectable is True
|
|
entry = hass.config_entries.async_entry_for_domain_unique_id(
|
|
"bluetooth", "AA:BB:CC:DD:EE:FC"
|
|
)
|
|
assert entry is not None
|
|
esp_device = device_registry.async_get_device(
|
|
connections={
|
|
(
|
|
dr.CONNECTION_NETWORK_MAC,
|
|
mock_bluetooth_entry_with_raw_adv.device_info.mac_address,
|
|
)
|
|
}
|
|
)
|
|
assert esp_device is not None
|
|
device = device_registry.async_get_device(
|
|
connections={(dr.CONNECTION_BLUETOOTH, "AA:BB:CC:DD:EE:FC")}
|
|
)
|
|
assert device is not None
|
|
assert device.via_device_id == esp_device.id
|
|
|
|
|
|
async def test_bluetooth_cleanup_on_remove_entry(
|
|
hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice
|
|
) -> None:
|
|
"""Test bluetooth is cleaned up on entry removal."""
|
|
scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC")
|
|
assert scanner.connectable is True
|
|
await hass.config_entries.async_unload(
|
|
mock_bluetooth_entry_with_raw_adv.entry.entry_id
|
|
)
|
|
|
|
with patch("homeassistant.components.esphome.async_remove_scanner") as remove_mock:
|
|
await hass.config_entries.async_remove(
|
|
mock_bluetooth_entry_with_raw_adv.entry.entry_id
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
remove_mock.assert_called_once_with(hass, scanner.source)
|
|
|
|
|
|
async def test_scanning_mode_saved_option_applied(
|
|
hass: HomeAssistant,
|
|
mock_bluetooth_entry: MockBluetoothEntryType,
|
|
) -> None:
|
|
"""A saved CONF_BLUETOOTH_SCANNING_MODE is applied immediately to the proxy."""
|
|
device = await mock_bluetooth_entry(
|
|
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
|
|
)
|
|
hass.config_entries.async_update_entry(
|
|
device.entry,
|
|
options={**device.entry.options, CONF_BLUETOOTH_SCANNING_MODE: "passive"},
|
|
)
|
|
set_mode_mock = MagicMock()
|
|
device.client.bluetooth_scanner_set_mode = set_mode_mock
|
|
await hass.config_entries.async_reload(device.entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
set_mode_mock.assert_any_call(BluetoothScannerMode.PASSIVE)
|
|
|
|
|
|
async def test_scanning_mode_invalid_option_falls_back_to_default(
|
|
hass: HomeAssistant,
|
|
mock_bluetooth_entry: MockBluetoothEntryType,
|
|
) -> None:
|
|
"""A malformed saved value falls back to the AUTO default instead of raising."""
|
|
device = await mock_bluetooth_entry(
|
|
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
|
|
)
|
|
hass.config_entries.async_update_entry(
|
|
device.entry,
|
|
options={**device.entry.options, CONF_BLUETOOTH_SCANNING_MODE: "bogus"},
|
|
)
|
|
set_mode_mock = MagicMock()
|
|
device.client.bluetooth_scanner_set_mode = set_mode_mock
|
|
await hass.config_entries.async_reload(device.entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
# AUTO maps to PASSIVE on the firmware.
|
|
set_mode_mock.assert_any_call(BluetoothScannerMode.PASSIVE)
|
|
|
|
|
|
async def test_scanning_mode_migration_passive_is_honored(
|
|
hass: HomeAssistant,
|
|
mock_bluetooth_entry: MockBluetoothEntryType,
|
|
) -> None:
|
|
"""Proxy configured PASSIVE in YAML is honored on first state update."""
|
|
set_mode_mock = MagicMock()
|
|
state_subscriptions: list[Callable[[BluetoothScannerStateResponse], None]] = []
|
|
|
|
def _subscribe(
|
|
callback: Callable[[BluetoothScannerStateResponse], None],
|
|
) -> Callable[[], None]:
|
|
state_subscriptions.append(callback)
|
|
return lambda: state_subscriptions.remove(callback)
|
|
|
|
device = await mock_bluetooth_entry(
|
|
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
|
|
)
|
|
device.client.bluetooth_scanner_set_mode = set_mode_mock
|
|
device.client.subscribe_bluetooth_scanner_state = _subscribe
|
|
await hass.config_entries.async_reload(device.entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert state_subscriptions
|
|
for callback in state_subscriptions[:]:
|
|
callback(
|
|
BluetoothScannerStateResponse(
|
|
state=BluetoothScannerState.RUNNING,
|
|
mode=BluetoothScannerMode.PASSIVE,
|
|
configured_mode=BluetoothScannerMode.PASSIVE,
|
|
)
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
assert device.entry.options[CONF_BLUETOOTH_SCANNING_MODE] == "passive"
|
|
set_mode_mock.assert_any_call(BluetoothScannerMode.PASSIVE)
|
|
|
|
|
|
async def test_scanning_mode_migration_waits_for_known_configured_mode(
|
|
hass: HomeAssistant,
|
|
mock_bluetooth_entry: MockBluetoothEntryType,
|
|
) -> None:
|
|
"""An initial state with configured_mode=None must not commit a migration."""
|
|
state_subscriptions: list[Callable[[BluetoothScannerStateResponse], None]] = []
|
|
set_mode_mock = MagicMock()
|
|
|
|
def _subscribe(
|
|
callback: Callable[[BluetoothScannerStateResponse], None],
|
|
) -> Callable[[], None]:
|
|
state_subscriptions.append(callback)
|
|
return lambda: state_subscriptions.remove(callback)
|
|
|
|
device = await mock_bluetooth_entry(
|
|
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
|
|
)
|
|
device.client.bluetooth_scanner_set_mode = set_mode_mock
|
|
device.client.subscribe_bluetooth_scanner_state = _subscribe
|
|
await hass.config_entries.async_reload(device.entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert state_subscriptions
|
|
for callback in state_subscriptions[:]:
|
|
callback(
|
|
BluetoothScannerStateResponse(
|
|
state=BluetoothScannerState.RUNNING,
|
|
mode=None,
|
|
configured_mode=None,
|
|
)
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
assert CONF_BLUETOOTH_SCANNING_MODE not in device.entry.options
|
|
# A second response with a real configured_mode commits the migration.
|
|
for callback in state_subscriptions[:]:
|
|
callback(
|
|
BluetoothScannerStateResponse(
|
|
state=BluetoothScannerState.RUNNING,
|
|
mode=BluetoothScannerMode.PASSIVE,
|
|
configured_mode=BluetoothScannerMode.PASSIVE,
|
|
)
|
|
)
|
|
await hass.async_block_till_done()
|
|
assert device.entry.options[CONF_BLUETOOTH_SCANNING_MODE] == "passive"
|
|
|
|
|
|
async def test_scanning_mode_pending_subscription_unsubscribes_on_unload(
|
|
hass: HomeAssistant,
|
|
mock_bluetooth_entry: MockBluetoothEntryType,
|
|
) -> None:
|
|
"""Unloading before the first state update cancels the migration subscription."""
|
|
state_subscriptions: list[Callable[[BluetoothScannerStateResponse], None]] = []
|
|
unsub_calls: list[Callable[[BluetoothScannerStateResponse], None]] = []
|
|
|
|
def _subscribe(
|
|
callback: Callable[[BluetoothScannerStateResponse], None],
|
|
) -> Callable[[], None]:
|
|
state_subscriptions.append(callback)
|
|
|
|
def _unsub() -> None:
|
|
unsub_calls.append(callback)
|
|
state_subscriptions.remove(callback)
|
|
|
|
return _unsub
|
|
|
|
device = await mock_bluetooth_entry(
|
|
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
|
|
)
|
|
device.client.subscribe_bluetooth_scanner_state = _subscribe
|
|
await hass.config_entries.async_reload(device.entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
# The migration subscription is pending; tear the entry down without
|
|
# firing a state update so _unsubscribe in bluetooth.py runs the
|
|
# cancellation arm.
|
|
assert state_subscriptions
|
|
await hass.config_entries.async_unload(device.entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
assert unsub_calls
|
|
|
|
|
|
async def test_scanning_mode_migration_active_becomes_auto(
|
|
hass: HomeAssistant,
|
|
mock_bluetooth_entry: MockBluetoothEntryType,
|
|
) -> None:
|
|
"""Proxy configured ACTIVE migrates to AUTO on first state update."""
|
|
set_mode_mock = MagicMock()
|
|
state_subscriptions: list[Callable[[BluetoothScannerStateResponse], None]] = []
|
|
|
|
def _subscribe(
|
|
callback: Callable[[BluetoothScannerStateResponse], None],
|
|
) -> Callable[[], None]:
|
|
state_subscriptions.append(callback)
|
|
return lambda: state_subscriptions.remove(callback)
|
|
|
|
device = await mock_bluetooth_entry(
|
|
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
|
|
)
|
|
device.client.bluetooth_scanner_set_mode = set_mode_mock
|
|
device.client.subscribe_bluetooth_scanner_state = _subscribe
|
|
await hass.config_entries.async_reload(device.entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
# AUTO was applied at setup before async_register_scanner so habluetooth's
|
|
# scheduler spawns a worker; AUTO maps to PASSIVE on the firmware.
|
|
assert set_mode_mock.call_args_list == [((BluetoothScannerMode.PASSIVE,), {})]
|
|
set_mode_mock.reset_mock()
|
|
assert state_subscriptions
|
|
for callback in state_subscriptions[:]:
|
|
callback(
|
|
BluetoothScannerStateResponse(
|
|
state=BluetoothScannerState.RUNNING,
|
|
mode=BluetoothScannerMode.ACTIVE,
|
|
configured_mode=BluetoothScannerMode.ACTIVE,
|
|
)
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
assert device.entry.options[CONF_BLUETOOTH_SCANNING_MODE] == "auto"
|
|
# AUTO -> AUTO does not re-send a firmware command.
|
|
set_mode_mock.assert_not_called()
|
|
|
|
|
|
async def test_scanning_mode_default_pinned_before_register(
|
|
hass: HomeAssistant,
|
|
mock_bluetooth_entry: MockBluetoothEntryType,
|
|
) -> None:
|
|
"""The default AUTO is applied immediately so the AUTO worker spawns at register."""
|
|
set_mode_mock = MagicMock()
|
|
requested_at_register: list[BluetoothScanningMode | None] = []
|
|
real_register = bluetooth.async_register_scanner
|
|
|
|
@hass_callback
|
|
def _spy_register(*args: Any, **kwargs: Any) -> Callable[[], None]:
|
|
requested_at_register.append(args[1].requested_mode)
|
|
return real_register(*args, **kwargs)
|
|
|
|
device = await mock_bluetooth_entry(
|
|
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
|
|
)
|
|
device.client.bluetooth_scanner_set_mode = set_mode_mock
|
|
with patch(
|
|
"homeassistant.components.esphome.bluetooth.async_register_scanner",
|
|
_spy_register,
|
|
):
|
|
await hass.config_entries.async_reload(device.entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
# AUTO -> PASSIVE is sent before async_register_scanner, so the
|
|
# habluetooth auto-mode worker is spawned at registration time.
|
|
set_mode_mock.assert_called_once_with(BluetoothScannerMode.PASSIVE)
|
|
assert requested_at_register == [BluetoothScanningMode.AUTO]
|