Honeywell Lyric Integration (#39695)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
ee55223065
commit
2d10c83150
17 changed files with 799 additions and 0 deletions
|
@ -518,6 +518,9 @@ omit =
|
||||||
homeassistant/components/lutron_caseta/switch.py
|
homeassistant/components/lutron_caseta/switch.py
|
||||||
homeassistant/components/lw12wifi/light.py
|
homeassistant/components/lw12wifi/light.py
|
||||||
homeassistant/components/lyft/sensor.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/magicseaweed/sensor.py
|
||||||
homeassistant/components/mailgun/notify.py
|
homeassistant/components/mailgun/notify.py
|
||||||
homeassistant/components/map/*
|
homeassistant/components/map/*
|
||||||
|
|
|
@ -260,6 +260,7 @@ homeassistant/components/luftdaten/* @fabaff
|
||||||
homeassistant/components/lupusec/* @majuss
|
homeassistant/components/lupusec/* @majuss
|
||||||
homeassistant/components/lutron/* @JonGilmore
|
homeassistant/components/lutron/* @JonGilmore
|
||||||
homeassistant/components/lutron_caseta/* @swails @bdraco
|
homeassistant/components/lutron_caseta/* @swails @bdraco
|
||||||
|
homeassistant/components/lyric/* @timmo001
|
||||||
homeassistant/components/mastodon/* @fabaff
|
homeassistant/components/mastodon/* @fabaff
|
||||||
homeassistant/components/matrix/* @tinloaf
|
homeassistant/components/matrix/* @tinloaf
|
||||||
homeassistant/components/mcp23017/* @jardiamj
|
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",
|
"logi_circle",
|
||||||
"luftdaten",
|
"luftdaten",
|
||||||
"lutron_caseta",
|
"lutron_caseta",
|
||||||
|
"lyric",
|
||||||
"mailgun",
|
"mailgun",
|
||||||
"melcloud",
|
"melcloud",
|
||||||
"met",
|
"met",
|
||||||
|
|
|
@ -41,6 +41,21 @@ DHCP = [
|
||||||
"hostname": "flume-gw-*",
|
"hostname": "flume-gw-*",
|
||||||
"macaddress": "B4E62D*"
|
"macaddress": "B4E62D*"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"domain": "lyric",
|
||||||
|
"hostname": "lyric-*",
|
||||||
|
"macaddress": "48A2E6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"domain": "lyric",
|
||||||
|
"hostname": "lyric-*",
|
||||||
|
"macaddress": "B82CA0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"domain": "lyric",
|
||||||
|
"hostname": "lyric-*",
|
||||||
|
"macaddress": "00D02D"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"domain": "nest",
|
"domain": "nest",
|
||||||
"macaddress": "18B430*"
|
"macaddress": "18B430*"
|
||||||
|
|
|
@ -199,6 +199,9 @@ aiolifx_effects==0.2.2
|
||||||
# homeassistant.components.lutron_caseta
|
# homeassistant.components.lutron_caseta
|
||||||
aiolip==1.0.1
|
aiolip==1.0.1
|
||||||
|
|
||||||
|
# homeassistant.components.lyric
|
||||||
|
aiolyric==1.0.5
|
||||||
|
|
||||||
# homeassistant.components.keyboard_remote
|
# homeassistant.components.keyboard_remote
|
||||||
aionotify==0.2.0
|
aionotify==0.2.0
|
||||||
|
|
||||||
|
|
|
@ -118,6 +118,9 @@ aiokafka==0.6.0
|
||||||
# homeassistant.components.lutron_caseta
|
# homeassistant.components.lutron_caseta
|
||||||
aiolip==1.0.1
|
aiolip==1.0.1
|
||||||
|
|
||||||
|
# homeassistant.components.lyric
|
||||||
|
aiolyric==1.0.5
|
||||||
|
|
||||||
# homeassistant.components.notion
|
# homeassistant.components.notion
|
||||||
aionotion==1.1.0
|
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…
Add table
Add a link
Reference in a new issue