Add Shelly support for REST sensors (#40429)
This commit is contained in:
parent
403514ccb3
commit
d8b067ebf9
9 changed files with 311 additions and 16 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue