Rework Shelly sensors (#39747)

* Rework Shelly sensors

* Lint

* Add shelly/entity to coveragerc
This commit is contained in:
Paulus Schoutsen 2020-09-07 14:13:20 +02:00 committed by GitHub
parent 90c6e1c449
commit b07628ae57
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 309 additions and 249 deletions

View file

@ -759,6 +759,7 @@ omit =
homeassistant/components/shodan/sensor.py homeassistant/components/shodan/sensor.py
homeassistant/components/shelly/__init__.py homeassistant/components/shelly/__init__.py
homeassistant/components/shelly/binary_sensor.py homeassistant/components/shelly/binary_sensor.py
homeassistant/components/shelly/entity.py
homeassistant/components/shelly/light.py homeassistant/components/shelly/light.py
homeassistant/components/shelly/sensor.py homeassistant/components/shelly/sensor.py
homeassistant/components/shelly/switch.py homeassistant/components/shelly/switch.py

View file

@ -14,14 +14,9 @@ from homeassistant.const import (
CONF_USERNAME, CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import ( from homeassistant.helpers import aiohttp_client, device_registry, update_coordinator
aiohttp_client,
device_registry,
entity,
update_coordinator,
)
from .const import DOMAIN from .const import DOMAIN
@ -135,56 +130,6 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
await self.shutdown() 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): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = all( unload_ok = all(

View file

@ -1,6 +1,4 @@
"""Binary sensor for Shelly.""" """Binary sensor for Shelly."""
import aioshelly
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DEVICE_CLASS_GAS, DEVICE_CLASS_GAS,
DEVICE_CLASS_MOISTURE, DEVICE_CLASS_MOISTURE,
@ -10,88 +8,47 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity, BinarySensorEntity,
) )
from . import ShellyBlockEntity, ShellyDeviceWrapper from .entity import (
from .const import DOMAIN BlockAttributeDescription,
ShellyBlockAttributeEntity,
async_setup_entry_attribute_entities,
)
SENSORS = { SENSORS = {
"dwIsOpened": DEVICE_CLASS_OPENING, ("device", "overtemp"): BlockAttributeDescription(name="overtemp"),
"flood": DEVICE_CLASS_MOISTURE, ("relay", "overpower"): BlockAttributeDescription(name="overpower"),
"gas": DEVICE_CLASS_GAS, ("sensor", "dwIsOpened"): BlockAttributeDescription(
"overpower": None, name="Door", device_class=DEVICE_CLASS_OPENING
"overtemp": None, ),
"smoke": DEVICE_CLASS_SMOKE, ("sensor", "flood"): BlockAttributeDescription(
"vibration": DEVICE_CLASS_VIBRATION, 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): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up sensors for device.""" """Set up sensors for device."""
wrapper = hass.data[DOMAIN][config_entry.entry_id] await async_setup_entry_attribute_entities(
sensors = [] hass, config_entry, async_add_entities, SENSORS, ShellyBinarySensor
)
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)
class ShellySensor(ShellyBlockEntity, BinarySensorEntity): class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity):
"""Switch that controls a relay block on Shelly devices.""" """Shelly binary sensor entity."""
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}"
@property @property
def is_on(self): def is_on(self):
"""Return true if sensor state is on.""" """Return true if sensor state is on."""
if self.attribute == "gas": return bool(self.attribute_value)
# 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

View file

@ -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)

View file

@ -4,8 +4,9 @@ from aioshelly import Block
from homeassistant.components.light import SUPPORT_BRIGHTNESS, LightEntity from homeassistant.components.light import SUPPORT_BRIGHTNESS, LightEntity
from homeassistant.core import callback from homeassistant.core import callback
from . import ShellyBlockEntity, ShellyDeviceWrapper from . import ShellyDeviceWrapper
from .const import DOMAIN from .const import DOMAIN
from .entity import ShellyBlockEntity
async def async_setup_entry(hass, config_entry, async_add_entities): async def async_setup_entry(hass, config_entry, async_add_entities):

View file

@ -1,6 +1,4 @@
"""Sensor for Shelly.""" """Sensor for Shelly."""
import aioshelly
from homeassistant.components import sensor from homeassistant.components import sensor
from homeassistant.const import ( from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_PARTS_PER_MILLION,
@ -9,128 +7,84 @@ from homeassistant.const import (
ENERGY_KILO_WATT_HOUR, ENERGY_KILO_WATT_HOUR,
PERCENTAGE, PERCENTAGE,
POWER_WATT, POWER_WATT,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
VOLT,
) )
from homeassistant.helpers.entity import Entity
from . import ShellyBlockEntity, ShellyDeviceWrapper from .entity import (
from .const import DOMAIN BlockAttributeDescription,
ShellyBlockAttributeEntity,
async_setup_entry_attribute_entities,
temperature_unit,
)
SENSORS = { SENSORS = {
"battery": [PERCENTAGE, sensor.DEVICE_CLASS_BATTERY], ("device", "battery"): BlockAttributeDescription(
"concentration": [CONCENTRATION_PARTS_PER_MILLION, None], name="Battery", unit=PERCENTAGE, device_class=sensor.DEVICE_CLASS_BATTERY
"current": [ELECTRICAL_CURRENT_AMPERE, sensor.DEVICE_CLASS_CURRENT], ),
"deviceTemp": [None, sensor.DEVICE_CLASS_TEMPERATURE], ("device", "deviceTemp"): BlockAttributeDescription(
"energy": [ENERGY_KILO_WATT_HOUR, sensor.DEVICE_CLASS_ENERGY], name="Device Temperature",
"energyReturned": [ENERGY_KILO_WATT_HOUR, sensor.DEVICE_CLASS_ENERGY], unit=temperature_unit,
"extTemp": [None, sensor.DEVICE_CLASS_TEMPERATURE], value=lambda value: round(value, 1),
"humidity": [PERCENTAGE, sensor.DEVICE_CLASS_HUMIDITY], device_class=sensor.DEVICE_CLASS_TEMPERATURE,
"luminosity": ["lx", sensor.DEVICE_CLASS_ILLUMINANCE], default_enabled=False,
"overpowerValue": [POWER_WATT, sensor.DEVICE_CLASS_POWER], ),
"power": [POWER_WATT, sensor.DEVICE_CLASS_POWER], ("emeter", "current"): BlockAttributeDescription(
"powerFactor": [PERCENTAGE, sensor.DEVICE_CLASS_POWER_FACTOR], name="Current",
"tilt": [DEGREE, None], unit=ELECTRICAL_CURRENT_AMPERE,
"voltage": [VOLT, sensor.DEVICE_CLASS_VOLTAGE], 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): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up sensors for device.""" """Set up sensors for device."""
wrapper = hass.data[DOMAIN][config_entry.entry_id] await async_setup_entry_attribute_entities(
sensors = [] hass, config_entry, async_add_entities, SENSORS, ShellySensor
)
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)
class ShellySensor(ShellyBlockEntity, Entity): class ShellySensor(ShellyBlockAttributeEntity):
"""Switch that controls a relay block on Shelly devices.""" """Represent a shelly sensor."""
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}"
@property @property
def state(self): def state(self):
"""Value of sensor.""" """Return value of sensor."""
value = getattr(self.block, self.attribute) return self.attribute_value
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

View file

@ -4,17 +4,15 @@ from aioshelly import RelayBlock
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.core import callback from homeassistant.core import callback
from . import ShellyBlockEntity, ShellyDeviceWrapper from . import ShellyDeviceWrapper
from .const import DOMAIN from .const import DOMAIN
from .entity import ShellyBlockEntity
async def async_setup_entry(hass, config_entry, async_add_entities): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up switches for device.""" """Set up switches for device."""
wrapper = hass.data[DOMAIN][config_entry.entry_id] 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"] relay_blocks = [block for block in wrapper.device.blocks if block.type == "relay"]
if not relay_blocks: if not relay_blocks: