diff --git a/.coveragerc b/.coveragerc index c692dfbba5e..65b499c372f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -518,6 +518,9 @@ omit = homeassistant/components/lutron_caseta/switch.py homeassistant/components/lw12wifi/light.py homeassistant/components/lyft/sensor.py + homeassistant/components/lyric/__init__.py + homeassistant/components/lyric/api.py + homeassistant/components/lyric/climate.py homeassistant/components/magicseaweed/sensor.py homeassistant/components/mailgun/notify.py homeassistant/components/map/* diff --git a/CODEOWNERS b/CODEOWNERS index e3cf58f9056..dc0129c8b8e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -260,6 +260,7 @@ homeassistant/components/luftdaten/* @fabaff homeassistant/components/lupusec/* @majuss homeassistant/components/lutron/* @JonGilmore homeassistant/components/lutron_caseta/* @swails @bdraco +homeassistant/components/lyric/* @timmo001 homeassistant/components/mastodon/* @fabaff homeassistant/components/matrix/* @tinloaf homeassistant/components/mcp23017/* @jardiamj diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py new file mode 100644 index 00000000000..d29fca09166 --- /dev/null +++ b/homeassistant/components/lyric/__init__.py @@ -0,0 +1,200 @@ +"""The Honeywell Lyric integration.""" +import asyncio +from datetime import timedelta +import logging +from typing import Any, Dict, Optional + +from aiolyric import Lyric +from aiolyric.objects.device import LyricDevice +from aiolyric.objects.location import LyricLocation +import async_timeout +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + config_validation as cv, + device_registry as dr, +) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .api import ConfigEntryLyricClient, LyricLocalOAuth2Implementation +from .config_flow import OAuth2FlowHandler +from .const import DOMAIN, LYRIC_EXCEPTIONS, OAUTH2_AUTHORIZE, OAUTH2_TOKEN + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["climate"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Honeywell Lyric component.""" + hass.data[DOMAIN] = {} + + if DOMAIN not in config: + return True + + hass.data[DOMAIN][CONF_CLIENT_ID] = config[DOMAIN][CONF_CLIENT_ID] + + OAuth2FlowHandler.async_register_implementation( + hass, + LyricLocalOAuth2Implementation( + hass, + DOMAIN, + config[DOMAIN][CONF_CLIENT_ID], + config[DOMAIN][CONF_CLIENT_SECRET], + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + ), + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Honeywell Lyric from a config entry.""" + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + + session = aiohttp_client.async_get_clientsession(hass) + oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + + client = ConfigEntryLyricClient(session, oauth_session) + + client_id = hass.data[DOMAIN][CONF_CLIENT_ID] + lyric: Lyric = Lyric(client, client_id) + + async def async_update_data() -> Lyric: + """Fetch data from Lyric.""" + try: + async with async_timeout.timeout(60): + await lyric.get_locations() + return lyric + except (*LYRIC_EXCEPTIONS, TimeoutError) as exception: + raise UpdateFailed(exception) from exception + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="lyric_coordinator", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=120), + ) + + hass.data[DOMAIN][entry.entry_id] = coordinator + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +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 + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class LyricEntity(CoordinatorEntity): + """Defines a base Honeywell Lyric entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + location: LyricLocation, + device: LyricDevice, + key: str, + name: str, + icon: Optional[str], + ) -> None: + """Initialize the Honeywell Lyric entity.""" + super().__init__(coordinator) + self._key = key + self._name = name + self._icon = icon + self._location = location + self._mac_id = device.macID + self._device_name = device.name + self._device_model = device.deviceModel + self._update_thermostat = coordinator.data.update_thermostat + + @property + def unique_id(self) -> str: + """Return the unique ID for this entity.""" + return self._key + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def location(self) -> LyricLocation: + """Get the Lyric Location.""" + return self.coordinator.data.locations_dict[self._location.locationID] + + @property + def device(self) -> LyricDevice: + """Get the Lyric Device.""" + return self.location.devices_dict[self._mac_id] + + +class LyricDeviceEntity(LyricEntity): + """Defines a Honeywell Lyric device entity.""" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this Honeywell Lyric instance.""" + return { + "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac_id)}, + "manufacturer": "Honeywell", + "model": self._device_model, + "name": self._device_name, + } diff --git a/homeassistant/components/lyric/api.py b/homeassistant/components/lyric/api.py new file mode 100644 index 00000000000..a77c6365baf --- /dev/null +++ b/homeassistant/components/lyric/api.py @@ -0,0 +1,55 @@ +"""API for Honeywell Lyric bound to Home Assistant OAuth.""" +import logging +from typing import cast + +from aiohttp import BasicAuth, ClientSession +from aiolyric.client import LyricClient + +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + + +class ConfigEntryLyricClient(LyricClient): + """Provide Honeywell Lyric authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ): + """Initialize Honeywell Lyric auth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self): + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return self._oauth_session.token["access_token"] + + +class LyricLocalOAuth2Implementation( + config_entry_oauth2_flow.LocalOAuth2Implementation +): + """Lyric Local OAuth2 implementation.""" + + async def _token_request(self, data: dict) -> dict: + """Make a token request.""" + session = async_get_clientsession(self.hass) + + data["client_id"] = self.client_id + + if self.client_secret is not None: + data["client_secret"] = self.client_secret + + headers = { + "Authorization": BasicAuth(self.client_id, self.client_secret).encode(), + "Content-Type": "application/x-www-form-urlencoded", + } + + resp = await session.post(self.token_url, headers=headers, data=data) + resp.raise_for_status() + return cast(dict, await resp.json()) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py new file mode 100644 index 00000000000..bcfefef9c93 --- /dev/null +++ b/homeassistant/components/lyric/climate.py @@ -0,0 +1,280 @@ +"""Support for Honeywell Lyric climate platform.""" +import logging +from time import gmtime, strftime, time +from typing import List, Optional + +from aiolyric.objects.device import LyricDevice +from aiolyric.objects.location import LyricLocation +import voluptuous as vol + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import LyricDeviceEntity +from .const import ( + DOMAIN, + LYRIC_EXCEPTIONS, + PRESET_HOLD_UNTIL, + PRESET_NO_HOLD, + PRESET_PERMANENT_HOLD, + PRESET_TEMPORARY_HOLD, + PRESET_VACATION_HOLD, +) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + +LYRIC_HVAC_MODE_OFF = "Off" +LYRIC_HVAC_MODE_HEAT = "Heat" +LYRIC_HVAC_MODE_COOL = "Cool" +LYRIC_HVAC_MODE_HEAT_COOL = "Auto" + +LYRIC_HVAC_MODES = { + HVAC_MODE_OFF: LYRIC_HVAC_MODE_OFF, + HVAC_MODE_HEAT: LYRIC_HVAC_MODE_HEAT, + HVAC_MODE_COOL: LYRIC_HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL: LYRIC_HVAC_MODE_HEAT_COOL, +} + +HVAC_MODES = { + LYRIC_HVAC_MODE_OFF: HVAC_MODE_OFF, + LYRIC_HVAC_MODE_HEAT: HVAC_MODE_HEAT, + LYRIC_HVAC_MODE_COOL: HVAC_MODE_COOL, + LYRIC_HVAC_MODE_HEAT_COOL: HVAC_MODE_HEAT_COOL, +} + +SERVICE_HOLD_TIME = "set_hold_time" +ATTR_TIME_PERIOD = "time_period" + +SCHEMA_HOLD_TIME = { + vol.Required(ATTR_TIME_PERIOD, default="01:00:00"): vol.All( + cv.time_period, + cv.positive_timedelta, + lambda td: strftime("%H:%M:%S", gmtime(time() + td.total_seconds())), + ) +} + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Honeywell Lyric climate platform based on a config entry.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [] + + for location in coordinator.data.locations: + for device in location.devices: + entities.append(LyricClimate(hass, coordinator, location, device)) + + async_add_entities(entities, True) + + platform = entity_platform.current_platform.get() + + platform.async_register_entity_service( + SERVICE_HOLD_TIME, + SCHEMA_HOLD_TIME, + "async_set_hold_time", + ) + + +class LyricClimate(LyricDeviceEntity, ClimateEntity): + """Defines a Honeywell Lyric climate entity.""" + + def __init__( + self, + hass: HomeAssistantType, + coordinator: DataUpdateCoordinator, + location: LyricLocation, + device: LyricDevice, + ) -> None: + """Initialize Honeywell Lyric climate entity.""" + self._temperature_unit = hass.config.units.temperature_unit + + # Setup supported hvac modes + self._hvac_modes = [HVAC_MODE_OFF] + + # Add supported lyric thermostat features + if LYRIC_HVAC_MODE_HEAT in device.allowedModes: + self._hvac_modes.append(HVAC_MODE_HEAT) + + if LYRIC_HVAC_MODE_COOL in device.allowedModes: + self._hvac_modes.append(HVAC_MODE_COOL) + + if ( + LYRIC_HVAC_MODE_HEAT in device.allowedModes + and LYRIC_HVAC_MODE_COOL in device.allowedModes + ): + self._hvac_modes.append(HVAC_MODE_HEAT_COOL) + + super().__init__( + coordinator, + location, + device, + f"{device.macID}_thermostat", + device.name, + None, + ) + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + return self._temperature_unit + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self.device.indoorTemperature + + @property + def hvac_mode(self) -> str: + """Return the hvac mode.""" + return HVAC_MODES[self.device.changeableValues.mode] + + @property + def hvac_modes(self) -> List[str]: + """List of available hvac modes.""" + return self._hvac_modes + + @property + def target_temperature(self) -> Optional[float]: + """Return the temperature we try to reach.""" + device: LyricDevice = self.device + if not device.hasDualSetpointStatus: + return device.changeableValues.heatSetpoint + + @property + def target_temperature_low(self) -> Optional[float]: + """Return the upper bound temperature we try to reach.""" + device: LyricDevice = self.device + if device.hasDualSetpointStatus: + return device.changeableValues.coolSetpoint + + @property + def target_temperature_high(self) -> Optional[float]: + """Return the upper bound temperature we try to reach.""" + device: LyricDevice = self.device + if device.hasDualSetpointStatus: + return device.changeableValues.heatSetpoint + + @property + def preset_mode(self) -> Optional[str]: + """Return current preset mode.""" + return self.device.changeableValues.thermostatSetpointStatus + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return preset modes.""" + return [ + PRESET_NO_HOLD, + PRESET_HOLD_UNTIL, + PRESET_PERMANENT_HOLD, + PRESET_TEMPORARY_HOLD, + PRESET_VACATION_HOLD, + ] + + @property + def min_temp(self) -> float: + """Identify min_temp in Lyric API or defaults if not available.""" + device: LyricDevice = self.device + if LYRIC_HVAC_MODE_COOL in device.allowedModes: + return device.minCoolSetpoint + return device.minHeatSetpoint + + @property + def max_temp(self) -> float: + """Identify max_temp in Lyric API or defaults if not available.""" + device: LyricDevice = self.device + if LYRIC_HVAC_MODE_HEAT in device.allowedModes: + return device.maxHeatSetpoint + return device.maxCoolSetpoint + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + + device: LyricDevice = self.device + if device.hasDualSetpointStatus: + if target_temp_low is not None and target_temp_high is not None: + temp = (target_temp_low, target_temp_high) + else: + raise HomeAssistantError( + "Could not find target_temp_low and/or target_temp_high in arguments" + ) + else: + temp = kwargs.get(ATTR_TEMPERATURE) + _LOGGER.debug("Set temperature: %s", temp) + try: + await self._update_thermostat(self.location, device, heatSetpoint=temp) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + await self.coordinator.async_refresh() + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set hvac mode.""" + _LOGGER.debug("Set hvac mode: %s", hvac_mode) + try: + await self._update_thermostat( + self.location, self.device, mode=LYRIC_HVAC_MODES[hvac_mode] + ) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + await self.coordinator.async_refresh() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set preset (PermanentHold, HoldUntil, NoHold, VacationHold) mode.""" + _LOGGER.debug("Set preset mode: %s", preset_mode) + try: + await self._update_thermostat( + self.location, self.device, thermostatSetpointStatus=preset_mode + ) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + await self.coordinator.async_refresh() + + async def async_set_preset_period(self, period: str) -> None: + """Set preset period (time).""" + try: + await self._update_thermostat( + self.location, self.device, nextPeriodTime=period + ) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + await self.coordinator.async_refresh() + + async def async_set_hold_time(self, time_period: str) -> None: + """Set the time to hold until.""" + _LOGGER.debug("set_hold_time: %s", time_period) + try: + await self._update_thermostat( + self.location, + self.device, + thermostatSetpointStatus=PRESET_HOLD_UNTIL, + nextPeriodTime=time_period, + ) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/lyric/config_flow.py b/homeassistant/components/lyric/config_flow.py new file mode 100644 index 00000000000..1370d5e67ea --- /dev/null +++ b/homeassistant/components/lyric/config_flow.py @@ -0,0 +1,23 @@ +"""Config flow for Honeywell Lyric.""" +import logging + +from homeassistant import config_entries +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Honeywell Lyric OAuth2 authentication.""" + + DOMAIN = DOMAIN + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) diff --git a/homeassistant/components/lyric/const.py b/homeassistant/components/lyric/const.py new file mode 100644 index 00000000000..4f2f72b937b --- /dev/null +++ b/homeassistant/components/lyric/const.py @@ -0,0 +1,20 @@ +"""Constants for the Honeywell Lyric integration.""" +from aiohttp.client_exceptions import ClientResponseError +from aiolyric.exceptions import LyricAuthenticationException, LyricException + +DOMAIN = "lyric" + +OAUTH2_AUTHORIZE = "https://api.honeywell.com/oauth2/authorize" +OAUTH2_TOKEN = "https://api.honeywell.com/oauth2/token" + +PRESET_NO_HOLD = "NoHold" +PRESET_TEMPORARY_HOLD = "TemporaryHold" +PRESET_HOLD_UNTIL = "HoldUntil" +PRESET_PERMANENT_HOLD = "PermanentHold" +PRESET_VACATION_HOLD = "VacationHold" + +LYRIC_EXCEPTIONS = ( + LyricAuthenticationException, + LyricException, + ClientResponseError, +) diff --git a/homeassistant/components/lyric/manifest.json b/homeassistant/components/lyric/manifest.json new file mode 100644 index 00000000000..460eb6e2a3d --- /dev/null +++ b/homeassistant/components/lyric/manifest.json @@ -0,0 +1,24 @@ +{ + "domain": "lyric", + "name": "Honeywell Lyric", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/lyric", + "dependencies": ["http"], + "requirements": ["aiolyric==1.0.5"], + "codeowners": ["@timmo001"], + "quality_scale": "silver", + "dhcp": [ + { + "hostname": "lyric-*", + "macaddress": "48A2E6" + }, + { + "hostname": "lyric-*", + "macaddress": "B82CA0" + }, + { + "hostname": "lyric-*", + "macaddress": "00D02D" + } + ] +} diff --git a/homeassistant/components/lyric/services.yaml b/homeassistant/components/lyric/services.yaml new file mode 100644 index 00000000000..b4ea74a9644 --- /dev/null +++ b/homeassistant/components/lyric/services.yaml @@ -0,0 +1,9 @@ +set_hold_time: + description: "Sets the time to hold until" + fields: + entity_id: + description: Name(s) of entities to change + example: "climate.thermostat" + time_period: + description: Time to hold until + example: "01:00:00" diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json new file mode 100644 index 00000000000..4e5f2330840 --- /dev/null +++ b/homeassistant/components/lyric/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/components/lyric/translations/en.json b/homeassistant/components/lyric/translations/en.json new file mode 100644 index 00000000000..b183398663e --- /dev/null +++ b/homeassistant/components/lyric/translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "authorize_url_timeout": "Timeout generating authorize URL.", + "missing_configuration": "The component is not configured. Please follow the documentation." + }, + "create_entry": { + "default": "Successfully authenticated" + }, + "step": { + "pick_implementation": { + "title": "Pick Authentication Method" + } + } + }, + "title": "Honeywell Lyric" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4c12ff30e49..282d039128d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -121,6 +121,7 @@ FLOWS = [ "logi_circle", "luftdaten", "lutron_caseta", + "lyric", "mailgun", "melcloud", "met", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 0b6f5166f88..61223bf00f7 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -41,6 +41,21 @@ DHCP = [ "hostname": "flume-gw-*", "macaddress": "B4E62D*" }, + { + "domain": "lyric", + "hostname": "lyric-*", + "macaddress": "48A2E6" + }, + { + "domain": "lyric", + "hostname": "lyric-*", + "macaddress": "B82CA0" + }, + { + "domain": "lyric", + "hostname": "lyric-*", + "macaddress": "00D02D" + }, { "domain": "nest", "macaddress": "18B430*" diff --git a/requirements_all.txt b/requirements_all.txt index d41b4c8fe0a..a8127ed7097 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -199,6 +199,9 @@ aiolifx_effects==0.2.2 # homeassistant.components.lutron_caseta aiolip==1.0.1 +# homeassistant.components.lyric +aiolyric==1.0.5 + # homeassistant.components.keyboard_remote aionotify==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2847be80a2..3e8972c9864 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -118,6 +118,9 @@ aiokafka==0.6.0 # homeassistant.components.lutron_caseta aiolip==1.0.1 +# homeassistant.components.lyric +aiolyric==1.0.5 + # homeassistant.components.notion aionotion==1.1.0 diff --git a/tests/components/lyric/__init__.py b/tests/components/lyric/__init__.py new file mode 100644 index 00000000000..794c6bf1ba0 --- /dev/null +++ b/tests/components/lyric/__init__.py @@ -0,0 +1 @@ +"""Tests for the Honeywell Lyric integration.""" diff --git a/tests/components/lyric/test_config_flow.py b/tests/components/lyric/test_config_flow.py new file mode 100644 index 00000000000..24b6b68d731 --- /dev/null +++ b/tests/components/lyric/test_config_flow.py @@ -0,0 +1,128 @@ +"""Test the Honeywell Lyric config flow.""" +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.http import CONF_BASE_URL, DOMAIN as DOMAIN_HTTP +from homeassistant.components.lyric import config_flow +from homeassistant.components.lyric.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.helpers import config_entry_oauth2_flow + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.fixture() +async def mock_impl(hass): + """Mock implementation.""" + await setup.async_setup_component(hass, "http", {}) + + impl = config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + DOMAIN, + CLIENT_ID, + CLIENT_SECRET, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + ) + config_flow.OAuth2FlowHandler.async_register_implementation(hass, impl) + return impl + + +async def test_abort_if_no_configuration(hass): + """Check flow abort when no configuration.""" + flow = config_flow.OAuth2FlowHandler() + flow.hass = hass + result = await flow.async_step_user() + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "missing_configuration" + + +async def test_full_flow( + hass, aiohttp_client, aioclient_mock, current_request_with_host +): + """Check full flow.""" + assert await setup.async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + }, + DOMAIN_HTTP: {CONF_BASE_URL: "https://example.com"}, + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await aiohttp_client(hass.http.app) + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch("homeassistant.components.lyric.api.ConfigEntryLyricClient"): + with patch( + "homeassistant.components.lyric.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["data"]["auth_implementation"] == DOMAIN + + result["data"]["token"].pop("expires_at") + assert result["data"]["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + + assert DOMAIN in hass.config.components + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state == config_entries.ENTRY_STATE_LOADED + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + +async def test_abort_if_authorization_timeout(hass, mock_impl): + """Check Somfy authorization timeout.""" + flow = config_flow.OAuth2FlowHandler() + flow.hass = hass + + with patch.object( + mock_impl, "async_generate_authorize_url", side_effect=asyncio.TimeoutError + ): + result = await flow.async_step_user() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "authorize_url_timeout"