diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 133031a54fe..02ebbdc530f 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -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([]) diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index 87461fd4901..7d44dd7c646 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -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, ) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index d81e6926f48..eb01a1a25ce 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -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 diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index f12ca9271fd..6900bcfb16f 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -237,6 +237,7 @@ class BLEScannerMode(StrEnum): DISABLED = "disabled" ACTIVE = "active" PASSIVE = "passive" + AUTO = "auto" BLE_SCANNER_MIN_FIRMWARE = "1.5.1" diff --git a/homeassistant/components/shelly/repairs.py b/homeassistant/components/shelly/repairs.py index 02998088bef..7e3161ebde5 100644 --- a/homeassistant/components/shelly/repairs.py +++ b/homeassistant/components/shelly/repairs.py @@ -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, diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index fdbcda0ce58..bad550bfd3d 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -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": { diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 72464a7894b..2d588d01825 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -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) diff --git a/tests/components/shelly/bluetooth/test_scanner.py b/tests/components/shelly/bluetooth/test_scanner.py index 26ddf9a50ba..3e615961d47 100644 --- a/tests/components/shelly/bluetooth/test_scanner.py +++ b/tests/components/shelly/bluetooth/test_scanner.py @@ -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) diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 0e86ae18f96..0c2df8646db 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -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) diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 70f005e5934..0eaa3192ca1 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -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,