From 432cbd31484ef9a82cc6c75fbc139dc58b9b18d2 Mon Sep 17 00:00:00 2001 From: lawtancool <26829131+lawtancool@users.noreply.github.com> Date: Sun, 19 Jul 2020 13:48:08 -0700 Subject: [PATCH] Add Control4 integration (#37632) Co-authored-by: J. Nick Koston Co-authored-by: Paulus Schoutsen --- .coveragerc | 4 + CODEOWNERS | 1 + homeassistant/components/control4/__init__.py | 226 ++++++++++++++++++ .../components/control4/config_flow.py | 171 +++++++++++++ homeassistant/components/control4/const.py | 18 ++ .../components/control4/director_utils.py | 62 +++++ homeassistant/components/control4/light.py | 206 ++++++++++++++++ .../components/control4/manifest.json | 13 + .../components/control4/strings.json | 31 +++ .../components/control4/translations/en.json | 31 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/ssdp.py | 5 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/control4/__init__.py | 1 + tests/components/control4/test_config_flow.py | 198 +++++++++++++++ 16 files changed, 974 insertions(+) create mode 100644 homeassistant/components/control4/__init__.py create mode 100644 homeassistant/components/control4/config_flow.py create mode 100644 homeassistant/components/control4/const.py create mode 100644 homeassistant/components/control4/director_utils.py create mode 100644 homeassistant/components/control4/light.py create mode 100644 homeassistant/components/control4/manifest.json create mode 100644 homeassistant/components/control4/strings.json create mode 100644 homeassistant/components/control4/translations/en.json create mode 100644 tests/components/control4/__init__.py create mode 100644 tests/components/control4/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 0ade0f20790..337842457f0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -139,6 +139,10 @@ omit = homeassistant/components/comfoconnect/* homeassistant/components/concord232/alarm_control_panel.py homeassistant/components/concord232/binary_sensor.py + homeassistant/components/control4/__init__.py + homeassistant/components/control4/light.py + homeassistant/components/control4/const.py + homeassistant/components/control4/director_utils.py homeassistant/components/coolmaster/__init__.py homeassistant/components/coolmaster/climate.py homeassistant/components/coolmaster/const.py diff --git a/CODEOWNERS b/CODEOWNERS index c224a61c068..f367da9325c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -77,6 +77,7 @@ homeassistant/components/cloudflare/* @ludeeus homeassistant/components/comfoconnect/* @michaelarnauts homeassistant/components/config/* @home-assistant/core homeassistant/components/configurator/* @home-assistant/core +homeassistant/components/control4/* @lawtancool homeassistant/components/conversation/* @home-assistant/core homeassistant/components/coolmaster/* @OnFreund homeassistant/components/coronavirus/* @home_assistant/core diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py new file mode 100644 index 00000000000..43af82678f4 --- /dev/null +++ b/homeassistant/components/control4/__init__.py @@ -0,0 +1,226 @@ +"""The Control4 integration.""" +import asyncio +import json +import logging + +from aiohttp import client_exceptions +from pyControl4.account import C4Account +from pyControl4.director import C4Director +from pyControl4.error_handling import BadCredentials + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_TOKEN, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, device_registry as dr, entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + CONF_ACCOUNT, + CONF_CONFIG_LISTENER, + CONF_CONTROLLER_UNIQUE_ID, + CONF_DIRECTOR, + CONF_DIRECTOR_ALL_ITEMS, + CONF_DIRECTOR_MODEL, + CONF_DIRECTOR_SW_VERSION, + CONF_DIRECTOR_TOKEN_EXPIRATION, + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["light"] + + +async def async_setup(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Stub to allow setting up this component. + + Configuration through YAML is not supported at this time. + """ + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Control4 from a config entry.""" + entry_data = hass.data[DOMAIN].setdefault(entry.entry_id, {}) + account_session = aiohttp_client.async_get_clientsession(hass) + + config = entry.data + account = C4Account(config[CONF_USERNAME], config[CONF_PASSWORD], account_session) + try: + await account.getAccountBearerToken() + except client_exceptions.ClientError as exception: + _LOGGER.error("Error connecting to Control4 account API: %s", exception) + raise ConfigEntryNotReady + except BadCredentials as exception: + _LOGGER.error( + "Error authenticating with Control4 account API, incorrect username or password: %s", + exception, + ) + return False + entry_data[CONF_ACCOUNT] = account + + controller_unique_id = config[CONF_CONTROLLER_UNIQUE_ID] + entry_data[CONF_CONTROLLER_UNIQUE_ID] = controller_unique_id + + director_token_dict = await account.getDirectorBearerToken(controller_unique_id) + director_session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) + + director = C4Director( + config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session + ) + entry_data[CONF_DIRECTOR] = director + entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] = director_token_dict["token_expiration"] + + # Add Control4 controller to device registry + controller_href = (await account.getAccountControllers())["href"] + entry_data[CONF_DIRECTOR_SW_VERSION] = await account.getControllerOSVersion( + controller_href + ) + + _, model, mac_address = controller_unique_id.split("_", 3) + entry_data[CONF_DIRECTOR_MODEL] = model.upper() + + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, controller_unique_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, + manufacturer="Control4", + name=controller_unique_id, + model=entry_data[CONF_DIRECTOR_MODEL], + sw_version=entry_data[CONF_DIRECTOR_SW_VERSION], + ) + + # Store all items found on controller for platforms to use + director_all_items = await director.getAllItemInfo() + director_all_items = json.loads(director_all_items) + entry_data[CONF_DIRECTOR_ALL_ITEMS] = director_all_items + + # Load options from config entry + entry_data[CONF_SCAN_INTERVAL] = entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) + + entry_data[CONF_CONFIG_LISTENER] = entry.add_update_listener(update_listener) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def update_listener(hass, config_entry): + """Update when config_entry options update.""" + _LOGGER.debug("Config entry was updated, rerunning setup") + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + hass.data[DOMAIN][entry.entry_id][CONF_CONFIG_LISTENER]() + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + _LOGGER.debug("Unloaded entry for %s", entry.entry_id) + + return unload_ok + + +async def get_items_of_category(hass: HomeAssistant, entry: ConfigEntry, category: str): + """Return a list of all Control4 items with the specified category.""" + director_all_items = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS] + return_list = [] + for item in director_all_items: + if "categories" in item and category in item["categories"]: + return_list.append(item) + return return_list + + +class Control4Entity(entity.Entity): + """Base entity for Control4.""" + + def __init__( + self, + entry_data: dict, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + name: str, + idx: int, + device_name: str, + device_manufacturer: str, + device_model: str, + device_id: int, + ): + """Initialize a Control4 entity.""" + self.entry = entry + self.account = entry_data[CONF_ACCOUNT] + self.director = entry_data[CONF_DIRECTOR] + self.director_token_expiry = entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] + self._name = name + self._idx = idx + self._coordinator = coordinator + self._controller_unique_id = entry_data[CONF_CONTROLLER_UNIQUE_ID] + self._device_name = device_name + self._device_manufacturer = device_manufacturer + self._device_model = device_model + self._device_id = device_id + + @property + def name(self): + """Return name of entity.""" + return self._name + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._idx + + @property + def device_info(self): + """Return info of parent Control4 device of entity.""" + return { + "config_entry_id": self.entry.entry_id, + "identifiers": {(DOMAIN, self._device_id)}, + "name": self._device_name, + "manufacturer": self._device_manufacturer, + "model": self._device_model, + "via_device": (DOMAIN, self._controller_unique_id), + } + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + @property + def available(self): + """Return if entity is available.""" + return self._coordinator.last_update_success + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self._coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update the state of the device.""" + await self._coordinator.async_request_refresh() diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py new file mode 100644 index 00000000000..03183edbfda --- /dev/null +++ b/homeassistant/components/control4/config_flow.py @@ -0,0 +1,171 @@ +"""Config flow for Control4 integration.""" +from asyncio import TimeoutError as asyncioTimeoutError +import logging + +from aiohttp.client_exceptions import ClientError +from pyControl4.account import C4Account +from pyControl4.director import C4Director +from pyControl4.error_handling import NotFound, Unauthorized +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.device_registry import format_mac + +from .const import CONF_CONTROLLER_UNIQUE_ID, DEFAULT_SCAN_INTERVAL, MIN_SCAN_INTERVAL +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class Control4Validator: + """Validates that config details can be used to authenticate and communicate with Control4.""" + + def __init__(self, host, username, password, hass): + """Initialize.""" + self.host = host + self.username = username + self.password = password + self.controller_unique_id = None + self.director_bearer_token = None + self.hass = hass + + async def authenticate(self) -> bool: + """Test if we can authenticate with the Control4 account API.""" + try: + account_session = aiohttp_client.async_get_clientsession(self.hass) + account = C4Account(self.username, self.password, account_session) + # Authenticate with Control4 account + await account.getAccountBearerToken() + + # Get controller name + account_controllers = await account.getAccountControllers() + self.controller_unique_id = account_controllers["controllerCommonName"] + + # Get bearer token to communicate with controller locally + self.director_bearer_token = ( + await account.getDirectorBearerToken(self.controller_unique_id) + )["token"] + return True + except (Unauthorized, NotFound): + return False + + async def connect_to_director(self) -> bool: + """Test if we can connect to the local Control4 Director.""" + try: + director_session = aiohttp_client.async_get_clientsession( + self.hass, verify_ssl=False + ) + director = C4Director( + self.host, self.director_bearer_token, director_session + ) + await director.getAllItemInfo() + return True + except (Unauthorized, ClientError, asyncioTimeoutError): + _LOGGER.error("Failed to connect to the Control4 controller") + return False + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Control4.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + + hub = Control4Validator( + user_input["host"], + user_input["username"], + user_input["password"], + self.hass, + ) + try: + if not await hub.authenticate(): + raise InvalidAuth + if not await hub.connect_to_director(): + raise CannotConnect + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + controller_unique_id = hub.controller_unique_id + mac = (controller_unique_id.split("_", 3))[2] + formatted_mac = format_mac(mac) + await self.async_set_unique_id(formatted_mac) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=controller_unique_id, + data={ + CONF_HOST: user_input["host"], + CONF_USERNAME: user_input["username"], + CONF_PASSWORD: user_input["password"], + CONF_CONTROLLER_UNIQUE_ID: controller_unique_id, + }, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for Control4.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL)), + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/control4/const.py b/homeassistant/components/control4/const.py new file mode 100644 index 00000000000..27590881985 --- /dev/null +++ b/homeassistant/components/control4/const.py @@ -0,0 +1,18 @@ +"""Constants for the Control4 integration.""" + +DOMAIN = "control4" + +DEFAULT_SCAN_INTERVAL = 5 +MIN_SCAN_INTERVAL = 1 + +CONF_ACCOUNT = "account" +CONF_DIRECTOR = "director" +CONF_DIRECTOR_TOKEN_EXPIRATION = "director_token_expiry" +CONF_DIRECTOR_SW_VERSION = "director_sw_version" +CONF_DIRECTOR_MODEL = "director_model" +CONF_DIRECTOR_ALL_ITEMS = "director_all_items" +CONF_CONTROLLER_UNIQUE_ID = "controller_unique_id" + +CONF_CONFIG_LISTENER = "config_listener" + +CONTROL4_ENTITY_TYPE = 7 diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py new file mode 100644 index 00000000000..fc4ca9e358d --- /dev/null +++ b/homeassistant/components/control4/director_utils.py @@ -0,0 +1,62 @@ +"""Provides data updates from the Control4 controller for platforms.""" +import logging + +from pyControl4.account import C4Account +from pyControl4.director import C4Director +from pyControl4.error_handling import BadToken + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import ( + CONF_ACCOUNT, + CONF_CONTROLLER_UNIQUE_ID, + CONF_DIRECTOR, + CONF_DIRECTOR_TOKEN_EXPIRATION, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def director_update_data( + hass: HomeAssistant, entry: ConfigEntry, var: str +) -> dict: + """Retrieve data from the Control4 director for update_coordinator.""" + # possibly implement usage of director_token_expiration to start + # token refresh without waiting for error to occur + try: + director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] + data = await director.getAllItemVariableValue(var) + except BadToken: + _LOGGER.info("Updating Control4 director token") + await refresh_tokens(hass, entry) + director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] + data = await director.getAllItemVariableValue(var) + return {key["id"]: key for key in data} + + +async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): + """Store updated authentication and director tokens in hass.data.""" + config = entry.data + account_session = aiohttp_client.async_get_clientsession(hass) + + account = C4Account(config[CONF_USERNAME], config[CONF_PASSWORD], account_session) + await account.getAccountBearerToken() + + controller_unique_id = config[CONF_CONTROLLER_UNIQUE_ID] + director_token_dict = await account.getDirectorBearerToken(controller_unique_id) + director_session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) + + director = C4Director( + config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session + ) + director_token_expiry = director_token_dict["token_expiration"] + + _LOGGER.debug("Saving new tokens in hass data") + entry_data = hass.data[DOMAIN][entry.entry_id] + entry_data[CONF_ACCOUNT] = account + entry_data[CONF_DIRECTOR] = director + entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] = director_token_expiry diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py new file mode 100644 index 00000000000..f121219fd36 --- /dev/null +++ b/homeassistant/components/control4/light.py @@ -0,0 +1,206 @@ +"""Platform for Control4 Lights.""" +import asyncio +from datetime import timedelta +import logging + +from pyControl4.error_handling import C4Exception +from pyControl4.light import C4Light + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, + SUPPORT_TRANSITION, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from . import Control4Entity, get_items_of_category +from .const import CONTROL4_ENTITY_TYPE, DOMAIN +from .director_utils import director_update_data + +_LOGGER = logging.getLogger(__name__) + +CONTROL4_CATEGORY = "lights" +CONTROL4_NON_DIMMER_VAR = "LIGHT_STATE" +CONTROL4_DIMMER_VAR = "LIGHT_LEVEL" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Set up Control4 lights from a config entry.""" + entry_data = hass.data[DOMAIN][entry.entry_id] + scan_interval = entry_data[CONF_SCAN_INTERVAL] + _LOGGER.debug( + "Scan interval = %s", scan_interval, + ) + + async def async_update_data_non_dimmer(): + """Fetch data from Control4 director for non-dimmer lights.""" + try: + return await director_update_data(hass, entry, CONTROL4_NON_DIMMER_VAR) + except C4Exception as err: + raise UpdateFailed(f"Error communicating with API: {err}") + + async def async_update_data_dimmer(): + """Fetch data from Control4 director for dimmer lights.""" + try: + return await director_update_data(hass, entry, CONTROL4_DIMMER_VAR) + except C4Exception as err: + raise UpdateFailed(f"Error communicating with API: {err}") + + non_dimmer_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="light", + update_method=async_update_data_non_dimmer, + update_interval=timedelta(seconds=scan_interval), + ) + dimmer_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="light", + update_method=async_update_data_dimmer, + update_interval=timedelta(seconds=scan_interval), + ) + + # Fetch initial data so we have data when entities subscribe + await non_dimmer_coordinator.async_refresh() + await dimmer_coordinator.async_refresh() + + items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY) + for item in items_of_category: + if item["type"] == CONTROL4_ENTITY_TYPE: + item_name = item["name"] + item_id = item["id"] + item_parent_id = item["parentId"] + item_is_dimmer = item["capabilities"]["dimmer"] + + if item_is_dimmer: + item_coordinator = dimmer_coordinator + else: + item_coordinator = non_dimmer_coordinator + + for parent_item in items_of_category: + if parent_item["id"] == item_parent_id: + item_manufacturer = parent_item["manufacturer"] + item_device_name = parent_item["name"] + item_model = parent_item["model"] + async_add_entities( + [ + Control4Light( + entry_data, + entry, + item_coordinator, + item_name, + item_id, + item_device_name, + item_manufacturer, + item_model, + item_parent_id, + item_is_dimmer, + ) + ], + True, + ) + + +class Control4Light(Control4Entity, LightEntity): + """Control4 light entity.""" + + def __init__( + self, + entry_data: dict, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + name: str, + idx: int, + device_name: str, + device_manufacturer: str, + device_model: str, + device_id: int, + is_dimmer: bool, + ): + """Initialize Control4 light entity.""" + super().__init__( + entry_data, + entry, + coordinator, + name, + idx, + device_name, + device_manufacturer, + device_model, + device_id, + ) + self._is_dimmer = is_dimmer + self._c4_light = None + + async def async_added_to_hass(self): + """When entity is added to hass.""" + await super().async_added_to_hass() + self._c4_light = C4Light(self.director, self._idx) + + @property + def is_on(self): + """Return whether this light is on or off.""" + return self._coordinator.data[self._idx]["value"] > 0 + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + if self._is_dimmer: + return round(self._coordinator.data[self._idx]["value"] * 2.55) + return None + + @property + def supported_features(self) -> int: + """Flag supported features.""" + flags = 0 + if self._is_dimmer: + flags |= SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION + return flags + + async def async_turn_on(self, **kwargs) -> None: + """Turn the entity on.""" + if self._is_dimmer: + if ATTR_TRANSITION in kwargs: + transition_length = kwargs[ATTR_TRANSITION] * 1000 + else: + transition_length = 0 + if ATTR_BRIGHTNESS in kwargs: + brightness = (kwargs[ATTR_BRIGHTNESS] / 255) * 100 + else: + brightness = 100 + await self._c4_light.rampToLevel(brightness, transition_length) + else: + transition_length = 0 + await self._c4_light.setLevel(100) + if transition_length == 0: + transition_length = 1000 + delay_time = (transition_length / 1000) + 0.7 + _LOGGER.debug("Delaying light update by %s seconds", delay_time) + await asyncio.sleep(delay_time) + await self._coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + if self._is_dimmer: + if ATTR_TRANSITION in kwargs: + transition_length = kwargs[ATTR_TRANSITION] * 1000 + else: + transition_length = 0 + await self._c4_light.rampToLevel(0, transition_length) + else: + transition_length = 0 + await self._c4_light.setLevel(0) + if transition_length == 0: + transition_length = 1500 + delay_time = (transition_length / 1000) + 0.7 + _LOGGER.debug("Delaying light update by %s seconds", delay_time) + await asyncio.sleep(delay_time) + await self._coordinator.async_request_refresh() diff --git a/homeassistant/components/control4/manifest.json b/homeassistant/components/control4/manifest.json new file mode 100644 index 00000000000..0d61b080745 --- /dev/null +++ b/homeassistant/components/control4/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "control4", + "name": "Control4", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/control4", + "requirements": ["pyControl4==0.0.6"], + "ssdp": [ + { + "st": "c4:director" + } + ], + "codeowners": ["@lawtancool"] +} diff --git a/homeassistant/components/control4/strings.json b/homeassistant/components/control4/strings.json new file mode 100644 index 00000000000..34331bc18fa --- /dev/null +++ b/homeassistant/components/control4/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "description": "Please enter your Control4 account details and the IP address of your local controller." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Seconds between updates" + } + } + } + } +} diff --git a/homeassistant/components/control4/translations/en.json b/homeassistant/components/control4/translations/en.json new file mode 100644 index 00000000000..035be90356a --- /dev/null +++ b/homeassistant/components/control4/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "password": "[%key:common::config_flow::data::username%]", + "username": "[%key:common::config_flow::data::password%]" + }, + "description": "Please enter your Control4 account details and the IP address of your local controller." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Seconds between updates" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 23fdc656af6..c1dbd1d05c0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -29,6 +29,7 @@ FLOWS = [ "bsblan", "cast", "cert_expiry", + "control4", "coolmaster", "coronavirus", "daikin", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 7271252c36f..d58842fe88e 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -12,6 +12,11 @@ SSDP = { "manufacturer": "ARCAM" } ], + "control4": [ + { + "st": "c4:director" + } + ], "deconz": [ { "manufacturer": "Royal Philips Electronics" diff --git a/requirements_all.txt b/requirements_all.txt index b1c8741f0cc..a8115a94796 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1167,6 +1167,9 @@ py17track==2.2.2 # homeassistant.components.hdmi_cec pyCEC==0.4.13 +# homeassistant.components.control4 +pyControl4==0.0.6 + # homeassistant.components.tplink pyHS100==0.3.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index acc69829b71..4fc18960e2b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -539,6 +539,9 @@ py-melissa-climate==2.0.0 # homeassistant.components.seventeentrack py17track==2.2.2 +# homeassistant.components.control4 +pyControl4==0.0.6 + # homeassistant.components.tplink pyHS100==0.3.5.1 diff --git a/tests/components/control4/__init__.py b/tests/components/control4/__init__.py new file mode 100644 index 00000000000..8995968d5dd --- /dev/null +++ b/tests/components/control4/__init__.py @@ -0,0 +1 @@ +"""Tests for the Control4 integration.""" diff --git a/tests/components/control4/test_config_flow.py b/tests/components/control4/test_config_flow.py new file mode 100644 index 00000000000..6d3293b147a --- /dev/null +++ b/tests/components/control4/test_config_flow.py @@ -0,0 +1,198 @@ +"""Test the Control4 config flow.""" +import datetime + +from pyControl4.account import C4Account +from pyControl4.director import C4Director +from pyControl4.error_handling import Unauthorized + +from homeassistant import config_entries, setup +from homeassistant.components.control4.const import DEFAULT_SCAN_INTERVAL, DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) + +from tests.async_mock import AsyncMock, patch +from tests.common import MockConfigEntry + + +def _get_mock_c4_account( + getAccountControllers={ + "controllerCommonName": "control4_model_00AA00AA00AA", + "href": "https://apis.control4.com/account/v3/rest/accounts/000000", + "name": "Name", + }, + getDirectorBearerToken={ + "token": "token", + "token_expiration": datetime.datetime(2020, 7, 15, 13, 50, 15, 26940), + }, +): + c4_account_mock = AsyncMock(C4Account) + + c4_account_mock.getAccountControllers.return_value = getAccountControllers + c4_account_mock.getDirectorBearerToken.return_value = getDirectorBearerToken + + return c4_account_mock + + +def _get_mock_c4_director(getAllItemInfo={}): + c4_director_mock = AsyncMock(C4Director) + c4_director_mock.getAllItemInfo.return_value = getAllItemInfo + + return c4_director_mock + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + c4_account = _get_mock_c4_account() + c4_director = _get_mock_c4_director() + with patch( + "homeassistant.components.control4.config_flow.C4Account", + return_value=c4_account, + ), patch( + "homeassistant.components.control4.config_flow.C4Director", + return_value=c4_director, + ), patch( + "homeassistant.components.control4.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.control4.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "control4_model_00AA00AA00AA" + assert result2["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + "controller_unique_id": "control4_model_00AA00AA00AA", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.control4.config_flow.C4Account", + side_effect=Unauthorized("message"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_unexpected_exception(hass): + """Test we handle an unexpected exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.control4.config_flow.C4Account", + side_effect=ValueError("message"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.control4.config_flow.Control4Validator.authenticate", + return_value=True, + ), patch( + "homeassistant.components.control4.config_flow.C4Director", + side_effect=Unauthorized("message"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_option_flow(hass): + """Test config flow options.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_SCAN_INTERVAL: 4}, + ) + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_SCAN_INTERVAL: 4, + } + + +async def test_option_flow_defaults(hass): + """Test config flow options.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + }