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 ( from .const import (
COAP,
DATA_CONFIG_ENTRY, DATA_CONFIG_ENTRY,
DOMAIN, DOMAIN,
POLLING_TIMEOUT_MULTIPLIER, POLLING_TIMEOUT_MULTIPLIER,
REST,
REST_SENSORS_UPDATE_INTERVAL,
SETUP_ENTRY_TIMEOUT_SEC, SETUP_ENTRY_TIMEOUT_SEC,
SLEEP_PERIOD_MULTIPLIER, SLEEP_PERIOD_MULTIPLIER,
UPDATE_PERIOD_MULTIPLIER, UPDATE_PERIOD_MULTIPLIER,
@ -82,10 +85,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
except (asyncio.TimeoutError, OSError) as err: except (asyncio.TimeoutError, OSError) as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {}
entry.entry_id coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][
COAP
] = ShellyDeviceWrapper(hass, entry, device) ] = 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: for component in PLATFORMS:
hass.async_create_task( hass.async_create_task(
@ -169,6 +177,37 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
self.device.shutdown() 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): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = all( unload_ok = all(
@ -180,6 +219,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
) )
) )
if unload_ok: 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 return unload_ok

View File

@ -1,5 +1,6 @@
"""Binary sensor for Shelly.""" """Binary sensor for Shelly."""
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DEVICE_CLASS_CONNECTIVITY,
DEVICE_CLASS_GAS, DEVICE_CLASS_GAS,
DEVICE_CLASS_MOISTURE, DEVICE_CLASS_MOISTURE,
DEVICE_CLASS_OPENING, DEVICE_CLASS_OPENING,
@ -11,8 +12,11 @@ from homeassistant.components.binary_sensor import (
from .entity import ( from .entity import (
BlockAttributeDescription, BlockAttributeDescription,
RestAttributeDescription,
ShellyBlockAttributeEntity, ShellyBlockAttributeEntity,
ShellyRestAttributeEntity,
async_setup_entry_attribute_entities, async_setup_entry_attribute_entities,
async_setup_entry_rest,
) )
SENSORS = { 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): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up sensors for device.""" """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 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): class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity):
"""Shelly binary sensor entity.""" """Shelly binary sensor entity."""
@ -63,3 +87,12 @@ class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity):
def is_on(self): def is_on(self):
"""Return true if sensor state is on.""" """Return true if sensor state is on."""
return bool(self.attribute_value) 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.""" """Constants for the Shelly integration."""
COAP = "coap"
DATA_CONFIG_ENTRY = "config_entry" DATA_CONFIG_ENTRY = "config_entry"
DOMAIN = "shelly" DOMAIN = "shelly"
REST = "rest"
# Used to calculate the timeout in "_async_update_data" used for polling data from devices. # Used to calculate the timeout in "_async_update_data" used for polling data from devices.
POLLING_TIMEOUT_MULTIPLIER = 1.2 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". # Timeout used for initial entry setup in "async_setup_entry".
SETUP_ENTRY_TIMEOUT_SEC = 10 SETUP_ENTRY_TIMEOUT_SEC = 10

View File

@ -12,13 +12,13 @@ from homeassistant.components.cover import (
from homeassistant.core import callback from homeassistant.core import callback
from . import ShellyDeviceWrapper from . import ShellyDeviceWrapper
from .const import DATA_CONFIG_ENTRY, DOMAIN from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN
from .entity import ShellyBlockEntity 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 cover for device.""" """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"] blocks = [block for block in wrapper.device.blocks if block.type == "roller"]
if not blocks: if not blocks:

View File

@ -4,12 +4,60 @@ from typing import Any, Callable, Optional, Union
import aioshelly import aioshelly
from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
from homeassistant.core import callback 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 . import ShellyDeviceRestWrapper, ShellyDeviceWrapper
from .const import DATA_CONFIG_ENTRY, DOMAIN from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN, REST
from .utils import get_entity_name 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( async def async_setup_entry_attribute_entities(
@ -18,7 +66,7 @@ async def async_setup_entry_attribute_entities(
"""Set up entities for block attributes.""" """Set up entities for block attributes."""
wrapper: ShellyDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ wrapper: ShellyDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][
config_entry.entry_id config_entry.entry_id
] ][COAP]
blocks = [] blocks = []
for block in wrapper.device.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 @dataclass
class BlockAttributeDescription: class BlockAttributeDescription:
"""Class to describe a sensor.""" """Class to describe a sensor."""
@ -60,6 +129,21 @@ class BlockAttributeDescription:
] = None ] = 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): class ShellyBlockEntity(entity.Entity):
"""Helper class to represent a block.""" """Helper class to represent a block."""
@ -133,7 +217,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity):
self._unit = unit self._unit = unit
self._unique_id = f"{super().unique_id}-{self.attribute}" 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 @property
def unique_id(self): def unique_id(self):
@ -187,3 +271,85 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity):
return None return None
return self.description.device_state_attributes(self.block) 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 . import ShellyDeviceWrapper
from .const import DATA_CONFIG_ENTRY, DOMAIN from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN
from .entity import ShellyBlockEntity from .entity import ShellyBlockEntity
from .utils import async_remove_entity_by_domain from .utils import async_remove_entity_by_domain
async def async_setup_entry(hass, config_entry, async_add_entities): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up lights for device.""" """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 = [] blocks = []
for block in wrapper.device.blocks: for block in wrapper.device.blocks:

View File

@ -8,13 +8,17 @@ from homeassistant.const import (
LIGHT_LUX, LIGHT_LUX,
PERCENTAGE, PERCENTAGE,
POWER_WATT, POWER_WATT,
SIGNAL_STRENGTH_DECIBELS,
VOLT, VOLT,
) )
from .entity import ( from .entity import (
BlockAttributeDescription, BlockAttributeDescription,
RestAttributeDescription,
ShellyBlockAttributeEntity, ShellyBlockAttributeEntity,
ShellyRestAttributeEntity,
async_setup_entry_attribute_entities, async_setup_entry_attribute_entities,
async_setup_entry_rest,
) )
from .utils import temperature_unit from .utils import temperature_unit
@ -142,12 +146,30 @@ SENSORS = {
("sensor", "tilt"): BlockAttributeDescription(name="tilt", unit=DEGREE), ("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): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up sensors for device.""" """Set up sensors for device."""
await async_setup_entry_attribute_entities( await async_setup_entry_attribute_entities(
hass, config_entry, async_add_entities, SENSORS, ShellySensor 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): class ShellySensor(ShellyBlockAttributeEntity):
@ -157,3 +179,12 @@ class ShellySensor(ShellyBlockAttributeEntity):
def state(self): def state(self):
"""Return value of sensor.""" """Return value of sensor."""
return self.attribute_value 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 homeassistant.core import callback
from . import ShellyDeviceWrapper from . import ShellyDeviceWrapper
from .const import DATA_CONFIG_ENTRY, DOMAIN from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN
from .entity import ShellyBlockEntity from .entity import ShellyBlockEntity
from .utils import async_remove_entity_by_domain from .utils import async_remove_entity_by_domain
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][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 # In roller mode the relay blocks exist but do not contain required info
if ( if (

View File

@ -1,9 +1,12 @@
"""Shelly helpers functions.""" """Shelly helpers functions."""
from datetime import datetime, timedelta
import logging import logging
from typing import Optional from typing import Optional
import aioshelly import aioshelly
from homeassistant.components.sensor import DEVICE_CLASS_TIMESTAMP
from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
from homeassistant.helpers import entity_registry from homeassistant.helpers import entity_registry
@ -73,3 +76,20 @@ def get_entity_name(
entity_name = f"{entity_name} {description}" entity_name = f"{entity_name} {description}"
return entity_name 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