From e9eb76c7dba8f4b14e993b166b8c04c9e3584110 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 13 Sep 2021 09:31:35 +0300 Subject: [PATCH] Add switch support for RPC device (#56153) * Add switch support for RPC device * Apply review comments * Apply review comments --- homeassistant/components/shelly/__init__.py | 196 ++++++++++++++---- homeassistant/components/shelly/const.py | 6 +- homeassistant/components/shelly/cover.py | 8 +- .../components/shelly/device_trigger.py | 13 +- homeassistant/components/shelly/entity.py | 91 ++++++-- homeassistant/components/shelly/light.py | 97 +++++++-- homeassistant/components/shelly/logbook.py | 6 +- homeassistant/components/shelly/switch.py | 107 ++++++++-- homeassistant/components/shelly/utils.py | 25 ++- tests/components/shelly/conftest.py | 57 ++++- .../components/shelly/test_device_trigger.py | 10 +- tests/components/shelly/test_switch.py | 74 ++++++- 12 files changed, 568 insertions(+), 122 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index c0d0016392f..6cd2a101a25 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -8,6 +8,7 @@ from typing import Any, Final, cast import aioshelly from aioshelly.block_device import BlockDevice +from aioshelly.rpc_device import RpcDevice import async_timeout import voluptuous as vol @@ -31,7 +32,7 @@ from .const import ( ATTR_CLICK_TYPE, ATTR_DEVICE, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, - COAP, + BLOCK, CONF_COAP_PORT, DATA_CONFIG_ENTRY, DEFAULT_COAP_PORT, @@ -42,6 +43,8 @@ from .const import ( POLLING_TIMEOUT_SEC, REST, REST_SENSORS_UPDATE_INTERVAL, + RPC, + RPC_RECONNECT_INTERVAL, SHBTN_MODELS, SLEEP_PERIOD_MULTIPLIER, UPDATE_PERIOD_MULTIPLIER, @@ -50,10 +53,13 @@ from .utils import ( get_block_device_name, get_block_device_sleep_period, get_coap_context, + get_device_entry_gen, + get_rpc_device_name, ) -PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"] -SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"] +BLOCK_PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"] +BLOCK_SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"] +RPC_PLATFORMS: Final = ["light", "switch"] _LOGGER: Final = logging.getLogger(__name__) COAP_SCHEMA: Final = vol.Schema( @@ -89,12 +95,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False - if entry.data.get("gen") == 2: - return True - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {} hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = None + if get_device_entry_gen(entry) == 2: + return await async_setup_rpc_entry(hass, entry) + + return await async_setup_block_entry(hass, entry) + + +async def async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Shelly block based device from a config entry.""" temperature_unit = "C" if hass.config.units.is_metric else "F" options = aioshelly.common.ConnectionOptions( @@ -113,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: False, ) - dev_reg = await device_registry.async_get_registry(hass) + dev_reg = device_registry.async_get(hass) device_entry = None if entry.unique_id is not None: device_entry = dev_reg.async_get_device( @@ -135,18 +146,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data["model"] = device.settings["device"]["type"] hass.config_entries.async_update_entry(entry, data=data) - hass.async_create_task(async_device_setup(hass, entry, device)) + hass.async_create_task(async_block_device_setup(hass, entry, device)) if sleep_period == 0: # Not a sleeping device, finish setup - _LOGGER.debug("Setting up online device %s", entry.title) + _LOGGER.debug("Setting up online block device %s", entry.title) try: async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): await device.initialize() except (asyncio.TimeoutError, OSError) as err: raise ConfigEntryNotReady from err - await async_device_setup(hass, entry, device) + await async_block_device_setup(hass, entry, device) elif sleep_period is None or device_entry is None: # Need to get sleep info or first time sleeping device setup, wait for device hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = device @@ -156,34 +167,61 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device.subscribe_updates(_async_device_online) else: # Restore sensors for sleeping device - _LOGGER.debug("Setting up offline device %s", entry.title) - await async_device_setup(hass, entry, device) + _LOGGER.debug("Setting up offline block device %s", entry.title) + await async_block_device_setup(hass, entry, device) return True -async def async_device_setup( +async def async_block_device_setup( hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice ) -> None: - """Set up a device that is online.""" + """Set up a block based device that is online.""" device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ - COAP - ] = ShellyDeviceWrapper(hass, entry, device) - await device_wrapper.async_setup() + BLOCK + ] = BlockDeviceWrapper(hass, entry, device) + device_wrapper.async_setup() - platforms = SLEEPING_PLATFORMS + platforms = BLOCK_SLEEPING_PLATFORMS if not entry.data.get("sleep_period"): hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ REST ] = ShellyDeviceRestWrapper(hass, device) - platforms = PLATFORMS + platforms = BLOCK_PLATFORMS hass.config_entries.async_setup_platforms(entry, platforms) -class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): - """Wrapper for a Shelly device with Home Assistant specific functions.""" +async def async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Shelly RPC based device from a config entry.""" + options = aioshelly.common.ConnectionOptions( + entry.data[CONF_HOST], + entry.data.get(CONF_USERNAME), + entry.data.get(CONF_PASSWORD), + ) + + _LOGGER.debug("Setting up online RPC device %s", entry.title) + try: + async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): + device = await RpcDevice.create( + aiohttp_client.async_get_clientsession(hass), options + ) + except (asyncio.TimeoutError, OSError) as err: + raise ConfigEntryNotReady from err + + device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ + RPC + ] = RpcDeviceWrapper(hass, entry, device) + device_wrapper.async_setup() + + hass.config_entries.async_setup_platforms(entry, RPC_PLATFORMS) + + return True + + +class BlockDeviceWrapper(update_coordinator.DataUpdateCoordinator): + """Wrapper for a Shelly block based device with Home Assistant specific functions.""" def __init__( self, hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice @@ -283,7 +321,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): # Sleeping device, no point polling it, just mark it unavailable raise update_coordinator.UpdateFailed("Sleeping device did not update") - _LOGGER.debug("Polling Shelly Device - %s", self.name) + _LOGGER.debug("Polling Shelly Block Device - %s", self.name) try: async with async_timeout.timeout(POLLING_TIMEOUT_SEC): await self.device.update() @@ -300,16 +338,14 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): """Mac address of the device.""" return cast(str, self.entry.unique_id) - async def async_setup(self) -> None: + def async_setup(self) -> None: """Set up the wrapper.""" - dev_reg = await device_registry.async_get_registry(self.hass) - sw_version = self.device.settings["fw"] if self.device.initialized else "" + dev_reg = device_registry.async_get(self.hass) + sw_version = self.device.firmware_version if self.device.initialized else "" entry = dev_reg.async_get_or_create( config_entry_id=self.entry.entry_id, name=self.name, connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)}, - # This is duplicate but otherwise via_device can't work - identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), sw_version=sw_version, @@ -325,7 +361,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): @callback def _handle_ha_stop(self, _event: Event) -> None: """Handle Home Assistant stopping.""" - _LOGGER.debug("Stopping ShellyDeviceWrapper for %s", self.name) + _LOGGER.debug("Stopping BlockDeviceWrapper for %s", self.name) self.shutdown() @@ -369,8 +405,15 @@ class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if entry.data.get("gen") == 2: - return True + if get_device_entry_gen(entry) == 2: + unload_ok = await hass.config_entries.async_unload_platforms( + entry, RPC_PLATFORMS + ) + if unload_ok: + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][RPC].shutdown() + hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) + + return unload_ok device = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id].get(DEVICE) if device is not None: @@ -378,15 +421,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device.shutdown() return True - platforms = SLEEPING_PLATFORMS + platforms = BLOCK_SLEEPING_PLATFORMS if not entry.data.get("sleep_period"): hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][REST] = None - platforms = PLATFORMS + platforms = BLOCK_PLATFORMS unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) if unload_ok: - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][COAP].shutdown() + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][BLOCK].shutdown() hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) return unload_ok @@ -394,17 +437,94 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def get_device_wrapper( hass: HomeAssistant, device_id: str -) -> ShellyDeviceWrapper | None: +) -> BlockDeviceWrapper | RpcDeviceWrapper | None: """Get a Shelly device wrapper for the given device id.""" if not hass.data.get(DOMAIN): return None for config_entry in hass.data[DOMAIN][DATA_CONFIG_ENTRY]: - wrapper: ShellyDeviceWrapper | None = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ + block_wrapper: BlockDeviceWrapper | None = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ config_entry - ].get(COAP) + ].get(BLOCK) - if wrapper and wrapper.device_id == device_id: - return wrapper + if block_wrapper and block_wrapper.device_id == device_id: + return block_wrapper + + rpc_wrapper: RpcDeviceWrapper | None = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ + config_entry + ].get(RPC) + + if rpc_wrapper and rpc_wrapper.device_id == device_id: + return rpc_wrapper return None + + +class RpcDeviceWrapper(update_coordinator.DataUpdateCoordinator): + """Wrapper for a Shelly RPC based device with Home Assistant specific functions.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, device: RpcDevice + ) -> None: + """Initialize the Shelly device wrapper.""" + self.device_id: str | None = None + + device_name = get_rpc_device_name(device) if device.initialized else entry.title + super().__init__( + hass, + _LOGGER, + name=device_name, + update_interval=timedelta(seconds=RPC_RECONNECT_INTERVAL), + ) + self.entry = entry + self.device = device + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) + ) + + async def _async_update_data(self) -> None: + """Fetch data.""" + if self.device.connected: + return + + try: + _LOGGER.debug("Reconnecting to Shelly RPC Device - %s", self.name) + async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): + await self.device.initialize() + except OSError as err: + raise update_coordinator.UpdateFailed("Device disconnected") from err + + @property + def model(self) -> str: + """Model of the device.""" + return cast(str, self.entry.data["model"]) + + @property + def mac(self) -> str: + """Mac address of the device.""" + return cast(str, self.entry.unique_id) + + def async_setup(self) -> None: + """Set up the wrapper.""" + dev_reg = device_registry.async_get(self.hass) + sw_version = self.device.firmware_version if self.device.initialized else "" + entry = dev_reg.async_get_or_create( + config_entry_id=self.entry.entry_id, + name=self.name, + connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)}, + manufacturer="Shelly", + model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), + sw_version=sw_version, + ) + self.device_id = entry.id + self.device.subscribe_updates(self.async_set_updated_data) + + async def shutdown(self) -> None: + """Shutdown the wrapper.""" + await self.device.shutdown() + + async def _handle_ha_stop(self, _event: Event) -> None: + """Handle Home Assistant stopping.""" + _LOGGER.debug("Stopping RpcDeviceWrapper for %s", self.name) + await self.shutdown() diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 5646086285d..14b56d2c584 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -4,11 +4,12 @@ from __future__ import annotations import re from typing import Final -COAP: Final = "coap" +BLOCK: Final = "block" DATA_CONFIG_ENTRY: Final = "config_entry" DEVICE: Final = "device" DOMAIN: Final = "shelly" REST: Final = "rest" +RPC: Final = "rpc" CONF_COAP_PORT: Final = "coap_port" DEFAULT_COAP_PORT: Final = 5683 @@ -44,6 +45,9 @@ SLEEP_PERIOD_MULTIPLIER: Final = 1.2 # Multiplier used to calculate the "update_interval" for non-sleeping devices. UPDATE_PERIOD_MULTIPLIER: Final = 2.2 +# Reconnect interval for GEN2 devices +RPC_RECONNECT_INTERVAL = 60 + # Shelly Air - Maximum work hours before lamp replacement SHAIR_MAX_WORK_HOURS: Final = 9000 diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index 40441ab74d3..47166ff2dbd 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -18,8 +18,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ShellyDeviceWrapper -from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN +from . import BlockDeviceWrapper +from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN from .entity import ShellyBlockEntity @@ -29,7 +29,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up cover for device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK] blocks = [block for block in wrapper.device.blocks if block.type == "roller"] if not blocks: @@ -43,7 +43,7 @@ class ShellyCover(ShellyBlockEntity, CoverEntity): _attr_device_class = DEVICE_CLASS_SHUTTER - def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: + def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: """Initialize light.""" super().__init__(wrapper, block) self.control_result: dict[str, Any] | None = None diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index 5d90a10dabc..552d1d62032 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -25,7 +25,7 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.typing import ConfigType -from . import get_device_wrapper +from . import RpcDeviceWrapper, get_device_wrapper from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, @@ -55,6 +55,10 @@ async def async_validate_trigger_config( # if device is available verify parameters against device capabilities wrapper = get_device_wrapper(hass, config[CONF_DEVICE_ID]) + + if isinstance(wrapper, RpcDeviceWrapper): + return config + if not wrapper or not wrapper.device.initialized: return config @@ -76,12 +80,15 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, Any]]: """List device triggers for Shelly devices.""" - triggers = [] - wrapper = get_device_wrapper(hass, device_id) if not wrapper: raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}") + if isinstance(wrapper, RpcDeviceWrapper): + return [] + + triggers = [] + if wrapper.model in SHBTN_MODELS: for trigger in SHBTN_INPUTS_EVENTS_TYPES: triggers.append( diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index a7b75116132..fd8dfe281ff 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -23,9 +23,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType -from . import ShellyDeviceRestWrapper, ShellyDeviceWrapper -from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, COAP, DATA_CONFIG_ENTRY, DOMAIN, REST -from .utils import async_remove_shelly_entity, get_entity_name +from . import BlockDeviceWrapper, RpcDeviceWrapper, ShellyDeviceRestWrapper +from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, BLOCK, DATA_CONFIG_ENTRY, DOMAIN, REST +from .utils import ( + async_remove_shelly_entity, + get_block_entity_name, + get_rpc_entity_name, +) _LOGGER: Final = logging.getLogger(__name__) @@ -38,9 +42,9 @@ async def async_setup_entry_attribute_entities( sensor_class: Callable, ) -> None: """Set up entities for attributes.""" - wrapper: ShellyDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ + wrapper: BlockDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ config_entry.entry_id - ][COAP] + ][BLOCK] if wrapper.device.initialized: await async_setup_block_attribute_entities( @@ -55,7 +59,7 @@ async def async_setup_entry_attribute_entities( async def async_setup_block_attribute_entities( hass: HomeAssistant, async_add_entities: AddEntitiesCallback, - wrapper: ShellyDeviceWrapper, + wrapper: BlockDeviceWrapper, sensors: dict[tuple[str, str], BlockAttributeDescription], sensor_class: Callable, ) -> None: @@ -99,7 +103,7 @@ async def async_restore_block_attribute_entities( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - wrapper: ShellyDeviceWrapper, + wrapper: BlockDeviceWrapper, sensors: dict[tuple[str, str], BlockAttributeDescription], sensor_class: Callable, ) -> None: @@ -198,13 +202,13 @@ class RestAttributeDescription: class ShellyBlockEntity(entity.Entity): - """Helper class to represent a block.""" + """Helper class to represent a block entity.""" - def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: + def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: """Initialize Shelly entity.""" self.wrapper = wrapper self.block = block - self._name = get_entity_name(wrapper.device, block) + self._name = get_block_entity_name(wrapper.device, block) @property def name(self) -> str: @@ -263,12 +267,67 @@ class ShellyBlockEntity(entity.Entity): return None +class ShellyRpcEntity(entity.Entity): + """Helper class to represent a rpc entity.""" + + def __init__(self, wrapper: RpcDeviceWrapper, key: str) -> None: + """Initialize Shelly entity.""" + self.wrapper = wrapper + self.key = key + self._attr_should_poll = False + self._attr_device_info = { + "connections": {(device_registry.CONNECTION_NETWORK_MAC, wrapper.mac)} + } + self._attr_unique_id = f"{wrapper.mac}-{key}" + self._attr_name = get_rpc_entity_name(wrapper.device, key) + + @property + def available(self) -> bool: + """Available.""" + return self.wrapper.device.connected + + async def async_added_to_hass(self) -> None: + """When entity is added to HASS.""" + self.async_on_remove(self.wrapper.async_add_listener(self._update_callback)) + + async def async_update(self) -> None: + """Update entity with latest info.""" + await self.wrapper.async_request_refresh() + + @callback + def _update_callback(self) -> None: + """Handle device update.""" + self.async_write_ha_state() + + async def call_rpc(self, method: str, params: Any) -> Any: + """Call RPC method.""" + _LOGGER.debug( + "Call RPC for entity %s, method: %s, params: %s", + self.name, + method, + params, + ) + try: + async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): + return await self.wrapper.device.call_rpc(method, params) + except asyncio.TimeoutError as err: + _LOGGER.error( + "Call RPC for entity %s failed, method: %s, params: %s, error: %s", + self.name, + method, + params, + repr(err), + ) + self.wrapper.last_update_success = False + return None + + class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): """Helper class to represent a block attribute.""" def __init__( self, - wrapper: ShellyDeviceWrapper, + wrapper: BlockDeviceWrapper, block: Block, attribute: str, description: BlockAttributeDescription, @@ -285,7 +344,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): self._unit: None | str | Callable[[dict], str] = unit self._unique_id: str = f"{super().unique_id}-{self.attribute}" - self._name = get_entity_name(wrapper.device, block, self.description.name) + self._name = get_block_entity_name(wrapper.device, block, self.description.name) @property def unique_id(self) -> str: @@ -346,7 +405,7 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): def __init__( self, - wrapper: ShellyDeviceWrapper, + wrapper: BlockDeviceWrapper, attribute: str, description: RestAttributeDescription, ) -> None: @@ -355,7 +414,7 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): self.wrapper = wrapper self.attribute = attribute self.description = description - self._name = get_entity_name(wrapper.device, None, self.description.name) + self._name = get_block_entity_name(wrapper.device, None, self.description.name) self._last_value = None @property @@ -419,7 +478,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti # pylint: disable=super-init-not-called def __init__( self, - wrapper: ShellyDeviceWrapper, + wrapper: BlockDeviceWrapper, block: Block | None, attribute: str, description: BlockAttributeDescription, @@ -440,7 +499,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti self._unit = self._unit(block.info(attribute)) self._unique_id = f"{self.wrapper.mac}-{block.description}-{attribute}" - self._name = get_entity_name( + self._name = get_block_entity_name( self.wrapper.device, block, self.description.name ) elif entry is not None: diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 9ecc16ecc5a..bb636013361 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -33,10 +33,10 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin, ) -from . import ShellyDeviceWrapper +from . import BlockDeviceWrapper, RpcDeviceWrapper from .const import ( AIOSHELLY_DEVICE_TIMEOUT_SEC, - COAP, + BLOCK, DATA_CONFIG_ENTRY, DOMAIN, FIRMWARE_PATTERN, @@ -46,11 +46,12 @@ from .const import ( LIGHT_TRANSITION_MIN_FIRMWARE_DATE, MAX_TRANSITION_TIME, MODELS_SUPPORTING_LIGHT_TRANSITION, + RPC, SHBLB_1_RGB_EFFECTS, STANDARD_RGB_EFFECTS, ) -from .entity import ShellyBlockEntity -from .utils import async_remove_shelly_entity +from .entity import ShellyBlockEntity, ShellyRpcEntity +from .utils import async_remove_shelly_entity, get_device_entry_gen _LOGGER: Final = logging.getLogger(__name__) @@ -61,33 +62,75 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up lights for device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] + if get_device_entry_gen(config_entry) == 2: + return await async_setup_rpc_entry(hass, config_entry, async_add_entities) + + return await async_setup_block_entry(hass, config_entry, async_add_entities) + + +async def async_setup_block_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for block device.""" + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK] blocks = [] + assert wrapper.device.blocks for block in wrapper.device.blocks: if block.type == "light": blocks.append(block) elif block.type == "relay": - appliance_type = wrapper.device.settings["relays"][int(block.channel)].get( + app_type = wrapper.device.settings["relays"][int(block.channel)].get( "appliance_type" ) - if appliance_type and appliance_type.lower() == "light": - blocks.append(block) - unique_id = ( - f'{wrapper.device.shelly["mac"]}-{block.type}_{block.channel}' - ) - await async_remove_shelly_entity(hass, "switch", unique_id) + if not app_type or app_type.lower() != "light": + continue + + blocks.append(block) + assert wrapper.device.shelly + unique_id = f"{wrapper.mac}-{block.type}_{block.channel}" + await async_remove_shelly_entity(hass, "switch", unique_id) if not blocks: return - async_add_entities(ShellyLight(wrapper, block) for block in blocks) + async_add_entities(BlockShellyLight(wrapper, block) for block in blocks) -class ShellyLight(ShellyBlockEntity, LightEntity): - """Switch that controls a relay block on Shelly devices.""" +async def async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][RPC] - def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: + switch_keys = [] + for i in range(4): + key = f"switch:{i}" + if not wrapper.device.status.get(key): + continue + + con_types = wrapper.device.config["sys"]["ui_data"].get("consumption_types") + if con_types is None or con_types[i] != "lights": + continue + + switch_keys.append((key, i)) + unique_id = f"{wrapper.mac}-{key}" + await async_remove_shelly_entity(hass, "switch", unique_id) + + if not switch_keys: + return + + async_add_entities(RpcShellyLight(wrapper, key, id_) for key, id_ in switch_keys) + + +class BlockShellyLight(ShellyBlockEntity, LightEntity): + """Entity that controls a light on block based Shelly devices.""" + + def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: """Initialize light.""" super().__init__(wrapper, block) self.control_result: dict[str, Any] | None = None @@ -369,3 +412,25 @@ class ShellyLight(ShellyBlockEntity, LightEntity): self.control_result = None self.mode_result = None super()._update_callback() + + +class RpcShellyLight(ShellyRpcEntity, LightEntity): + """Entity that controls a light on RPC based Shelly devices.""" + + def __init__(self, wrapper: RpcDeviceWrapper, key: str, id_: int) -> None: + """Initialize light.""" + super().__init__(wrapper, key) + self._id = id_ + + @property + def is_on(self) -> bool: + """If light is on.""" + return bool(self.wrapper.device.status[self.key]["output"]) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on light.""" + await self.call_rpc("Switch.Set", {"id": self._id, "on": True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off light.""" + await self.call_rpc("Switch.Set", {"id": self._id, "on": False}) diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index ca4818085d0..d58691439cf 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -7,7 +7,7 @@ from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.typing import EventType -from . import get_device_wrapper +from . import RpcDeviceWrapper, get_device_wrapper from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, @@ -29,6 +29,10 @@ def async_describe_events( def async_describe_shelly_click_event(event: EventType) -> dict[str, str]: """Describe shelly.click logbook event.""" wrapper = get_device_wrapper(hass, event.data[ATTR_DEVICE_ID]) + + if isinstance(wrapper, RpcDeviceWrapper): + return {} + if wrapper and wrapper.device.initialized: device_name = get_block_device_name(wrapper.device) else: diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index b36bcd42d59..97f44d9c40e 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -10,10 +10,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ShellyDeviceWrapper -from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN -from .entity import ShellyBlockEntity -from .utils import async_remove_shelly_entity +from . import BlockDeviceWrapper, RpcDeviceWrapper +from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN, RPC +from .entity import ShellyBlockEntity, ShellyRpcEntity +from .utils import async_remove_shelly_entity, get_device_entry_gen async def async_setup_entry( @@ -22,7 +22,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches for device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] + if get_device_entry_gen(config_entry) == 2: + return await async_setup_rpc_entry(hass, config_entry, async_add_entities) + + return await async_setup_block_entry(hass, config_entry, async_add_entities) + + +async def async_setup_block_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for block device.""" + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK] # In roller mode the relay blocks exist but do not contain required info if ( @@ -32,32 +44,59 @@ async def async_setup_entry( return relay_blocks = [] + assert wrapper.device.blocks for block in wrapper.device.blocks: - if block.type == "relay": - appliance_type = wrapper.device.settings["relays"][int(block.channel)].get( - "appliance_type" - ) - if not appliance_type or appliance_type.lower() != "light": - relay_blocks.append(block) - unique_id = ( - f'{wrapper.device.shelly["mac"]}-{block.type}_{block.channel}' - ) - await async_remove_shelly_entity( - hass, - "light", - unique_id, - ) + if block.type != "relay": + continue + + app_type = wrapper.device.settings["relays"][int(block.channel)].get( + "appliance_type" + ) + if app_type and app_type.lower() == "light": + continue + + relay_blocks.append(block) + unique_id = f"{wrapper.mac}-{block.type}_{block.channel}" + await async_remove_shelly_entity(hass, "light", unique_id) if not relay_blocks: return - async_add_entities(RelaySwitch(wrapper, block) for block in relay_blocks) + async_add_entities(BlockRelaySwitch(wrapper, block) for block in relay_blocks) -class RelaySwitch(ShellyBlockEntity, SwitchEntity): - """Switch that controls a relay block on Shelly devices.""" +async def async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][RPC] - def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: + switch_keys = [] + for i in range(4): + key = f"switch:{i}" + if not wrapper.device.status.get(key): + continue + + con_types = wrapper.device.config["sys"]["ui_data"].get("consumption_types") + if con_types is not None and con_types[i] == "lights": + continue + + switch_keys.append((key, i)) + unique_id = f"{wrapper.mac}-{key}" + await async_remove_shelly_entity(hass, "light", unique_id) + + if not switch_keys: + return + + async_add_entities(RpcRelaySwitch(wrapper, key, id_) for key, id_ in switch_keys) + + +class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): + """Entity that controls a relay on Block based Shelly devices.""" + + def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: """Initialize relay switch.""" super().__init__(wrapper, block) self.control_result: dict[str, Any] | None = None @@ -85,3 +124,25 @@ class RelaySwitch(ShellyBlockEntity, SwitchEntity): """When device updates, clear control result that overrides state.""" self.control_result = None super()._update_callback() + + +class RpcRelaySwitch(ShellyRpcEntity, SwitchEntity): + """Entity that controls a relay on RPC based Shelly devices.""" + + def __init__(self, wrapper: RpcDeviceWrapper, key: str, id_: int) -> None: + """Initialize relay switch.""" + super().__init__(wrapper, key) + self._id = id_ + + @property + def is_on(self) -> bool: + """If switch is on.""" + return bool(self.wrapper.device.status[self.key]["output"]) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on relay.""" + await self.call_rpc("Switch.Set", {"id": self._id, "on": True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off relay.""" + await self.call_rpc("Switch.Set", {"id": self._id, "on": False}) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 405c34e6eb9..10046ccd4b0 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -9,6 +9,7 @@ from aioshelly.block_device import BLOCK_VALUE_UNIT, COAP, Block, BlockDevice from aioshelly.const import MODEL_NAMES from aioshelly.rpc_device import RpcDevice +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import singleton @@ -81,12 +82,12 @@ def get_number_of_channels(device: BlockDevice, block: Block) -> int: return channels or 1 -def get_entity_name( +def get_block_entity_name( device: BlockDevice, block: Block | None, description: str | None = None, ) -> str: - """Naming for switch and sensors.""" + """Naming for block based switch and sensors.""" channel_name = get_device_channel_name(device, block) if description: @@ -237,3 +238,23 @@ def get_model_name(info: dict[str, Any]) -> str: return cast(str, MODEL_NAMES.get(info["model"], info["model"])) return cast(str, MODEL_NAMES.get(info["type"], info["type"])) + + +def get_rpc_entity_name( + device: RpcDevice, key: str, description: str | None = None +) -> str: + """Naming for RPC based switch and sensors.""" + entity_name: str | None = device.config[key].get("name") + + if entity_name is None: + entity_name = f"{get_rpc_device_name(device)} {key.replace(':', '_')}" + + if description: + return f"{entity_name} {description}" + + return entity_name + + +def get_device_entry_gen(entry: ConfigEntry) -> int: + """Return the device generation from config entry.""" + return entry.data.get("gen", 1) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 71157124806..e38dd252b3a 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -3,12 +3,13 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant.components.shelly import ShellyDeviceWrapper +from homeassistant.components.shelly import BlockDeviceWrapper, RpcDeviceWrapper from homeassistant.components.shelly.const import ( - COAP, + BLOCK, DATA_CONFIG_ENTRY, DOMAIN, EVENT_SHELLY_CLICK, + RPC, ) from homeassistant.setup import async_setup_component @@ -54,6 +55,13 @@ MOCK_BLOCKS = [ ), ] +MOCK_CONFIG = { + "switch:0": {"name": "test switch_0"}, + "sys": {"ui_data": {}}, + "wifi": { + "ap": {"ssid": "Test name"}, + }, +} MOCK_SHELLY = { "mac": "test-mac", @@ -62,6 +70,10 @@ MOCK_SHELLY = { "num_outputs": 2, } +MOCK_STATUS = { + "switch:0": {"output": True}, +} + @pytest.fixture(autouse=True) def mock_coap(): @@ -104,6 +116,7 @@ async def coap_wrapper(hass): blocks=MOCK_BLOCKS, settings=MOCK_SETTINGS, shelly=MOCK_SHELLY, + firmware_version="some fw string", update=AsyncMock(), initialized=True, ) @@ -111,9 +124,43 @@ async def coap_wrapper(hass): hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - COAP - ] = ShellyDeviceWrapper(hass, config_entry, device) + BLOCK + ] = BlockDeviceWrapper(hass, config_entry, device) - await wrapper.async_setup() + wrapper.async_setup() + + return wrapper + + +@pytest.fixture +async def rpc_wrapper(hass): + """Setups a coap wrapper with mocked device.""" + await async_setup_component(hass, "shelly", {}) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={"sleep_period": 0, "model": "SNSW-001P16EU", "gen": 2}, + unique_id="12345678", + ) + config_entry.add_to_hass(hass) + + device = Mock( + call_rpc=AsyncMock(), + config=MOCK_CONFIG, + shelly=MOCK_SHELLY, + status=MOCK_STATUS, + firmware_version="some fw string", + update=AsyncMock(), + initialized=True, + shutdown=AsyncMock(), + ) + + hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} + hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ + RPC + ] = RpcDeviceWrapper(hass, config_entry, device) + + wrapper.async_setup() return wrapper diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index bedf4abc0f2..bf1529e4aaf 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -8,11 +8,11 @@ from homeassistant.components import automation from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) -from homeassistant.components.shelly import ShellyDeviceWrapper +from homeassistant.components.shelly import BlockDeviceWrapper from homeassistant.components.shelly.const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, - COAP, + BLOCK, CONF_SUBTYPE, DATA_CONFIG_ENTRY, DOMAIN, @@ -79,10 +79,10 @@ async def test_get_triggers_button(hass): hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ - COAP - ] = ShellyDeviceWrapper(hass, config_entry, device) + BLOCK + ] = BlockDeviceWrapper(hass, config_entry, device) - await coap_wrapper.async_setup() + coap_wrapper.async_setup() expected_triggers = [ { diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index b1dcc05bb80..fc61102507b 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -11,8 +11,8 @@ from homeassistant.const import ( RELAY_BLOCK_ID = 0 -async def test_services(hass, coap_wrapper): - """Test device turn on/off services.""" +async def test_block_device_services(hass, coap_wrapper): + """Test block device turn on/off services.""" assert coap_wrapper hass.async_create_task( @@ -37,8 +37,8 @@ async def test_services(hass, coap_wrapper): assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF -async def test_update(hass, coap_wrapper, monkeypatch): - """Test device update.""" +async def test_block_device_update(hass, coap_wrapper, monkeypatch): + """Test block device update.""" assert coap_wrapper hass.async_create_task( @@ -61,8 +61,8 @@ async def test_update(hass, coap_wrapper, monkeypatch): assert hass.states.get("switch.test_name_channel_1").state == STATE_ON -async def test_no_relay_blocks(hass, coap_wrapper, monkeypatch): - """Test device without relay blocks.""" +async def test_block_device_no_relay_blocks(hass, coap_wrapper, monkeypatch): + """Test block device without relay blocks.""" assert coap_wrapper monkeypatch.setattr(coap_wrapper.device.blocks[RELAY_BLOCK_ID], "type", "roller") @@ -73,8 +73,8 @@ async def test_no_relay_blocks(hass, coap_wrapper, monkeypatch): assert hass.states.get("switch.test_name_channel_1") is None -async def test_device_mode_roller(hass, coap_wrapper, monkeypatch): - """Test switch device in roller mode.""" +async def test_block_device_mode_roller(hass, coap_wrapper, monkeypatch): + """Test block device in roller mode.""" assert coap_wrapper monkeypatch.setitem(coap_wrapper.device.settings, "mode", "roller") @@ -83,3 +83,61 @@ async def test_device_mode_roller(hass, coap_wrapper, monkeypatch): ) await hass.async_block_till_done() assert hass.states.get("switch.test_name_channel_1") is None + + +async def test_block_device_app_type_light(hass, coap_wrapper, monkeypatch): + """Test block device in app type set to light mode.""" + assert coap_wrapper + + monkeypatch.setitem( + coap_wrapper.device.settings["relays"][0], "appliance_type", "light" + ) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, SWITCH_DOMAIN) + ) + await hass.async_block_till_done() + assert hass.states.get("switch.test_name_channel_1") is None + + +async def test_rpc_device_services(hass, rpc_wrapper, monkeypatch): + """Test RPC device turn on/off services.""" + assert rpc_wrapper + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, SWITCH_DOMAIN) + ) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_switch_0"}, + blocking=True, + ) + assert hass.states.get("switch.test_switch_0").state == STATE_ON + + monkeypatch.setitem(rpc_wrapper.device.status["switch:0"], "output", False) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_switch_0"}, + blocking=True, + ) + rpc_wrapper.async_set_updated_data("") + assert hass.states.get("switch.test_switch_0").state == STATE_OFF + + +async def test_rpc_device_switch_type_lights_mode(hass, rpc_wrapper, monkeypatch): + """Test RPC device with switch in consumption type lights mode.""" + assert rpc_wrapper + + monkeypatch.setitem( + rpc_wrapper.device.config["sys"]["ui_data"], + "consumption_types", + ["lights"], + ) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, SWITCH_DOMAIN) + ) + await hass.async_block_till_done() + assert hass.states.get("switch.test_switch_0") is None