[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:
parent
9181660497
commit
1d784bdc05
3 changed files with 175 additions and 61 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
92
homeassistant/components/evohome/water_heater.py
Normal file
92
homeassistant/components/evohome/water_heater.py
Normal 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)
|
Loading…
Add table
Reference in a new issue