Change default ESPHome bluetooth proxy scanning mode to Auto (#171996)

This commit is contained in:
J. Nick Koston
2026-05-23 18:21:37 -05:00
committed by GitHub
parent fb8f82542e
commit 7b4ed59861
7 changed files with 528 additions and 35 deletions
+94 -18
View File
@@ -1,16 +1,33 @@
"""Bluetooth support for esphome."""
from functools import partial
import logging
from typing import TYPE_CHECKING
from aioesphomeapi import APIClient, DeviceInfo
from aioesphomeapi import (
APIClient,
APIVersion,
BluetoothProxyFeature,
BluetoothScannerMode,
BluetoothScannerStateResponse,
DeviceInfo,
)
from bleak_esphome import connect_scanner
from homeassistant.components.bluetooth import async_register_scanner
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
async_register_scanner,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
from .const import DOMAIN
from .entry_data import RuntimeEntryData
from .const import CONF_BLUETOOTH_SCANNING_MODE, DEFAULT_BLUETOOTH_SCANNING_MODE, DOMAIN
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
if TYPE_CHECKING:
from bleak_esphome.backend.scanner import ESPHomeScanner
_LOGGER = logging.getLogger(__name__)
_VALID_SCANNING_MODES = {mode.value for mode in BluetoothScanningMode}
@hass_callback
@@ -23,6 +40,7 @@ def _async_unload(unload_callbacks: list[CALLBACK_TYPE]) -> None:
@hass_callback
def async_connect_scanner(
hass: HomeAssistant,
entry: ESPHomeConfigEntry,
entry_data: RuntimeEntryData,
cli: APIClient,
device_info: DeviceInfo,
@@ -35,17 +53,75 @@ def async_connect_scanner(
scanner = client_data.scanner
if TYPE_CHECKING:
assert scanner is not None
return partial(
_async_unload,
[
async_register_scanner(
hass,
scanner,
source_domain=DOMAIN,
source_model=device_info.model,
source_config_entry_id=entry_data.entry_id,
source_device_id=device_id,
),
scanner.async_setup(),
],
)
api_version = cli.api_version or APIVersion()
feature_flags = device_info.bluetooth_proxy_feature_flags_compat(api_version)
state_and_mode = bool(feature_flags & BluetoothProxyFeature.FEATURE_STATE_AND_MODE)
# Pin mode before async_register_scanner so habluetooth spawns the AUTO worker.
deferred_migration: CALLBACK_TYPE | None = None
if state_and_mode:
deferred_migration = _async_apply_scanning_mode(hass, entry, scanner, cli)
callbacks: list[CALLBACK_TYPE] = [
async_register_scanner(
hass,
scanner,
source_domain=DOMAIN,
source_model=device_info.model,
source_config_entry_id=entry_data.entry_id,
source_device_id=device_id,
),
scanner.async_setup(),
]
if deferred_migration is not None:
callbacks.append(deferred_migration)
return partial(_async_unload, callbacks)
@hass_callback
def _async_apply_scanning_mode(
hass: HomeAssistant,
entry: ESPHomeConfigEntry,
scanner: ESPHomeScanner,
cli: APIClient,
) -> CALLBACK_TYPE | None:
"""Apply saved scanning mode synchronously; migrate from configured_mode later."""
saved = entry.options.get(CONF_BLUETOOTH_SCANNING_MODE)
if saved is not None and saved not in _VALID_SCANNING_MODES:
_LOGGER.warning("%s: unknown scanning mode %r", entry.title, saved)
saved = None
initial_value = saved if saved is not None else DEFAULT_BLUETOOTH_SCANNING_MODE
scanner.async_set_scanning_mode(BluetoothScanningMode(initial_value))
if saved is not None:
return None
unsub_holder: list[CALLBACK_TYPE] = []
@hass_callback
def _migrate(state: BluetoothScannerStateResponse) -> None:
# proto3 unset enums decode to None; wait for a real value.
if (configured_pb := state.configured_mode) is None:
return
if unsub_holder:
unsub_holder.pop()()
if configured_pb is BluetoothScannerMode.PASSIVE:
new_mode = BluetoothScanningMode.PASSIVE
else:
new_mode = BluetoothScanningMode(DEFAULT_BLUETOOTH_SCANNING_MODE)
hass.config_entries.async_update_entry(
entry,
options={
**entry.options,
CONF_BLUETOOTH_SCANNING_MODE: new_mode.value,
},
)
# AUTO -> AUTO is already pinned; only re-apply on a downgrade.
if new_mode is not BluetoothScanningMode(DEFAULT_BLUETOOTH_SCANNING_MODE):
scanner.async_set_scanning_mode(new_mode)
unsub_holder.append(cli.subscribe_bluetooth_scanner_state(_migrate))
@hass_callback
def _unsubscribe() -> None:
if unsub_holder:
unsub_holder.pop()()
return _unsubscribe
+59 -12
View File
@@ -9,6 +9,7 @@ from typing import Any, cast
from aioesphomeapi import (
APIClient,
APIConnectionError,
BluetoothProxyFeature,
DeviceInfo,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
@@ -20,6 +21,7 @@ import aiohttp
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.config_entries import (
SOURCE_ESPHOME,
SOURCE_IGNORE,
@@ -38,6 +40,11 @@ from homeassistant.data_entry_flow import AbortFlow, FlowResultType
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.importlib import async_import_module
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
@@ -47,10 +54,12 @@ from homeassistant.util.json import json_loads_object
from .const import (
CONF_ALLOW_SERVICE_CALLS,
CONF_BLUETOOTH_SCANNING_MODE,
CONF_DEVICE_NAME,
CONF_NOISE_PSK,
CONF_SUBSCRIBE_LOGS,
DEFAULT_ALLOW_SERVICE_CALLS,
DEFAULT_BLUETOOTH_SCANNING_MODE,
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DEFAULT_PORT,
DOMAIN,
@@ -68,6 +77,18 @@ _LOGGER = logging.getLogger(__name__)
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
DEFAULT_NAME = "ESPHome"
_BLUETOOTH_SCANNING_MODE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[
BluetoothScanningMode.AUTO.value,
BluetoothScanningMode.ACTIVE.value,
BluetoothScanningMode.PASSIVE.value,
],
translation_key="bluetooth_scanning_mode",
mode=SelectSelectorMode.DROPDOWN,
)
)
class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a esphome config flow."""
@@ -936,18 +957,44 @@ class OptionsFlowHandler(OptionsFlowWithReload):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
data_schema = vol.Schema(
{
options = self.config_entry.options
schema: dict[Any, Any] = {
vol.Required(
CONF_ALLOW_SERVICE_CALLS,
default=options.get(
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
),
): bool,
vol.Required(
CONF_SUBSCRIBE_LOGS,
default=options.get(CONF_SUBSCRIBE_LOGS, False),
): bool,
}
if _entry_has_bluetooth_scanner(self.config_entry):
schema[
vol.Required(
CONF_ALLOW_SERVICE_CALLS,
default=self.config_entry.options.get(
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
CONF_BLUETOOTH_SCANNING_MODE,
default=options.get(
CONF_BLUETOOTH_SCANNING_MODE, DEFAULT_BLUETOOTH_SCANNING_MODE
),
): bool,
vol.Required(
CONF_SUBSCRIBE_LOGS,
default=self.config_entry.options.get(CONF_SUBSCRIBE_LOGS, False),
): bool,
}
)
] = _BLUETOOTH_SCANNING_MODE_SELECTOR
return self.async_show_form(step_id="init", data_schema=vol.Schema(schema))
@callback
def _entry_has_bluetooth_scanner(entry: ESPHomeConfigEntry) -> bool:
"""Return True if the entry exposes a bluetooth proxy scanner or has one saved."""
# Keep showing the option if it was previously saved, even when the
# device is offline or stops advertising the feature flag, so the
# saved value isn't silently dropped on the next options save.
if CONF_BLUETOOTH_SCANNING_MODE in entry.options:
return True
if entry.state is ConfigEntryState.LOADED and (
device_info := entry.runtime_data.device_info
):
flags = device_info.bluetooth_proxy_feature_flags_compat(
entry.runtime_data.api_version
)
return self.async_show_form(step_id="init", data_schema=data_schema)
return bool(flags & BluetoothProxyFeature.FEATURE_STATE_AND_MODE)
return False
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Final
from awesomeversion import AwesomeVersion
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
@@ -18,9 +19,11 @@ CONF_SUBSCRIBE_LOGS = "subscribe_logs"
CONF_DEVICE_NAME = "device_name"
CONF_NOISE_PSK = "noise_psk"
CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address"
CONF_BLUETOOTH_SCANNING_MODE = "bluetooth_scanning_mode"
DEFAULT_ALLOW_SERVICE_CALLS = True
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
DEFAULT_BLUETOOTH_SCANNING_MODE = BluetoothScanningMode.AUTO.value
DEFAULT_PORT: Final = 6053
+1 -1
View File
@@ -669,7 +669,7 @@ class ESPHomeManager:
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
entry_data.disconnect_callbacks.add(
async_connect_scanner(
hass, entry_data, cli, device_info, self.device_id
hass, self.entry, entry_data, cli, device_info, self.device_id
)
)
else:
@@ -209,13 +209,24 @@
"init": {
"data": {
"allow_service_calls": "Allow the device to perform Home Assistant actions.",
"bluetooth_scanning_mode": "Bluetooth scanning mode",
"subscribe_logs": "Subscribe to logs from the device."
},
"data_description": {
"allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions or send events. Only enable this if you trust the device.",
"bluetooth_scanning_mode": "Auto is recommended for most setups. It saves battery on your Bluetooth devices while still catching new devices and updates quickly.",
"subscribe_logs": "When enabled, the device will send logs to Home Assistant and you can view them in the logs panel."
}
}
}
},
"selector": {
"bluetooth_scanning_mode": {
"options": {
"active": "Active (uses more device battery, fastest updates)",
"auto": "Auto (recommended, saves device battery)",
"passive": "Passive (lowest device battery use, some details may be missing)"
}
}
}
}
+253 -3
View File
@@ -1,12 +1,30 @@
"""Test the ESPHome bluetooth integration."""
from unittest.mock import patch
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.core import HomeAssistant
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 MockESPHomeDevice
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(
@@ -94,3 +112,235 @@ async def test_bluetooth_cleanup_on_remove_entry(
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]
+107 -1
View File
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from aioesphomeapi import (
APIClient,
APIConnectionError,
BluetoothProxyFeature,
DeviceInfo,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
@@ -22,9 +23,11 @@ from homeassistant import config_entries
from homeassistant.components.esphome import dashboard
from homeassistant.components.esphome.const import (
CONF_ALLOW_SERVICE_CALLS,
CONF_BLUETOOTH_SCANNING_MODE,
CONF_DEVICE_NAME,
CONF_NOISE_PSK,
CONF_SUBSCRIBE_LOGS,
DEFAULT_BLUETOOTH_SCANNING_MODE,
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DOMAIN,
)
@@ -43,7 +46,11 @@ from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import VALID_NOISE_PSK
from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType
from .conftest import (
MockBluetoothEntryType,
MockESPHomeDeviceType,
MockGenericDeviceEntryType,
)
from tests.common import MockConfigEntry
@@ -2078,6 +2085,105 @@ async def test_option_flow_subscribe_logs(
assert len(mock_reload.mock_calls) == 1
async def test_option_flow_shows_saved_scanning_mode_when_proxy_unavailable(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
) -> None:
"""A previously-saved mode keeps surfacing even if the proxy feature flag is gone."""
entry = await mock_generic_device_entry(mock_client=mock_client)
hass.config_entries.async_update_entry(
entry,
options={**entry.options, CONF_BLUETOOTH_SCANNING_MODE: "passive"},
)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert CONF_BLUETOOTH_SCANNING_MODE in result["data_schema"].schema
with patch("homeassistant.components.esphome.async_setup_entry", return_value=True):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_ALLOW_SERVICE_CALLS: False,
CONF_SUBSCRIBE_LOGS: False,
CONF_BLUETOOTH_SCANNING_MODE: "auto",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_BLUETOOTH_SCANNING_MODE] == "auto"
async def test_option_flow_unloaded_entry_without_saved_mode(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
) -> None:
"""An unloaded entry without a saved scanning mode hides the option."""
entry = await mock_generic_device_entry(mock_client=mock_client)
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert CONF_BLUETOOTH_SCANNING_MODE not in result["data_schema"].schema
async def test_option_flow_hides_bluetooth_scanning_mode_without_proxy(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
) -> None:
"""Devices without a bluetooth proxy must not see the scanning mode option."""
entry = await mock_generic_device_entry(mock_client=mock_client)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert CONF_BLUETOOTH_SCANNING_MODE not in result["data_schema"].schema
with patch("homeassistant.components.esphome.async_setup_entry", return_value=True):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={CONF_ALLOW_SERVICE_CALLS: False, CONF_SUBSCRIBE_LOGS: False},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert CONF_BLUETOOTH_SCANNING_MODE not in result["data"]
async def test_option_flow_bluetooth_scanning_mode(
hass: HomeAssistant,
mock_bluetooth_entry: MockBluetoothEntryType,
) -> None:
"""Bluetooth proxy devices with FEATURE_STATE_AND_MODE expose the option."""
device = await mock_bluetooth_entry(
bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN
| BluetoothProxyFeature.ACTIVE_CONNECTIONS
| BluetoothProxyFeature.RAW_ADVERTISEMENTS
| BluetoothProxyFeature.FEATURE_STATE_AND_MODE
)
entry = device.entry
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["data_schema"]({}) == {
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
CONF_SUBSCRIBE_LOGS: False,
CONF_BLUETOOTH_SCANNING_MODE: DEFAULT_BLUETOOTH_SCANNING_MODE,
}
with patch("homeassistant.components.esphome.async_setup_entry", return_value=True):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_ALLOW_SERVICE_CALLS: False,
CONF_SUBSCRIBE_LOGS: False,
CONF_BLUETOOTH_SCANNING_MODE: "passive",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_BLUETOOTH_SCANNING_MODE] == "passive"
@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf")
async def test_user_discovers_name_no_dashboard(
hass: HomeAssistant,