From 39408ab2408207ced5b13d181e59e0b316f3f1c5 Mon Sep 17 00:00:00 2001 From: Ziv <16467659+ziv1234@users.noreply.github.com> Date: Thu, 2 Apr 2020 21:26:36 +0300 Subject: [PATCH] Add cover platform to Dynalite (#32594) * lib version * unit-test refactoring * added type hints * added cover * added test to see that consts have the same value as library consts * Update tests/components/dynalite/test_init.py Co-Authored-By: Martin Hjelmare * removed trigger template * Update homeassistant/components/dynalite/__init__.py Co-Authored-By: Martin Hjelmare * Update homeassistant/components/dynalite/const.py Co-Authored-By: Martin Hjelmare * removed CONF_TRIGGER from const corrected type hints - not clear why mypy didn't catch it * conversion of the config to library CONFs * moved to use the value since it should come from the library * taking CONF_HOST from homeassistant.const instead of module const * use dict.get removed leftover log * force device_class to be from homeassistant consts * move dict.get to inline * removed CONF from values changed "channelcover" to "channel_cover" * moved some CONF values out of const.py and taking them from homeassistant.const * verifying that device class is a valid HA device class * moved shutter to home assistant const Co-authored-by: Martin Hjelmare --- homeassistant/components/dynalite/__init__.py | 110 ++++++++++++++---- homeassistant/components/dynalite/bridge.py | 33 ++++-- .../components/dynalite/config_flow.py | 6 +- homeassistant/components/dynalite/const.py | 41 +++++-- .../components/dynalite/convert_config.py | 78 +++++++++++++ homeassistant/components/dynalite/cover.py | 98 ++++++++++++++++ .../components/dynalite/dynalitebase.py | 31 +++-- homeassistant/components/dynalite/light.py | 18 ++- .../components/dynalite/manifest.json | 2 +- homeassistant/components/dynalite/switch.py | 14 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/dynalite/common.py | 1 + tests/components/dynalite/test_bridge.py | 8 ++ tests/components/dynalite/test_config_flow.py | 66 ++++++----- tests/components/dynalite/test_cover.py | 100 ++++++++++++++++ tests/components/dynalite/test_init.py | 83 +++++++++++-- 17 files changed, 587 insertions(+), 106 deletions(-) create mode 100644 homeassistant/components/dynalite/convert_config.py create mode 100644 homeassistant/components/dynalite/cover.py create mode 100644 tests/components/dynalite/test_cover.py diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 973d09a384f..2f2ed8f2fa2 100755 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -1,43 +1,55 @@ """Support for the Dynalite networks.""" import asyncio +from typing import Any, Dict, Union import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_HOST +from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv # Loading the config flow file will register the flow from .bridge import DynaliteBridge from .const import ( + ACTIVE_INIT, + ACTIVE_OFF, + ACTIVE_ON, CONF_ACTIVE, - CONF_ACTIVE_INIT, - CONF_ACTIVE_OFF, - CONF_ACTIVE_ON, CONF_AREA, CONF_AUTO_DISCOVER, CONF_BRIDGES, CONF_CHANNEL, - CONF_CHANNEL_TYPE, + CONF_CHANNEL_COVER, + CONF_CLOSE_PRESET, CONF_DEFAULT, + CONF_DEVICE_CLASS, + CONF_DURATION, CONF_FADE, - CONF_NAME, CONF_NO_DEFAULT, - CONF_POLLTIMER, - CONF_PORT, + CONF_OPEN_PRESET, + CONF_POLL_TIMER, CONF_PRESET, + CONF_ROOM_OFF, + CONF_ROOM_ON, + CONF_STOP_PRESET, + CONF_TEMPLATE, + CONF_TILT_TIME, DEFAULT_CHANNEL_TYPE, DEFAULT_NAME, DEFAULT_PORT, + DEFAULT_TEMPLATES, DOMAIN, ENTITY_PLATFORMS, LOGGER, ) -def num_string(value): +def num_string(value: Union[int, str]) -> str: """Test if value is a string of digits, aka an integer.""" new_value = str(value) if new_value.isdigit(): @@ -49,7 +61,7 @@ CHANNEL_DATA_SCHEMA = vol.Schema( { vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_CHANNEL_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any( + vol.Optional(CONF_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any( "light", "switch" ), } @@ -63,15 +75,66 @@ PRESET_DATA_SCHEMA = vol.Schema( PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)}) +TEMPLATE_ROOM_SCHEMA = vol.Schema( + {vol.Optional(CONF_ROOM_ON): num_string, vol.Optional(CONF_ROOM_OFF): num_string} +) + +TEMPLATE_TIMECOVER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_CHANNEL_COVER): num_string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPEN_PRESET): num_string, + vol.Optional(CONF_CLOSE_PRESET): num_string, + vol.Optional(CONF_STOP_PRESET): num_string, + vol.Optional(CONF_DURATION): vol.Coerce(float), + vol.Optional(CONF_TILT_TIME): vol.Coerce(float), + } +) + +TEMPLATE_DATA_SCHEMA = vol.Any(TEMPLATE_ROOM_SCHEMA, TEMPLATE_TIMECOVER_SCHEMA) + +TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA}) + + +def validate_area(config: Dict[str, Any]) -> Dict[str, Any]: + """Validate that template parameters are only used if area is using the relevant template.""" + conf_set = set() + for template in DEFAULT_TEMPLATES: + for conf in DEFAULT_TEMPLATES[template]: + conf_set.add(conf) + if config.get(CONF_TEMPLATE): + for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]: + conf_set.remove(conf) + for conf in conf_set: + if config.get(conf): + raise vol.Invalid( + f"{conf} should not be part of area {config[CONF_NAME]} config" + ) + return config + AREA_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_NO_DEFAULT): vol.Coerce(bool), - vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, - vol.Optional(CONF_PRESET): PRESET_SCHEMA, - }, + vol.All( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_TEMPLATE): cv.string, + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_NO_DEFAULT): cv.boolean, + vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, + vol.Optional(CONF_PRESET): PRESET_SCHEMA, + # the next ones can be part of the templates + vol.Optional(CONF_ROOM_ON): num_string, + vol.Optional(CONF_ROOM_OFF): num_string, + vol.Optional(CONF_CHANNEL_COVER): num_string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPEN_PRESET): num_string, + vol.Optional(CONF_CLOSE_PRESET): num_string, + vol.Optional(CONF_STOP_PRESET): num_string, + vol.Optional(CONF_DURATION): vol.Coerce(float), + vol.Optional(CONF_TILT_TIME): vol.Coerce(float), + }, + validate_area, + ) ) AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)}) @@ -85,13 +148,14 @@ BRIDGE_SCHEMA = vol.Schema( vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool), - vol.Optional(CONF_POLLTIMER, default=1.0): vol.Coerce(float), + vol.Optional(CONF_POLL_TIMER, default=1.0): vol.Coerce(float), vol.Optional(CONF_AREA): AREA_SCHEMA, vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA, vol.Optional(CONF_ACTIVE, default=False): vol.Any( - CONF_ACTIVE_ON, CONF_ACTIVE_OFF, CONF_ACTIVE_INIT, cv.boolean + ACTIVE_ON, ACTIVE_OFF, ACTIVE_INIT, cv.boolean ), vol.Optional(CONF_PRESET): PRESET_SCHEMA, + vol.Optional(CONF_TEMPLATE): TEMPLATE_SCHEMA, } ) @@ -105,7 +169,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool: """Set up the Dynalite platform.""" conf = config.get(DOMAIN) @@ -137,7 +201,7 @@ async def async_setup(hass, config): return True -async def async_entry_changed(hass, entry): +async def async_entry_changed(hass: HomeAssistant, entry: ConfigEntry) -> None: """Reload entry since the data has changed.""" LOGGER.debug("Reconfiguring entry %s", entry.data) bridge = hass.data[DOMAIN][entry.entry_id] @@ -145,7 +209,7 @@ async def async_entry_changed(hass, entry): LOGGER.debug("Reconfiguring entry finished %s", entry.data) -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a bridge from a config entry.""" LOGGER.debug("Setting up entry %s", entry.data) bridge = DynaliteBridge(hass, entry.data) @@ -163,7 +227,7 @@ async def async_setup_entry(hass, entry): return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" LOGGER.debug("Unloading entry %s", entry.data) hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index fa0a91bfab1..09cf8e25a10 100755 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -1,17 +1,26 @@ """Code to handle a Dynalite bridge.""" +from typing import TYPE_CHECKING, Any, Callable, Dict, List + from dynalite_devices_lib.dynalite_devices import DynaliteDevices -from homeassistant.core import callback +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import CONF_ALL, CONF_HOST, ENTITY_PLATFORMS, LOGGER +from .const import CONF_ALL, ENTITY_PLATFORMS, LOGGER +from .convert_config import convert_config + +if TYPE_CHECKING: # pragma: no cover + from dynalite_devices_lib.dynalite_devices import ( # pylint: disable=ungrouped-imports + DynaliteBaseDevice, + ) class DynaliteBridge: """Manages a single Dynalite bridge.""" - def __init__(self, hass, config): + def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: """Initialize the system based on host parameter.""" self.hass = hass self.area = {} @@ -23,20 +32,20 @@ class DynaliteBridge: new_device_func=self.add_devices_when_registered, update_device_func=self.update_device, ) - self.dynalite_devices.configure(config) + self.dynalite_devices.configure(convert_config(config)) - async def async_setup(self): + async def async_setup(self) -> bool: """Set up a Dynalite bridge.""" # Configure the dynalite devices LOGGER.debug("Setting up bridge - host %s", self.host) return await self.dynalite_devices.async_setup() - def reload_config(self, config): + def reload_config(self, config: Dict[str, Any]) -> None: """Reconfigure a bridge when config changes.""" LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config) - self.dynalite_devices.configure(config) + self.dynalite_devices.configure(convert_config(config)) - def update_signal(self, device=None): + def update_signal(self, device: "DynaliteBaseDevice" = None) -> str: """Create signal to use to trigger entity update.""" if device: signal = f"dynalite-update-{self.host}-{device.unique_id}" @@ -45,12 +54,12 @@ class DynaliteBridge: return signal @callback - def update_device(self, device): + def update_device(self, device: "DynaliteBaseDevice") -> None: """Call when a device or all devices should be updated.""" if device == CONF_ALL: # This is used to signal connection or disconnection, so all devices may become available or not. log_string = ( - "Connected" if self.dynalite_devices.available else "Disconnected" + "Connected" if self.dynalite_devices.connected else "Disconnected" ) LOGGER.info("%s to dynalite host", log_string) async_dispatcher_send(self.hass, self.update_signal()) @@ -58,13 +67,13 @@ class DynaliteBridge: async_dispatcher_send(self.hass, self.update_signal(device)) @callback - def register_add_devices(self, platform, async_add_devices): + def register_add_devices(self, platform: str, async_add_devices: Callable) -> None: """Add an async_add_entities for a category.""" self.async_add_devices[platform] = async_add_devices if platform in self.waiting_devices: self.async_add_devices[platform](self.waiting_devices[platform]) - def add_devices_when_registered(self, devices): + def add_devices_when_registered(self, devices: List["DynaliteBaseDevice"]) -> None: """Add the devices to HA if the add devices callback was registered, otherwise queue until it is.""" for platform in ENTITY_PLATFORMS: platform_devices = [ diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index ca95c0754a6..4c5b2ceb7d8 100755 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -1,4 +1,6 @@ """Config flow to configure Dynalite hub.""" +from typing import Any, Dict + from homeassistant import config_entries from homeassistant.const import CONF_HOST @@ -12,11 +14,11 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - def __init__(self): + def __init__(self) -> None: """Initialize the Dynalite flow.""" self.host = None - async def async_step_import(self, import_info): + async def async_step_import(self, import_info: Dict[str, Any]) -> Any: """Import a new bridge as a config entry.""" LOGGER.debug("Starting async_step_import - %s", import_info) host = import_info[CONF_HOST] diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py index 267b5727b83..ade167e1b3e 100755 --- a/homeassistant/components/dynalite/const.py +++ b/homeassistant/components/dynalite/const.py @@ -1,31 +1,54 @@ """Constants for the Dynalite component.""" import logging +from homeassistant.components.cover import DEVICE_CLASS_SHUTTER +from homeassistant.const import CONF_ROOM + LOGGER = logging.getLogger(__package__) DOMAIN = "dynalite" -ENTITY_PLATFORMS = ["light", "switch"] +ENTITY_PLATFORMS = ["light", "switch", "cover"] CONF_ACTIVE = "active" -CONF_ACTIVE_INIT = "init" -CONF_ACTIVE_OFF = "off" -CONF_ACTIVE_ON = "on" +ACTIVE_INIT = "init" +ACTIVE_OFF = "off" +ACTIVE_ON = "on" CONF_ALL = "ALL" CONF_AREA = "area" CONF_AUTO_DISCOVER = "autodiscover" CONF_BRIDGES = "bridges" CONF_CHANNEL = "channel" -CONF_CHANNEL_TYPE = "type" +CONF_CHANNEL_COVER = "channel_cover" +CONF_CLOSE_PRESET = "close" CONF_DEFAULT = "default" +CONF_DEVICE_CLASS = "class" +CONF_DURATION = "duration" CONF_FADE = "fade" -CONF_HOST = "host" -CONF_NAME = "name" CONF_NO_DEFAULT = "nodefault" -CONF_POLLTIMER = "polltimer" -CONF_PORT = "port" +CONF_OPEN_PRESET = "open" +CONF_POLL_TIMER = "polltimer" CONF_PRESET = "preset" +CONF_ROOM_OFF = "room_off" +CONF_ROOM_ON = "room_on" +CONF_STOP_PRESET = "stop" +CONF_TEMPLATE = "template" +CONF_TILT_TIME = "tilt" +CONF_TIME_COVER = "time_cover" DEFAULT_CHANNEL_TYPE = "light" +DEFAULT_COVER_CLASS = DEVICE_CLASS_SHUTTER DEFAULT_NAME = "dynalite" DEFAULT_PORT = 12345 +DEFAULT_TEMPLATES = { + CONF_ROOM: [CONF_ROOM_ON, CONF_ROOM_OFF], + CONF_TIME_COVER: [ + CONF_CHANNEL_COVER, + CONF_DEVICE_CLASS, + CONF_OPEN_PRESET, + CONF_CLOSE_PRESET, + CONF_STOP_PRESET, + CONF_DURATION, + CONF_TILT_TIME, + ], +} diff --git a/homeassistant/components/dynalite/convert_config.py b/homeassistant/components/dynalite/convert_config.py new file mode 100644 index 00000000000..03ece744d41 --- /dev/null +++ b/homeassistant/components/dynalite/convert_config.py @@ -0,0 +1,78 @@ +"""Convert the HA config to the dynalite config.""" + +from typing import Any, Dict + +from dynalite_devices_lib import const as dyn_const + +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_ROOM, CONF_TYPE + +from .const import ( + ACTIVE_INIT, + ACTIVE_OFF, + ACTIVE_ON, + CONF_ACTIVE, + CONF_AREA, + CONF_AUTO_DISCOVER, + CONF_CHANNEL, + CONF_CHANNEL_COVER, + CONF_CLOSE_PRESET, + CONF_DEFAULT, + CONF_DEVICE_CLASS, + CONF_DURATION, + CONF_FADE, + CONF_NO_DEFAULT, + CONF_OPEN_PRESET, + CONF_POLL_TIMER, + CONF_PRESET, + CONF_ROOM_OFF, + CONF_ROOM_ON, + CONF_STOP_PRESET, + CONF_TEMPLATE, + CONF_TILT_TIME, + CONF_TIME_COVER, +) + +CONF_MAP = { + CONF_ACTIVE: dyn_const.CONF_ACTIVE, + ACTIVE_INIT: dyn_const.CONF_ACTIVE_INIT, + ACTIVE_OFF: dyn_const.CONF_ACTIVE_OFF, + ACTIVE_ON: dyn_const.CONF_ACTIVE_ON, + CONF_AREA: dyn_const.CONF_AREA, + CONF_AUTO_DISCOVER: dyn_const.CONF_AUTO_DISCOVER, + CONF_CHANNEL: dyn_const.CONF_CHANNEL, + CONF_CHANNEL_COVER: dyn_const.CONF_CHANNEL_COVER, + CONF_TYPE: dyn_const.CONF_CHANNEL_TYPE, + CONF_CLOSE_PRESET: dyn_const.CONF_CLOSE_PRESET, + CONF_DEFAULT: dyn_const.CONF_DEFAULT, + CONF_DEVICE_CLASS: dyn_const.CONF_DEVICE_CLASS, + CONF_DURATION: dyn_const.CONF_DURATION, + CONF_FADE: dyn_const.CONF_FADE, + CONF_HOST: dyn_const.CONF_HOST, + CONF_NAME: dyn_const.CONF_NAME, + CONF_NO_DEFAULT: dyn_const.CONF_NO_DEFAULT, + CONF_OPEN_PRESET: dyn_const.CONF_OPEN_PRESET, + CONF_POLL_TIMER: dyn_const.CONF_POLL_TIMER, + CONF_PORT: dyn_const.CONF_PORT, + CONF_PRESET: dyn_const.CONF_PRESET, + CONF_ROOM: dyn_const.CONF_ROOM, + CONF_ROOM_OFF: dyn_const.CONF_ROOM_OFF, + CONF_ROOM_ON: dyn_const.CONF_ROOM_ON, + CONF_STOP_PRESET: dyn_const.CONF_STOP_PRESET, + CONF_TEMPLATE: dyn_const.CONF_TEMPLATE, + CONF_TILT_TIME: dyn_const.CONF_TILT_TIME, + CONF_TIME_COVER: dyn_const.CONF_TIME_COVER, +} + + +def convert_config(config: Dict[str, Any]) -> Dict[str, Any]: + """Convert a config dict by replacing component consts with library consts.""" + result = {} + for (key, value) in config.items(): + if isinstance(value, dict): + new_value = convert_config(value) + elif isinstance(value, str): + new_value = CONF_MAP.get(value, value) + else: + new_value = value + result[CONF_MAP.get(key, key)] = new_value + return result diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py new file mode 100644 index 00000000000..dcf16ede58c --- /dev/null +++ b/homeassistant/components/dynalite/cover.py @@ -0,0 +1,98 @@ +"""Support for the Dynalite channels as covers.""" +from typing import Callable + +from homeassistant.components.cover import DEVICE_CLASSES, CoverDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback + +from .const import DEFAULT_COVER_CLASS +from .dynalitebase import DynaliteBase, async_setup_entry_base + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +) -> None: + """Record the async_add_entities function to add them later when received from Dynalite.""" + + @callback + def cover_from_device(device, bridge): + if device.has_tilt: + return DynaliteCoverWithTilt(device, bridge) + return DynaliteCover(device, bridge) + + async_setup_entry_base( + hass, config_entry, async_add_entities, "cover", cover_from_device + ) + + +class DynaliteCover(DynaliteBase, CoverDevice): + """Representation of a Dynalite Channel as a Home Assistant Cover.""" + + @property + def device_class(self) -> str: + """Return the class of the device.""" + dev_cls = self._device.device_class + if dev_cls in DEVICE_CLASSES: + return dev_cls + return DEFAULT_COVER_CLASS + + @property + def current_cover_position(self) -> int: + """Return the position of the cover from 0 to 100.""" + return self._device.current_cover_position + + @property + def is_opening(self) -> bool: + """Return true if cover is opening.""" + return self._device.is_opening + + @property + def is_closing(self) -> bool: + """Return true if cover is closing.""" + return self._device.is_closing + + @property + def is_closed(self) -> bool: + """Return true if cover is closed.""" + return self._device.is_closed + + async def async_open_cover(self, **kwargs) -> None: + """Open the cover.""" + await self._device.async_open_cover(**kwargs) + + async def async_close_cover(self, **kwargs) -> None: + """Close the cover.""" + await self._device.async_close_cover(**kwargs) + + async def async_set_cover_position(self, **kwargs) -> None: + """Set the cover position.""" + await self._device.async_set_cover_position(**kwargs) + + async def async_stop_cover(self, **kwargs) -> None: + """Stop the cover.""" + await self._device.async_stop_cover(**kwargs) + + +class DynaliteCoverWithTilt(DynaliteCover): + """Representation of a Dynalite Channel as a Home Assistant Cover that uses up and down for tilt.""" + + @property + def current_cover_tilt_position(self) -> int: + """Return the current tilt position.""" + return self._device.current_cover_tilt_position + + async def async_open_cover_tilt(self, **kwargs) -> None: + """Open cover tilt.""" + await self._device.async_open_cover_tilt(**kwargs) + + async def async_close_cover_tilt(self, **kwargs) -> None: + """Close cover tilt.""" + await self._device.async_close_cover_tilt(**kwargs) + + async def async_set_cover_tilt_position(self, **kwargs) -> None: + """Set the cover tilt position.""" + await self._device.async_set_cover_tilt_position(**kwargs) + + async def async_stop_cover_tilt(self, **kwargs) -> None: + """Stop the cover tilt.""" + await self._device.async_stop_cover_tilt(**kwargs) diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py index 8bb1ab2dc42..31879c5c118 100755 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -1,5 +1,9 @@ """Support for the Dynalite devices as entities.""" -from homeassistant.core import callback +from typing import Any, Callable, Dict + +from homeassistant.components.dynalite.bridge import DynaliteBridge +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -7,8 +11,12 @@ from .const import DOMAIN, LOGGER def async_setup_entry_base( - hass, config_entry, async_add_entities, platform, entity_from_device -): + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable, + platform: str, + entity_from_device: Callable, +) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" LOGGER.debug("Setting up %s entry = %s", platform, config_entry.data) bridge = hass.data[DOMAIN][config_entry.entry_id] @@ -18,8 +26,7 @@ def async_setup_entry_base( # assumes it is called with a single platform added_entities = [] for device in devices: - if device.category == platform: - added_entities.append(entity_from_device(device, bridge)) + added_entities.append(entity_from_device(device, bridge)) if added_entities: async_add_entities(added_entities) @@ -29,29 +36,29 @@ def async_setup_entry_base( class DynaliteBase(Entity): """Base class for the Dynalite entities.""" - def __init__(self, device, bridge): + def __init__(self, device: Any, bridge: DynaliteBridge) -> None: """Initialize the base class.""" self._device = device self._bridge = bridge self._unsub_dispatchers = [] @property - def name(self): + def name(self) -> str: """Return the name of the entity.""" return self._device.name @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID of the entity.""" return self._device.unique_id @property - def available(self): + def available(self) -> bool: """Return if entity is available.""" return self._device.available @property - def device_info(self): + def device_info(self) -> Dict[str, Any]: """Device info for this entity.""" return { "identifiers": {(DOMAIN, self._device.unique_id)}, @@ -59,7 +66,7 @@ class DynaliteBase(Entity): "manufacturer": "Dynalite", } - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Added to hass so need to register to dispatch.""" # register for device specific update self._unsub_dispatchers.append( @@ -78,7 +85,7 @@ class DynaliteBase(Entity): ) ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Unregister signal dispatch listeners when being removed.""" for unsub in self._unsub_dispatchers: unsub() diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py index a5b7139803c..283b1ee2286 100755 --- a/homeassistant/components/dynalite/light.py +++ b/homeassistant/components/dynalite/light.py @@ -1,10 +1,16 @@ """Support for Dynalite channels as lights.""" +from typing import Callable + from homeassistant.components.light import SUPPORT_BRIGHTNESS, Light +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from .dynalitebase import DynaliteBase, async_setup_entry_base -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" async_setup_entry_base( @@ -16,24 +22,24 @@ class DynaliteLight(DynaliteBase, Light): """Representation of a Dynalite Channel as a Home Assistant Light.""" @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return self._device.brightness @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._device.is_on - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs) -> None: """Turn the light on.""" await self._device.async_turn_on(**kwargs) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs) -> None: """Turn the light off.""" await self._device.async_turn_off(**kwargs) @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_BRIGHTNESS diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json index d6351db17b2..a6ae0e96c45 100755 --- a/homeassistant/components/dynalite/manifest.json +++ b/homeassistant/components/dynalite/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/dynalite", "dependencies": [], "codeowners": ["@ziv1234"], - "requirements": ["dynalite_devices==0.1.32"] + "requirements": ["dynalite_devices==0.1.39"] } diff --git a/homeassistant/components/dynalite/switch.py b/homeassistant/components/dynalite/switch.py index 84be74cee36..45e24d8193a 100755 --- a/homeassistant/components/dynalite/switch.py +++ b/homeassistant/components/dynalite/switch.py @@ -1,10 +1,16 @@ """Support for the Dynalite channels and presets as switches.""" +from typing import Callable + from homeassistant.components.switch import SwitchDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from .dynalitebase import DynaliteBase, async_setup_entry_base -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" async_setup_entry_base( @@ -16,14 +22,14 @@ class DynaliteSwitch(DynaliteBase, SwitchDevice): """Representation of a Dynalite Channel as a Home Assistant Switch.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" return self._device.is_on - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs) -> None: """Turn the switch on.""" await self._device.async_turn_on() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs) -> None: """Turn the switch off.""" await self._device.async_turn_off() diff --git a/requirements_all.txt b/requirements_all.txt index 52010147131..66254138e51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -474,7 +474,7 @@ dsmr_parser==0.18 dweepy==0.3.0 # homeassistant.components.dynalite -dynalite_devices==0.1.32 +dynalite_devices==0.1.39 # homeassistant.components.rainforest_eagle eagle200_reader==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1977b187aa..73cea897aa4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -190,7 +190,7 @@ doorbirdpy==2.0.8 dsmr_parser==0.18 # homeassistant.components.dynalite -dynalite_devices==0.1.32 +dynalite_devices==0.1.39 # homeassistant.components.ee_brightbox eebrightbox==0.0.4 diff --git a/tests/components/dynalite/common.py b/tests/components/dynalite/common.py index 56554efaa07..b90e6120444 100755 --- a/tests/components/dynalite/common.py +++ b/tests/components/dynalite/common.py @@ -44,6 +44,7 @@ async def create_entity_from_device(hass, device): new_device_func = mock_dyn_dev.mock_calls[1][2]["new_device_func"] new_device_func([device]) await hass.async_block_till_done() + return mock_dyn_dev.mock_calls[1][2]["update_device_func"] async def run_service_tests(hass, device, platform, services): diff --git a/tests/components/dynalite/test_bridge.py b/tests/components/dynalite/test_bridge.py index 97759e96b69..938bc09f59a 100755 --- a/tests/components/dynalite/test_bridge.py +++ b/tests/components/dynalite/test_bridge.py @@ -61,8 +61,15 @@ async def test_add_devices_then_register(hass): device2.name = "NAME2" device2.unique_id = "unique2" new_device_func([device1, device2]) + device3 = Mock() + device3.category = "switch" + device3.name = "NAME3" + device3.unique_id = "unique3" + new_device_func([device3]) await hass.async_block_till_done() assert hass.states.get("light.name") + assert hass.states.get("switch.name2") + assert hass.states.get("switch.name3") async def test_register_then_add_devices(hass): @@ -89,3 +96,4 @@ async def test_register_then_add_devices(hass): new_device_func([device1, device2]) await hass.async_block_till_done() assert hass.states.get("light.name") + assert hass.states.get("switch.name2") diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py index 96e361e260f..1a1cdc16f49 100755 --- a/tests/components/dynalite/test_config_flow.py +++ b/tests/components/dynalite/test_config_flow.py @@ -1,6 +1,7 @@ """Test Dynalite config flow.""" from asynctest import CoroutineMock, patch +import pytest from homeassistant import config_entries from homeassistant.components import dynalite @@ -8,12 +9,20 @@ from homeassistant.components import dynalite from tests.common import MockConfigEntry -async def run_flow(hass, connection): +@pytest.mark.parametrize( + "first_con, second_con,exp_type, exp_result, exp_reason", + [ + (True, True, "create_entry", "loaded", ""), + (False, False, "abort", "", "no_connection"), + (True, False, "create_entry", "setup_retry", ""), + ], +) +async def test_flow(hass, first_con, second_con, exp_type, exp_result, exp_reason): """Run a flow with or without errors and return result.""" host = "1.2.3.4" with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", - side_effect=connection, + side_effect=[first_con, second_con], ): result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, @@ -21,35 +30,18 @@ async def run_flow(hass, connection): data={dynalite.CONF_HOST: host}, ) await hass.async_block_till_done() - return result - - -async def test_flow_works(hass): - """Test a successful config flow.""" - result = await run_flow(hass, [True, True]) - assert result["type"] == "create_entry" - assert result["result"].state == "loaded" - - -async def test_flow_setup_fails(hass): - """Test a flow where async_setup fails.""" - result = await run_flow(hass, [False]) - assert result["type"] == "abort" - assert result["reason"] == "no_connection" - - -async def test_flow_setup_fails_in_setup_entry(hass): - """Test a flow where the initial check works but inside setup_entry, the bridge setup fails.""" - result = await run_flow(hass, [True, False]) - assert result["type"] == "create_entry" - assert result["result"].state == "setup_retry" + assert result["type"] == exp_type + if exp_result: + assert result["result"].state == exp_result + if exp_reason: + assert result["reason"] == exp_reason async def test_existing(hass): """Test when the entry exists with the same config.""" host = "1.2.3.4" MockConfigEntry( - domain=dynalite.DOMAIN, unique_id=host, data={dynalite.CONF_HOST: host} + domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host} ).add_to_hass(hass) with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", @@ -81,7 +73,7 @@ async def test_existing_update(hass): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() mock_dyn_dev().configure.assert_called_once() - assert mock_dyn_dev().configure.mock_calls[0][1][0][dynalite.CONF_PORT] == port1 + assert mock_dyn_dev().configure.mock_calls[0][1][0]["port"] == port1 result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -89,6 +81,26 @@ async def test_existing_update(hass): ) await hass.async_block_till_done() assert mock_dyn_dev().configure.call_count == 2 - assert mock_dyn_dev().configure.mock_calls[1][1][0][dynalite.CONF_PORT] == port2 + assert mock_dyn_dev().configure.mock_calls[1][1][0]["port"] == port2 assert result["type"] == "abort" assert result["reason"] == "already_configured" + + +async def test_two_entries(hass): + """Test when two different entries exist with different hosts.""" + host1 = "1.2.3.4" + host2 = "5.6.7.8" + MockConfigEntry( + domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host1} + ).add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + dynalite.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={dynalite.CONF_HOST: host2}, + ) + assert result["type"] == "create_entry" + assert result["result"].state == "loaded" diff --git a/tests/components/dynalite/test_cover.py b/tests/components/dynalite/test_cover.py new file mode 100644 index 00000000000..cef4081c607 --- /dev/null +++ b/tests/components/dynalite/test_cover.py @@ -0,0 +1,100 @@ +"""Test Dynalite cover.""" +from dynalite_devices_lib.cover import DynaliteTimeCoverWithTiltDevice +import pytest + +from .common import ( + ATTR_ARGS, + ATTR_METHOD, + ATTR_SERVICE, + create_entity_from_device, + create_mock_device, + run_service_tests, +) + + +@pytest.fixture +def mock_device(): + """Mock a Dynalite device.""" + mock_dev = create_mock_device("cover", DynaliteTimeCoverWithTiltDevice) + mock_dev.device_class = "blind" + return mock_dev + + +async def test_cover_setup(hass, mock_device): + """Test a successful setup.""" + await create_entity_from_device(hass, mock_device) + entity_state = hass.states.get("cover.name") + assert entity_state.attributes["friendly_name"] == mock_device.name + assert ( + entity_state.attributes["current_position"] + == mock_device.current_cover_position + ) + assert ( + entity_state.attributes["current_tilt_position"] + == mock_device.current_cover_tilt_position + ) + assert entity_state.attributes["device_class"] == mock_device.device_class + await run_service_tests( + hass, + mock_device, + "cover", + [ + {ATTR_SERVICE: "open_cover", ATTR_METHOD: "async_open_cover"}, + {ATTR_SERVICE: "close_cover", ATTR_METHOD: "async_close_cover"}, + {ATTR_SERVICE: "stop_cover", ATTR_METHOD: "async_stop_cover"}, + { + ATTR_SERVICE: "set_cover_position", + ATTR_METHOD: "async_set_cover_position", + ATTR_ARGS: {"position": 50}, + }, + {ATTR_SERVICE: "open_cover_tilt", ATTR_METHOD: "async_open_cover_tilt"}, + {ATTR_SERVICE: "close_cover_tilt", ATTR_METHOD: "async_close_cover_tilt"}, + {ATTR_SERVICE: "stop_cover_tilt", ATTR_METHOD: "async_stop_cover_tilt"}, + { + ATTR_SERVICE: "set_cover_tilt_position", + ATTR_METHOD: "async_set_cover_tilt_position", + ATTR_ARGS: {"tilt_position": 50}, + }, + ], + ) + + +async def test_cover_without_tilt(hass, mock_device): + """Test a cover with no tilt.""" + mock_device.has_tilt = False + await create_entity_from_device(hass, mock_device) + await hass.services.async_call( + "cover", "open_cover_tilt", {"entity_id": "cover.name"}, blocking=True + ) + await hass.async_block_till_done() + mock_device.async_open_cover_tilt.assert_not_called() + + +async def check_cover_position( + hass, update_func, device, closing, opening, closed, expected +): + """Check that a given position behaves correctly.""" + device.is_closing = closing + device.is_opening = opening + device.is_closed = closed + update_func(device) + await hass.async_block_till_done() + entity_state = hass.states.get("cover.name") + assert entity_state.state == expected + + +async def test_cover_positions(hass, mock_device): + """Test that the state updates in the various positions.""" + update_func = await create_entity_from_device(hass, mock_device) + await check_cover_position( + hass, update_func, mock_device, True, False, False, "closing" + ) + await check_cover_position( + hass, update_func, mock_device, False, True, False, "opening" + ) + await check_cover_position( + hass, update_func, mock_device, False, False, True, "closed" + ) + await check_cover_position( + hass, update_func, mock_device, False, False, False, "open" + ) diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py index b74fcd64da0..8e2290a9c40 100755 --- a/tests/components/dynalite/test_init.py +++ b/tests/components/dynalite/test_init.py @@ -3,7 +3,8 @@ from asynctest import call, patch -from homeassistant.components import dynalite +import homeassistant.components.dynalite.const as dynalite +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_ROOM from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -17,8 +18,7 @@ async def test_empty_config(hass): async def test_async_setup(hass): - """Test a successful setup.""" - host = "1.2.3.4" + """Test a successful setup with all of the different options.""" with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", return_value=True, @@ -30,8 +30,46 @@ async def test_async_setup(hass): dynalite.DOMAIN: { dynalite.CONF_BRIDGES: [ { - dynalite.CONF_HOST: host, - dynalite.CONF_AREA: {"1": {dynalite.CONF_NAME: "Name"}}, + CONF_HOST: "1.2.3.4", + CONF_PORT: 1234, + dynalite.CONF_AUTO_DISCOVER: True, + dynalite.CONF_POLL_TIMER: 5.5, + dynalite.CONF_AREA: { + "1": { + CONF_NAME: "Name1", + dynalite.CONF_CHANNEL: {"4": {}}, + dynalite.CONF_NO_DEFAULT: True, + }, + "2": {CONF_NAME: "Name2"}, + "3": { + CONF_NAME: "Name3", + dynalite.CONF_TEMPLATE: CONF_ROOM, + }, + "4": { + CONF_NAME: "Name4", + dynalite.CONF_TEMPLATE: dynalite.CONF_TIME_COVER, + }, + }, + dynalite.CONF_DEFAULT: {dynalite.CONF_FADE: 2.3}, + dynalite.CONF_ACTIVE: dynalite.ACTIVE_INIT, + dynalite.CONF_PRESET: { + "5": {CONF_NAME: "pres5", dynalite.CONF_FADE: 4.5} + }, + dynalite.CONF_TEMPLATE: { + CONF_ROOM: { + dynalite.CONF_ROOM_ON: 6, + dynalite.CONF_ROOM_OFF: 7, + }, + dynalite.CONF_TIME_COVER: { + dynalite.CONF_OPEN_PRESET: 8, + dynalite.CONF_CLOSE_PRESET: 9, + dynalite.CONF_STOP_PRESET: 10, + dynalite.CONF_CHANNEL_COVER: 3, + dynalite.CONF_DURATION: 2.2, + dynalite.CONF_TILT_TIME: 3.3, + dynalite.CONF_DEVICE_CLASS: "awning", + }, + }, } ] } @@ -41,6 +79,35 @@ async def test_async_setup(hass): assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 1 +async def test_async_setup_bad_config1(hass): + """Test a successful with bad config on templates.""" + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert not await async_setup_component( + hass, + dynalite.DOMAIN, + { + dynalite.DOMAIN: { + dynalite.CONF_BRIDGES: [ + { + CONF_HOST: "1.2.3.4", + dynalite.CONF_AREA: { + "1": { + dynalite.CONF_TEMPLATE: dynalite.CONF_TIME_COVER, + CONF_NAME: "Name", + dynalite.CONF_ROOM_ON: 7, + } + }, + } + ] + } + }, + ) + await hass.async_block_till_done() + + async def test_async_setup_bad_config2(hass): """Test a successful with bad config on numbers.""" host = "1.2.3.4" @@ -55,8 +122,8 @@ async def test_async_setup_bad_config2(hass): dynalite.DOMAIN: { dynalite.CONF_BRIDGES: [ { - dynalite.CONF_HOST: host, - dynalite.CONF_AREA: {"WRONG": {dynalite.CONF_NAME: "Name"}}, + CONF_HOST: host, + dynalite.CONF_AREA: {"WRONG": {CONF_NAME: "Name"}}, } ] } @@ -69,7 +136,7 @@ async def test_async_setup_bad_config2(hass): async def test_unload_entry(hass): """Test being able to unload an entry.""" host = "1.2.3.4" - entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host}) + entry = MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host}) entry.add_to_hass(hass) with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",