* Climate 1.0 / part 1/2/3 * fix flake * Lint * Update Google Assistant * ambiclimate to climate 1.0 (#24911) * Fix Alexa * Lint * Migrate zhong_hong * Migrate tuya * Migrate honeywell to new climate schema (#24257) * Update one * Fix model climate v2 * Cleanup p4 * Add comfort hold mode * Fix old code * Update homeassistant/components/climate/__init__.py Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io> * Update homeassistant/components/climate/const.py Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io> * First renaming * Rename operation to hvac for paulus * Rename hold mode to preset mode * Cleanup & update comments * Remove on/off * Fix supported feature count * Update services * Update demo * Fix tests & use current_hvac * Update comment * Fix tests & add typing * Add more typing * Update modes * Fix tests * Cleanup low/high with range * Update homematic part 1 * Finish homematic * Fix lint * fix hm mapping * Support simple devices * convert lcn * migrate oem * Fix xs1 * update hive * update mil * Update toon * migrate deconz * cleanup * update tesla * Fix lint * Fix vera * Migrate zwave * Migrate velbus * Cleanup humity feature * Cleanup * Migrate wink * migrate dyson * Fix current hvac * Renaming * Fix lint * Migrate tfiac * migrate tado * Fix PRESET can be None * apply PR#23913 from dev * remove EU component, etc. * remove EU component, etc. * ready to test now * de-linted * some tweaks * de-lint * better handling of edge cases * delint * fix set_mode typos * apply PR#23913 from dev * remove EU component, etc. * ready to test now * de-linted * some tweaks * de-lint * better handling of edge cases * delint * fix set_mode typos * delint, move debug code * away preset now working * code tidy-up * code tidy-up 2 * code tidy-up 3 * address issues #18932, #15063 * address issues #18932, #15063 - 2/2 * refactor MODE_AUTO to MODE_HEAT_COOL and use F not C * add low/high to set_temp * add low/high to set_temp 2 * add low/high to set_temp - delint * run HA scripts * port changes from PR #24402 * manual rebase * manual rebase 2 * delint * minor change * remove SUPPORT_HVAC_ACTION * Migrate radiotherm * Convert touchline * Migrate flexit * Migrate nuheat * Migrate maxcube * Fix names maxcube const * Migrate proliphix * Migrate heatmiser * Migrate fritzbox * Migrate opentherm_gw * Migrate venstar * Migrate daikin * Migrate modbus * Fix elif * Migrate Homematic IP Cloud to climate-1.0 (#24913) * hmip climate fix * Update hvac_mode and preset_mode * fix lint * Fix lint * Migrate generic_thermostat * Migrate incomfort to new climate schema (#24915) * initial commit * Update climate.py * Migrate eq3btsmart * Lint * cleanup PRESET_MANUAL * Migrate ecobee * No conditional features * KNX: Migrate climate component to new climate platform (#24931) * Migrate climate component * Remove unused code * Corrected line length * Lint * Lint * fix tests * Fix value * Migrate geniushub to new climate schema (#24191) * Update one * Fix model climate v2 * Cleanup p4 * Add comfort hold mode * Fix old code * Update homeassistant/components/climate/__init__.py Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io> * Update homeassistant/components/climate/const.py Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io> * First renaming * Rename operation to hvac for paulus * Rename hold mode to preset mode * Cleanup & update comments * Remove on/off * Fix supported feature count * Update services * Update demo * Fix tests & use current_hvac * Update comment * Fix tests & add typing * Add more typing * Update modes * Fix tests * Cleanup low/high with range * Update homematic part 1 * Finish homematic * Fix lint * fix hm mapping * Support simple devices * convert lcn * migrate oem * Fix xs1 * update hive * update mil * Update toon * migrate deconz * cleanup * update tesla * Fix lint * Fix vera * Migrate zwave * Migrate velbus * Cleanup humity feature * Cleanup * Migrate wink * migrate dyson * Fix current hvac * Renaming * Fix lint * Migrate tfiac * migrate tado * delinted * delinted * use latest client * clean up mappings * clean up mappings * add duration to set_temperature * add duration to set_temperature * manual rebase * tweak * fix regression * small fix * fix rebase mixup * address comments * finish refactor * fix regression * tweak type hints * delint * manual rebase * WIP: Fixes for honeywell migration to climate-1.0 (#24938) * add type hints * code tidy-up * Fixes for incomfort migration to climate-1.0 (#24936) * delint type hints * no async unless await * revert: no async unless await * revert: no async unless await 2 * delint * fix typo * Fix homekit_controller on climate-1.0 (#24948) * Fix tests on climate-1.0 branch * As part of climate-1.0, make state return the heating-cooling.current characteristic * Fixes from review * lint * Fix imports * Migrate stibel_eltron * Fix lint * Migrate coolmaster to climate 1.0 (#24967) * Migrate coolmaster to climate 1.0 * fix lint errors * More lint fixes * Fix demo to work with UI * Migrate spider * Demo update * Updated frontend to 20190705.0 * Fix boost mode (#24980) * Prepare Netatmo for climate 1.0 (#24973) * Migration Netatmo * Address comments * Update climate.py * Migrate ephember * Migrate Sensibo * Implemented review comments (#24942) * Migrate ESPHome * Migrate MQTT * Migrate Nest * Migrate melissa * Initial/partial migration of ST * Migrate ST * Remove Away mode (#24995) * Migrate evohome, cache access tokens (#24491) * add water_heater, add storage - initial commit * add water_heater, add storage - initial commit delint add missing code desiderata update honeywell client library & CODEOWNER add auth_tokens code, refactor & delint refactor for broker delint * Add Broker, Water Heater & Refactor add missing code desiderata * update honeywell client library & CODEOWNER add auth_tokens code, refactor & delint refactor for broker * bugfix - loc_idx may not be 0 more refactor - ensure pure async more refactoring appears all r/o attributes are working tweak precsion, DHW & delint remove unused code remove unused code 2 remove unused code, refactor _save_auth_tokens() * support RoundThermostat bugfix opmode, switch to util.dt, add until=1h revert breaking change * store at_expires as naive UTC remove debug code delint tidy up exception handling delint add water_heater, add storage - initial commit delint add missing code desiderata update honeywell client library & CODEOWNER add auth_tokens code, refactor & delint refactor for broker add water_heater, add storage - initial commit delint add missing code desiderata update honeywell client library & CODEOWNER add auth_tokens code, refactor & delint refactor for broker delint bugfix - loc_idx may not be 0 more refactor - ensure pure async more refactoring appears all r/o attributes are working tweak precsion, DHW & delint remove unused code remove unused code 2 remove unused code, refactor _save_auth_tokens() support RoundThermostat bugfix opmode, switch to util.dt, add until=1h revert breaking change store at_expires as naive UTC remove debug code delint tidy up exception handling delint * update CODEOWNERS * fix regression * fix requirements * migrate to climate-1.0 * tweaking * de-lint * TCS working? & delint * tweaking * TCS code finalised * remove available() logic * refactor _switchpoints() * tidy up switchpoint code * tweak * teaking device_state_attributes * some refactoring * move PRESET_CUSTOM back to evohome * move CONF_ACCESS_TOKEN_EXPIRES CONF_REFRESH_TOKEN back to evohome * refactor SP code and dt conversion * delinted * delinted * remove water_heater * fix regression * Migrate homekit * Cleanup away mode * Fix tests * add helpers * fix tests melissa * Fix nehueat * fix zwave * add more tests * fix deconz * Fix climate test emulate_hue * fix tests * fix dyson tests * fix demo with new layout * fix honeywell * Switch homekit_controller to use HVAC_MODE_HEAT_COOL instead of HVAC_MODE_AUTO (#25009) * Lint * PyLint * Pylint * fix fritzbox tests * Fix google * Fix all tests * Fix lint * Fix auto for homekit like controler * Fix lint * fix lint
359 lines
12 KiB
Python
359 lines
12 KiB
Python
"""Support for (EMEA/EU-based) Honeywell TCC climate systems.
|
|
|
|
Such systems include evohome (multi-zone), and Round Thermostat (single zone).
|
|
"""
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
from typing import Any, Dict, Tuple
|
|
|
|
from dateutil.tz import tzlocal
|
|
import requests.exceptions
|
|
import voluptuous as vol
|
|
import evohomeclient2
|
|
|
|
from homeassistant.const import (
|
|
CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME,
|
|
HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS, TEMP_CELSIUS)
|
|
from homeassistant.core import callback
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.discovery import load_platform
|
|
from homeassistant.helpers.dispatcher import (
|
|
async_dispatcher_connect, async_dispatcher_send)
|
|
from homeassistant.helpers.entity import Entity
|
|
from homeassistant.helpers.event import (
|
|
async_track_point_in_utc_time, async_track_time_interval)
|
|
from homeassistant.util.dt import as_utc, parse_datetime, utcnow
|
|
|
|
from .const import DOMAIN, EVO_STRFTIME, STORAGE_VERSION, STORAGE_KEY, GWS, TCS
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
CONF_ACCESS_TOKEN_EXPIRES = 'access_token_expires'
|
|
CONF_REFRESH_TOKEN = 'refresh_token'
|
|
|
|
CONF_LOCATION_IDX = 'location_idx'
|
|
SCAN_INTERVAL_DEFAULT = timedelta(seconds=300)
|
|
SCAN_INTERVAL_MINIMUM = timedelta(seconds=60)
|
|
|
|
CONFIG_SCHEMA = vol.Schema({
|
|
DOMAIN: vol.Schema({
|
|
vol.Required(CONF_USERNAME): cv.string,
|
|
vol.Required(CONF_PASSWORD): cv.string,
|
|
vol.Optional(CONF_LOCATION_IDX, default=0): cv.positive_int,
|
|
vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL_DEFAULT):
|
|
vol.All(cv.time_period, vol.Range(min=SCAN_INTERVAL_MINIMUM)),
|
|
}),
|
|
}, extra=vol.ALLOW_EXTRA)
|
|
|
|
|
|
def _local_dt_to_utc(dt_naive: datetime) -> datetime:
|
|
dt_aware = as_utc(dt_naive.replace(microsecond=0, tzinfo=tzlocal()))
|
|
return dt_aware.replace(tzinfo=None)
|
|
|
|
|
|
def _handle_exception(err):
|
|
try:
|
|
raise err
|
|
|
|
except evohomeclient2.AuthenticationError:
|
|
_LOGGER.error(
|
|
"Failed to (re)authenticate with the vendor's server. "
|
|
"Check that your username and password are correct. "
|
|
"Message is: %s",
|
|
err
|
|
)
|
|
return False
|
|
|
|
except requests.exceptions.ConnectionError:
|
|
# this appears to be common with Honeywell's servers
|
|
_LOGGER.warning(
|
|
"Unable to connect with the vendor's server. "
|
|
"Check your network and the vendor's status page."
|
|
"Message is: %s",
|
|
err
|
|
)
|
|
return False
|
|
|
|
except requests.exceptions.HTTPError:
|
|
if err.response.status_code == HTTP_SERVICE_UNAVAILABLE:
|
|
_LOGGER.warning(
|
|
"Vendor says their server is currently unavailable. "
|
|
"Check the vendor's status page."
|
|
)
|
|
return False
|
|
|
|
if err.response.status_code == HTTP_TOO_MANY_REQUESTS:
|
|
_LOGGER.warning(
|
|
"The vendor's API rate limit has been exceeded. "
|
|
"Consider increasing the %s.", CONF_SCAN_INTERVAL
|
|
)
|
|
return False
|
|
|
|
raise # we don't expect/handle any other HTTPErrors
|
|
|
|
|
|
async def async_setup(hass, hass_config):
|
|
"""Create a (EMEA/EU-based) Honeywell evohome system."""
|
|
broker = EvoBroker(hass, hass_config[DOMAIN])
|
|
if not await broker.init_client():
|
|
return False
|
|
|
|
load_platform(hass, 'climate', DOMAIN, {}, hass_config)
|
|
if broker.tcs.hotwater:
|
|
_LOGGER.warning("DHW controller detected, however this integration "
|
|
"does not currently support DHW controllers.")
|
|
|
|
async_track_time_interval(
|
|
hass, broker.update, hass_config[DOMAIN][CONF_SCAN_INTERVAL]
|
|
)
|
|
|
|
return True
|
|
|
|
|
|
class EvoBroker:
|
|
"""Container for evohome client and data."""
|
|
|
|
def __init__(self, hass, params) -> None:
|
|
"""Initialize the evohome client and data structure."""
|
|
self.hass = hass
|
|
self.params = params
|
|
|
|
self.config = self.status = self.timers = {}
|
|
|
|
self.client = self.tcs = None
|
|
self._app_storage = None
|
|
|
|
hass.data[DOMAIN] = {}
|
|
hass.data[DOMAIN]['broker'] = self
|
|
|
|
async def init_client(self) -> bool:
|
|
"""Initialse the evohome data broker.
|
|
|
|
Return True if this is successful, otherwise return False.
|
|
"""
|
|
refresh_token, access_token, access_token_expires = \
|
|
await self._load_auth_tokens()
|
|
|
|
try:
|
|
client = self.client = await self.hass.async_add_executor_job(
|
|
evohomeclient2.EvohomeClient,
|
|
self.params[CONF_USERNAME],
|
|
self.params[CONF_PASSWORD],
|
|
False,
|
|
refresh_token,
|
|
access_token,
|
|
access_token_expires
|
|
)
|
|
|
|
except (requests.exceptions.RequestException,
|
|
evohomeclient2.AuthenticationError) as err:
|
|
if not _handle_exception(err):
|
|
return False
|
|
|
|
else:
|
|
if access_token != self.client.access_token:
|
|
await self._save_auth_tokens()
|
|
|
|
finally:
|
|
self.params[CONF_PASSWORD] = 'REDACTED'
|
|
|
|
loc_idx = self.params[CONF_LOCATION_IDX]
|
|
try:
|
|
self.config = client.installation_info[loc_idx][GWS][0][TCS][0]
|
|
|
|
except IndexError:
|
|
_LOGGER.error(
|
|
"Config error: '%s' = %s, but its valid range is 0-%s. "
|
|
"Unable to continue. "
|
|
"Fix any configuration errors and restart HA.",
|
|
CONF_LOCATION_IDX, loc_idx, len(client.installation_info) - 1
|
|
)
|
|
return False
|
|
|
|
else:
|
|
self.tcs = \
|
|
client.locations[loc_idx]._gateways[0]._control_systems[0] # noqa: E501; pylint: disable=protected-access
|
|
|
|
_LOGGER.debug("Config = %s", self.config)
|
|
|
|
return True
|
|
|
|
async def _load_auth_tokens(self) -> Tuple[str, str, datetime]:
|
|
store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
|
app_storage = self._app_storage = await store.async_load()
|
|
|
|
if app_storage.get(CONF_USERNAME) == self.params[CONF_USERNAME]:
|
|
refresh_token = app_storage.get(CONF_REFRESH_TOKEN)
|
|
access_token = app_storage.get(CONF_ACCESS_TOKEN)
|
|
at_expires_str = app_storage.get(CONF_ACCESS_TOKEN_EXPIRES)
|
|
if at_expires_str:
|
|
at_expires_dt = as_utc(parse_datetime(at_expires_str))
|
|
at_expires_dt = at_expires_dt.astimezone(tzlocal())
|
|
at_expires_dt = at_expires_dt.replace(tzinfo=None)
|
|
else:
|
|
at_expires_dt = None
|
|
|
|
return (refresh_token, access_token, at_expires_dt)
|
|
|
|
return (None, None, None) # account switched: so tokens wont be valid
|
|
|
|
async def _save_auth_tokens(self, *args) -> None:
|
|
access_token_expires_utc = _local_dt_to_utc(
|
|
self.client.access_token_expires)
|
|
|
|
self._app_storage[CONF_USERNAME] = self.params[CONF_USERNAME]
|
|
self._app_storage[CONF_REFRESH_TOKEN] = self.client.refresh_token
|
|
self._app_storage[CONF_ACCESS_TOKEN] = self.client.access_token
|
|
self._app_storage[CONF_ACCESS_TOKEN_EXPIRES] = \
|
|
access_token_expires_utc.isoformat()
|
|
|
|
store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
|
await store.async_save(self._app_storage)
|
|
|
|
async_track_point_in_utc_time(
|
|
self.hass,
|
|
self._save_auth_tokens,
|
|
access_token_expires_utc
|
|
)
|
|
|
|
def update(self, *args, **kwargs) -> None:
|
|
"""Get the latest state data of the entire evohome Location.
|
|
|
|
This includes state data for the Controller and all its child devices,
|
|
such as the operating mode of the Controller and the current temp of
|
|
its children (e.g. Zones, DHW controller).
|
|
"""
|
|
loc_idx = self.params[CONF_LOCATION_IDX]
|
|
|
|
try:
|
|
status = self.client.locations[loc_idx].status()[GWS][0][TCS][0]
|
|
except (requests.exceptions.RequestException,
|
|
evohomeclient2.AuthenticationError) as err:
|
|
_handle_exception(err)
|
|
else:
|
|
self.timers['statusUpdated'] = utcnow()
|
|
|
|
_LOGGER.debug("Status = %s", status)
|
|
|
|
# inform the evohome devices that state data has been updated
|
|
async_dispatcher_send(self.hass, DOMAIN, {'signal': 'refresh'})
|
|
|
|
|
|
class EvoDevice(Entity):
|
|
"""Base for any evohome device.
|
|
|
|
This includes the Controller, (up to 12) Heating Zones and
|
|
(optionally) a DHW controller.
|
|
"""
|
|
|
|
def __init__(self, evo_broker, evo_device) -> None:
|
|
"""Initialize the evohome entity."""
|
|
self._evo_device = evo_device
|
|
self._evo_tcs = evo_broker.tcs
|
|
|
|
self._name = self._icon = self._precision = None
|
|
self._state_attributes = []
|
|
|
|
self._supported_features = None
|
|
self._setpoints = None
|
|
|
|
@callback
|
|
def _refresh(self, packet):
|
|
if packet['signal'] == 'refresh':
|
|
self.async_schedule_update_ha_state(force_refresh=True)
|
|
|
|
def get_setpoints(self) -> Dict[str, Any]:
|
|
"""Return the current/next scheduled switchpoints.
|
|
|
|
Only Zones & DHW controllers (but not the TCS) have schedules.
|
|
"""
|
|
switchpoints = {}
|
|
schedule = self._evo_device.schedule()
|
|
|
|
day_time = datetime.now()
|
|
day_of_week = int(day_time.strftime('%w')) # 0 is Sunday
|
|
|
|
# Iterate today's switchpoints until past the current time of day...
|
|
day = schedule['DailySchedules'][day_of_week]
|
|
sp_idx = -1 # last switchpoint of the day before
|
|
for i, tmp in enumerate(day['Switchpoints']):
|
|
if day_time.strftime('%H:%M:%S') > tmp['TimeOfDay']:
|
|
sp_idx = i # current setpoint
|
|
else:
|
|
break
|
|
|
|
# Did the current SP start yesterday? Does the next start SP tomorrow?
|
|
current_sp_day = -1 if sp_idx == -1 else 0
|
|
next_sp_day = 1 if sp_idx + 1 == len(day['Switchpoints']) else 0
|
|
|
|
for key, offset, idx in [
|
|
('current', current_sp_day, sp_idx),
|
|
('next', next_sp_day, (sp_idx + 1) * (1 - next_sp_day))]:
|
|
|
|
spt = switchpoints[key] = {}
|
|
|
|
sp_date = (day_time + timedelta(days=offset)).strftime('%Y-%m-%d')
|
|
day = schedule['DailySchedules'][(day_of_week + offset) % 7]
|
|
switchpoint = day['Switchpoints'][idx]
|
|
|
|
dt_naive = datetime.strptime(
|
|
'{}T{}'.format(sp_date, switchpoint['TimeOfDay']),
|
|
'%Y-%m-%dT%H:%M:%S')
|
|
|
|
spt['target_temp'] = switchpoint['heatSetpoint']
|
|
spt['from_datetime'] = \
|
|
_local_dt_to_utc(dt_naive).strftime(EVO_STRFTIME)
|
|
|
|
return switchpoints
|
|
|
|
@property
|
|
def should_poll(self) -> bool:
|
|
"""Evohome entities should not be polled."""
|
|
return False
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""Return the name of the Evohome entity."""
|
|
return self._name
|
|
|
|
@property
|
|
def device_state_attributes(self) -> Dict[str, Any]:
|
|
"""Return the Evohome-specific state attributes."""
|
|
status = {}
|
|
for attr in self._state_attributes:
|
|
if attr != 'setpoints':
|
|
status[attr] = getattr(self._evo_device, attr)
|
|
|
|
if 'setpoints' in self._state_attributes:
|
|
status['setpoints'] = self._setpoints
|
|
|
|
return {'status': status}
|
|
|
|
@property
|
|
def icon(self) -> str:
|
|
"""Return the icon to use in the frontend UI."""
|
|
return self._icon
|
|
|
|
@property
|
|
def supported_features(self) -> int:
|
|
"""Get the flag of supported features of the device."""
|
|
return self._supported_features
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
"""Run when entity about to be added to hass."""
|
|
async_dispatcher_connect(self.hass, DOMAIN, self._refresh)
|
|
|
|
@property
|
|
def precision(self) -> float:
|
|
"""Return the temperature precision to use in the frontend UI."""
|
|
return self._precision
|
|
|
|
@property
|
|
def temperature_unit(self) -> str:
|
|
"""Return the temperature unit to use in the frontend UI."""
|
|
return TEMP_CELSIUS
|
|
|
|
def update(self) -> None:
|
|
"""Get the latest state data."""
|
|
self._setpoints = self.get_setpoints()
|