[climate] Add water_heater to evohome (#25035)

* initial commit

* refactor for sync

* minor tweak

* refactor convert code

* fix regression

* remove bad await

* de-lint

* de-lint 2

* address edge case - invalid tokens

* address edge case - delint

* handle no schedule

* improve support for RoundThermostat

* tweak logging

* delint

* refactor for greatness

* use time_zone: for state attributes

* small tweak

* small tweak 2

* have datetime state attributes as UTC

* have datetime state attributes as UTC - delint

* have datetime state attributes as UTC - tweak

* missed this - remove

* de-lint type hint

* use parse_datetime instead of datetime.strptime)

* remove debug code

* state atrribute datetimes are UTC now

* revert

* de-lint (again)

* tweak type hints

* de-lint (again, again)

* tweak type hints

* Convert datetime closer to sending it out
This commit is contained in:
David Bonnes 2019-07-12 20:29:45 +01:00 committed by Pascal Vizeli
parent 49abda2d49
commit de43237f6d
3 changed files with 175 additions and 61 deletions

View File

@ -2,11 +2,11 @@
Such systems include evohome (multi-zone), and Round Thermostat (single zone). Such systems include evohome (multi-zone), and Round Thermostat (single zone).
""" """
import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from typing import Any, Dict, Tuple from typing import Any, Dict, Optional, Tuple
from dateutil.tz import tzlocal
import requests.exceptions import requests.exceptions
import voluptuous as vol import voluptuous as vol
import evohomeclient2 import evohomeclient2
@ -21,10 +21,10 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect, async_dispatcher_send) async_dispatcher_connect, async_dispatcher_send)
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import ( from homeassistant.helpers.event import (
async_track_point_in_utc_time, async_track_time_interval) async_track_point_in_utc_time, track_time_interval)
from homeassistant.util.dt import as_utc, parse_datetime, utcnow from homeassistant.util.dt import parse_datetime, utcnow
from .const import DOMAIN, EVO_STRFTIME, STORAGE_VERSION, STORAGE_KEY, GWS, TCS from .const import DOMAIN, STORAGE_VERSION, STORAGE_KEY, GWS, TCS
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -47,11 +47,20 @@ CONFIG_SCHEMA = vol.Schema({
def _local_dt_to_utc(dt_naive: datetime) -> datetime: def _local_dt_to_utc(dt_naive: datetime) -> datetime:
dt_aware = as_utc(dt_naive.replace(microsecond=0, tzinfo=tzlocal())) dt_aware = utcnow() + (dt_naive - datetime.now())
return dt_aware.replace(tzinfo=None) if dt_aware.microsecond >= 500000:
dt_aware += timedelta(seconds=1)
return dt_aware.replace(microsecond=0)
def _handle_exception(err): def _utc_to_local_dt(dt_aware: datetime) -> datetime:
dt_naive = datetime.now() + (dt_aware - utcnow())
if dt_naive.microsecond >= 500000:
dt_naive += timedelta(seconds=1)
return dt_naive.replace(microsecond=0)
def _handle_exception(err) -> bool:
try: try:
raise err raise err
@ -92,18 +101,17 @@ def _handle_exception(err):
raise # we don't expect/handle any other HTTPErrors raise # we don't expect/handle any other HTTPErrors
async def async_setup(hass, hass_config): def setup(hass, hass_config) -> bool:
"""Create a (EMEA/EU-based) Honeywell evohome system.""" """Create a (EMEA/EU-based) Honeywell evohome system."""
broker = EvoBroker(hass, hass_config[DOMAIN]) broker = EvoBroker(hass, hass_config[DOMAIN])
if not await broker.init_client(): if not broker.init_client():
return False return False
load_platform(hass, 'climate', DOMAIN, {}, hass_config) load_platform(hass, 'climate', DOMAIN, {}, hass_config)
if broker.tcs.hotwater: if broker.tcs.hotwater:
_LOGGER.warning("DHW controller detected, however this integration " load_platform(hass, 'water_heater', DOMAIN, {}, hass_config)
"does not currently support DHW controllers.")
async_track_time_interval( track_time_interval(
hass, broker.update, hass_config[DOMAIN][CONF_SCAN_INTERVAL] hass, broker.update, hass_config[DOMAIN][CONF_SCAN_INTERVAL]
) )
@ -126,23 +134,26 @@ class EvoBroker:
hass.data[DOMAIN] = {} hass.data[DOMAIN] = {}
hass.data[DOMAIN]['broker'] = self hass.data[DOMAIN]['broker'] = self
async def init_client(self) -> bool: def init_client(self) -> bool:
"""Initialse the evohome data broker. """Initialse the evohome data broker.
Return True if this is successful, otherwise return False. Return True if this is successful, otherwise return False.
""" """
refresh_token, access_token, access_token_expires = \ refresh_token, access_token, access_token_expires = \
await self._load_auth_tokens() asyncio.run_coroutine_threadsafe(
self._load_auth_tokens(), self.hass.loop).result()
# evohomeclient2 uses local datetimes
if access_token_expires is not None:
access_token_expires = _utc_to_local_dt(access_token_expires)
try: try:
client = self.client = await self.hass.async_add_executor_job( client = self.client = evohomeclient2.EvohomeClient(
evohomeclient2.EvohomeClient,
self.params[CONF_USERNAME], self.params[CONF_USERNAME],
self.params[CONF_PASSWORD], self.params[CONF_PASSWORD],
False, refresh_token=refresh_token,
refresh_token, access_token=access_token,
access_token, access_token_expires=access_token_expires
access_token_expires
) )
except (requests.exceptions.RequestException, except (requests.exceptions.RequestException,
@ -150,13 +161,11 @@ class EvoBroker:
if not _handle_exception(err): if not _handle_exception(err):
return False return False
else:
if access_token != self.client.access_token:
await self._save_auth_tokens()
finally: finally:
self.params[CONF_PASSWORD] = 'REDACTED' self.params[CONF_PASSWORD] = 'REDACTED'
self.hass.add_job(self._save_auth_tokens())
loc_idx = self.params[CONF_LOCATION_IDX] loc_idx = self.params[CONF_LOCATION_IDX]
try: try:
self.config = client.installation_info[loc_idx][GWS][0][TCS][0] self.config = client.installation_info[loc_idx][GWS][0][TCS][0]
@ -170,15 +179,19 @@ class EvoBroker:
) )
return False return False
else: self.tcs = client.locations[loc_idx]._gateways[0]._control_systems[0] # noqa: E501; pylint: disable=protected-access
self.tcs = \
client.locations[loc_idx]._gateways[0]._control_systems[0] # noqa: E501; pylint: disable=protected-access
_LOGGER.debug("Config = %s", self.config) _LOGGER.debug("Config = %s", self.config)
if _LOGGER.isEnabledFor(logging.DEBUG):
# don't do an I/O unless required
_LOGGER.debug(
"Status = %s",
client.locations[loc_idx].status()[GWS][0][TCS][0])
return True return True
async def _load_auth_tokens(self) -> Tuple[str, str, datetime]: async def _load_auth_tokens(self) -> Tuple[
Optional[str], Optional[str], Optional[datetime]]:
store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
app_storage = self._app_storage = await store.async_load() app_storage = self._app_storage = await store.async_load()
@ -187,9 +200,7 @@ class EvoBroker:
access_token = app_storage.get(CONF_ACCESS_TOKEN) access_token = app_storage.get(CONF_ACCESS_TOKEN)
at_expires_str = app_storage.get(CONF_ACCESS_TOKEN_EXPIRES) at_expires_str = app_storage.get(CONF_ACCESS_TOKEN_EXPIRES)
if at_expires_str: if at_expires_str:
at_expires_dt = as_utc(parse_datetime(at_expires_str)) at_expires_dt = parse_datetime(at_expires_str)
at_expires_dt = at_expires_dt.astimezone(tzlocal())
at_expires_dt = at_expires_dt.replace(tzinfo=None)
else: else:
at_expires_dt = None at_expires_dt = None
@ -198,14 +209,15 @@ class EvoBroker:
return (None, None, None) # account switched: so tokens wont be valid return (None, None, None) # account switched: so tokens wont be valid
async def _save_auth_tokens(self, *args) -> None: async def _save_auth_tokens(self, *args) -> None:
access_token_expires_utc = _local_dt_to_utc( # evohomeclient2 uses local datetimes
access_token_expires = _local_dt_to_utc(
self.client.access_token_expires) self.client.access_token_expires)
self._app_storage[CONF_USERNAME] = self.params[CONF_USERNAME] self._app_storage[CONF_USERNAME] = self.params[CONF_USERNAME]
self._app_storage[CONF_REFRESH_TOKEN] = self.client.refresh_token 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] = self.client.access_token
self._app_storage[CONF_ACCESS_TOKEN_EXPIRES] = \ self._app_storage[CONF_ACCESS_TOKEN_EXPIRES] = \
access_token_expires_utc.isoformat() access_token_expires.isoformat()
store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
await store.async_save(self._app_storage) await store.async_save(self._app_storage)
@ -213,7 +225,7 @@ class EvoBroker:
async_track_point_in_utc_time( async_track_point_in_utc_time(
self.hass, self.hass,
self._save_auth_tokens, self._save_auth_tokens,
access_token_expires_utc access_token_expires + self.params[CONF_SCAN_INTERVAL]
) )
def update(self, *args, **kwargs) -> None: def update(self, *args, **kwargs) -> None:
@ -262,7 +274,7 @@ class EvoDevice(Entity):
if packet['signal'] == 'refresh': if packet['signal'] == 'refresh':
self.async_schedule_update_ha_state(force_refresh=True) self.async_schedule_update_ha_state(force_refresh=True)
def get_setpoints(self) -> Dict[str, Any]: def get_setpoints(self) -> Optional[Dict[str, Any]]:
"""Return the current/next scheduled switchpoints. """Return the current/next scheduled switchpoints.
Only Zones & DHW controllers (but not the TCS) have schedules. Only Zones & DHW controllers (but not the TCS) have schedules.
@ -270,6 +282,9 @@ class EvoDevice(Entity):
switchpoints = {} switchpoints = {}
schedule = self._evo_device.schedule() schedule = self._evo_device.schedule()
if not schedule['DailySchedules']:
return None
day_time = datetime.now() day_time = datetime.now()
day_of_week = int(day_time.strftime('%w')) # 0 is Sunday day_of_week = int(day_time.strftime('%w')) # 0 is Sunday
@ -300,9 +315,11 @@ class EvoDevice(Entity):
'{}T{}'.format(sp_date, switchpoint['TimeOfDay']), '{}T{}'.format(sp_date, switchpoint['TimeOfDay']),
'%Y-%m-%dT%H:%M:%S') '%Y-%m-%dT%H:%M:%S')
spt['target_temp'] = switchpoint['heatSetpoint'] spt['from'] = _local_dt_to_utc(dt_naive).isoformat()
spt['from_datetime'] = \ try:
_local_dt_to_utc(dt_naive).strftime(EVO_STRFTIME) spt['temperature'] = switchpoint['heatSetpoint']
except KeyError:
spt['state'] = switchpoint['DhwState']
return switchpoints return switchpoints

View File

@ -11,11 +11,11 @@ from homeassistant.components.climate.const import (
HVAC_MODE_HEAT, HVAC_MODE_AUTO, HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_AUTO, HVAC_MODE_OFF,
PRESET_AWAY, PRESET_ECO, PRESET_HOME, PRESET_AWAY, PRESET_ECO, PRESET_HOME,
SUPPORT_TARGET_TEMPERATURE, SUPPORT_PRESET_MODE) SUPPORT_TARGET_TEMPERATURE, SUPPORT_PRESET_MODE)
from homeassistant.util.dt import parse_datetime
from . import CONF_LOCATION_IDX, _handle_exception, EvoDevice from . import CONF_LOCATION_IDX, _handle_exception, EvoDevice
from .const import ( from .const import (
DOMAIN, EVO_STRFTIME, DOMAIN, EVO_RESET, EVO_AUTO, EVO_AUTOECO, EVO_AWAY, EVO_DAYOFF, EVO_CUSTOM,
EVO_RESET, EVO_AUTO, EVO_AUTOECO, EVO_AWAY, EVO_DAYOFF, EVO_CUSTOM,
EVO_HEATOFF, EVO_FOLLOW, EVO_TEMPOVER, EVO_PERMOVER) EVO_HEATOFF, EVO_FOLLOW, EVO_TEMPOVER, EVO_PERMOVER)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -43,7 +43,7 @@ HA_PRESET_TO_EVO = {
EVO_PRESET_TO_HA = {v: k for k, v in HA_PRESET_TO_EVO.items()} EVO_PRESET_TO_HA = {v: k for k, v in HA_PRESET_TO_EVO.items()}
async def async_setup_platform(hass, hass_config, async_add_entities, def setup_platform(hass, hass_config, add_entities,
discovery_info=None) -> None: discovery_info=None) -> None:
"""Create the evohome Controller, and its Zones, if any.""" """Create the evohome Controller, and its Zones, if any."""
broker = hass.data[DOMAIN]['broker'] broker = hass.data[DOMAIN]['broker']
@ -60,13 +60,14 @@ async def async_setup_platform(hass, hass_config, async_add_entities,
for zone_idx in broker.tcs.zones: for zone_idx in broker.tcs.zones:
evo_zone = broker.tcs.zones[zone_idx] evo_zone = broker.tcs.zones[zone_idx]
_LOGGER.debug( _LOGGER.debug(
"Found Zone, id=%s [%s], name=%s", "Found %s, id=%s [%s], name=%s",
evo_zone.zoneId, evo_zone.zone_type, evo_zone.name) evo_zone.zoneType, evo_zone.zoneId, evo_zone.modelType,
evo_zone.name)
zones.append(EvoZone(broker, evo_zone)) zones.append(EvoZone(broker, evo_zone))
entities = [controller] + zones entities = [controller] + zones
async_add_entities(entities, update_before_add=True) add_entities(entities, update_before_add=True)
class EvoClimateDevice(EvoDevice, ClimateDevice): class EvoClimateDevice(EvoDevice, ClimateDevice):
@ -141,7 +142,7 @@ class EvoZone(EvoClimateDevice):
if self._evo_device.temperatureStatus['isAvailable'] else None) if self._evo_device.temperatureStatus['isAvailable'] else None)
@property @property
def target_temperature(self) -> Optional[float]: def target_temperature(self) -> float:
"""Return the target temperature of the evohome Zone.""" """Return the target temperature of the evohome Zone."""
if self._evo_tcs.systemModeStatus['mode'] == EVO_HEATOFF: if self._evo_tcs.systemModeStatus['mode'] == EVO_HEATOFF:
return self._evo_device.setpointCapabilities['minHeatSetpoint'] return self._evo_device.setpointCapabilities['minHeatSetpoint']
@ -172,7 +173,7 @@ class EvoZone(EvoClimateDevice):
return self._evo_device.setpointCapabilities['maxHeatSetpoint'] return self._evo_device.setpointCapabilities['maxHeatSetpoint']
def _set_temperature(self, temperature: float, def _set_temperature(self, temperature: float,
until: Optional[datetime] = None): until: Optional[datetime] = None) -> None:
"""Set a new target temperature for the Zone. """Set a new target temperature for the Zone.
until == None means indefinitely (i.e. PermanentOverride) until == None means indefinitely (i.e. PermanentOverride)
@ -187,11 +188,11 @@ class EvoZone(EvoClimateDevice):
"""Set a new target temperature for an hour.""" """Set a new target temperature for an hour."""
until = kwargs.get('until') until = kwargs.get('until')
if until: if until:
until = datetime.strptime(until, EVO_STRFTIME) until = parse_datetime(until)
self._set_temperature(kwargs['temperature'], until) self._set_temperature(kwargs['temperature'], until)
def _set_operation_mode(self, op_mode) -> None: def _set_operation_mode(self, op_mode: str) -> None:
"""Set the Zone to one of its native EVO_* operating modes.""" """Set the Zone to one of its native EVO_* operating modes."""
if op_mode == EVO_FOLLOW: if op_mode == EVO_FOLLOW:
try: try:
@ -201,14 +202,13 @@ class EvoZone(EvoClimateDevice):
_handle_exception(err) _handle_exception(err)
return return
self._setpoints = self.get_setpoints()
temperature = self._evo_device.setpointStatus['targetHeatTemperature'] temperature = self._evo_device.setpointStatus['targetHeatTemperature']
until = None # EVO_PERMOVER
if op_mode == EVO_TEMPOVER: if op_mode == EVO_TEMPOVER:
until = self._setpoints['next']['from_datetime'] self._setpoints = self.get_setpoints()
until = datetime.strptime(until, EVO_STRFTIME) if self._setpoints:
else: # EVO_PERMOVER: until = parse_datetime(self._setpoints['next']['from'])
until = None
self._set_temperature(temperature, until=until) self._set_temperature(temperature, until=until)
@ -220,7 +220,7 @@ class EvoZone(EvoClimateDevice):
else: # HVAC_MODE_HEAT else: # HVAC_MODE_HEAT
self._set_operation_mode(EVO_FOLLOW) self._set_operation_mode(EVO_FOLLOW)
def set_preset_mode(self, preset_mode: str) -> None: def set_preset_mode(self, preset_mode: Optional[str]) -> None:
"""Set a new preset mode. """Set a new preset mode.
If preset_mode is None, then revert to following the schedule. If preset_mode is None, then revert to following the schedule.
@ -244,14 +244,19 @@ class EvoController(EvoClimateDevice):
self._icon = 'mdi:thermostat' self._icon = 'mdi:thermostat'
self._precision = None self._precision = None
self._state_attributes = [ self._state_attributes = ['activeFaults', 'systemModeStatus']
'activeFaults', 'systemModeStatus']
self._supported_features = SUPPORT_PRESET_MODE self._supported_features = SUPPORT_PRESET_MODE
self._hvac_modes = list(HA_HVAC_TO_TCS) self._hvac_modes = list(HA_HVAC_TO_TCS)
self._preset_modes = list(HA_PRESET_TO_TCS)
self._config = dict(evo_broker.config) self._config = dict(evo_broker.config)
# special case of RoundThermostat
if self._config['zones'][0]['modelType'] == 'RoundModulation':
self._preset_modes = [PRESET_AWAY, PRESET_ECO]
else:
self._preset_modes = list(HA_PRESET_TO_TCS)
self._config['zones'] = '...' self._config['zones'] = '...'
if 'dhw' in self._config: if 'dhw' in self._config:
self._config['dhw'] = '...' self._config['dhw'] = '...'
@ -307,7 +312,7 @@ class EvoController(EvoClimateDevice):
for z in self._evo_device._zones] # noqa: E501; pylint: disable=protected-access for z in self._evo_device._zones] # noqa: E501; pylint: disable=protected-access
return max(temps) if temps else 35 return max(temps) if temps else 35
def _set_operation_mode(self, op_mode) -> None: def _set_operation_mode(self, op_mode: str) -> None:
"""Set the Controller to any of its native EVO_* operating modes.""" """Set the Controller to any of its native EVO_* operating modes."""
try: try:
self._evo_device._set_status(op_mode) # noqa: E501; pylint: disable=protected-access self._evo_device._set_status(op_mode) # noqa: E501; pylint: disable=protected-access
@ -319,7 +324,7 @@ class EvoController(EvoClimateDevice):
"""Set an operating mode for the Controller.""" """Set an operating mode for the Controller."""
self._set_operation_mode(HA_HVAC_TO_TCS.get(hvac_mode)) self._set_operation_mode(HA_HVAC_TO_TCS.get(hvac_mode))
def set_preset_mode(self, preset_mode: str) -> None: def set_preset_mode(self, preset_mode: Optional[str]) -> None:
"""Set a new preset mode. """Set a new preset mode.
If preset_mode is None, then revert to 'Auto' mode. If preset_mode is None, then revert to 'Auto' mode.

View File

@ -0,0 +1,92 @@
"""Support for WaterHeater devices of (EMEA/EU) Honeywell TCC systems."""
import logging
from typing import List
import requests.exceptions
import evohomeclient2
from homeassistant.components.water_heater import (
SUPPORT_OPERATION_MODE, WaterHeaterDevice)
from homeassistant.const import PRECISION_WHOLE, STATE_OFF, STATE_ON
from homeassistant.util.dt import parse_datetime
from . import _handle_exception, EvoDevice
from .const import DOMAIN, EVO_STRFTIME, EVO_FOLLOW, EVO_TEMPOVER, EVO_PERMOVER
_LOGGER = logging.getLogger(__name__)
HA_STATE_TO_EVO = {STATE_ON: 'On', STATE_OFF: 'Off'}
EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items()}
HA_OPMODE_TO_DHW = {STATE_ON: EVO_FOLLOW, STATE_OFF: EVO_PERMOVER}
def setup_platform(hass, hass_config, add_entities,
discovery_info=None) -> None:
"""Create the DHW controller."""
broker = hass.data[DOMAIN]['broker']
_LOGGER.debug(
"Found DHW device, id: %s [%s]",
broker.tcs.hotwater.zoneId, broker.tcs.hotwater.zone_type)
evo_dhw = EvoDHW(broker, broker.tcs.hotwater)
add_entities([evo_dhw], update_before_add=True)
class EvoDHW(EvoDevice, WaterHeaterDevice):
"""Base for a Honeywell evohome DHW controller (aka boiler)."""
def __init__(self, evo_broker, evo_device) -> None:
"""Initialize the evohome DHW controller."""
super().__init__(evo_broker, evo_device)
self._id = evo_device.dhwId
self._name = 'DHW controller'
self._icon = 'mdi:thermometer-lines'
self._precision = PRECISION_WHOLE
self._state_attributes = [
'activeFaults', 'stateStatus', 'temperatureStatus', 'setpoints']
self._supported_features = SUPPORT_OPERATION_MODE
self._operation_list = list(HA_OPMODE_TO_DHW)
self._config = evo_broker.config['dhw']
@property
def current_operation(self) -> str:
"""Return the current operating mode (On, or Off)."""
return EVO_STATE_TO_HA[self._evo_device.stateStatus['state']]
@property
def operation_list(self) -> List[str]:
"""Return the list of available operations."""
return self._operation_list
@property
def current_temperature(self) -> float:
"""Return the current temperature."""
return self._evo_device.temperatureStatus['temperature']
def set_operation_mode(self, operation_mode: str) -> None:
"""Set new operation mode for a DHW controller."""
op_mode = HA_OPMODE_TO_DHW[operation_mode]
state = '' if op_mode == EVO_FOLLOW else HA_STATE_TO_EVO[STATE_OFF]
until = None # EVO_FOLLOW, EVO_PERMOVER
if op_mode == EVO_TEMPOVER:
self._setpoints = self.get_setpoints()
if self._setpoints:
until = parse_datetime(self._setpoints['next']['from'])
until = until.strftime(EVO_STRFTIME)
data = {'Mode': op_mode, 'State': state, 'UntilTime': until}
try:
self._evo_device._set_dhw(data) # pylint: disable=protected-access
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
_handle_exception(err)