From 956120662e58a4c114fbb4de2899018fbd3cad99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Nov 2022 12:58:10 -0600 Subject: [PATCH] Add the ability to register for shelly event callbacks (#82052) --- .../components/shelly/coordinator.py | 57 +++++++++++++------ tests/components/shelly/__init__.py | 19 +++++++ tests/components/shelly/test_cover.py | 41 +++++++++---- tests/components/shelly/test_light.py | 4 +- 4 files changed, 91 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 23f905b0fd9..4a5b168e85c 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -1,7 +1,7 @@ """Coordinators for the Shelly integration.""" from __future__ import annotations -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta from typing import Any, cast @@ -13,7 +13,7 @@ from aioshelly.rpc_device import RpcDevice from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import device_registry from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -337,6 +337,7 @@ class ShellyRpcCoordinator(DataUpdateCoordinator): self.entry = entry self.device = device + self._event_listeners: list[Callable[[dict[str, Any]], None]] = [] self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer( hass, LOGGER, @@ -346,10 +347,8 @@ class ShellyRpcCoordinator(DataUpdateCoordinator): ) entry.async_on_unload(self._debounced_reload.async_cancel) - entry.async_on_unload( - self.async_add_listener(self._async_device_updates_handler) - ) 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) @@ -379,24 +378,32 @@ class ShellyRpcCoordinator(DataUpdateCoordinator): return True @callback - def _async_device_updates_handler(self) -> None: - """Handle device updates.""" - if ( - not self.device.initialized - or not self.device.event - or self.device.event == self._last_event - ): - return + def async_subscribe_events( + self, event_callback: Callable[[dict[str, Any]], None] + ) -> CALLBACK_TYPE: + """Subscribe to events.""" + def _unsubscribe() -> None: + self._event_listeners.remove(event_callback) + + self._event_listeners.append(event_callback) + + return _unsubscribe + + @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"] - self._last_event = self.device.event - - for event in self.device.event["events"]: + for event in events: event_type = event.get("event") if event_type is None: continue + for event_callback in self._event_listeners: + event_callback(event) + if event_type == "config_changed": LOGGER.info( "Config for %s changed, reloading entry in %s seconds", @@ -453,6 +460,22 @@ 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 + + if event and event != self._last_event: + self._last_event = 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.""" dev_reg = device_registry.async_get(self.hass) @@ -467,7 +490,7 @@ class ShellyRpcCoordinator(DataUpdateCoordinator): configuration_url=f"http://{self.entry.data[CONF_HOST]}", ) self.device_id = entry.id - self.device.subscribe_updates(self.async_set_updated_data) + self.device.subscribe_updates(self._async_handle_update) async def shutdown(self) -> None: """Shutdown the coordinator.""" diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index a3c571d7177..da98520332a 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -1,4 +1,10 @@ """Tests for the Shelly integration.""" +from copy import deepcopy +from typing import Any +from unittest.mock import Mock + +import pytest + from homeassistant.components.shelly.const import CONF_SLEEP_PERIOD, DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -26,3 +32,16 @@ async def init_integration( await hass.async_block_till_done() return entry + + +def mutate_rpc_device_status( + monkeypatch: pytest.MonkeyPatch, + mock_rpc_device: Mock, + top_level_key: str, + key: str, + value: Any, +) -> None: + """Mutate status for rpc device.""" + new_status = deepcopy(mock_rpc_device.status) + new_status[top_level_key][key] = value + monkeypatch.setattr(mock_rpc_device, "status", new_status) diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index 51fef7dc030..34f63b7690c 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -1,4 +1,8 @@ """Tests for Shelly cover platform.""" +from unittest.mock import Mock + +import pytest + from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, @@ -13,8 +17,9 @@ from homeassistant.components.cover import ( STATE_OPENING, ) from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant -from . import init_integration +from . import init_integration, mutate_rpc_device_status ROLLER_BLOCK_ID = 1 @@ -77,7 +82,9 @@ async def test_block_device_no_roller_blocks(hass, mock_block_device, monkeypatc assert hass.states.get("cover.test_name") is None -async def test_rpc_device_services(hass, mock_rpc_device, monkeypatch): +async def test_rpc_device_services( + hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: """Test RPC device cover services.""" await init_integration(hass, 2) @@ -90,7 +97,9 @@ async def test_rpc_device_services(hass, mock_rpc_device, monkeypatch): state = hass.states.get("cover.test_cover_0") assert state.attributes[ATTR_CURRENT_POSITION] == 50 - monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "opening") + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "cover:0", "state", "opening" + ) await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, @@ -100,7 +109,9 @@ async def test_rpc_device_services(hass, mock_rpc_device, monkeypatch): mock_rpc_device.mock_update() assert hass.states.get("cover.test_cover_0").state == STATE_OPENING - monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "closing") + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "cover:0", "state", "closing" + ) await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, @@ -110,7 +121,7 @@ async def test_rpc_device_services(hass, mock_rpc_device, monkeypatch): mock_rpc_device.mock_update() assert hass.states.get("cover.test_cover_0").state == STATE_CLOSING - monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "closed") + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "closed") await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, @@ -121,26 +132,34 @@ async def test_rpc_device_services(hass, mock_rpc_device, monkeypatch): assert hass.states.get("cover.test_cover_0").state == STATE_CLOSED -async def test_rpc_device_no_cover_keys(hass, mock_rpc_device, monkeypatch): +async def test_rpc_device_no_cover_keys( + hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: """Test RPC device without cover keys.""" monkeypatch.delitem(mock_rpc_device.status, "cover:0") await init_integration(hass, 2) assert hass.states.get("cover.test_cover_0") is None -async def test_rpc_device_update(hass, mock_rpc_device, monkeypatch): +async def test_rpc_device_update( + hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: """Test RPC device update.""" - monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "closed") + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "closed") await init_integration(hass, 2) assert hass.states.get("cover.test_cover_0").state == STATE_CLOSED - monkeypatch.setitem(mock_rpc_device.status["cover:0"], "state", "open") + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "open") mock_rpc_device.mock_update() assert hass.states.get("cover.test_cover_0").state == STATE_OPEN -async def test_rpc_device_no_position_control(hass, mock_rpc_device, monkeypatch): +async def test_rpc_device_no_position_control( + hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: """Test RPC device with no position control.""" - monkeypatch.setitem(mock_rpc_device.status["cover:0"], "pos_control", False) + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "cover:0", "pos_control", False + ) await init_integration(hass, 2) assert hass.states.get("cover.test_cover_0").state == STATE_OPEN diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index b0162f43e13..5f8d49fa8aa 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_ON, ) -from . import init_integration +from . import init_integration, mutate_rpc_device_status RELAY_BLOCK_ID = 0 LIGHT_BLOCK_ID = 2 @@ -374,7 +374,7 @@ async def test_rpc_device_switch_type_lights_mode(hass, mock_rpc_device, monkeyp ) assert hass.states.get("light.test_switch_0").state == STATE_ON - monkeypatch.setitem(mock_rpc_device.status["switch:0"], "output", False) + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "switch:0", "output", False) await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF,