[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 Paulus Schoutsen
parent 9181660497
commit 1d784bdc05
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).
"""
import asyncio
from datetime import datetime, timedelta
import logging
from typing import Any, Dict, Tuple
from typing import Any, Dict, Optional, Tuple
from dateutil.tz import tzlocal
import requests.exceptions
import voluptuous as vol
import evohomeclient2
@ -21,10 +21,10 @@ 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
async_track_point_in_utc_time, track_time_interval)
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__)
@ -47,11 +47,20 @@ CONFIG_SCHEMA = vol.Schema({
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)
dt_aware = utcnow() + (dt_naive - datetime.now())
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:
raise err
@ -92,18 +101,17 @@ def _handle_exception(err):
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."""
broker = EvoBroker(hass, hass_config[DOMAIN])
if not await broker.init_client():
if not 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.")
load_platform(hass, 'water_heater', DOMAIN, {}, hass_config)
async_track_time_interval(
track_time_interval(
hass, broker.update, hass_config[DOMAIN][CONF_SCAN_INTERVAL]
)
@ -126,23 +134,26 @@ class EvoBroker:
hass.data[DOMAIN] = {}
hass.data[DOMAIN]['broker'] = self
async def init_client(self) -> bool:
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()
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:
client = self.client = await self.hass.async_add_executor_job(
evohomeclient2.EvohomeClient,
client = self.client = evohomeclient2.EvohomeClient(
self.params[CONF_USERNAME],
self.params[CONF_PASSWORD],
False,
refresh_token,
access_token,
access_token_expires
refresh_token=refresh_token,
access_token=access_token,
access_token_expires=access_token_expires
)
except (requests.exceptions.RequestException,
@ -150,13 +161,11 @@ class EvoBroker:
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'
self.hass.add_job(self._save_auth_tokens())
loc_idx = self.params[CONF_LOCATION_IDX]
try:
self.config = client.installation_info[loc_idx][GWS][0][TCS][0]
@ -170,15 +179,19 @@ class EvoBroker:
)
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)
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
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)
app_storage = self._app_storage = await store.async_load()
@ -187,9 +200,7 @@ class EvoBroker:
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)
at_expires_dt = parse_datetime(at_expires_str)
else:
at_expires_dt = None
@ -198,14 +209,15 @@ class EvoBroker:
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(
# evohomeclient2 uses local datetimes
access_token_expires = _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()
access_token_expires.isoformat()
store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
await store.async_save(self._app_storage)
@ -213,7 +225,7 @@ class EvoBroker:
async_track_point_in_utc_time(
self.hass,
self._save_auth_tokens,
access_token_expires_utc
access_token_expires + self.params[CONF_SCAN_INTERVAL]
)
def update(self, *args, **kwargs) -> None:
@ -262,7 +274,7 @@ class EvoDevice(Entity):
if packet['signal'] == 'refresh':
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.
Only Zones & DHW controllers (but not the TCS) have schedules.
@ -270,6 +282,9 @@ class EvoDevice(Entity):
switchpoints = {}
schedule = self._evo_device.schedule()
if not schedule['DailySchedules']:
return None
day_time = datetime.now()
day_of_week = int(day_time.strftime('%w')) # 0 is Sunday
@ -300,9 +315,11 @@ class EvoDevice(Entity):
'{}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)
spt['from'] = _local_dt_to_utc(dt_naive).isoformat()
try:
spt['temperature'] = switchpoint['heatSetpoint']
except KeyError:
spt['state'] = switchpoint['DhwState']
return switchpoints

View file

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