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:
@@ -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
|
||||
|
||||
@@ -237,6 +237,7 @@ class BLEScannerMode(StrEnum):
|
||||
DISABLED = "disabled"
|
||||
ACTIVE = "active"
|
||||
PASSIVE = "passive"
|
||||
AUTO = "auto"
|
||||
|
||||
|
||||
BLE_SCANNER_MIN_FIRMWARE = "1.5.1"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user