diff --git a/CODEOWNERS b/CODEOWNERS index 93715c3f93b..6ff584963dc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1013,8 +1013,8 @@ build.json @home-assistant/supervisor /tests/components/sharkiq/ @JeffResc @funkybunch @AritroSaha10 /homeassistant/components/shell_command/ @home-assistant/core /tests/components/shell_command/ @home-assistant/core -/homeassistant/components/shelly/ @balloob @bieniu @thecode @chemelli74 -/tests/components/shelly/ @balloob @bieniu @thecode @chemelli74 +/homeassistant/components/shelly/ @balloob @bieniu @thecode @chemelli74 @bdraco +/tests/components/shelly/ @balloob @bieniu @thecode @chemelli74 @bdraco /homeassistant/components/shodan/ @fabaff /homeassistant/components/sia/ @eavanvalkenburg /tests/components/sia/ @eavanvalkenburg diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index b65c314789a..1e77fae439e 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -6,7 +6,7 @@ from typing import Any, Final import aioshelly from aioshelly.block_device import BlockDevice from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError -from aioshelly.rpc_device import RpcDevice +from aioshelly.rpc_device import RpcDevice, UpdateType import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -252,7 +252,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo hass.config_entries.async_setup_platforms(entry, platforms) @callback - def _async_device_online(_: Any) -> None: + def _async_device_online(_: Any, update_type: UpdateType) -> None: LOGGER.debug("Device %s is online, resuming setup", entry.title) shelly_entry_data.device = None diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py new file mode 100644 index 00000000000..5e7f1c0ad4f --- /dev/null +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -0,0 +1,66 @@ +"""Bluetooth support for shelly.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from aioshelly.ble import async_start_scanner +from aioshelly.ble.const import ( + BLE_SCAN_RESULT_EVENT, + BLE_SCAN_RESULT_VERSION, + DEFAULT_DURATION_MS, + DEFAULT_INTERVAL_MS, + DEFAULT_WINDOW_MS, +) + +from homeassistant.components.bluetooth import ( + HaBluetoothConnector, + async_get_advertisement_callback, + async_register_scanner, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback +from homeassistant.helpers.device_registry import format_mac + +from ..const import BLEScannerMode +from .scanner import ShellyBLEScanner + +if TYPE_CHECKING: + from ..coordinator import ShellyRpcCoordinator + + +async def async_connect_scanner( + hass: HomeAssistant, + coordinator: ShellyRpcCoordinator, + scanner_mode: BLEScannerMode, +) -> CALLBACK_TYPE: + """Connect scanner.""" + device = coordinator.device + source = format_mac(coordinator.mac).upper() + new_info_callback = async_get_advertisement_callback(hass) + connector = HaBluetoothConnector( + # no active connections to shelly yet + client=None, # type: ignore[arg-type] + source=source, + can_connect=lambda: False, + ) + scanner = ShellyBLEScanner(hass, source, new_info_callback, connector, False) + unload_callbacks = [ + async_register_scanner(hass, scanner, False), + scanner.async_setup(), + coordinator.async_subscribe_events(scanner.async_on_event), + ] + await async_start_scanner( + device=device, + active=scanner_mode == BLEScannerMode.ACTIVE, + event_type=BLE_SCAN_RESULT_EVENT, + data_version=BLE_SCAN_RESULT_VERSION, + interval_ms=DEFAULT_INTERVAL_MS, + window_ms=DEFAULT_WINDOW_MS, + duration_ms=DEFAULT_DURATION_MS, + ) + + @hass_callback + def _async_unload() -> None: + for callback in unload_callbacks: + callback() + + return _async_unload diff --git a/homeassistant/components/shelly/bluetooth/scanner.py b/homeassistant/components/shelly/bluetooth/scanner.py new file mode 100644 index 00000000000..a6fb72ecc98 --- /dev/null +++ b/homeassistant/components/shelly/bluetooth/scanner.py @@ -0,0 +1,47 @@ +"""Bluetooth scanner for shelly.""" +from __future__ import annotations + +import logging +from typing import Any + +from aioshelly.ble import parse_ble_scan_result_event +from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION + +from homeassistant.components.bluetooth import BaseHaRemoteScanner +from homeassistant.core import callback + +_LOGGER = logging.getLogger(__name__) + + +class ShellyBLEScanner(BaseHaRemoteScanner): + """Scanner for shelly.""" + + @callback + def async_on_event(self, event: dict[str, Any]) -> None: + """Process an event from the shelly and ignore if its not a ble.scan_result.""" + if event.get("event") != BLE_SCAN_RESULT_EVENT: + return + + data = event["data"] + + if data[0] != BLE_SCAN_RESULT_VERSION: + _LOGGER.warning("Unsupported BLE scan result version: %s", data[0]) + return + + try: + address, rssi, parsed = parse_ble_scan_result_event(data) + except Exception as err: # pylint: disable=broad-except + # Broad exception catch because we have no + # control over the data that is coming in. + _LOGGER.error("Failed to parse BLE event: %s", err, exc_info=True) + return + + self._async_on_advertisement( + address, + rssi, + parsed.local_name, + parsed.service_uuids, + parsed.service_data, + parsed.manufacturer_data, + parsed.tx_power, + ) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 0f6ae9c9da6..d02fe9556bf 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -12,16 +12,25 @@ from aioshelly.exceptions import ( InvalidAuthError, ) from aioshelly.rpc_device import RpcDevice +from awesomeversion import AwesomeVersion import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client +from homeassistant.helpers import aiohttp_client, selector -from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER +from .const import ( + BLE_MIN_VERSION, + CONF_BLE_SCANNER_MODE, + CONF_SLEEP_PERIOD, + DOMAIN, + LOGGER, + BLEScannerMode, +) +from .coordinator import get_entry_data from .utils import ( get_block_device_name, get_block_device_sleep_period, @@ -37,6 +46,13 @@ from .utils import ( HOST_SCHEMA: Final = vol.Schema({vol.Required(CONF_HOST): str}) +BLE_SCANNER_OPTIONS = [ + selector.SelectOptionDict(value=BLEScannerMode.DISABLED, label="Disabled"), + selector.SelectOptionDict(value=BLEScannerMode.ACTIVE, label="Active"), + selector.SelectOptionDict(value=BLEScannerMode.PASSIVE, label="Passive"), +] + + async def validate_input( hass: HomeAssistant, host: str, @@ -310,3 +326,59 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await aioshelly.common.get_info( aiohttp_client.async_get_clientsession(self.hass), host ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + @classmethod + @callback + def async_supports_options_flow( + cls, config_entry: config_entries.ConfigEntry + ) -> bool: + """Return options flow support for this handler.""" + return config_entry.data.get("gen") == 2 + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle the option flow for shelly.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle options flow.""" + if user_input is not None: + entry_data = get_entry_data(self.hass)[self.config_entry.entry_id] + if user_input[CONF_BLE_SCANNER_MODE] != BLEScannerMode.DISABLED and ( + not entry_data.rpc + or AwesomeVersion(entry_data.rpc.device.version) < BLE_MIN_VERSION + ): + return self.async_abort( + reason="ble_unsupported", + description_placeholders={"ble_min_version": BLE_MIN_VERSION}, + ) + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_BLE_SCANNER_MODE, + default=self.config_entry.options.get( + CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED + ), + ): selector.SelectSelector( + selector.SelectSelectorConfig(options=BLE_SCANNER_OPTIONS), + ), + } + ), + ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 39ca515e5ed..81d4bfd7b14 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -5,6 +5,10 @@ from logging import Logger, getLogger import re from typing import Final +from awesomeversion import AwesomeVersion + +from homeassistant.backports.enum import StrEnum + DOMAIN: Final = "shelly" LOGGER: Logger = getLogger(__package__) @@ -156,3 +160,15 @@ UPTIME_DEVIATION: Final = 5 ENTRY_RELOAD_COOLDOWN = 60 SHELLY_GAS_MODELS = ["SHGS-1"] + +BLE_MIN_VERSION = AwesomeVersion("0.12.0-beta2") + +CONF_BLE_SCANNER_MODE = "ble_scanner_mode" + + +class BLEScannerMode(StrEnum): + """BLE scanner mode.""" + + DISABLED = "disabled" + ACTIVE = "active" + PASSIVE = "passive" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 4a5b168e85c..9177d390c91 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -1,6 +1,7 @@ """Coordinators for the Shelly integration.""" from __future__ import annotations +import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta @@ -9,7 +10,8 @@ from typing import Any, cast import aioshelly from aioshelly.block_device import BlockDevice from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError -from aioshelly.rpc_device import RpcDevice +from aioshelly.rpc_device import RpcDevice, UpdateType +from awesomeversion import AwesomeVersion from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, EVENT_HOMEASSISTANT_STOP @@ -18,12 +20,15 @@ from homeassistant.helpers import device_registry from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .bluetooth import async_connect_scanner from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, ATTR_DEVICE, ATTR_GENERATION, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, + BLE_MIN_VERSION, + CONF_BLE_SCANNER_MODE, CONF_SLEEP_PERIOD, DATA_CONFIG_ENTRY, DOMAIN, @@ -40,6 +45,7 @@ from .const import ( SHBTN_MODELS, SLEEP_PERIOD_MULTIPLIER, UPDATE_PERIOD_MULTIPLIER, + BLEScannerMode, ) from .utils import ( device_update_info, @@ -336,7 +342,10 @@ class ShellyRpcCoordinator(DataUpdateCoordinator): ) self.entry = entry self.device = device + self.connected = False + self._disconnected_callbacks: list[CALLBACK_TYPE] = [] + self._connection_lock = asyncio.Lock() self._event_listeners: list[Callable[[dict[str, Any]], None]] = [] self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer( hass, @@ -346,16 +355,14 @@ class ShellyRpcCoordinator(DataUpdateCoordinator): function=self._async_reload_entry, ) entry.async_on_unload(self._debounced_reload.async_cancel) - - self._last_event: dict[str, Any] | None = None - self._last_status: dict[str, Any] | None = None - entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) ) + entry.async_on_unload(entry.add_update_listener(self._async_update_listener)) async def _async_reload_entry(self) -> None: """Reload entry.""" + self._debounced_reload.async_cancel() LOGGER.debug("Reloading entry %s", self.name) await self.hass.config_entries.async_reload(self.entry.entry_id) @@ -390,12 +397,19 @@ class ShellyRpcCoordinator(DataUpdateCoordinator): return _unsubscribe + async def _async_update_listener( + self, hass: HomeAssistant, entry: ConfigEntry + ) -> None: + """Reconfigure on update.""" + async with self._connection_lock: + if self.connected: + self._async_run_disconnected_events() + await self._async_run_connected_events() + @callback def _async_device_event_handler(self, event_data: dict[str, Any]) -> None: """Handle device events.""" - self.update_sleep_period() events: list[dict[str, Any]] = event_data["events"] - for event in events: event_type = event.get("event") if event_type is None: @@ -405,6 +419,7 @@ class ShellyRpcCoordinator(DataUpdateCoordinator): event_callback(event) if event_type == "config_changed": + self.update_sleep_period() LOGGER.info( "Config for %s changed, reloading entry in %s seconds", self.name, @@ -460,21 +475,71 @@ class ShellyRpcCoordinator(DataUpdateCoordinator): """Firmware version of the device.""" return self.device.firmware_version if self.device.initialized else "" - @callback - def _async_handle_update(self, device_: RpcDevice) -> None: - """Handle device update.""" - device = self.device - if not device.initialized: - return - event = device.event - status = device.status + async def _async_disconnected(self) -> None: + """Handle device disconnected.""" + async with self._connection_lock: + if not self.connected: # Already disconnected + return + self.connected = False + self._async_run_disconnected_events() - if event and event != self._last_event: - self._last_event = event + @callback + def _async_run_disconnected_events(self) -> None: + """Run disconnected events. + + This will be executed on disconnect or when the config entry + is updated. + """ + for disconnected_callback in self._disconnected_callbacks: + disconnected_callback() + self._disconnected_callbacks.clear() + + async def _async_connected(self) -> None: + """Handle device connected.""" + async with self._connection_lock: + if self.connected: # Already connected + return + self.connected = True + await self._async_run_connected_events() + + async def _async_run_connected_events(self) -> None: + """Run connected events. + + This will be executed on connect or when the config entry + is updated. + """ + await self._async_connect_ble_scanner() + + async def _async_connect_ble_scanner(self) -> None: + """Connect BLE scanner.""" + ble_scanner_mode = self.entry.options.get( + CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED + ) + if ble_scanner_mode == BLEScannerMode.DISABLED: + return + if AwesomeVersion(self.device.version) < BLE_MIN_VERSION: + LOGGER.error( + "BLE not supported on device %s with firmware %s; upgrade to %s", + self.name, + self.device.version, + BLE_MIN_VERSION, + ) + return + self._disconnected_callbacks.append( + await async_connect_scanner(self.hass, self, ble_scanner_mode) + ) + + @callback + def _async_handle_update(self, device_: RpcDevice, update_type: UpdateType) -> None: + """Handle device update.""" + if update_type is UpdateType.INITIALIZED: + self.hass.async_create_task(self._async_connected()) + elif update_type is UpdateType.DISCONNECTED: + self.hass.async_create_task(self._async_disconnected()) + elif update_type is UpdateType.STATUS: + self.async_set_updated_data(self.device) + elif update_type is UpdateType.EVENT and (event := self.device.event): self._async_device_event_handler(event) - if status and status != self._last_status: - self._last_status = status - self.async_set_updated_data(device) def async_setup(self) -> None: """Set up the coordinator.""" @@ -491,10 +556,14 @@ class ShellyRpcCoordinator(DataUpdateCoordinator): ) self.device_id = entry.id self.device.subscribe_updates(self._async_handle_update) + if self.device.initialized: + # If we are already initialized, we are connected + self.hass.async_create_task(self._async_connected()) async def shutdown(self) -> None: """Shutdown the coordinator.""" await self.device.shutdown() + await self._async_disconnected() async def _handle_ha_stop(self, _event: Event) -> None: """Handle Home Assistant stopping.""" diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 70970e73e30..ce32dbcc41a 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -3,15 +3,15 @@ "name": "Shelly", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/shelly", - "requirements": ["aioshelly==4.1.2"], - "dependencies": ["http"], + "requirements": ["aioshelly==5.0.0"], + "dependencies": ["bluetooth", "http"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "shelly*" } ], - "codeowners": ["@balloob", "@bieniu", "@thecode", "@chemelli74"], + "codeowners": ["@balloob", "@bieniu", "@thecode", "@chemelli74", "@bdraco"], "iot_class": "local_push", "loggers": ["aioshelly"], "integration_type": "device" diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index d3684f85be2..15f3be4d1e5 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -58,5 +58,18 @@ "double_push": "{subtype} double push", "long_push": "{subtype} long push" } + }, + "options": { + "step": { + "init": { + "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.", + "data": { + "ble_scanner_mode": "Bluetooth scanner mode" + } + } + }, + "abort": { + "ble_unsupported": "Bluetooth support requires firmware version {ble_min_version} or newer." + } } } diff --git a/homeassistant/components/shelly/translations/en.json b/homeassistant/components/shelly/translations/en.json index dfc2e8aebec..4ca783b6fe4 100644 --- a/homeassistant/components/shelly/translations/en.json +++ b/homeassistant/components/shelly/translations/en.json @@ -58,5 +58,18 @@ "single_push": "{subtype} single push", "triple": "{subtype} triple clicked" } + }, + "options": { + "abort": { + "ble_unsupported": "Bluetooth support requires firmware version {ble_min_version} or newer." + }, + "step": { + "init": { + "data": { + "ble_scanner_mode": "Bluetooth scanner mode" + }, + "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." + } + } } } \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index 7803f34f498..3be11491e42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -261,7 +261,7 @@ aiosenseme==0.6.1 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==4.1.2 +aioshelly==5.0.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4268e45c12d..a7b8fdd9a5c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -236,7 +236,7 @@ aiosenseme==0.6.1 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==4.1.2 +aioshelly==5.0.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index da98520332a..a844367bced 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -1,4 +1,6 @@ """Tests for the Shelly integration.""" +from __future__ import annotations + from copy import deepcopy from typing import Any from unittest.mock import Mock @@ -15,7 +17,11 @@ MOCK_MAC = "123456789ABC" async def init_integration( - hass: HomeAssistant, gen: int, model="SHSW-25", sleep_period=0 + hass: HomeAssistant, + gen: int, + model="SHSW-25", + sleep_period=0, + options: dict[str, Any] | None = None, ) -> MockConfigEntry: """Set up the Shelly integration in Home Assistant.""" data = { @@ -25,7 +31,9 @@ async def init_integration( "gen": gen, } - entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=MOCK_MAC) + entry = MockConfigEntry( + domain=DOMAIN, data=data, unique_id=MOCK_MAC, options=options + ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -45,3 +53,13 @@ def mutate_rpc_device_status( new_status = deepcopy(mock_rpc_device.status) new_status[top_level_key][key] = value monkeypatch.setattr(mock_rpc_device, "status", new_status) + + +def inject_rpc_device_event( + monkeypatch: pytest.MonkeyPatch, + mock_rpc_device: Mock, + event: dict[str, dict[str, Any]], +) -> None: + """Inject event for rpc device.""" + monkeypatch.setattr(mock_rpc_device, "event", event) + mock_rpc_device.mock_event() diff --git a/tests/components/shelly/bluetooth/__init__.py b/tests/components/shelly/bluetooth/__init__.py new file mode 100644 index 00000000000..a4b1f4cdb7e --- /dev/null +++ b/tests/components/shelly/bluetooth/__init__.py @@ -0,0 +1,2 @@ +"""Bluetooth tests for Shelly integration.""" +from __future__ import annotations diff --git a/tests/components/shelly/bluetooth/test_scanner.py b/tests/components/shelly/bluetooth/test_scanner.py new file mode 100644 index 00000000000..8a429e20532 --- /dev/null +++ b/tests/components/shelly/bluetooth/test_scanner.py @@ -0,0 +1,133 @@ +"""Test the shelly bluetooth scanner.""" +from __future__ import annotations + +from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT + +from homeassistant.components import bluetooth +from homeassistant.components.shelly.const import CONF_BLE_SCANNER_MODE, BLEScannerMode + +from .. import init_integration, inject_rpc_device_event + + +async def test_scanner(hass, mock_rpc_device, monkeypatch): + """Test injecting data into the scanner.""" + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + assert mock_rpc_device.initialized is True + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "component": "script:1", + "data": [ + 1, + "aa:bb:cc:dd:ee:ff", + -62, + "AgEGCf9ZANH7O3TIkA==", + "EQcbxdWlAgC4n+YRTSIADaLLBhYADUgQYQ==", + ], + "event": BLE_SCAN_RESULT_EVENT, + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + ble_device = bluetooth.async_ble_device_from_address( + hass, "AA:BB:CC:DD:EE:FF", connectable=False + ) + assert ble_device is not None + ble_device = bluetooth.async_ble_device_from_address( + hass, "AA:BB:CC:DD:EE:FF", connectable=True + ) + assert ble_device is None + + +async def test_scanner_ignores_non_ble_events(hass, mock_rpc_device, monkeypatch): + """Test injecting non ble data into the scanner.""" + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + assert mock_rpc_device.initialized is True + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "component": "script:1", + "data": [], + "event": "not_ble_scan_result", + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + + +async def test_scanner_ignores_wrong_version_and_logs( + hass, mock_rpc_device, monkeypatch, caplog +): + """Test injecting wrong version of ble data into the scanner.""" + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + assert mock_rpc_device.initialized is True + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "component": "script:1", + "data": [ + 0, + "aa:bb:cc:dd:ee:ff", + -62, + "AgEGCf9ZANH7O3TIkA==", + "EQcbxdWlAgC4n+YRTSIADaLLBhYADUgQYQ==", + ], + "event": BLE_SCAN_RESULT_EVENT, + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + assert "Unsupported BLE scan result version: 0" in caplog.text + + +async def test_scanner_warns_on_corrupt_event( + hass, mock_rpc_device, monkeypatch, caplog +): + """Test injecting garbage ble data into the scanner.""" + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + assert mock_rpc_device.initialized is True + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "component": "script:1", + "data": [ + 1, + ], + "event": BLE_SCAN_RESULT_EVENT, + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + assert "Failed to parse BLE event" in caplog.text diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index cd23cc240c5..f27f91cbfe7 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -1,8 +1,10 @@ """Test configuration for Shelly.""" +from __future__ import annotations + from unittest.mock import AsyncMock, Mock, patch from aioshelly.block_device import BlockDevice -from aioshelly.rpc_device import RpcDevice +from aioshelly.rpc_device import RpcDevice, UpdateType import pytest from homeassistant.components.shelly.const import ( @@ -194,6 +196,7 @@ async def mock_block_device(): blocks=MOCK_BLOCKS, settings=MOCK_SETTINGS, shelly=MOCK_SHELLY_COAP, + version="0.10.0", status=MOCK_STATUS_COAP, firmware_version="some fw string", initialized=True, @@ -204,25 +207,62 @@ async def mock_block_device(): yield block_device_mock.return_value -@pytest.fixture -async def mock_rpc_device(): +def _mock_rpc_device(version: str | None = None): """Mock rpc (Gen2, Websocket) device.""" + return Mock( + spec=RpcDevice, + config=MOCK_CONFIG, + event={}, + shelly=MOCK_SHELLY_RPC, + version=version or "0.12.0", + status=MOCK_STATUS_RPC, + firmware_version="some fw string", + initialized=True, + ) + + +@pytest.fixture +async def mock_pre_ble_rpc_device(): + """Mock rpc (Gen2, Websocket) device pre BLE.""" with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock: def update(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]({}) - - device = Mock( - spec=RpcDevice, - config=MOCK_CONFIG, - event={}, - shelly=MOCK_SHELLY_RPC, - status=MOCK_STATUS_RPC, - firmware_version="some fw string", - initialized=True, - ) + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, UpdateType.STATUS + ) + device = _mock_rpc_device("0.11.0") rpc_device_mock.return_value = device rpc_device_mock.return_value.mock_update = Mock(side_effect=update) yield rpc_device_mock.return_value + + +@pytest.fixture +async def mock_rpc_device(): + """Mock rpc (Gen2, Websocket) device with BLE support.""" + with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock, patch( + "homeassistant.components.shelly.bluetooth.async_start_scanner" + ): + + def update(): + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, UpdateType.STATUS + ) + + def event(): + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, UpdateType.EVENT + ) + + device = _mock_rpc_device("0.12.0") + rpc_device_mock.return_value = device + rpc_device_mock.return_value.mock_update = Mock(side_effect=update) + rpc_device_mock.return_value.mock_event = Mock(side_effect=event) + + yield rpc_device_mock.return_value + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index bd20be7c645..2661f55d178 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -1,4 +1,6 @@ """Tests for Shelly button platform.""" +from __future__ import annotations + from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index ad28ffbd4f0..fa53f8d3467 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -10,8 +10,15 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import ( + CONF_BLE_SCANNER_MODE, + DOMAIN, + BLEScannerMode, +) from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.setup import async_setup_component + +from . import init_integration from tests.common import MockConfigEntry @@ -880,3 +887,149 @@ async def test_reauth_get_info_error(hass, error): assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "reauth_unsuccessful" + + +async def test_options_flow_disabled_gen_1(hass, mock_block_device, hass_ws_client): + """Test options are disabled for gen1 devices.""" + await async_setup_component(hass, "config", {}) + entry = await init_integration(hass, 1) + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + "id": 5, + "type": "config_entries/get", + "domain": "shelly", + } + ) + response = await ws_client.receive_json() + assert response["result"][0]["supports_options"] is False + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_options_flow_enabled_gen_2(hass, mock_rpc_device, hass_ws_client): + """Test options are enabled for gen2 devices.""" + await async_setup_component(hass, "config", {}) + entry = await init_integration(hass, 2) + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + "id": 5, + "type": "config_entries/get", + "domain": "shelly", + } + ) + response = await ws_client.receive_json() + assert response["result"][0]["supports_options"] is True + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_options_flow_ble(hass, mock_rpc_device): + """Test setting ble options for gen2 devices.""" + entry = await init_integration(hass, 2) + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == data_entry_flow.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.DISABLED, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.DISABLED + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == data_entry_flow.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.ACTIVE, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.ACTIVE + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == data_entry_flow.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.PASSIVE, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.PASSIVE + + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_options_flow_pre_ble_device(hass, mock_pre_ble_rpc_device): + """Test setting ble options for gen2 devices with pre ble firmware.""" + entry = await init_integration(hass, 2) + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == data_entry_flow.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.DISABLED, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.DISABLED + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == data_entry_flow.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.ACTIVE, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "ble_unsupported" + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == data_entry_flow.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.PASSIVE, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "ble_unsupported" + + 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 f795b79132f..251dc8a8da7 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -1,4 +1,5 @@ """Test cases for the Shelly component.""" +from __future__ import annotations from unittest.mock import AsyncMock