Skip to content

Commit

Permalink
[climate] Add water_heater to evohome (#25035)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
zxdavb authored and pvizeli committed Jul 12, 2019
1 parent 49abda2 commit de43237
Show file tree
Hide file tree
Showing 3 changed files with 175 additions and 61 deletions.
95 changes: 56 additions & 39 deletions homeassistant/components/evohome/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,10 +21,10 @@
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__)

Expand All @@ -47,11 +47,20 @@


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

Expand Down Expand Up @@ -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]
)

Expand All @@ -126,37 +134,38 @@ def __init__(self, hass, params) -> None:
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,
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'

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]
Expand All @@ -170,15 +179,19 @@ async def init_client(self) -> bool:
)
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()

Expand All @@ -187,9 +200,7 @@ async def _load_auth_tokens(self) -> Tuple[str, str, datetime]:
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

Expand All @@ -198,22 +209,23 @@ async def _load_auth_tokens(self) -> Tuple[str, str, datetime]:
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)

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:
Expand Down Expand Up @@ -262,14 +274,17 @@ def _refresh(self, packet):
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.
"""
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

Expand Down Expand Up @@ -300,9 +315,11 @@ def get_setpoints(self) -> Dict[str, Any]:
'{}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

Expand Down
49 changes: 27 additions & 22 deletions homeassistant/components/evohome/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
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__)
Expand Down Expand Up @@ -43,8 +43,8 @@
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]
Expand All @@ -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):
Expand Down Expand Up @@ -141,7 +142,7 @@ def current_temperature(self) -> Optional[float]:
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']
Expand Down Expand Up @@ -172,7 +173,7 @@ def max_temp(self) -> float:
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)
Expand All @@ -187,11 +188,11 @@ def set_temperature(self, **kwargs) -> None:
"""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:
Expand All @@ -201,14 +202,13 @@ def _set_operation_mode(self, op_mode) -> None:
_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)

Expand All @@ -220,7 +220,7 @@ def set_hvac_mode(self, hvac_mode: str) -> None:
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.
Expand All @@ -244,14 +244,19 @@ def __init__(self, evo_broker, evo_device) -> None:
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'] = '...'
Expand Down Expand Up @@ -307,7 +312,7 @@ def max_temp(self) -> float:
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
Expand All @@ -319,7 +324,7 @@ def set_hvac_mode(self, hvac_mode: str) -> None:
"""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.
Expand Down
Loading

0 comments on commit de43237

Please sign in to comment.