Add support for Shelly battery operated devices (#45406)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
Shay Levy 2021-02-03 18:03:22 +02:00 committed by GitHub
parent fcc14933d0
commit 0875f654c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 366 additions and 105 deletions

View File

@ -17,12 +17,7 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
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,
singleton,
update_coordinator,
)
from .const import ( from .const import (
AIOSHELLY_DEVICE_TIMEOUT_SEC, AIOSHELLY_DEVICE_TIMEOUT_SEC,
@ -32,36 +27,23 @@ from .const import (
BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION,
COAP, COAP,
DATA_CONFIG_ENTRY, DATA_CONFIG_ENTRY,
DEVICE,
DOMAIN, DOMAIN,
EVENT_SHELLY_CLICK, EVENT_SHELLY_CLICK,
INPUTS_EVENTS_DICT, INPUTS_EVENTS_DICT,
POLLING_TIMEOUT_MULTIPLIER, POLLING_TIMEOUT_SEC,
REST, REST,
REST_SENSORS_UPDATE_INTERVAL, REST_SENSORS_UPDATE_INTERVAL,
SLEEP_PERIOD_MULTIPLIER, SLEEP_PERIOD_MULTIPLIER,
UPDATE_PERIOD_MULTIPLIER, UPDATE_PERIOD_MULTIPLIER,
) )
from .utils import get_device_name from .utils import get_coap_context, get_device_name, get_device_sleep_period
PLATFORMS = ["binary_sensor", "cover", "light", "sensor", "switch"] PLATFORMS = ["binary_sensor", "cover", "light", "sensor", "switch"]
SLEEPING_PLATFORMS = ["binary_sensor", "sensor"]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@singleton.singleton("shelly_coap")
async def get_coap_context(hass):
"""Get CoAP context to be used in all Shelly devices."""
context = aioshelly.COAP()
await context.initialize()
@callback
def shutdown_listener(ev):
context.close()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_listener)
return context
async def async_setup(hass: HomeAssistant, config: dict): async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Shelly component.""" """Set up the Shelly component."""
hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}}
@ -70,6 +52,9 @@ async def async_setup(hass: HomeAssistant, config: dict):
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Shelly from a config entry.""" """Set up Shelly from a config entry."""
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {}
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = None
temperature_unit = "C" if hass.config.units.is_metric else "F" temperature_unit = "C" if hass.config.units.is_metric else "F"
ip_address = await hass.async_add_executor_job(gethostbyname, entry.data[CONF_HOST]) ip_address = await hass.async_add_executor_job(gethostbyname, entry.data[CONF_HOST])
@ -83,33 +68,79 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
coap_context = await get_coap_context(hass) coap_context = await get_coap_context(hass)
try: device = await aioshelly.Device.create(
async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): aiohttp_client.async_get_clientsession(hass),
device = await aioshelly.Device.create( coap_context,
aiohttp_client.async_get_clientsession(hass), options,
coap_context, False,
options, )
)
except (asyncio.TimeoutError, OSError) as err:
raise ConfigEntryNotReady from err
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {} dev_reg = await device_registry.async_get_registry(hass)
coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ identifier = (DOMAIN, entry.unique_id)
device_entry = dev_reg.async_get_device(identifiers={identifier}, connections=set())
sleep_period = entry.data.get("sleep_period")
@callback
def _async_device_online(_):
_LOGGER.debug("Device %s is online, resuming setup", entry.title)
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = None
if sleep_period is None:
data = {**entry.data}
data["sleep_period"] = get_device_sleep_period(device.settings)
data["model"] = device.settings["device"]["type"]
hass.config_entries.async_update_entry(entry, data=data)
hass.async_create_task(async_device_setup(hass, entry, device))
if sleep_period == 0:
# Not a sleeping device, finish setup
_LOGGER.debug("Setting up online device %s", entry.title)
try:
async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC):
await device.initialize(True)
except (asyncio.TimeoutError, OSError) as err:
raise ConfigEntryNotReady from err
await async_device_setup(hass, entry, device)
elif sleep_period is None or device_entry is None:
# Need to get sleep info or first time sleeping device setup, wait for device
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = device
_LOGGER.debug(
"Setup for device %s will resume when device is online", entry.title
)
device.subscribe_updates(_async_device_online)
else:
# Restore sensors for sleeping device
_LOGGER.debug("Setting up offline device %s", entry.title)
await async_device_setup(hass, entry, device)
return True
async def async_device_setup(
hass: HomeAssistant, entry: ConfigEntry, device: aioshelly.Device
):
"""Set up a device that is online."""
device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][
COAP COAP
] = ShellyDeviceWrapper(hass, entry, device) ] = ShellyDeviceWrapper(hass, entry, device)
await coap_wrapper.async_setup() await device_wrapper.async_setup()
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ platforms = SLEEPING_PLATFORMS
REST
] = ShellyDeviceRestWrapper(hass, device)
for component in PLATFORMS: if not entry.data.get("sleep_period"):
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][
REST
] = ShellyDeviceRestWrapper(hass, device)
platforms = PLATFORMS
for component in platforms:
hass.async_create_task( hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component) hass.config_entries.async_forward_entry_setup(entry, component)
) )
return True
class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
"""Wrapper for a Shelly device with Home Assistant specific functions.""" """Wrapper for a Shelly device with Home Assistant specific functions."""
@ -117,43 +148,40 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
def __init__(self, hass, entry, device: aioshelly.Device): def __init__(self, hass, entry, device: aioshelly.Device):
"""Initialize the Shelly device wrapper.""" """Initialize the Shelly device wrapper."""
self.device_id = None self.device_id = None
sleep_mode = device.settings.get("sleep_mode") sleep_period = entry.data["sleep_period"]
if sleep_mode: if sleep_period:
sleep_period = sleep_mode["period"] update_interval = SLEEP_PERIOD_MULTIPLIER * sleep_period
if sleep_mode["unit"] == "h":
sleep_period *= 60 # hours to minutes
update_interval = (
SLEEP_PERIOD_MULTIPLIER * sleep_period * 60
) # minutes to seconds
else: else:
update_interval = ( update_interval = (
UPDATE_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"] UPDATE_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"]
) )
device_name = get_device_name(device) if device.initialized else entry.title
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
name=get_device_name(device), name=device_name,
update_interval=timedelta(seconds=update_interval), update_interval=timedelta(seconds=update_interval),
) )
self.hass = hass self.hass = hass
self.entry = entry self.entry = entry
self.device = device self.device = device
self.device.subscribe_updates(self.async_set_updated_data) self._async_remove_device_updates_handler = self.async_add_listener(
self._async_device_updates_handler
self._async_remove_input_events_handler = self.async_add_listener(
self._async_input_events_handler
) )
self._last_input_events_count = dict() self._last_input_events_count = {}
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop)
@callback @callback
def _async_input_events_handler(self): def _async_device_updates_handler(self):
"""Handle device input events.""" """Handle device updates."""
if not self.device.initialized:
return
# Check for input events
for block in self.device.blocks: for block in self.device.blocks:
if ( if (
"inputEvent" not in block.sensor_ids "inputEvent" not in block.sensor_ids
@ -192,13 +220,9 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
async def _async_update_data(self): async def _async_update_data(self):
"""Fetch data.""" """Fetch data."""
_LOGGER.debug("Polling Shelly Device - %s", self.name) _LOGGER.debug("Polling Shelly Device - %s", self.name)
try: try:
async with async_timeout.timeout( async with async_timeout.timeout(POLLING_TIMEOUT_SEC):
POLLING_TIMEOUT_MULTIPLIER
* self.device.settings["coiot"]["update_period"]
):
return await self.device.update() return await self.device.update()
except OSError as err: except OSError as err:
raise update_coordinator.UpdateFailed("Error fetching data") from err raise update_coordinator.UpdateFailed("Error fetching data") from err
@ -206,18 +230,17 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
@property @property
def model(self): def model(self):
"""Model of the device.""" """Model of the device."""
return self.device.settings["device"]["type"] return self.entry.data["model"]
@property @property
def mac(self): def mac(self):
"""Mac address of the device.""" """Mac address of the device."""
return self.device.settings["device"]["mac"] return self.entry.unique_id
async def async_setup(self): async def async_setup(self):
"""Set up the wrapper.""" """Set up the wrapper."""
dev_reg = await device_registry.async_get_registry(self.hass) dev_reg = await device_registry.async_get_registry(self.hass)
model_type = self.device.settings["device"]["type"] sw_version = self.device.settings["fw"] if self.device.initialized else ""
entry = dev_reg.async_get_or_create( entry = dev_reg.async_get_or_create(
config_entry_id=self.entry.entry_id, config_entry_id=self.entry.entry_id,
name=self.name, name=self.name,
@ -225,15 +248,16 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
# This is duplicate but otherwise via_device can't work # This is duplicate but otherwise via_device can't work
identifiers={(DOMAIN, self.mac)}, identifiers={(DOMAIN, self.mac)},
manufacturer="Shelly", manufacturer="Shelly",
model=aioshelly.MODEL_NAMES.get(model_type, model_type), model=aioshelly.MODEL_NAMES.get(self.model, self.model),
sw_version=self.device.settings["fw"], sw_version=sw_version,
) )
self.device_id = entry.id self.device_id = entry.id
self.device.subscribe_updates(self.async_set_updated_data)
def shutdown(self): def shutdown(self):
"""Shutdown the wrapper.""" """Shutdown the wrapper."""
self.device.shutdown() self.device.shutdown()
self._async_remove_input_events_handler() self._async_remove_device_updates_handler()
@callback @callback
def _handle_ha_stop(self, _): def _handle_ha_stop(self, _):
@ -282,11 +306,23 @@ class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator):
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."""
device = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id].get(DEVICE)
if device is not None:
# If device is present, device wrapper is not setup yet
device.shutdown()
return True
platforms = SLEEPING_PLATFORMS
if not entry.data.get("sleep_period"):
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][REST] = None
platforms = PLATFORMS
unload_ok = all( unload_ok = all(
await asyncio.gather( await asyncio.gather(
*[ *[
hass.config_entries.async_forward_entry_unload(entry, component) hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS for component in platforms
] ]
) )
) )

View File

@ -9,6 +9,7 @@ from homeassistant.components.binary_sensor import (
DEVICE_CLASS_PROBLEM, DEVICE_CLASS_PROBLEM,
DEVICE_CLASS_SMOKE, DEVICE_CLASS_SMOKE,
DEVICE_CLASS_VIBRATION, DEVICE_CLASS_VIBRATION,
STATE_ON,
BinarySensorEntity, BinarySensorEntity,
) )
@ -17,6 +18,7 @@ from .entity import (
RestAttributeDescription, RestAttributeDescription,
ShellyBlockAttributeEntity, ShellyBlockAttributeEntity,
ShellyRestAttributeEntity, ShellyRestAttributeEntity,
ShellySleepingBlockAttributeEntity,
async_setup_entry_attribute_entities, async_setup_entry_attribute_entities,
async_setup_entry_rest, async_setup_entry_rest,
) )
@ -98,13 +100,25 @@ REST_SENSORS = {
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( if config_entry.data["sleep_period"]:
hass, config_entry, async_add_entities, SENSORS, ShellyBinarySensor await async_setup_entry_attribute_entities(
) hass,
config_entry,
await async_setup_entry_rest( async_add_entities,
hass, config_entry, async_add_entities, REST_SENSORS, ShellyRestBinarySensor SENSORS,
) ShellySleepingBinarySensor,
)
else:
await async_setup_entry_attribute_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): class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity):
@ -123,3 +137,17 @@ class ShellyRestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity):
def is_on(self): def is_on(self):
"""Return true if REST sensor state is on.""" """Return true if REST sensor state is on."""
return bool(self.attribute_value) return bool(self.attribute_value)
class ShellySleepingBinarySensor(
ShellySleepingBlockAttributeEntity, BinarySensorEntity
):
"""Represent a shelly sleeping binary sensor."""
@property
def is_on(self):
"""Return true if sensor state is on."""
if self.block is not None:
return bool(self.attribute_value)
return self.last_state == STATE_ON

View File

@ -17,9 +17,9 @@ from homeassistant.const import (
) )
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
from . import get_coap_context
from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC
from .const import DOMAIN # pylint:disable=unused-import from .const import DOMAIN # pylint:disable=unused-import
from .utils import get_coap_context, get_device_sleep_period
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -53,6 +53,8 @@ async def validate_input(hass: core.HomeAssistant, host, data):
return { return {
"title": device.settings["name"], "title": device.settings["name"],
"hostname": device.settings["device"]["hostname"], "hostname": device.settings["device"]["hostname"],
"sleep_period": get_device_sleep_period(device.settings),
"model": device.settings["device"]["type"],
} }
@ -95,7 +97,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
else: else:
return self.async_create_entry( return self.async_create_entry(
title=device_info["title"] or device_info["hostname"], title=device_info["title"] or device_info["hostname"],
data=user_input, data={
**user_input,
"sleep_period": device_info["sleep_period"],
"model": device_info["model"],
},
) )
return self.async_show_form( return self.async_show_form(
@ -121,7 +127,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
else: else:
return self.async_create_entry( return self.async_create_entry(
title=device_info["title"] or device_info["hostname"], title=device_info["title"] or device_info["hostname"],
data={**user_input, CONF_HOST: self.host}, data={
**user_input,
CONF_HOST: self.host,
"sleep_period": device_info["sleep_period"],
"model": device_info["model"],
},
) )
else: else:
user_input = {} user_input = {}
@ -172,7 +183,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
else: else:
return self.async_create_entry( return self.async_create_entry(
title=device_info["title"] or device_info["hostname"], title=device_info["title"] or device_info["hostname"],
data={"host": self.host}, data={
"host": self.host,
"sleep_period": device_info["sleep_period"],
"model": device_info["model"],
},
) )
return self.async_show_form( return self.async_show_form(

View File

@ -2,11 +2,12 @@
COAP = "coap" COAP = "coap"
DATA_CONFIG_ENTRY = "config_entry" DATA_CONFIG_ENTRY = "config_entry"
DEVICE = "device"
DOMAIN = "shelly" DOMAIN = "shelly"
REST = "rest" REST = "rest"
# Used to calculate the timeout in "_async_update_data" used for polling data from devices. # Used in "_async_update_data" as timeout for polling data from devices.
POLLING_TIMEOUT_MULTIPLIER = 1.2 POLLING_TIMEOUT_SEC = 18
# Refresh interval for REST sensors # Refresh interval for REST sensors
REST_SENSORS_UPDATE_INTERVAL = 60 REST_SENSORS_UPDATE_INTERVAL = 60

View File

@ -1,24 +1,49 @@
"""Shelly entity helper.""" """Shelly entity helper."""
from dataclasses import dataclass from dataclasses import dataclass
import logging
from typing import Any, Callable, Optional, Union from typing import Any, Callable, Optional, Union
import aioshelly import aioshelly
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers import device_registry, entity, update_coordinator from homeassistant.helpers import (
device_registry,
entity,
entity_registry,
update_coordinator,
)
from homeassistant.helpers.restore_state import RestoreEntity
from . import ShellyDeviceRestWrapper, ShellyDeviceWrapper from . import ShellyDeviceRestWrapper, ShellyDeviceWrapper
from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN, REST from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN, REST
from .utils import async_remove_shelly_entity, get_entity_name from .utils import async_remove_shelly_entity, get_entity_name
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry_attribute_entities( async def async_setup_entry_attribute_entities(
hass, config_entry, async_add_entities, sensors, sensor_class hass, config_entry, async_add_entities, sensors, sensor_class
): ):
"""Set up entities for block attributes.""" """Set up entities for 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] ][COAP]
if wrapper.device.initialized:
await async_setup_block_attribute_entities(
hass, async_add_entities, wrapper, sensors, sensor_class
)
else:
await async_restore_block_attribute_entities(
hass, config_entry, async_add_entities, wrapper, sensor_class
)
async def async_setup_block_attribute_entities(
hass, async_add_entities, wrapper, sensors, sensor_class
):
"""Set up entities for block attributes."""
blocks = [] blocks = []
for block in wrapper.device.blocks: for block in wrapper.device.blocks:
@ -36,9 +61,7 @@ async def async_setup_entry_attribute_entities(
wrapper.device.settings, block wrapper.device.settings, block
): ):
domain = sensor_class.__module__.split(".")[-1] domain = sensor_class.__module__.split(".")[-1]
unique_id = sensor_class( unique_id = f"{wrapper.mac}-{block.description}-{sensor_id}"
wrapper, block, sensor_id, description
).unique_id
await async_remove_shelly_entity(hass, domain, unique_id) await async_remove_shelly_entity(hass, domain, unique_id)
else: else:
blocks.append((block, sensor_id, description)) blocks.append((block, sensor_id, description))
@ -54,6 +77,39 @@ async def async_setup_entry_attribute_entities(
) )
async def async_restore_block_attribute_entities(
hass, config_entry, async_add_entities, wrapper, sensor_class
):
"""Restore block attributes entities."""
entities = []
ent_reg = await entity_registry.async_get_registry(hass)
entries = entity_registry.async_entries_for_config_entry(
ent_reg, config_entry.entry_id
)
domain = sensor_class.__module__.split(".")[-1]
for entry in entries:
if entry.domain != domain:
continue
attribute = entry.unique_id.split("-")[-1]
description = BlockAttributeDescription(
name="",
icon=entry.original_icon,
unit=entry.unit_of_measurement,
device_class=entry.device_class,
)
entities.append(sensor_class(wrapper, None, attribute, description, entry))
if not entities:
return
async_add_entities(entities)
async def async_setup_entry_rest( async def async_setup_entry_rest(
hass, config_entry, async_add_entities, sensors, sensor_class hass, config_entry, async_add_entities, sensors, sensor_class
): ):
@ -163,7 +219,7 @@ class ShellyBlockEntity(entity.Entity):
class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity):
"""Switch that controls a relay block on Shelly devices.""" """Helper class to represent a block attribute."""
def __init__( def __init__(
self, self,
@ -176,12 +232,11 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity):
super().__init__(wrapper, block) super().__init__(wrapper, block)
self.attribute = attribute self.attribute = attribute
self.description = description self.description = description
self.info = block.info(attribute)
unit = self.description.unit unit = self.description.unit
if callable(unit): if callable(unit):
unit = unit(self.info) unit = unit(block.info(attribute))
self._unit = unit self._unit = unit
self._unique_id = f"{super().unique_id}-{self.attribute}" self._unique_id = f"{super().unique_id}-{self.attribute}"
@ -320,3 +375,67 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity):
return None return None
return self.description.device_state_attributes(self.wrapper.device.status) return self.description.device_state_attributes(self.wrapper.device.status)
class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEntity):
"""Represent a shelly sleeping block attribute entity."""
# pylint: disable=super-init-not-called
def __init__(
self,
wrapper: ShellyDeviceWrapper,
block: aioshelly.Block,
attribute: str,
description: BlockAttributeDescription,
entry: Optional[ConfigEntry] = None,
) -> None:
"""Initialize the sleeping sensor."""
self.last_state = None
self.wrapper = wrapper
self.attribute = attribute
self.block = block
self.description = description
self._unit = self.description.unit
if block is not None:
if callable(self._unit):
self._unit = self._unit(block.info(attribute))
self._unique_id = f"{self.wrapper.mac}-{block.description}-{attribute}"
self._name = get_entity_name(
self.wrapper.device, block, self.description.name
)
else:
self._unique_id = entry.unique_id
self._name = entry.original_name
async def async_added_to_hass(self):
"""Handle entity which will be added."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state is not None:
self.last_state = last_state.state
@callback
def _update_callback(self):
"""Handle device update."""
if self.block is not None:
super()._update_callback()
return
_, entity_block, entity_sensor = self.unique_id.split("-")
for block in self.wrapper.device.blocks:
if block.description != entity_block:
continue
for sensor_id in block.sensor_ids:
if sensor_id != entity_sensor:
continue
self.block = block
_LOGGER.debug("Entity %s attached to block", self.name)
super()._update_callback()
return

View File

@ -3,7 +3,7 @@
"name": "Shelly", "name": "Shelly",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/shelly", "documentation": "https://www.home-assistant.io/integrations/shelly",
"requirements": ["aioshelly==0.5.3"], "requirements": ["aioshelly==0.5.4"],
"zeroconf": [{ "type": "_http._tcp.local.", "name": "shelly*" }], "zeroconf": [{ "type": "_http._tcp.local.", "name": "shelly*" }],
"codeowners": ["@balloob", "@bieniu", "@thecode", "@chemelli74"] "codeowners": ["@balloob", "@bieniu", "@thecode", "@chemelli74"]
} }

View File

@ -18,6 +18,7 @@ from .entity import (
RestAttributeDescription, RestAttributeDescription,
ShellyBlockAttributeEntity, ShellyBlockAttributeEntity,
ShellyRestAttributeEntity, ShellyRestAttributeEntity,
ShellySleepingBlockAttributeEntity,
async_setup_entry_attribute_entities, async_setup_entry_attribute_entities,
async_setup_entry_rest, async_setup_entry_rest,
) )
@ -185,12 +186,17 @@ REST_SENSORS = {
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( if config_entry.data["sleep_period"]:
hass, config_entry, async_add_entities, SENSORS, ShellySensor await async_setup_entry_attribute_entities(
) hass, config_entry, async_add_entities, SENSORS, ShellySleepingSensor
await async_setup_entry_rest( )
hass, config_entry, async_add_entities, REST_SENSORS, ShellyRestSensor else:
) 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): class ShellySensor(ShellyBlockAttributeEntity):
@ -209,3 +215,15 @@ class ShellyRestSensor(ShellyRestAttributeEntity):
def state(self): def state(self):
"""Return value of sensor.""" """Return value of sensor."""
return self.attribute_value return self.attribute_value
class ShellySleepingSensor(ShellySleepingBlockAttributeEntity):
"""Represent a shelly sleeping sensor."""
@property
def state(self):
"""Return value of sensor."""
if self.block is not None:
return self.attribute_value
return self.last_state

View File

@ -6,8 +6,9 @@ from typing import List, Optional, Tuple
import aioshelly import aioshelly
from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.const import EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import singleton
from homeassistant.util.dt import parse_datetime, utcnow from homeassistant.util.dt import parse_datetime, utcnow
from .const import ( from .const import (
@ -182,3 +183,30 @@ def get_device_wrapper(hass: HomeAssistant, device_id: str):
return wrapper return wrapper
return None return None
@singleton.singleton("shelly_coap")
async def get_coap_context(hass):
"""Get CoAP context to be used in all Shelly devices."""
context = aioshelly.COAP()
await context.initialize()
@callback
def shutdown_listener(ev):
context.close()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_listener)
return context
def get_device_sleep_period(settings: dict) -> int:
"""Return the device sleep period in seconds or 0 for non sleeping devices."""
sleep_period = 0
if settings.get("sleep_mode", False):
sleep_period = settings["sleep_mode"]["period"]
if settings["sleep_mode"]["unit"] == "h":
sleep_period *= 60 # hours to minutes
return sleep_period * 60 # minutes to seconds

View File

@ -218,7 +218,7 @@ aiopylgtv==0.3.3
aiorecollect==1.0.1 aiorecollect==1.0.1
# homeassistant.components.shelly # homeassistant.components.shelly
aioshelly==0.5.3 aioshelly==0.5.4
# homeassistant.components.switcher_kis # homeassistant.components.switcher_kis
aioswitcher==1.2.1 aioswitcher==1.2.1

View File

@ -137,7 +137,7 @@ aiopylgtv==0.3.3
aiorecollect==1.0.1 aiorecollect==1.0.1
# homeassistant.components.shelly # homeassistant.components.shelly
aioshelly==0.5.3 aioshelly==0.5.4
# homeassistant.components.switcher_kis # homeassistant.components.switcher_kis
aioswitcher==1.2.1 aioswitcher==1.2.1

View File

@ -91,7 +91,11 @@ async def coap_wrapper(hass):
"""Setups a coap wrapper with mocked device.""" """Setups a coap wrapper with mocked device."""
await async_setup_component(hass, "shelly", {}) await async_setup_component(hass, "shelly", {})
config_entry = MockConfigEntry(domain=DOMAIN, data={}) config_entry = MockConfigEntry(
domain=DOMAIN,
data={"sleep_period": 0, "model": "SHSW-25"},
unique_id="12345678",
)
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
device = Mock( device = Mock(
@ -99,6 +103,7 @@ async def coap_wrapper(hass):
settings=MOCK_SETTINGS, settings=MOCK_SETTINGS,
shelly=MOCK_SHELLY, shelly=MOCK_SHELLY,
update=AsyncMock(), update=AsyncMock(),
initialized=True,
) )
hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}}

View File

@ -13,7 +13,8 @@ from tests.common import MockConfigEntry
MOCK_SETTINGS = { MOCK_SETTINGS = {
"name": "Test name", "name": "Test name",
"device": {"mac": "test-mac", "hostname": "test-host"}, "device": {"mac": "test-mac", "hostname": "test-host", "type": "SHSW-1"},
"sleep_period": 0,
} }
DISCOVERY_INFO = { DISCOVERY_INFO = {
"host": "1.1.1.1", "host": "1.1.1.1",
@ -57,6 +58,8 @@ async def test_form(hass):
assert result2["title"] == "Test name" assert result2["title"] == "Test name"
assert result2["data"] == { assert result2["data"] == {
"host": "1.1.1.1", "host": "1.1.1.1",
"model": "SHSW-1",
"sleep_period": 0,
} }
assert len(mock_setup.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -101,6 +104,8 @@ async def test_title_without_name(hass):
assert result2["title"] == "shelly1pm-12345" assert result2["title"] == "shelly1pm-12345"
assert result2["data"] == { assert result2["data"] == {
"host": "1.1.1.1", "host": "1.1.1.1",
"model": "SHSW-1",
"sleep_period": 0,
} }
assert len(mock_setup.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -149,6 +154,8 @@ async def test_form_auth(hass):
assert result3["title"] == "Test name" assert result3["title"] == "Test name"
assert result3["data"] == { assert result3["data"] == {
"host": "1.1.1.1", "host": "1.1.1.1",
"model": "SHSW-1",
"sleep_period": 0,
"username": "test username", "username": "test username",
"password": "test password", "password": "test password",
} }
@ -369,6 +376,8 @@ async def test_zeroconf(hass):
assert result2["title"] == "Test name" assert result2["title"] == "Test name"
assert result2["data"] == { assert result2["data"] == {
"host": "1.1.1.1", "host": "1.1.1.1",
"model": "SHSW-1",
"sleep_period": 0,
} }
assert len(mock_setup.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -502,6 +511,8 @@ async def test_zeroconf_require_auth(hass):
assert result3["title"] == "Test name" assert result3["title"] == "Test name"
assert result3["data"] == { assert result3["data"] == {
"host": "1.1.1.1", "host": "1.1.1.1",
"model": "SHSW-1",
"sleep_period": 0,
"username": "test username", "username": "test username",
"password": "test password", "password": "test password",
} }