Add Shelly support for REST sensors (#40429)

This commit is contained in:
Simone Chemelli 2020-11-11 20:13:14 +01:00 committed by GitHub
parent 403514ccb3
commit d8b067ebf9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 311 additions and 16 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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