From d8b067ebf913c1cd1f7d370a8fa1db3913250212 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 11 Nov 2020 20:13:14 +0100 Subject: [PATCH] Add Shelly support for REST sensors (#40429) --- homeassistant/components/shelly/__init__.py | 48 ++++- .../components/shelly/binary_sensor.py | 33 ++++ homeassistant/components/shelly/const.py | 5 + homeassistant/components/shelly/cover.py | 4 +- homeassistant/components/shelly/entity.py | 178 +++++++++++++++++- homeassistant/components/shelly/light.py | 4 +- homeassistant/components/shelly/sensor.py | 31 +++ homeassistant/components/shelly/switch.py | 4 +- homeassistant/components/shelly/utils.py | 20 ++ 9 files changed, 311 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 1f6ecdbd031..6aa08286dc1 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -24,9 +24,12 @@ from homeassistant.helpers import ( ) from .const import ( + COAP, DATA_CONFIG_ENTRY, DOMAIN, POLLING_TIMEOUT_MULTIPLIER, + REST, + REST_SENSORS_UPDATE_INTERVAL, SETUP_ENTRY_TIMEOUT_SEC, SLEEP_PERIOD_MULTIPLIER, UPDATE_PERIOD_MULTIPLIER, @@ -82,10 +85,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): except (asyncio.TimeoutError, OSError) as err: raise ConfigEntryNotReady from err - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ - entry.entry_id + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {} + coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ + COAP ] = ShellyDeviceWrapper(hass, entry, device) - await wrapper.async_setup() + await coap_wrapper.async_setup() + + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ + REST + ] = ShellyDeviceRestWrapper(hass, device) for component in PLATFORMS: hass.async_create_task( @@ -169,6 +177,37 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): self.device.shutdown() +class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): + """Rest Wrapper for a Shelly device with Home Assistant specific functions.""" + + def __init__(self, hass, device: aioshelly.Device): + """Initialize the Shelly device wrapper.""" + + super().__init__( + hass, + _LOGGER, + name=device.settings["name"] or device.settings["device"]["hostname"], + update_interval=timedelta(seconds=REST_SENSORS_UPDATE_INTERVAL), + ) + self.device = device + + async def _async_update_data(self): + """Fetch data.""" + try: + async with async_timeout.timeout(5): + _LOGGER.debug( + "REST update for %s", self.device.settings["device"]["hostname"] + ) + return await self.device.update_status() + except OSError as err: + raise update_coordinator.UpdateFailed("Error fetching data") from err + + @property + def mac(self): + """Mac address of the device.""" + return self.device.settings["device"]["mac"] + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" unload_ok = all( @@ -180,6 +219,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): ) ) if unload_ok: - hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id).shutdown() + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][COAP].shutdown() + hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 1460c62f153..4f771a9cc46 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -1,5 +1,6 @@ """Binary sensor for Shelly.""" from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_GAS, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_OPENING, @@ -11,8 +12,11 @@ from homeassistant.components.binary_sensor import ( from .entity import ( BlockAttributeDescription, + RestAttributeDescription, ShellyBlockAttributeEntity, + ShellyRestAttributeEntity, async_setup_entry_attribute_entities, + async_setup_entry_rest, ) SENSORS = { @@ -48,6 +52,22 @@ SENSORS = { ), } +REST_SENSORS = { + "cloud": RestAttributeDescription( + name="Cloud", + device_class=DEVICE_CLASS_CONNECTIVITY, + default_enabled=False, + path="cloud/connected", + ), + "fwupdate": RestAttributeDescription( + name="Firmware update", + icon="mdi:update", + default_enabled=False, + path="update/has_update", + attributes={"description": "available version:", "path": "update/new_version"}, + ), +} + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up sensors for device.""" @@ -55,6 +75,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): hass, config_entry, async_add_entities, SENSORS, ShellyBinarySensor ) + await async_setup_entry_rest( + hass, config_entry, async_add_entities, REST_SENSORS, ShellyRestBinarySensor + ) + class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity): """Shelly binary sensor entity.""" @@ -63,3 +87,12 @@ class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity): def is_on(self): """Return true if sensor state is on.""" return bool(self.attribute_value) + + +class ShellyRestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): + """Shelly REST binary sensor entity.""" + + @property + def is_on(self): + """Return true if REST sensor state is on.""" + return bool(self.attribute_value) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 50af82c2b7d..d058a8c4588 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -1,11 +1,16 @@ """Constants for the Shelly integration.""" +COAP = "coap" DATA_CONFIG_ENTRY = "config_entry" DOMAIN = "shelly" +REST = "rest" # Used to calculate the timeout in "_async_update_data" used for polling data from devices. POLLING_TIMEOUT_MULTIPLIER = 1.2 +# Refresh interval for REST sensors +REST_SENSORS_UPDATE_INTERVAL = 60 + # Timeout used for initial entry setup in "async_setup_entry". SETUP_ENTRY_TIMEOUT_SEC = 10 diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index a65ab5a05af..6caa7d5132c 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -12,13 +12,13 @@ from homeassistant.components.cover import ( from homeassistant.core import callback from . import ShellyDeviceWrapper -from .const import DATA_CONFIG_ENTRY, DOMAIN +from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN from .entity import ShellyBlockEntity async def async_setup_entry(hass, config_entry, async_add_entities): """Set up cover for device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] blocks = [block for block in wrapper.device.blocks if block.type == "roller"] if not blocks: diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 314ee48cf7e..75070403c2c 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -4,12 +4,60 @@ from typing import Any, Callable, Optional, Union import aioshelly +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import callback -from homeassistant.helpers import device_registry, entity +from homeassistant.helpers import device_registry, entity, update_coordinator -from . import ShellyDeviceWrapper -from .const import DATA_CONFIG_ENTRY, DOMAIN -from .utils import get_entity_name +from . import ShellyDeviceRestWrapper, ShellyDeviceWrapper +from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN, REST +from .utils import get_entity_name, get_rest_value_from_path + + +def temperature_unit(block_info: dict) -> str: + """Detect temperature unit.""" + if block_info[aioshelly.BLOCK_VALUE_UNIT] == "F": + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + +def shelly_naming(self, block, entity_type: str): + """Naming for switch and sensors.""" + + entity_name = self.wrapper.name + if not block: + return f"{entity_name} {self.description.name}" + + channels = 0 + mode = block.type + "s" + if "num_outputs" in self.wrapper.device.shelly: + channels = self.wrapper.device.shelly["num_outputs"] + if ( + self.wrapper.model in ["SHSW-21", "SHSW-25"] + and self.wrapper.device.settings["mode"] == "roller" + ): + channels = 1 + if block.type == "emeter" and "num_emeters" in self.wrapper.device.shelly: + channels = self.wrapper.device.shelly["num_emeters"] + if channels > 1 and block.type != "device": + # Shelly EM (SHEM) with firmware v1.8.1 doesn't have "name" key; will be fixed in next firmware release + if "name" in self.wrapper.device.settings[mode][int(block.channel)]: + entity_name = self.wrapper.device.settings[mode][int(block.channel)]["name"] + else: + entity_name = None + if not entity_name: + if self.wrapper.model == "SHEM-3": + base = ord("A") + else: + base = ord("1") + entity_name = f"{self.wrapper.name} channel {chr(int(block.channel)+base)}" + + if entity_type == "switch": + return entity_name + + if entity_type == "sensor": + return f"{entity_name} {self.description.name}" + + raise ValueError async def async_setup_entry_attribute_entities( @@ -18,7 +66,7 @@ async def async_setup_entry_attribute_entities( """Set up entities for block attributes.""" wrapper: ShellyDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ config_entry.entry_id - ] + ][COAP] blocks = [] for block in wrapper.device.blocks: @@ -44,6 +92,27 @@ async def async_setup_entry_attribute_entities( ) +async def async_setup_entry_rest( + hass, config_entry, async_add_entities, sensors, sensor_class +): + """Set up entities for REST sensors.""" + wrapper: ShellyDeviceRestWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ + config_entry.entry_id + ][REST] + + entities = [] + for sensor_id in sensors: + _desc = sensors.get(sensor_id) + + if not wrapper.device.settings.get("sleep_mode"): + entities.append(_desc) + + if not entities: + return + + async_add_entities([sensor_class(wrapper, description) for description in entities]) + + @dataclass class BlockAttributeDescription: """Class to describe a sensor.""" @@ -60,6 +129,21 @@ class BlockAttributeDescription: ] = None +@dataclass +class RestAttributeDescription: + """Class to describe a REST sensor.""" + + path: str + name: str + # Callable = lambda attr_info: unit + icon: Optional[str] = None + unit: Union[None, str, Callable[[dict], str]] = None + value: Callable[[Any], Any] = lambda val: val + device_class: Optional[str] = None + default_enabled: bool = True + attributes: Optional[dict] = None + + class ShellyBlockEntity(entity.Entity): """Helper class to represent a block.""" @@ -133,7 +217,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): self._unit = unit self._unique_id = f"{super().unique_id}-{self.attribute}" - self._name = get_entity_name(wrapper, block, self.description.name) + self._name = shelly_naming(self, block, "sensor") @property def unique_id(self): @@ -187,3 +271,85 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): return None return self.description.device_state_attributes(self.block) + + +class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): + """Class to load info from REST.""" + + def __init__( + self, wrapper: ShellyDeviceWrapper, description: RestAttributeDescription + ) -> None: + """Initialize sensor.""" + super().__init__(wrapper) + self.wrapper = wrapper + self.description = description + + self._unit = self.description.unit + self._name = shelly_naming(self, None, "sensor") + self.path = self.description.path + self._attributes = self.description.attributes + + @property + def name(self): + """Name of sensor.""" + return self._name + + @property + def device_info(self): + """Device info.""" + return { + "connections": {(device_registry.CONNECTION_NETWORK_MAC, self.wrapper.mac)} + } + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if it should be enabled by default.""" + return self.description.default_enabled + + @property + def available(self): + """Available.""" + return self.wrapper.last_update_success + + @property + def attribute_value(self): + """Attribute.""" + return get_rest_value_from_path( + self.wrapper.device.status, self.description.device_class, self.path + ) + + @property + def unit_of_measurement(self): + """Return unit of sensor.""" + return self.description.unit + + @property + def device_class(self): + """Device class of sensor.""" + return self.description.device_class + + @property + def icon(self): + """Icon of sensor.""" + return self.description.icon + + @property + def unique_id(self): + """Return unique ID of entity.""" + return f"{self.wrapper.mac}-{self.description.path}" + + @property + def device_state_attributes(self): + """Return the state attributes.""" + + if self._attributes is None: + return None + + _description = self._attributes.get("description") + _attribute_value = get_rest_value_from_path( + self.wrapper.device.status, + self.description.device_class, + self._attributes.get("path"), + ) + + return {_description: _attribute_value} diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 7c4af9cf1ae..b62b5ee5e8c 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -17,14 +17,14 @@ from homeassistant.util.color import ( ) from . import ShellyDeviceWrapper -from .const import DATA_CONFIG_ENTRY, DOMAIN +from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN from .entity import ShellyBlockEntity from .utils import async_remove_entity_by_domain async def async_setup_entry(hass, config_entry, async_add_entities): """Set up lights for device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] blocks = [] for block in wrapper.device.blocks: diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index ddd61b6b613..a9a2e8d8d6b 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -8,13 +8,17 @@ from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, POWER_WATT, + SIGNAL_STRENGTH_DECIBELS, VOLT, ) from .entity import ( BlockAttributeDescription, + RestAttributeDescription, ShellyBlockAttributeEntity, + ShellyRestAttributeEntity, async_setup_entry_attribute_entities, + async_setup_entry_rest, ) from .utils import temperature_unit @@ -142,12 +146,30 @@ SENSORS = { ("sensor", "tilt"): BlockAttributeDescription(name="tilt", unit=DEGREE), } +REST_SENSORS = { + "rssi": RestAttributeDescription( + name="RSSI", + unit=SIGNAL_STRENGTH_DECIBELS, + device_class=sensor.DEVICE_CLASS_SIGNAL_STRENGTH, + default_enabled=False, + path="wifi_sta/rssi", + ), + "uptime": RestAttributeDescription( + name="Uptime", + device_class=sensor.DEVICE_CLASS_TIMESTAMP, + path="uptime", + ), +} + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up sensors for device.""" await async_setup_entry_attribute_entities( hass, config_entry, async_add_entities, SENSORS, ShellySensor ) + await async_setup_entry_rest( + hass, config_entry, async_add_entities, REST_SENSORS, ShellyRestSensor + ) class ShellySensor(ShellyBlockAttributeEntity): @@ -157,3 +179,12 @@ class ShellySensor(ShellyBlockAttributeEntity): def state(self): """Return value of sensor.""" return self.attribute_value + + +class ShellyRestSensor(ShellyRestAttributeEntity): + """Represent a shelly REST sensor.""" + + @property + def state(self): + """Return value of sensor.""" + return self.attribute_value diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 48cb6d728e9..ee5ecc5d8f8 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -5,14 +5,14 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.core import callback from . import ShellyDeviceWrapper -from .const import DATA_CONFIG_ENTRY, DOMAIN +from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN from .entity import ShellyBlockEntity from .utils import async_remove_entity_by_domain async def async_setup_entry(hass, config_entry, async_add_entities): """Set up switches for device.""" - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] # In roller mode the relay blocks exist but do not contain required info if ( diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 36ce48b5421..d6907e55e00 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -1,9 +1,12 @@ """Shelly helpers functions.""" + +from datetime import datetime, timedelta import logging from typing import Optional import aioshelly +from homeassistant.components.sensor import DEVICE_CLASS_TIMESTAMP from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.helpers import entity_registry @@ -73,3 +76,20 @@ def get_entity_name( entity_name = f"{entity_name} {description}" return entity_name + + +def get_rest_value_from_path(status, device_class, path: str): + """Parser for REST path from device status.""" + + if "/" not in path: + _attribute_value = status[path] + else: + _attribute_value = status[path.split("/")[0]][path.split("/")[1]] + if device_class == DEVICE_CLASS_TIMESTAMP: + last_boot = datetime.utcnow() - timedelta(seconds=_attribute_value) + _attribute_value = last_boot.replace(microsecond=0).isoformat() + + if "new_version" in path: + _attribute_value = _attribute_value.split("/")[1].split("@")[0] + + return _attribute_value