From 5c5dff034c166e86a52468399833114d362d6d18 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 23 Sep 2023 14:03:57 +0000 Subject: [PATCH] Add `event` platform for Shelly gen1 devices (#100655) * Initial commit * Use description.key * Add translations * Check event_types * Rename input_id to channel * Fix removeal confition * Add tests * Sort classes and consts * Use ShellyBlockEntity class * Update tests * Update homeassistant/components/shelly/event.py Co-authored-by: Shay Levy --------- Co-authored-by: Shay Levy --- homeassistant/components/shelly/__init__.py | 1 + .../components/shelly/coordinator.py | 18 +++ homeassistant/components/shelly/event.py | 106 ++++++++++++++++-- homeassistant/components/shelly/strings.json | 8 +- tests/components/shelly/test_event.py | 44 ++++++++ 5 files changed, 166 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 29a0506fcc0..5efc5c849d7 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -58,6 +58,7 @@ BLOCK_PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, + Platform.EVENT, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index c19aac93dab..1a8081b2053 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -170,6 +170,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): self._last_input_events_count: dict = {} self._last_target_temp: float | None = None self._push_update_failures: int = 0 + self._input_event_listeners: list[Callable[[dict[str, Any]], None]] = [] entry.async_on_unload( self.async_add_listener(self._async_device_updates_handler) @@ -178,6 +179,19 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) ) + @callback + def async_subscribe_input_events( + self, input_event_callback: Callable[[dict[str, Any]], None] + ) -> CALLBACK_TYPE: + """Subscribe to input events.""" + + def _unsubscribe() -> None: + self._input_event_listeners.remove(input_event_callback) + + self._input_event_listeners.append(input_event_callback) + + return _unsubscribe + @callback def _async_device_updates_handler(self) -> None: """Handle device updates.""" @@ -242,6 +256,10 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): continue if event_type in INPUTS_EVENTS_DICT: + for event_callback in self._input_event_listeners: + event_callback( + {"channel": channel, "event": INPUTS_EVENTS_DICT[event_type]} + ) self.hass.bus.async_fire( EVENT_SHELLY_CLICK, { diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 92624db3ce3..1b0fedd5cda 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -3,7 +3,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final + +from aioshelly.block_device import Block from homeassistant.components.event import ( DOMAIN as EVENT_DOMAIN, @@ -17,25 +19,46 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import RPC_INPUTS_EVENTS_TYPES -from .coordinator import ShellyRpcCoordinator, get_entry_data +from .const import ( + BASIC_INPUTS_EVENTS_TYPES, + RPC_INPUTS_EVENTS_TYPES, + SHIX3_1_INPUTS_EVENTS_TYPES, +) +from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data +from .entity import ShellyBlockEntity from .utils import ( async_remove_shelly_entity, get_device_entry_gen, get_rpc_entity_name, get_rpc_key_instances, + is_block_momentary_input, is_rpc_momentary_input, ) @dataclass -class ShellyEventDescription(EventEntityDescription): +class ShellyBlockEventDescription(EventEntityDescription): + """Class to describe Shelly event.""" + + removal_condition: Callable[[dict, Block], bool] | None = None + + +@dataclass +class ShellyRpcEventDescription(EventEntityDescription): """Class to describe Shelly event.""" removal_condition: Callable[[dict, dict, str], bool] | None = None -RPC_EVENT: Final = ShellyEventDescription( +BLOCK_EVENT: Final = ShellyBlockEventDescription( + key="input", + translation_key="input", + device_class=EventDeviceClass.BUTTON, + removal_condition=lambda settings, block: not is_block_momentary_input( + settings, block, True + ), +) +RPC_EVENT: Final = ShellyRpcEventDescription( key="input", translation_key="input", device_class=EventDeviceClass.BUTTON, @@ -52,11 +75,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for device.""" + entities: list[ShellyBlockEvent | ShellyRpcEvent] = [] + + coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None + if get_device_entry_gen(config_entry) == 2: coordinator = get_entry_data(hass)[config_entry.entry_id].rpc - assert coordinator + if TYPE_CHECKING: + assert coordinator - entities = [] key_instances = get_rpc_key_instances(coordinator.device.status, RPC_EVENT.key) for key in key_instances: @@ -67,21 +94,80 @@ async def async_setup_entry( async_remove_shelly_entity(hass, EVENT_DOMAIN, unique_id) else: entities.append(ShellyRpcEvent(coordinator, key, RPC_EVENT)) + else: + coordinator = get_entry_data(hass)[config_entry.entry_id].block + if TYPE_CHECKING: + assert coordinator + assert coordinator.device.blocks - async_add_entities(entities) + for block in coordinator.device.blocks: + if ( + "inputEvent" not in block.sensor_ids + or "inputEventCnt" not in block.sensor_ids + ): + continue + + if BLOCK_EVENT.removal_condition and BLOCK_EVENT.removal_condition( + coordinator.device.settings, block + ): + channel = int(block.channel or 0) + 1 + unique_id = f"{coordinator.mac}-{block.description}-{channel}" + async_remove_shelly_entity(hass, EVENT_DOMAIN, unique_id) + else: + entities.append(ShellyBlockEvent(coordinator, block, BLOCK_EVENT)) + + async_add_entities(entities) + + +class ShellyBlockEvent(ShellyBlockEntity, EventEntity): + """Represent Block event entity.""" + + _attr_should_poll = False + entity_description: ShellyBlockEventDescription + + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block, + description: ShellyBlockEventDescription, + ) -> None: + """Initialize Shelly entity.""" + super().__init__(coordinator, block) + self.channel = channel = int(block.channel or 0) + 1 + self._attr_unique_id = f"{super().unique_id}-{channel}" + + if coordinator.model == "SHIX3-1": + self._attr_event_types = list(SHIX3_1_INPUTS_EVENTS_TYPES) + else: + self._attr_event_types = list(BASIC_INPUTS_EVENTS_TYPES) + self.entity_description = description + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_subscribe_input_events(self._async_handle_event) + ) + + @callback + def _async_handle_event(self, event: dict[str, Any]) -> None: + """Handle the demo button event.""" + if event["channel"] == self.channel: + self._trigger_event(event["event"]) + self.async_write_ha_state() class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): """Represent RPC event entity.""" _attr_should_poll = False - entity_description: ShellyEventDescription + entity_description: ShellyRpcEventDescription def __init__( self, coordinator: ShellyRpcCoordinator, key: str, - description: ShellyEventDescription, + description: ShellyRpcEventDescription, ) -> None: """Initialize Shelly entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index d2e72ee81da..b12ad3e4823 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -106,9 +106,15 @@ "btn_down": "Button down", "btn_up": "Button up", "double_push": "Double push", + "double": "Double push", "long_push": "Long push", + "long_single": "Long push and then short push", + "long": "Long push", + "single_long": "Short push and then long push", "single_push": "Single push", - "triple_push": "Triple push" + "single": "Single push", + "triple_push": "Triple push", + "triple": "Triple push" } } } diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py index 8222e42408b..b7824d8d7ac 100644 --- a/tests/components/shelly/test_event.py +++ b/tests/components/shelly/test_event.py @@ -15,6 +15,8 @@ from homeassistant.helpers.entity_registry import async_get from . import init_integration, inject_rpc_device_event, register_entity +DEVICE_BLOCK_ID = 4 + async def test_rpc_button(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: """Test RPC device event.""" @@ -68,3 +70,45 @@ async def test_rpc_event_removal( await init_integration(hass, 2) assert registry.async_get(entity_id) is None + + +async def test_block_event(hass: HomeAssistant, monkeypatch, mock_block_device) -> None: + """Test block device event.""" + await init_integration(hass, 1) + entity_id = "event.test_name_channel_1" + registry = async_get(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered(["single", "long"]) + assert state.attributes.get(ATTR_EVENT_TYPE) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == EventDeviceClass.BUTTON + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-relay_0-1" + + monkeypatch.setattr( + mock_block_device.blocks[DEVICE_BLOCK_ID], + "sensor_ids", + {"inputEvent": "L", "inputEventCnt": 0}, + ) + monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "inputEvent", "L") + mock_block_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_EVENT_TYPE) == "long" + + +async def test_block_event_shix3_1(hass: HomeAssistant, mock_block_device) -> None: + """Test block device event for SHIX3-1.""" + await init_integration(hass, 1, model="SHIX3-1") + entity_id = "event.test_name_channel_1" + + state = hass.states.get(entity_id) + assert state + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["double", "long", "long_single", "single", "single_long", "triple"] + )