mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 00:37:53 +00:00
Honeywell Lyric Integration (#39695)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
ee55223065
commit
2d10c83150
@ -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/*
|
||||
|
@ -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
|
||||
|
200
homeassistant/components/lyric/__init__.py
Normal file
200
homeassistant/components/lyric/__init__.py
Normal file
@ -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,
|
||||
}
|
55
homeassistant/components/lyric/api.py
Normal file
55
homeassistant/components/lyric/api.py
Normal file
@ -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())
|
280
homeassistant/components/lyric/climate.py
Normal file
280
homeassistant/components/lyric/climate.py
Normal file
@ -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()
|
23
homeassistant/components/lyric/config_flow.py
Normal file
23
homeassistant/components/lyric/config_flow.py
Normal file
@ -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__)
|
20
homeassistant/components/lyric/const.py
Normal file
20
homeassistant/components/lyric/const.py
Normal file
@ -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,
|
||||
)
|
24
homeassistant/components/lyric/manifest.json
Normal file
24
homeassistant/components/lyric/manifest.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
9
homeassistant/components/lyric/services.yaml
Normal file
9
homeassistant/components/lyric/services.yaml
Normal file
@ -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"
|
16
homeassistant/components/lyric/strings.json
Normal file
16
homeassistant/components/lyric/strings.json
Normal file
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
17
homeassistant/components/lyric/translations/en.json
Normal file
17
homeassistant/components/lyric/translations/en.json
Normal file
@ -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"
|
||||
}
|
@ -121,6 +121,7 @@ FLOWS = [
|
||||
"logi_circle",
|
||||
"luftdaten",
|
||||
"lutron_caseta",
|
||||
"lyric",
|
||||
"mailgun",
|
||||
"melcloud",
|
||||
"met",
|
||||
|
@ -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*"
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
1
tests/components/lyric/__init__.py
Normal file
1
tests/components/lyric/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the Honeywell Lyric integration."""
|
128
tests/components/lyric/test_config_flow.py
Normal file
128
tests/components/lyric/test_config_flow.py
Normal file
@ -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"
|
Loading…
x
Reference in New Issue
Block a user