From 0875f654c800492fd093c7be6b006a8ffd972c2b Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 3 Feb 2021 18:03:22 +0200 Subject: [PATCH] Add support for Shelly battery operated devices (#45406) Co-authored-by: Paulus Schoutsen --- homeassistant/components/shelly/__init__.py | 178 +++++++++++------- .../components/shelly/binary_sensor.py | 42 ++++- .../components/shelly/config_flow.py | 23 ++- homeassistant/components/shelly/const.py | 5 +- homeassistant/components/shelly/entity.py | 135 ++++++++++++- homeassistant/components/shelly/manifest.json | 2 +- homeassistant/components/shelly/sensor.py | 30 ++- homeassistant/components/shelly/utils.py | 32 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/shelly/conftest.py | 7 +- tests/components/shelly/test_config_flow.py | 13 +- 12 files changed, 366 insertions(+), 105 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index d2df03b44a5..84bc73f3c0f 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -17,12 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import ( - aiohttp_client, - device_registry, - singleton, - update_coordinator, -) +from homeassistant.helpers import aiohttp_client, device_registry, update_coordinator from .const import ( AIOSHELLY_DEVICE_TIMEOUT_SEC, @@ -32,36 +27,23 @@ from .const import ( BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, COAP, DATA_CONFIG_ENTRY, + DEVICE, DOMAIN, EVENT_SHELLY_CLICK, INPUTS_EVENTS_DICT, - POLLING_TIMEOUT_MULTIPLIER, + POLLING_TIMEOUT_SEC, REST, REST_SENSORS_UPDATE_INTERVAL, SLEEP_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"] +SLEEPING_PLATFORMS = ["binary_sensor", "sensor"] _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): """Set up the Shelly component.""" 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): """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" 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) - try: - async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - device = await aioshelly.Device.create( - aiohttp_client.async_get_clientsession(hass), - coap_context, - options, - ) - except (asyncio.TimeoutError, OSError) as err: - raise ConfigEntryNotReady from err + device = await aioshelly.Device.create( + aiohttp_client.async_get_clientsession(hass), + coap_context, + options, + False, + ) - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {} - coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ + dev_reg = await device_registry.async_get_registry(hass) + 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 ] = ShellyDeviceWrapper(hass, entry, device) - await coap_wrapper.async_setup() + await device_wrapper.async_setup() - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ - REST - ] = ShellyDeviceRestWrapper(hass, device) + platforms = SLEEPING_PLATFORMS - 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.config_entries.async_forward_entry_setup(entry, component) ) - return True - class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): """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): """Initialize the Shelly device wrapper.""" self.device_id = None - sleep_mode = device.settings.get("sleep_mode") + sleep_period = entry.data["sleep_period"] - if sleep_mode: - sleep_period = sleep_mode["period"] - if sleep_mode["unit"] == "h": - sleep_period *= 60 # hours to minutes - - update_interval = ( - SLEEP_PERIOD_MULTIPLIER * sleep_period * 60 - ) # minutes to seconds + if sleep_period: + update_interval = SLEEP_PERIOD_MULTIPLIER * sleep_period else: update_interval = ( UPDATE_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"] ) + device_name = get_device_name(device) if device.initialized else entry.title super().__init__( hass, _LOGGER, - name=get_device_name(device), + name=device_name, update_interval=timedelta(seconds=update_interval), ) self.hass = hass self.entry = entry self.device = device - self.device.subscribe_updates(self.async_set_updated_data) - - self._async_remove_input_events_handler = self.async_add_listener( - self._async_input_events_handler + self._async_remove_device_updates_handler = self.async_add_listener( + self._async_device_updates_handler ) - self._last_input_events_count = dict() + self._last_input_events_count = {} hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) @callback - def _async_input_events_handler(self): - """Handle device input events.""" + def _async_device_updates_handler(self): + """Handle device updates.""" + if not self.device.initialized: + return + + # Check for input events for block in self.device.blocks: if ( "inputEvent" not in block.sensor_ids @@ -192,13 +220,9 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): async def _async_update_data(self): """Fetch data.""" - _LOGGER.debug("Polling Shelly Device - %s", self.name) try: - async with async_timeout.timeout( - POLLING_TIMEOUT_MULTIPLIER - * self.device.settings["coiot"]["update_period"] - ): + async with async_timeout.timeout(POLLING_TIMEOUT_SEC): return await self.device.update() except OSError as err: raise update_coordinator.UpdateFailed("Error fetching data") from err @@ -206,18 +230,17 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): @property def model(self): """Model of the device.""" - return self.device.settings["device"]["type"] + return self.entry.data["model"] @property def mac(self): """Mac address of the device.""" - return self.device.settings["device"]["mac"] + return self.entry.unique_id async def async_setup(self): """Set up the wrapper.""" - 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( config_entry_id=self.entry.entry_id, name=self.name, @@ -225,15 +248,16 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): # This is duplicate but otherwise via_device can't work identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", - model=aioshelly.MODEL_NAMES.get(model_type, model_type), - sw_version=self.device.settings["fw"], + model=aioshelly.MODEL_NAMES.get(self.model, self.model), + sw_version=sw_version, ) self.device_id = entry.id + self.device.subscribe_updates(self.async_set_updated_data) def shutdown(self): """Shutdown the wrapper.""" self.device.shutdown() - self._async_remove_input_events_handler() + self._async_remove_device_updates_handler() @callback def _handle_ha_stop(self, _): @@ -282,11 +306,23 @@ class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """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( await asyncio.gather( *[ hass.config_entries.async_forward_entry_unload(entry, component) - for component in PLATFORMS + for component in platforms ] ) ) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index d53f089054a..8f99e6a7a6e 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_PROBLEM, DEVICE_CLASS_SMOKE, DEVICE_CLASS_VIBRATION, + STATE_ON, BinarySensorEntity, ) @@ -17,6 +18,7 @@ from .entity import ( RestAttributeDescription, ShellyBlockAttributeEntity, ShellyRestAttributeEntity, + ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rest, ) @@ -98,13 +100,25 @@ REST_SENSORS = { 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, ShellyBinarySensor - ) - - await async_setup_entry_rest( - hass, config_entry, async_add_entities, REST_SENSORS, ShellyRestBinarySensor - ) + if config_entry.data["sleep_period"]: + await async_setup_entry_attribute_entities( + hass, + config_entry, + async_add_entities, + 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): @@ -123,3 +137,17 @@ class ShellyRestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): def is_on(self): """Return true if REST sensor state is on.""" 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 diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index b47c76cbb7a..09fc477e512 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -17,9 +17,9 @@ from homeassistant.const import ( ) from homeassistant.helpers import aiohttp_client -from . import get_coap_context from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC from .const import DOMAIN # pylint:disable=unused-import +from .utils import get_coap_context, get_device_sleep_period _LOGGER = logging.getLogger(__name__) @@ -53,6 +53,8 @@ async def validate_input(hass: core.HomeAssistant, host, data): return { "title": device.settings["name"], "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: return self.async_create_entry( 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( @@ -121,7 +127,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: return self.async_create_entry( 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: user_input = {} @@ -172,7 +183,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: return self.async_create_entry( 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( diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index a5922d0b9c0..9d1c333b201 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -2,11 +2,12 @@ COAP = "coap" DATA_CONFIG_ENTRY = "config_entry" +DEVICE = "device" DOMAIN = "shelly" REST = "rest" -# Used to calculate the timeout in "_async_update_data" used for polling data from devices. -POLLING_TIMEOUT_MULTIPLIER = 1.2 +# Used in "_async_update_data" as timeout for polling data from devices. +POLLING_TIMEOUT_SEC = 18 # Refresh interval for REST sensors REST_SENSORS_UPDATE_INTERVAL = 60 diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index b4df2d486f8..b934a41728f 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -1,24 +1,49 @@ """Shelly entity helper.""" from dataclasses import dataclass +import logging from typing import Any, Callable, Optional, Union import aioshelly +from homeassistant.config_entries import ConfigEntry 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 .const import COAP, DATA_CONFIG_ENTRY, DOMAIN, REST from .utils import async_remove_shelly_entity, get_entity_name +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry_attribute_entities( 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][ config_entry.entry_id ][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 = [] for block in wrapper.device.blocks: @@ -36,9 +61,7 @@ async def async_setup_entry_attribute_entities( wrapper.device.settings, block ): domain = sensor_class.__module__.split(".")[-1] - unique_id = sensor_class( - wrapper, block, sensor_id, description - ).unique_id + unique_id = f"{wrapper.mac}-{block.description}-{sensor_id}" await async_remove_shelly_entity(hass, domain, unique_id) else: 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( hass, config_entry, async_add_entities, sensors, sensor_class ): @@ -163,7 +219,7 @@ class 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__( self, @@ -176,12 +232,11 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): 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) + unit = unit(block.info(attribute)) self._unit = unit self._unique_id = f"{super().unique_id}-{self.attribute}" @@ -320,3 +375,67 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): return None 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 diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 923bcdced34..fffa98b6870 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -3,7 +3,7 @@ "name": "Shelly", "config_flow": true, "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*" }], "codeowners": ["@balloob", "@bieniu", "@thecode", "@chemelli74"] } diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index b92b90c1b46..36656740b92 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -18,6 +18,7 @@ from .entity import ( RestAttributeDescription, ShellyBlockAttributeEntity, ShellyRestAttributeEntity, + ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rest, ) @@ -185,12 +186,17 @@ REST_SENSORS = { 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 - ) + if config_entry.data["sleep_period"]: + await async_setup_entry_attribute_entities( + hass, config_entry, async_add_entities, SENSORS, ShellySleepingSensor + ) + 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): @@ -209,3 +215,15 @@ class ShellyRestSensor(ShellyRestAttributeEntity): def state(self): """Return value of sensor.""" 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 diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 97d8bda609b..b4148801b35 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -6,8 +6,9 @@ from typing import List, Optional, Tuple import aioshelly -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import singleton from homeassistant.util.dt import parse_datetime, utcnow from .const import ( @@ -182,3 +183,30 @@ def get_device_wrapper(hass: HomeAssistant, device_id: str): return wrapper 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 diff --git a/requirements_all.txt b/requirements_all.txt index c25ea05aa33..4315c577283 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -218,7 +218,7 @@ aiopylgtv==0.3.3 aiorecollect==1.0.1 # homeassistant.components.shelly -aioshelly==0.5.3 +aioshelly==0.5.4 # homeassistant.components.switcher_kis aioswitcher==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b156395df13..d8c7d112fba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -137,7 +137,7 @@ aiopylgtv==0.3.3 aiorecollect==1.0.1 # homeassistant.components.shelly -aioshelly==0.5.3 +aioshelly==0.5.4 # homeassistant.components.switcher_kis aioswitcher==1.2.1 diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 804d5a75952..7e7bd068842 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -91,7 +91,11 @@ async def coap_wrapper(hass): """Setups a coap wrapper with mocked device.""" 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) device = Mock( @@ -99,6 +103,7 @@ async def coap_wrapper(hass): settings=MOCK_SETTINGS, shelly=MOCK_SHELLY, update=AsyncMock(), + initialized=True, ) hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 60f899296f6..1d5099cec1c 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -13,7 +13,8 @@ from tests.common import MockConfigEntry MOCK_SETTINGS = { "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 = { "host": "1.1.1.1", @@ -57,6 +58,8 @@ async def test_form(hass): assert result2["title"] == "Test name" assert result2["data"] == { "host": "1.1.1.1", + "model": "SHSW-1", + "sleep_period": 0, } assert len(mock_setup.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["data"] == { "host": "1.1.1.1", + "model": "SHSW-1", + "sleep_period": 0, } assert len(mock_setup.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["data"] == { "host": "1.1.1.1", + "model": "SHSW-1", + "sleep_period": 0, "username": "test username", "password": "test password", } @@ -369,6 +376,8 @@ async def test_zeroconf(hass): assert result2["title"] == "Test name" assert result2["data"] == { "host": "1.1.1.1", + "model": "SHSW-1", + "sleep_period": 0, } assert len(mock_setup.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["data"] == { "host": "1.1.1.1", + "model": "SHSW-1", + "sleep_period": 0, "username": "test username", "password": "test password", }