1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-28 11:16:40 +01:00

Add AUTO bluetooth scanner mode to Shelly (#172008)

This commit is contained in:
J. Nick Koston
2026-05-24 14:53:54 -05:00
committed by GitHub
parent cb55accc3b
commit 04bb84cd03
10 changed files with 162 additions and 15 deletions
@@ -39,6 +39,7 @@ from homeassistant.helpers.typing import ConfigType
from .const import (
BLOCK_EXPECTED_SLEEP_PERIOD,
BLOCK_WRONG_SLEEP_PERIOD,
CONF_BLE_SCANNER_MODE,
CONF_COAP_PORT,
CONF_SLEEP_PERIOD,
DOMAIN,
@@ -46,6 +47,7 @@ from .const import (
LOGGER,
MODELS_WITH_WRONG_SLEEP_PERIOD,
PUSH_UPDATE_ISSUE_ID,
BLEScannerMode,
)
from .coordinator import (
ShellyBlockCoordinator,
@@ -125,6 +127,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bool:
"""Migrate old config entries."""
if entry.version > 1 or (entry.version == 1 and entry.minor_version > 3):
return False
if entry.minor_version < 3:
# One-time flip of explicit Active scanning to Auto so existing
# installs get the new battery-friendly default; Passive stays
# Passive because users picked it deliberately.
options = dict(entry.options)
if options.get(CONF_BLE_SCANNER_MODE) == BLEScannerMode.ACTIVE:
options[CONF_BLE_SCANNER_MODE] = BLEScannerMode.AUTO
hass.config_entries.async_update_entry(entry, options=options, minor_version=3)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bool:
"""Set up Shelly from a config entry."""
entry.runtime_data = ShellyEntryData([])
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE = {
BLEScannerMode.PASSIVE: BluetoothScanningMode.PASSIVE,
BLEScannerMode.ACTIVE: BluetoothScanningMode.ACTIVE,
BLEScannerMode.AUTO: BluetoothScanningMode.AUTO,
}
@@ -31,13 +32,25 @@ async def async_connect_scanner(
"""Connect scanner."""
device = coordinator.device
entry = coordinator.config_entry
bluetooth_scanning_mode = BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE[scanner_mode]
# Options persist as plain strings, coerce so `is` checks work.
scanner_mode = BLEScannerMode(scanner_mode)
requested_mode = BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE[scanner_mode]
# AUTO runs the radio passive and lets habluetooth's auto-scheduler
# flip the BLE script to active on demand.
firmware_active = scanner_mode is BLEScannerMode.ACTIVE
current_mode = (
BluetoothScanningMode.ACTIVE
if firmware_active
else BluetoothScanningMode.PASSIVE
)
scanner = create_scanner(
coordinator.bluetooth_source,
entry.title,
requested_mode=bluetooth_scanning_mode,
current_mode=bluetooth_scanning_mode,
requested_mode=requested_mode,
current_mode=current_mode,
)
if scanner_mode is BLEScannerMode.AUTO:
scanner.set_active_window_provider(device)
unload_callbacks = [
async_register_scanner(
hass,
@@ -52,7 +65,7 @@ async def async_connect_scanner(
]
await async_start_scanner(
device=device,
active=scanner_mode == BLEScannerMode.ACTIVE,
active=firmware_active,
event_type=BLE_SCAN_RESULT_EVENT,
data_version=BLE_SCAN_RESULT_VERSION,
)
@@ -103,6 +103,7 @@ CONFIG_SCHEMA: Final = vol.Schema(
BLE_SCANNER_OPTIONS = [
BLEScannerMode.DISABLED,
BLEScannerMode.AUTO,
BLEScannerMode.ACTIVE,
BLEScannerMode.PASSIVE,
]
@@ -205,7 +206,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Shelly."""
VERSION = 1
MINOR_VERSION = 2
MINOR_VERSION = 3
host: str = ""
port: int = DEFAULT_HTTP_PORT
+1
View File
@@ -237,6 +237,7 @@ class BLEScannerMode(StrEnum):
DISABLED = "disabled"
ACTIVE = "active"
PASSIVE = "passive"
AUTO = "auto"
BLE_SCANNER_MIN_FIRMWARE = "1.5.1"
+3 -4
View File
@@ -53,10 +53,9 @@ def async_manage_ble_scanner_firmware_unsupported_issue(
if supports_scripts and device.model not in (MODEL_PLUG_S_G3, MODEL_OUT_PLUG_S_G3):
firmware = AwesomeVersion(device.shelly["ver"])
if (
firmware < BLE_SCANNER_MIN_FIRMWARE
and entry.options.get(CONF_BLE_SCANNER_MODE) == BLEScannerMode.ACTIVE
):
if firmware < BLE_SCANNER_MIN_FIRMWARE and entry.options.get(
CONF_BLE_SCANNER_MODE
) in (BLEScannerMode.ACTIVE, BLEScannerMode.AUTO):
ir.async_create_issue(
hass,
DOMAIN,
+5 -4
View File
@@ -685,7 +685,7 @@
},
"step": {
"confirm": {
"description": "Your Shelly device {device_name} with IP address {ip_address} is running firmware {firmware} and acts as BLE scanner with active mode. This firmware version is not supported for BLE scanner active mode.\n\nSelect **Submit** button to start the OTA update to the latest stable firmware version.",
"description": "Your Shelly device {device_name} with IP address {ip_address} is running firmware {firmware} and acts as a BLE scanner in Active or Auto mode. This firmware version is not supported for these BLE scanner modes.\n\nSelect **Submit** button to start the OTA update to the latest stable firmware version.",
"title": "[%key:component::shelly::issues::ble_scanner_firmware_unsupported::title%]"
}
}
@@ -787,16 +787,17 @@
"data_description": {
"ble_scanner_mode": "The scanner mode to use for Bluetooth scanning."
},
"description": "Bluetooth scanning can be active or passive. With active, the Shelly requests data from nearby devices; with passive, the Shelly receives unsolicited data from nearby devices."
"description": "Auto is recommended for most setups; the Shelly listens passively and only briefly switches to active when needed, saving battery on your Bluetooth devices."
}
}
},
"selector": {
"ble_scanner_mode": {
"options": {
"active": "[%key:common::state::active%]",
"active": "Active (uses more device battery, fastest updates)",
"auto": "Auto (recommended, saves device battery)",
"disabled": "[%key:common::state::disabled%]",
"passive": "Passive"
"passive": "Passive (lowest device battery use, some details may be missing)"
}
},
"device": {
+6 -1
View File
@@ -61,7 +61,12 @@ async def init_integration(
data[CONF_GEN] = gen
entry = MockConfigEntry(
domain=DOMAIN, data=data, unique_id=MOCK_MAC, options=options, title="Test name"
domain=DOMAIN,
data=data,
unique_id=MOCK_MAC,
options=options,
title="Test name",
minor_version=3,
)
entry.add_to_hass(hass)
@@ -1,9 +1,13 @@
"""Test the shelly bluetooth scanner."""
from unittest.mock import Mock, patch
from aioshelly.ble.backend.scanner import ShellyBLEScanner
from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT
import pytest
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.components.shelly.const import CONF_BLE_SCANNER_MODE, BLEScannerMode
from homeassistant.core import HomeAssistant
@@ -186,3 +190,19 @@ async def test_scanner_warns_on_corrupt_event(
},
)
assert "Failed to parse BLE event" in caplog.text
async def test_scanner_auto_mode_starts_passive_and_binds_provider(
hass: HomeAssistant, mock_rpc_device: Mock
) -> None:
"""AUTO runs the radio passive; the scanner is pinned AUTO so the worker spawns."""
with patch.object(
ShellyBLEScanner, "set_active_window_provider", autospec=True
) as mock_set_provider:
await init_integration(hass, 2, options={CONF_BLE_SCANNER_MODE: "auto"})
assert mock_rpc_device.initialized is True
scanner = bluetooth.async_scanner_by_source(hass, "12:34:56:78:9A:BE")
assert scanner is not None
assert scanner.requested_mode is BluetoothScanningMode.AUTO
assert scanner.current_mode is BluetoothScanningMode.PASSIVE
mock_set_provider.assert_called_once_with(scanner, mock_rpc_device)
@@ -2658,6 +2658,21 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device: Mock) -> N
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.PASSIVE
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_BLE_SCANNER_MODE: BLEScannerMode.AUTO,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.AUTO
await hass.config_entries.async_unload(entry.entry_id)
+76 -1
View File
@@ -1,6 +1,7 @@
"""Test cases for the Shelly component."""
from ipaddress import IPv4Address
from typing import Any
from unittest.mock import AsyncMock, Mock, call, patch
from aioshelly.block_device import COAP
@@ -608,7 +609,7 @@ async def test_ble_scanner_unsupported_firmware_fixed(
"""Test device init with unsupported firmware."""
issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC)
entry = await init_integration(
hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE}
hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.AUTO}
)
assert issue_registry.async_get_issue(DOMAIN, issue_id)
@@ -623,6 +624,80 @@ async def test_ble_scanner_unsupported_firmware_fixed(
assert len(issue_registry.issues) == 0
@pytest.mark.parametrize(
("starting_options", "expected_mode"),
[
({CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE}, BLEScannerMode.AUTO),
({CONF_BLE_SCANNER_MODE: BLEScannerMode.PASSIVE}, BLEScannerMode.PASSIVE),
({CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED}, BLEScannerMode.DISABLED),
({}, None),
],
ids=["active_to_auto", "passive_kept", "disabled_kept", "no_option"],
)
async def test_migrate_ble_scanner_mode(
hass: HomeAssistant,
mock_rpc_device: Mock,
starting_options: dict[str, Any],
expected_mode: BLEScannerMode | None,
) -> None:
"""Active migrates to Auto once; other modes stay put."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "192.168.1.37",
CONF_SLEEP_PERIOD: 0,
CONF_MODEL: MODEL_PLUS_2PM,
"gen": 2,
},
unique_id=MOCK_MAC,
options=starting_options,
title="Test name",
minor_version=2,
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
assert entry.minor_version == 3
assert entry.options.get(CONF_BLE_SCANNER_MODE) == expected_mode
@pytest.mark.parametrize(
("entry_version", "entry_minor_version"),
[(2, 1), (1, 4)],
ids=["future_major", "future_minor"],
)
async def test_migrate_ble_scanner_mode_future_version(
hass: HomeAssistant,
mock_rpc_device: Mock,
entry_version: int,
entry_minor_version: int,
) -> None:
"""Future versions are not downgraded."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "192.168.1.37",
CONF_SLEEP_PERIOD: 0,
CONF_MODEL: MODEL_PLUS_2PM,
"gen": 2,
},
unique_id=MOCK_MAC,
options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE},
title="Test name",
version=entry_version,
minor_version=entry_minor_version,
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
assert entry.state is ConfigEntryState.MIGRATION_ERROR
assert entry.version == entry_version
assert entry.minor_version == entry_minor_version
assert entry.options[CONF_BLE_SCANNER_MODE] == BLEScannerMode.ACTIVE
async def test_blu_trv_stale_device_removal(
hass: HomeAssistant,
mock_blu_trv: Mock,