mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Change default Bluetooth scanning mode to Auto (#171985)
This commit is contained in:
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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'>",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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"] == {
|
||||
|
||||
Reference in New Issue
Block a user