Change default Bluetooth scanning mode to Auto (#171985)

This commit is contained in:
J. Nick Koston
2026-05-23 17:44:19 -05:00
committed by GitHub
parent 29425fd0ac
commit 2a943369d5
9 changed files with 148 additions and 40 deletions
@@ -11,6 +11,7 @@ from bluetooth_adapters import (
ADAPTER_CONNECTION_SLOTS,
ADAPTER_HW_VERSION,
ADAPTER_MANUFACTURER,
ADAPTER_PASSIVE_SCAN,
ADAPTER_SW_VERSION,
DEFAULT_ADDRESS,
DEFAULT_CONNECTION_SLOTS,
@@ -79,7 +80,6 @@ from .const import (
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
CONF_ADAPTER,
CONF_DETAILS,
CONF_PASSIVE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
@@ -93,7 +93,7 @@ from .manager import HomeAssistantBluetoothManager
from .match import BluetoothCallbackMatcher, IntegrationMatcher
from .models import BluetoothCallback, BluetoothChange
from .storage import BluetoothStorage
from .util import adapter_title
from .util import adapter_title, resolve_scanning_mode
if TYPE_CHECKING:
from homeassistant.helpers.typing import ConfigType
@@ -387,12 +387,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady(
f"Bluetooth adapter {adapter} with address {address} not found"
)
passive = entry.options.get(CONF_PASSIVE)
adapters = await manager.async_get_bluetooth_adapters()
mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE
details = adapters[adapter]
mode = resolve_scanning_mode(entry.options)
# AUTO needs passive scanning support to flip on demand; without it
# the scanner would start passive on hardware that can't do passive.
if mode is BluetoothScanningMode.AUTO and not details.get(ADAPTER_PASSIVE_SCAN):
mode = BluetoothScanningMode.ACTIVE
scanner = HaScanner(mode, adapter, address)
scanner.async_setup()
details = adapters[adapter]
if entry.title == address:
hass.config_entries.async_update_entry(
entry, title=adapter_title(adapter, details)
@@ -12,7 +12,7 @@ from bluetooth_adapters import (
adapter_model,
get_adapters,
)
from habluetooth import get_manager
from habluetooth import BluetoothScanningMode, get_manager
import voluptuous as vol
from homeassistant.components import onboarding
@@ -24,14 +24,21 @@ from homeassistant.config_entries import (
)
from homeassistant.core import callback
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaFlowFormStep,
SchemaOptionsFlowHandler,
)
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.helpers.typing import DiscoveryInfoType
from .const import (
CONF_ADAPTER,
CONF_DETAILS,
CONF_MODE,
CONF_PASSIVE,
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
@@ -40,15 +47,39 @@ from .const import (
CONF_SOURCE_MODEL,
DOMAIN,
)
from .util import adapter_title
from .util import adapter_title, resolve_scanning_mode
OPTIONS_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSIVE, default=False): bool,
}
_MODE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[
BluetoothScanningMode.AUTO.value,
BluetoothScanningMode.ACTIVE.value,
BluetoothScanningMode.PASSIVE.value,
],
translation_key="mode",
mode=SelectSelectorMode.DROPDOWN,
)
)
async def _options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Build the options schema with the saved mode as the default."""
current = resolve_scanning_mode(handler.options).value
return vol.Schema({vol.Required(CONF_MODE, default=current): _MODE_SELECTOR})
async def _validate_options(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Mirror CONF_MODE into the legacy CONF_PASSIVE for downgrade safety."""
user_input[CONF_PASSIVE] = (
user_input[CONF_MODE] == BluetoothScanningMode.PASSIVE.value
)
return user_input
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(OPTIONS_SCHEMA),
"init": SchemaFlowFormStep(_options_schema, validate_user_input=_validate_options),
}
@@ -7,14 +7,21 @@ from habluetooth import ( # noqa: F401
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
BluetoothScanningMode,
)
from homeassistant.const import CONF_MODE # noqa: F401
DOMAIN = "bluetooth"
CONF_ADAPTER = "adapter"
CONF_DETAILS = "details"
# CONF_PASSIVE is the legacy boolean option; we keep writing it alongside
# CONF_MODE so a downgrade to a pre-AUTO release reads a sensible value.
CONF_PASSIVE = "passive"
DEFAULT_MODE = BluetoothScanningMode.AUTO.value
# pylint: disable-next=home-assistant-duplicate-const
CONF_SOURCE: Final = "source"
@@ -48,9 +48,21 @@
"step": {
"init": {
"data": {
"passive": "Passive scanning"
"mode": "Scanning mode"
},
"data_description": {
"mode": "Auto is recommended for most setups. It saves battery on your Bluetooth devices while still catching new devices and updates quickly."
}
}
}
},
"selector": {
"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)"
}
}
}
}
+23 -1
View File
@@ -1,5 +1,9 @@
"""The bluetooth integration utilities."""
from collections.abc import Mapping
import logging
from typing import Any
from bluetooth_adapters import (
ADAPTER_ADDRESS,
ADAPTER_MANUFACTURER,
@@ -9,14 +13,32 @@ from bluetooth_adapters import (
adapter_unique_name,
)
from bluetooth_data_tools import monotonic_time_coarse
from habluetooth import get_manager
from habluetooth import BluetoothScanningMode, get_manager
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from .const import CONF_MODE, CONF_PASSIVE, DEFAULT_MODE
from .models import BluetoothServiceInfoBleak
from .storage import BluetoothStorage
_LOGGER = logging.getLogger(__name__)
def resolve_scanning_mode(options: Mapping[str, Any]) -> BluetoothScanningMode:
"""Resolve CONF_MODE, falling back to legacy CONF_PASSIVE or DEFAULT_MODE."""
if (mode_value := options.get(CONF_MODE)) is not None:
try:
return BluetoothScanningMode(mode_value)
except TypeError, ValueError:
_LOGGER.warning("Unknown bluetooth scanning mode %r", mode_value)
return BluetoothScanningMode(DEFAULT_MODE)
if (legacy_passive := options.get(CONF_PASSIVE)) is True:
return BluetoothScanningMode.PASSIVE
if legacy_passive is False:
return BluetoothScanningMode.ACTIVE
return BluetoothScanningMode(DEFAULT_MODE)
class InvalidConfigEntryID(HomeAssistantError):
"""Invalid config entry id."""
+40 -19
View File
@@ -1,5 +1,6 @@
"""Test the bluetooth config flow."""
from typing import Any
from unittest.mock import patch
from bluetooth_adapters import DEFAULT_ADDRESS, AdapterDetails
@@ -10,6 +11,7 @@ from homeassistant.components.bluetooth import HaBluetoothConnector
from homeassistant.components.bluetooth.const import (
CONF_ADAPTER,
CONF_DETAILS,
CONF_MODE,
CONF_PASSIVE,
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
@@ -347,10 +349,11 @@ async def test_async_step_integration_discovery_already_exists(
assert result["reason"] == "already_configured"
@pytest.mark.parametrize("mode", ["auto", "active", "passive"])
@pytest.mark.usefixtures(
"one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters"
)
async def test_options_flow_linux(hass: HomeAssistant) -> None:
async def test_options_flow_linux(hass: HomeAssistant, mode: str) -> None:
"""Test options on Linux."""
entry = MockConfigEntry(
domain=DOMAIN,
@@ -370,32 +373,50 @@ async def test_options_flow_linux(hass: HomeAssistant) -> None:
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_PASSIVE: True,
},
user_input={CONF_MODE: mode},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_PASSIVE] is True
assert result["data"][CONF_MODE] == mode
assert result["data"][CONF_PASSIVE] is (mode == "passive")
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
# Verify we can change it to False
@pytest.mark.parametrize(
("options", "expected_default"),
[
({}, "auto"),
({CONF_PASSIVE: True}, "passive"),
({CONF_PASSIVE: False}, "active"),
({CONF_MODE: "passive"}, "passive"),
],
ids=["fresh", "legacy_passive_true", "legacy_passive_false", "explicit_mode"],
)
@pytest.mark.usefixtures(
"one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters"
)
async def test_options_flow_default_reflects_existing_options(
hass: HomeAssistant, options: dict[str, Any], expected_default: str
) -> None:
"""Options form preselects the current mode, including legacy CONF_PASSIVE."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options=options,
unique_id="00:00:00:00:00:01",
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(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 result["step_id"] == "init"
assert result["errors"] is None
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_PASSIVE: False,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_PASSIVE] is False
schema = result["data_schema"].schema
mode_key = next(k for k in schema if k == CONF_MODE)
assert mode_key.default() == expected_default
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
@@ -239,11 +239,11 @@ async def test_diagnostics(
"type": "FakeHaScanner",
"current_mode": {
"__type": "<enum 'BluetoothScanningMode'>",
"repr": "<BluetoothScanningMode.ACTIVE: 'active'>",
"repr": "<BluetoothScanningMode.AUTO: 'auto'>",
},
"requested_mode": {
"__type": "<enum 'BluetoothScanningMode'>",
"repr": "<BluetoothScanningMode.ACTIVE: 'active'>",
"repr": "<BluetoothScanningMode.AUTO: 'auto'>",
},
},
],
+16 -4
View File
@@ -26,6 +26,7 @@ from homeassistant.components.bluetooth import (
)
from homeassistant.components.bluetooth.const import (
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
CONF_MODE,
CONF_PASSIVE,
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
@@ -95,15 +96,26 @@ async def test_setup_and_stop(
assert len(mock_bleak_scanner_start.mock_calls) == 1
@pytest.mark.parametrize(
"options",
[{CONF_MODE: "passive"}, {CONF_PASSIVE: True}],
ids=["mode_passive", "legacy_passive_true"],
)
@pytest.mark.usefixtures("one_adapter")
async def test_setup_and_stop_passive(
hass: HomeAssistant, mock_bleak_scanner_start: MagicMock
hass: HomeAssistant,
mock_bleak_scanner_start: MagicMock,
options: dict[str, Any],
) -> None:
"""Test we and setup and stop the scanner the passive scanner."""
"""Test we set up and stop the scanner in passive mode.
Covers both the new CONF_MODE key and the legacy CONF_PASSIVE boolean
so the fallback path in async_setup_entry stays exercised.
"""
entry = MockConfigEntry(
domain=bluetooth.DOMAIN,
data={},
options={CONF_PASSIVE: True},
options=options,
unique_id="00:00:00:00:00:01",
)
entry.add_to_hass(hass)
@@ -151,7 +163,7 @@ async def test_setup_and_stop_old_bluez(
mock_bleak_scanner_start: MagicMock,
one_adapter_old_bluez: None,
) -> None:
"""Test we and setup and stop the scanner the passive scanner with older bluez."""
"""Default AUTO falls back to active on adapters without passive scan support."""
entry = MockConfigEntry(
domain=bluetooth.DOMAIN,
data={},
@@ -461,7 +461,7 @@ async def test_subscribe_scanner_state(
response = await client.receive_json()
assert response["success"]
# Should receive initial state for existing scanner
# hci0 has passive_scan=False so AUTO falls back to ACTIVE.
async with asyncio.timeout(1):
response = await client.receive_json()
assert response["event"] == {