From b07628ae577e53034dcee7093573fcf5337c8506 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 7 Sep 2020 14:13:20 +0200 Subject: [PATCH] Rework Shelly sensors (#39747) * Rework Shelly sensors * Lint * Add shelly/entity to coveragerc --- .coveragerc | 1 + homeassistant/components/shelly/__init__.py | 59 +---- .../components/shelly/binary_sensor.py | 105 +++------ homeassistant/components/shelly/entity.py | 204 ++++++++++++++++++ homeassistant/components/shelly/light.py | 3 +- homeassistant/components/shelly/sensor.py | 180 ++++++---------- homeassistant/components/shelly/switch.py | 6 +- 7 files changed, 309 insertions(+), 249 deletions(-) create mode 100644 homeassistant/components/shelly/entity.py diff --git a/.coveragerc b/.coveragerc index 271e1f0c7ec..8fdbc410af2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -759,6 +759,7 @@ omit = homeassistant/components/shodan/sensor.py homeassistant/components/shelly/__init__.py homeassistant/components/shelly/binary_sensor.py + homeassistant/components/shelly/entity.py homeassistant/components/shelly/light.py homeassistant/components/shelly/sensor.py homeassistant/components/shelly/switch.py diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 85c8879756f..cfc566fdb3e 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -14,14 +14,9 @@ from homeassistant.const import ( CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import ( - aiohttp_client, - device_registry, - entity, - update_coordinator, -) +from homeassistant.helpers import aiohttp_client, device_registry, update_coordinator from .const import DOMAIN @@ -135,56 +130,6 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): await self.shutdown() -class ShellyBlockEntity(entity.Entity): - """Helper class to represent a block.""" - - def __init__(self, wrapper: ShellyDeviceWrapper, block): - """Initialize Shelly entity.""" - self.wrapper = wrapper - self.block = block - self._name = f"{self.wrapper.name} - {self.block.description.replace('_', ' ')}" - - @property - def name(self): - """Name of entity.""" - return self._name - - @property - def should_poll(self): - """If device should be polled.""" - return False - - @property - def device_info(self): - """Device info.""" - return { - "connections": {(device_registry.CONNECTION_NETWORK_MAC, self.wrapper.mac)} - } - - @property - def available(self): - """Available.""" - return self.wrapper.last_update_success - - @property - def unique_id(self): - """Return unique ID of entity.""" - return f"{self.wrapper.mac}-{self.block.description}" - - async def async_added_to_hass(self): - """When entity is added to HASS.""" - self.async_on_remove(self.wrapper.async_add_listener(self._update_callback)) - - async def async_update(self): - """Update entity with latest info.""" - await self.wrapper.async_request_refresh() - - @callback - def _update_callback(self): - """Handle device update.""" - self.async_write_ha_state() - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" unload_ok = all( diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 80e935168f0..c1c241a32ef 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -1,6 +1,4 @@ """Binary sensor for Shelly.""" -import aioshelly - from homeassistant.components.binary_sensor import ( DEVICE_CLASS_GAS, DEVICE_CLASS_MOISTURE, @@ -10,88 +8,47 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) -from . import ShellyBlockEntity, ShellyDeviceWrapper -from .const import DOMAIN +from .entity import ( + BlockAttributeDescription, + ShellyBlockAttributeEntity, + async_setup_entry_attribute_entities, +) SENSORS = { - "dwIsOpened": DEVICE_CLASS_OPENING, - "flood": DEVICE_CLASS_MOISTURE, - "gas": DEVICE_CLASS_GAS, - "overpower": None, - "overtemp": None, - "smoke": DEVICE_CLASS_SMOKE, - "vibration": DEVICE_CLASS_VIBRATION, + ("device", "overtemp"): BlockAttributeDescription(name="overtemp"), + ("relay", "overpower"): BlockAttributeDescription(name="overpower"), + ("sensor", "dwIsOpened"): BlockAttributeDescription( + name="Door", device_class=DEVICE_CLASS_OPENING + ), + ("sensor", "flood"): BlockAttributeDescription( + name="flood", device_class=DEVICE_CLASS_MOISTURE + ), + ("sensor", "gas"): BlockAttributeDescription( + name="gas", + device_class=DEVICE_CLASS_GAS, + value=lambda value: value in ["mild", "heavy"], + device_state_attributes=lambda block: {"detected": block.gas}, + ), + ("sensor", "smoke"): BlockAttributeDescription( + name="smoke", device_class=DEVICE_CLASS_SMOKE + ), + ("sensor", "vibration"): BlockAttributeDescription( + name="vibration", device_class=DEVICE_CLASS_VIBRATION + ), } async def async_setup_entry(hass, config_entry, async_add_entities): """Set up sensors for device.""" - wrapper = hass.data[DOMAIN][config_entry.entry_id] - sensors = [] - - for block in wrapper.device.blocks: - for attr in SENSORS: - if not hasattr(block, attr): - continue - - sensors.append(ShellySensor(wrapper, block, attr)) - - if sensors: - async_add_entities(sensors) + await async_setup_entry_attribute_entities( + hass, config_entry, async_add_entities, SENSORS, ShellyBinarySensor + ) -class ShellySensor(ShellyBlockEntity, BinarySensorEntity): - """Switch that controls a relay block on Shelly devices.""" - - def __init__( - self, - wrapper: ShellyDeviceWrapper, - block: aioshelly.Block, - attribute: str, - ) -> None: - """Initialize sensor.""" - super().__init__(wrapper, block) - self.attribute = attribute - device_class = SENSORS[attribute] - - self._device_class = device_class - - @property - def unique_id(self): - """Return unique ID of entity.""" - return f"{super().unique_id}-{self.attribute}" - - @property - def name(self): - """Name of sensor.""" - return f"{self.wrapper.name} - {self.attribute}" +class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity): + """Shelly binary sensor entity.""" @property def is_on(self): """Return true if sensor state is on.""" - if self.attribute == "gas": - # Gas sensor value of Shelly Gas can be none/mild/heavy/test. We return True - # when the value is mild or heavy. - return getattr(self.block, self.attribute) in ["mild", "heavy"] - return bool(getattr(self.block, self.attribute)) - - @property - def device_class(self): - """Device class of sensor.""" - return self._device_class - - @property - def device_state_attributes(self): - """Return the state attributes.""" - if self.attribute == "gas": - # We return raw value of the gas sensor as an attribute. - return {"detected": getattr(self.block, self.attribute)} - - @property - def available(self): - """Available.""" - if self.attribute == "gas": - # "sensorOp" is "normal" when Shelly Gas is working properly and taking - # measurements. - return super().available and self.block.sensorOp == "normal" - return super().available + return bool(self.attribute_value) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py new file mode 100644 index 00000000000..c7619ab7383 --- /dev/null +++ b/homeassistant/components/shelly/entity.py @@ -0,0 +1,204 @@ +"""Shelly entity helper.""" +from collections import Counter +from dataclasses import dataclass +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 . import ShellyDeviceWrapper +from .const import DOMAIN + + +def temperature_unit(block_info: dict) -> str: + """Detect temperature unit.""" + if block_info[aioshelly.BLOCK_VALUE_UNIT] == "F": + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + +async def async_setup_entry_attribute_entities( + hass, config_entry, async_add_entities, sensors, sensor_class +): + """Set up entities for block attributes.""" + wrapper: ShellyDeviceWrapper = hass.data[DOMAIN][config_entry.entry_id] + blocks = [] + + for block in wrapper.device.blocks: + for sensor_id in block.sensor_ids: + description = sensors.get((block.type, sensor_id)) + if description is None: + continue + + # Filter out non-existing sensors and sensors without a value + if getattr(block, sensor_id, None) in (-1, None): + continue + + blocks.append((block, sensor_id, description)) + + if not blocks: + return + + counts = Counter([item[0].type for item in blocks]) + + async_add_entities( + [ + sensor_class(wrapper, block, sensor_id, description, counts[block.type]) + for block, sensor_id, description in blocks + ] + ) + + +@dataclass +class BlockAttributeDescription: + """Class to describe a sensor.""" + + name: str + # Callable = lambda attr_info: unit + unit: Union[None, str, Callable[[dict], str]] = None + value: Callable[[Any], Any] = lambda val: val + device_class: Optional[str] = None + default_enabled: bool = True + available: Optional[Callable[[aioshelly.Block], bool]] = None + device_state_attributes: Optional[ + Callable[[aioshelly.Block], Optional[dict]] + ] = None + + +class ShellyBlockEntity(entity.Entity): + """Helper class to represent a block.""" + + def __init__(self, wrapper: ShellyDeviceWrapper, block): + """Initialize Shelly entity.""" + self.wrapper = wrapper + self.block = block + self._name = f"{self.wrapper.name} {self.block.description.replace('_', ' ')}" + + @property + def name(self): + """Name of entity.""" + return self._name + + @property + def should_poll(self): + """If device should be polled.""" + return False + + @property + def device_info(self): + """Device info.""" + return { + "connections": {(device_registry.CONNECTION_NETWORK_MAC, self.wrapper.mac)} + } + + @property + def available(self): + """Available.""" + return self.wrapper.last_update_success + + @property + def unique_id(self): + """Return unique ID of entity.""" + return f"{self.wrapper.mac}-{self.block.description}" + + async def async_added_to_hass(self): + """When entity is added to HASS.""" + self.async_on_remove(self.wrapper.async_add_listener(self._update_callback)) + + async def async_update(self): + """Update entity with latest info.""" + await self.wrapper.async_request_refresh() + + @callback + def _update_callback(self): + """Handle device update.""" + self.async_write_ha_state() + + +class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): + """Switch that controls a relay block on Shelly devices.""" + + def __init__( + self, + wrapper: ShellyDeviceWrapper, + block: aioshelly.Block, + attribute: str, + description: BlockAttributeDescription, + same_type_count: int, + ) -> None: + """Initialize sensor.""" + super().__init__(wrapper, block) + self.attribute = attribute + self.description = description + self.info = block.info(attribute) + + unit = self.description.unit + + if callable(unit): + unit = unit(self.info) + + self._unit = unit + self._unique_id = f"{super().unique_id}-{self.attribute}" + + name_parts = [self.wrapper.name] + if same_type_count > 1: + name_parts.append(str(block.index)) + name_parts.append(self.description.name) + + self._name = " ".join(name_parts) + + @property + def unique_id(self): + """Return unique ID of entity.""" + return self._unique_id + + @property + def name(self): + """Name of sensor.""" + return self._name + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if it should be enabled by default.""" + return self.description.default_enabled + + @property + def attribute_value(self): + """Value of sensor.""" + value = getattr(self.block, self.attribute) + + if value is None: + return None + + return self.description.value(value) + + @property + def unit_of_measurement(self): + """Return unit of sensor.""" + return self._unit + + @property + def device_class(self): + """Device class of sensor.""" + return self.description.device_class + + @property + def available(self): + """Available.""" + available = super().available + + if not available or not self.description.available: + return available + + return self.description.available(self.block) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.description.device_state_attributes is None: + return None + + return self.description.device_state_attributes(self.block) diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 9e9b9e350a0..06004b40198 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -4,8 +4,9 @@ from aioshelly import Block from homeassistant.components.light import SUPPORT_BRIGHTNESS, LightEntity from homeassistant.core import callback -from . import ShellyBlockEntity, ShellyDeviceWrapper +from . import ShellyDeviceWrapper from .const import DOMAIN +from .entity import ShellyBlockEntity async def async_setup_entry(hass, config_entry, async_add_entities): diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 618abc994b1..8e648a1a269 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -1,6 +1,4 @@ """Sensor for Shelly.""" -import aioshelly - from homeassistant.components import sensor from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, @@ -9,128 +7,84 @@ from homeassistant.const import ( ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_WATT, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - VOLT, ) -from homeassistant.helpers.entity import Entity -from . import ShellyBlockEntity, ShellyDeviceWrapper -from .const import DOMAIN +from .entity import ( + BlockAttributeDescription, + ShellyBlockAttributeEntity, + async_setup_entry_attribute_entities, + temperature_unit, +) SENSORS = { - "battery": [PERCENTAGE, sensor.DEVICE_CLASS_BATTERY], - "concentration": [CONCENTRATION_PARTS_PER_MILLION, None], - "current": [ELECTRICAL_CURRENT_AMPERE, sensor.DEVICE_CLASS_CURRENT], - "deviceTemp": [None, sensor.DEVICE_CLASS_TEMPERATURE], - "energy": [ENERGY_KILO_WATT_HOUR, sensor.DEVICE_CLASS_ENERGY], - "energyReturned": [ENERGY_KILO_WATT_HOUR, sensor.DEVICE_CLASS_ENERGY], - "extTemp": [None, sensor.DEVICE_CLASS_TEMPERATURE], - "humidity": [PERCENTAGE, sensor.DEVICE_CLASS_HUMIDITY], - "luminosity": ["lx", sensor.DEVICE_CLASS_ILLUMINANCE], - "overpowerValue": [POWER_WATT, sensor.DEVICE_CLASS_POWER], - "power": [POWER_WATT, sensor.DEVICE_CLASS_POWER], - "powerFactor": [PERCENTAGE, sensor.DEVICE_CLASS_POWER_FACTOR], - "tilt": [DEGREE, None], - "voltage": [VOLT, sensor.DEVICE_CLASS_VOLTAGE], + ("device", "battery"): BlockAttributeDescription( + name="Battery", unit=PERCENTAGE, device_class=sensor.DEVICE_CLASS_BATTERY + ), + ("device", "deviceTemp"): BlockAttributeDescription( + name="Device Temperature", + unit=temperature_unit, + value=lambda value: round(value, 1), + device_class=sensor.DEVICE_CLASS_TEMPERATURE, + default_enabled=False, + ), + ("emeter", "current"): BlockAttributeDescription( + name="Current", + unit=ELECTRICAL_CURRENT_AMPERE, + value=lambda value: value, + device_class=sensor.DEVICE_CLASS_CURRENT, + ), + ("light", "power"): BlockAttributeDescription( + name="Power", + unit=POWER_WATT, + value=lambda value: round(value, 1), + device_class=sensor.DEVICE_CLASS_POWER, + default_enabled=False, + ), + ("relay", "energy"): BlockAttributeDescription( + name="Energy", + unit=ENERGY_KILO_WATT_HOUR, + value=lambda value: round(value / 60 / 1000, 2), + device_class=sensor.DEVICE_CLASS_ENERGY, + ), + ("sensor", "concentration"): BlockAttributeDescription( + name="Gas Concentration", + unit=CONCENTRATION_PARTS_PER_MILLION, + value=lambda value: value, + # "sensorOp" is "normal" when the Shelly Gas is working properly and taking measurements. + available=lambda block: block.sensorOp == "normal", + ), + ("sensor", "extTemp"): BlockAttributeDescription( + name="Temperature", + unit=temperature_unit, + value=lambda value: round(value, 1), + device_class=sensor.DEVICE_CLASS_TEMPERATURE, + ), + ("sensor", "humidity"): BlockAttributeDescription( + name="Humidity", + unit=PERCENTAGE, + value=lambda value: round(value, 1), + device_class=sensor.DEVICE_CLASS_HUMIDITY, + ), + ("sensor", "luminosity"): BlockAttributeDescription( + name="Luminosity", + unit="lx", + device_class=sensor.DEVICE_CLASS_ILLUMINANCE, + ), + ("sensor", "tilt"): BlockAttributeDescription(name="tilt", unit=DEGREE), } async def async_setup_entry(hass, config_entry, async_add_entities): """Set up sensors for device.""" - wrapper = hass.data[DOMAIN][config_entry.entry_id] - sensors = [] - - for block in wrapper.device.blocks: - for attr in SENSORS: - # Filter out non-existing sensors and sensors without a value - if getattr(block, attr, None) is None: - continue - - sensors.append(ShellySensor(wrapper, block, attr)) - - if sensors: - async_add_entities(sensors) + await async_setup_entry_attribute_entities( + hass, config_entry, async_add_entities, SENSORS, ShellySensor + ) -class ShellySensor(ShellyBlockEntity, Entity): - """Switch that controls a relay block on Shelly devices.""" - - def __init__( - self, - wrapper: ShellyDeviceWrapper, - block: aioshelly.Block, - attribute: str, - ) -> None: - """Initialize sensor.""" - super().__init__(wrapper, block) - self.attribute = attribute - unit, device_class = SENSORS[attribute] - self.info = block.info(attribute) - - if ( - self.info[aioshelly.BLOCK_VALUE_TYPE] - == aioshelly.BLOCK_VALUE_TYPE_TEMPERATURE - ): - if self.info[aioshelly.BLOCK_VALUE_UNIT] == "C": - unit = TEMP_CELSIUS - else: - unit = TEMP_FAHRENHEIT - - self._unit = unit - self._device_class = device_class - - @property - def unique_id(self): - """Return unique ID of entity.""" - return f"{super().unique_id}-{self.attribute}" - - @property - def name(self): - """Name of sensor.""" - return f"{self.wrapper.name} - {self.attribute}" +class ShellySensor(ShellyBlockAttributeEntity): + """Represent a shelly sensor.""" @property def state(self): - """Value of sensor.""" - value = getattr(self.block, self.attribute) - if value is None: - return None - - if self.attribute in ["luminosity", "tilt"]: - return round(value) - if self.attribute in [ - "deviceTemp", - "extTemp", - "humidity", - "overpowerValue", - "power", - ]: - return round(value, 1) - if self.attribute == "powerFactor": - return round(value * 100, 1) - # Energy unit change from Wmin or Wh to kWh - if self.info.get(aioshelly.BLOCK_VALUE_UNIT) == "Wmin": - return round(value / 60 / 1000, 2) - if self.info.get(aioshelly.BLOCK_VALUE_UNIT) == "Wh": - return round(value / 1000, 2) - return value - - @property - def unit_of_measurement(self): - """Return unit of sensor.""" - return self._unit - - @property - def device_class(self): - """Device class of sensor.""" - return self._device_class - - @property - def available(self): - """Available.""" - if self.attribute == "concentration": - # "sensorOp" is "normal" when the Shelly Gas is working properly and taking - # measurements. - return super().available and self.block.sensorOp == "normal" - return super().available + """Return value of sensor.""" + return self.attribute_value diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 7dd57467045..1c3c48637e9 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -4,17 +4,15 @@ from aioshelly import RelayBlock from homeassistant.components.switch import SwitchEntity from homeassistant.core import callback -from . import ShellyBlockEntity, ShellyDeviceWrapper +from . import ShellyDeviceWrapper from .const import DOMAIN +from .entity import ShellyBlockEntity async def async_setup_entry(hass, config_entry, async_add_entities): """Set up switches for device.""" wrapper = hass.data[DOMAIN][config_entry.entry_id] - if wrapper.model == "SHSW-25" and wrapper.device.settings["mode"] != "relay": - return - relay_blocks = [block for block in wrapper.device.blocks if block.type == "relay"] if not relay_blocks: