Skip to content

Commit 04bb84c

Browse files
authored
Add AUTO bluetooth scanner mode to Shelly (home-assistant#172008)
1 parent cb55acc commit 04bb84c

10 files changed

Lines changed: 162 additions & 15 deletions

File tree

homeassistant/components/shelly/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,15 @@
3939
from .const import (
4040
BLOCK_EXPECTED_SLEEP_PERIOD,
4141
BLOCK_WRONG_SLEEP_PERIOD,
42+
CONF_BLE_SCANNER_MODE,
4243
CONF_COAP_PORT,
4344
CONF_SLEEP_PERIOD,
4445
DOMAIN,
4546
FIRMWARE_UNSUPPORTED_ISSUE_ID,
4647
LOGGER,
4748
MODELS_WITH_WRONG_SLEEP_PERIOD,
4849
PUSH_UPDATE_ISSUE_ID,
50+
BLEScannerMode,
4951
)
5052
from .coordinator import (
5153
ShellyBlockCoordinator,
@@ -125,6 +127,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
125127
return True
126128

127129

130+
async def async_migrate_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bool:
131+
"""Migrate old config entries."""
132+
if entry.version > 1 or (entry.version == 1 and entry.minor_version > 3):
133+
return False
134+
if entry.minor_version < 3:
135+
# One-time flip of explicit Active scanning to Auto so existing
136+
# installs get the new battery-friendly default; Passive stays
137+
# Passive because users picked it deliberately.
138+
options = dict(entry.options)
139+
if options.get(CONF_BLE_SCANNER_MODE) == BLEScannerMode.ACTIVE:
140+
options[CONF_BLE_SCANNER_MODE] = BLEScannerMode.AUTO
141+
hass.config_entries.async_update_entry(entry, options=options, minor_version=3)
142+
return True
143+
144+
128145
async def async_setup_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bool:
129146
"""Set up Shelly from a config entry."""
130147
entry.runtime_data = ShellyEntryData([])

homeassistant/components/shelly/bluetooth/__init__.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE = {
2020
BLEScannerMode.PASSIVE: BluetoothScanningMode.PASSIVE,
2121
BLEScannerMode.ACTIVE: BluetoothScanningMode.ACTIVE,
22+
BLEScannerMode.AUTO: BluetoothScanningMode.AUTO,
2223
}
2324

2425

@@ -31,13 +32,25 @@ async def async_connect_scanner(
3132
"""Connect scanner."""
3233
device = coordinator.device
3334
entry = coordinator.config_entry
34-
bluetooth_scanning_mode = BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE[scanner_mode]
35+
# Options persist as plain strings, coerce so `is` checks work.
36+
scanner_mode = BLEScannerMode(scanner_mode)
37+
requested_mode = BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE[scanner_mode]
38+
# AUTO runs the radio passive and lets habluetooth's auto-scheduler
39+
# flip the BLE script to active on demand.
40+
firmware_active = scanner_mode is BLEScannerMode.ACTIVE
41+
current_mode = (
42+
BluetoothScanningMode.ACTIVE
43+
if firmware_active
44+
else BluetoothScanningMode.PASSIVE
45+
)
3546
scanner = create_scanner(
3647
coordinator.bluetooth_source,
3748
entry.title,
38-
requested_mode=bluetooth_scanning_mode,
39-
current_mode=bluetooth_scanning_mode,
49+
requested_mode=requested_mode,
50+
current_mode=current_mode,
4051
)
52+
if scanner_mode is BLEScannerMode.AUTO:
53+
scanner.set_active_window_provider(device)
4154
unload_callbacks = [
4255
async_register_scanner(
4356
hass,
@@ -52,7 +65,7 @@ async def async_connect_scanner(
5265
]
5366
await async_start_scanner(
5467
device=device,
55-
active=scanner_mode == BLEScannerMode.ACTIVE,
68+
active=firmware_active,
5669
event_type=BLE_SCAN_RESULT_EVENT,
5770
data_version=BLE_SCAN_RESULT_VERSION,
5871
)

homeassistant/components/shelly/config_flow.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103

104104
BLE_SCANNER_OPTIONS = [
105105
BLEScannerMode.DISABLED,
106+
BLEScannerMode.AUTO,
106107
BLEScannerMode.ACTIVE,
107108
BLEScannerMode.PASSIVE,
108109
]
@@ -205,7 +206,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
205206
"""Handle a config flow for Shelly."""
206207

207208
VERSION = 1
208-
MINOR_VERSION = 2
209+
MINOR_VERSION = 3
209210

210211
host: str = ""
211212
port: int = DEFAULT_HTTP_PORT

homeassistant/components/shelly/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ class BLEScannerMode(StrEnum):
237237
DISABLED = "disabled"
238238
ACTIVE = "active"
239239
PASSIVE = "passive"
240+
AUTO = "auto"
240241

241242

242243
BLE_SCANNER_MIN_FIRMWARE = "1.5.1"

homeassistant/components/shelly/repairs.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,9 @@ def async_manage_ble_scanner_firmware_unsupported_issue(
5353

5454
if supports_scripts and device.model not in (MODEL_PLUG_S_G3, MODEL_OUT_PLUG_S_G3):
5555
firmware = AwesomeVersion(device.shelly["ver"])
56-
if (
57-
firmware < BLE_SCANNER_MIN_FIRMWARE
58-
and entry.options.get(CONF_BLE_SCANNER_MODE) == BLEScannerMode.ACTIVE
59-
):
56+
if firmware < BLE_SCANNER_MIN_FIRMWARE and entry.options.get(
57+
CONF_BLE_SCANNER_MODE
58+
) in (BLEScannerMode.ACTIVE, BLEScannerMode.AUTO):
6059
ir.async_create_issue(
6160
hass,
6261
DOMAIN,

homeassistant/components/shelly/strings.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,7 @@
685685
},
686686
"step": {
687687
"confirm": {
688-
"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.",
688+
"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.",
689689
"title": "[%key:component::shelly::issues::ble_scanner_firmware_unsupported::title%]"
690690
}
691691
}
@@ -787,16 +787,17 @@
787787
"data_description": {
788788
"ble_scanner_mode": "The scanner mode to use for Bluetooth scanning."
789789
},
790-
"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."
790+
"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."
791791
}
792792
}
793793
},
794794
"selector": {
795795
"ble_scanner_mode": {
796796
"options": {
797-
"active": "[%key:common::state::active%]",
797+
"active": "Active (uses more device battery, fastest updates)",
798+
"auto": "Auto (recommended, saves device battery)",
798799
"disabled": "[%key:common::state::disabled%]",
799-
"passive": "Passive"
800+
"passive": "Passive (lowest device battery use, some details may be missing)"
800801
}
801802
},
802803
"device": {

tests/components/shelly/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,12 @@ async def init_integration(
6161
data[CONF_GEN] = gen
6262

6363
entry = MockConfigEntry(
64-
domain=DOMAIN, data=data, unique_id=MOCK_MAC, options=options, title="Test name"
64+
domain=DOMAIN,
65+
data=data,
66+
unique_id=MOCK_MAC,
67+
options=options,
68+
title="Test name",
69+
minor_version=3,
6570
)
6671
entry.add_to_hass(hass)
6772

tests/components/shelly/bluetooth/test_scanner.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""Test the shelly bluetooth scanner."""
22

3+
from unittest.mock import Mock, patch
4+
5+
from aioshelly.ble.backend.scanner import ShellyBLEScanner
36
from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT
47
import pytest
58

69
from homeassistant.components import bluetooth
10+
from homeassistant.components.bluetooth import BluetoothScanningMode
711
from homeassistant.components.shelly.const import CONF_BLE_SCANNER_MODE, BLEScannerMode
812
from homeassistant.core import HomeAssistant
913

@@ -186,3 +190,19 @@ async def test_scanner_warns_on_corrupt_event(
186190
},
187191
)
188192
assert "Failed to parse BLE event" in caplog.text
193+
194+
195+
async def test_scanner_auto_mode_starts_passive_and_binds_provider(
196+
hass: HomeAssistant, mock_rpc_device: Mock
197+
) -> None:
198+
"""AUTO runs the radio passive; the scanner is pinned AUTO so the worker spawns."""
199+
with patch.object(
200+
ShellyBLEScanner, "set_active_window_provider", autospec=True
201+
) as mock_set_provider:
202+
await init_integration(hass, 2, options={CONF_BLE_SCANNER_MODE: "auto"})
203+
assert mock_rpc_device.initialized is True
204+
scanner = bluetooth.async_scanner_by_source(hass, "12:34:56:78:9A:BE")
205+
assert scanner is not None
206+
assert scanner.requested_mode is BluetoothScanningMode.AUTO
207+
assert scanner.current_mode is BluetoothScanningMode.PASSIVE
208+
mock_set_provider.assert_called_once_with(scanner, mock_rpc_device)

tests/components/shelly/test_config_flow.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2658,6 +2658,21 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device: Mock) -> N
26582658
assert result["type"] is FlowResultType.CREATE_ENTRY
26592659
assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.PASSIVE
26602660

2661+
result = await hass.config_entries.options.async_init(entry.entry_id)
2662+
assert result["type"] is FlowResultType.FORM
2663+
assert result["step_id"] == "init"
2664+
assert result["errors"] is None
2665+
2666+
result = await hass.config_entries.options.async_configure(
2667+
result["flow_id"],
2668+
user_input={
2669+
CONF_BLE_SCANNER_MODE: BLEScannerMode.AUTO,
2670+
},
2671+
)
2672+
2673+
assert result["type"] is FlowResultType.CREATE_ENTRY
2674+
assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.AUTO
2675+
26612676
await hass.config_entries.async_unload(entry.entry_id)
26622677

26632678

tests/components/shelly/test_init.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Test cases for the Shelly component."""
22

33
from ipaddress import IPv4Address
4+
from typing import Any
45
from unittest.mock import AsyncMock, Mock, call, patch
56

67
from aioshelly.block_device import COAP
@@ -608,7 +609,7 @@ async def test_ble_scanner_unsupported_firmware_fixed(
608609
"""Test device init with unsupported firmware."""
609610
issue_id = BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC)
610611
entry = await init_integration(
611-
hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE}
612+
hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.AUTO}
612613
)
613614

614615
assert issue_registry.async_get_issue(DOMAIN, issue_id)
@@ -623,6 +624,80 @@ async def test_ble_scanner_unsupported_firmware_fixed(
623624
assert len(issue_registry.issues) == 0
624625

625626

627+
@pytest.mark.parametrize(
628+
("starting_options", "expected_mode"),
629+
[
630+
({CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE}, BLEScannerMode.AUTO),
631+
({CONF_BLE_SCANNER_MODE: BLEScannerMode.PASSIVE}, BLEScannerMode.PASSIVE),
632+
({CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED}, BLEScannerMode.DISABLED),
633+
({}, None),
634+
],
635+
ids=["active_to_auto", "passive_kept", "disabled_kept", "no_option"],
636+
)
637+
async def test_migrate_ble_scanner_mode(
638+
hass: HomeAssistant,
639+
mock_rpc_device: Mock,
640+
starting_options: dict[str, Any],
641+
expected_mode: BLEScannerMode | None,
642+
) -> None:
643+
"""Active migrates to Auto once; other modes stay put."""
644+
entry = MockConfigEntry(
645+
domain=DOMAIN,
646+
data={
647+
CONF_HOST: "192.168.1.37",
648+
CONF_SLEEP_PERIOD: 0,
649+
CONF_MODEL: MODEL_PLUS_2PM,
650+
"gen": 2,
651+
},
652+
unique_id=MOCK_MAC,
653+
options=starting_options,
654+
title="Test name",
655+
minor_version=2,
656+
)
657+
entry.add_to_hass(hass)
658+
await hass.config_entries.async_setup(entry.entry_id)
659+
await hass.async_block_till_done(wait_background_tasks=True)
660+
661+
assert entry.minor_version == 3
662+
assert entry.options.get(CONF_BLE_SCANNER_MODE) == expected_mode
663+
664+
665+
@pytest.mark.parametrize(
666+
("entry_version", "entry_minor_version"),
667+
[(2, 1), (1, 4)],
668+
ids=["future_major", "future_minor"],
669+
)
670+
async def test_migrate_ble_scanner_mode_future_version(
671+
hass: HomeAssistant,
672+
mock_rpc_device: Mock,
673+
entry_version: int,
674+
entry_minor_version: int,
675+
) -> None:
676+
"""Future versions are not downgraded."""
677+
entry = MockConfigEntry(
678+
domain=DOMAIN,
679+
data={
680+
CONF_HOST: "192.168.1.37",
681+
CONF_SLEEP_PERIOD: 0,
682+
CONF_MODEL: MODEL_PLUS_2PM,
683+
"gen": 2,
684+
},
685+
unique_id=MOCK_MAC,
686+
options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE},
687+
title="Test name",
688+
version=entry_version,
689+
minor_version=entry_minor_version,
690+
)
691+
entry.add_to_hass(hass)
692+
await hass.config_entries.async_setup(entry.entry_id)
693+
await hass.async_block_till_done(wait_background_tasks=True)
694+
695+
assert entry.state is ConfigEntryState.MIGRATION_ERROR
696+
assert entry.version == entry_version
697+
assert entry.minor_version == entry_minor_version
698+
assert entry.options[CONF_BLE_SCANNER_MODE] == BLEScannerMode.ACTIVE
699+
700+
626701
async def test_blu_trv_stale_device_removal(
627702
hass: HomeAssistant,
628703
mock_blu_trv: Mock,

0 commit comments

Comments
 (0)