Refactor evohome to prepare for water_heater (#23489)

* refactor - add const.py, change order of propertys, methods

* import client at top of file

* remove debug line

* de-lint

* delint

* add me as CODEOWNER

* remove lint hint

* delint
This commit is contained in:
David Bonnes 2019-05-03 00:43:19 +01:00 committed by Martin Hjelmare
parent 3338f5c9b4
commit 6130831a43
5 changed files with 257 additions and 241 deletions

View File

@ -73,6 +73,7 @@ homeassistant/components/epsonworkforce/* @ThaStealth
homeassistant/components/eq3btsmart/* @rytilahti homeassistant/components/eq3btsmart/* @rytilahti
homeassistant/components/esphome/* @OttoWinter homeassistant/components/esphome/* @OttoWinter
homeassistant/components/essent/* @TheLastProject homeassistant/components/essent/* @TheLastProject
homeassistant/components/evohome/* @zxdavb
homeassistant/components/file/* @fabaff homeassistant/components/file/* @fabaff
homeassistant/components/filter/* @dgomes homeassistant/components/filter/* @dgomes
homeassistant/components/fitbit/* @robbiet480 homeassistant/components/fitbit/* @robbiet480

View File

@ -5,26 +5,31 @@
# 0-12 Heating zones (a.k.a. Zone), and # 0-12 Heating zones (a.k.a. Zone), and
# 0-1 DHW controller, (a.k.a. Boiler) # 0-1 DHW controller, (a.k.a. Boiler)
# The TCS & Zones are implemented as Climate devices, Boiler as a WaterHeater # The TCS & Zones are implemented as Climate devices, Boiler as a WaterHeater
from datetime import timedelta from datetime import datetime, timedelta
import logging import logging
import requests.exceptions import requests.exceptions
import voluptuous as vol import voluptuous as vol
import evohomeclient2
from homeassistant.const import ( from homeassistant.const import (
CONF_SCAN_INTERVAL, CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, CONF_PASSWORD,
EVENT_HOMEASSISTANT_START) EVENT_HOMEASSISTANT_START,
HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS,
PRECISION_HALVES, TEMP_CELSIUS)
from homeassistant.core import callback from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import (
async_dispatcher_connect, async_dispatcher_send)
from homeassistant.helpers.entity import Entity
from .const import (
DOMAIN, DATA_EVOHOME, DISPATCHER_EVOHOME, GWS, TCS)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = 'evohome'
DATA_EVOHOME = 'data_' + DOMAIN
DISPATCHER_EVOHOME = 'dispatcher_' + DOMAIN
CONF_LOCATION_IDX = 'location_idx' CONF_LOCATION_IDX = 'location_idx'
SCAN_INTERVAL_DEFAULT = timedelta(seconds=300) SCAN_INTERVAL_DEFAULT = timedelta(seconds=300)
SCAN_INTERVAL_MINIMUM = timedelta(seconds=180) SCAN_INTERVAL_MINIMUM = timedelta(seconds=180)
@ -43,10 +48,6 @@ CONF_SECRETS = [
CONF_USERNAME, CONF_PASSWORD, CONF_USERNAME, CONF_PASSWORD,
] ]
# These are used to help prevent E501 (line too long) violations.
GWS = 'gateways'
TCS = 'temperatureControlSystems'
# bit masks for dispatcher packets # bit masks for dispatcher packets
EVO_PARENT = 0x01 EVO_PARENT = 0x01
EVO_CHILD = 0x02 EVO_CHILD = 0x02
@ -66,8 +67,6 @@ def setup(hass, hass_config):
scan_interval = timedelta( scan_interval = timedelta(
minutes=(scan_interval.total_seconds() + 59) // 60) minutes=(scan_interval.total_seconds() + 59) // 60)
import evohomeclient2
try: try:
client = evo_data['client'] = evohomeclient2.EvohomeClient( client = evo_data['client'] = evohomeclient2.EvohomeClient(
evo_data['params'][CONF_USERNAME], evo_data['params'][CONF_USERNAME],
@ -145,3 +144,129 @@ def setup(hass, hass_config):
hass.bus.listen(EVENT_HOMEASSISTANT_START, _first_update) hass.bus.listen(EVENT_HOMEASSISTANT_START, _first_update)
return True return True
class EvoDevice(Entity):
"""Base for any Honeywell evohome device.
Such devices include the Controller, (up to 12) Heating Zones and
(optionally) a DHW controller.
"""
def __init__(self, evo_data, client, obj_ref):
"""Initialize the evohome entity."""
self._client = client
self._obj = obj_ref
self._name = None
self._icon = None
self._type = None
self._supported_features = None
self._operation_list = None
self._params = evo_data['params']
self._timers = evo_data['timers']
self._status = {}
self._available = False # should become True after first update()
@callback
def _connect(self, packet):
if packet['to'] & self._type and packet['signal'] == 'refresh':
self.async_schedule_update_ha_state(force_refresh=True)
def _handle_exception(self, err):
try:
raise err
except evohomeclient2.AuthenticationError:
_LOGGER.error(
"Failed to (re)authenticate with the vendor's server. "
"This may be a temporary error. Message is: %s",
err
)
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."
)
except requests.exceptions.HTTPError:
if err.response.status_code == HTTP_SERVICE_UNAVAILABLE:
_LOGGER.warning(
"Vendor says their server is currently unavailable. "
"This may be temporary; check the vendor's status page."
)
elif err.response.status_code == HTTP_TOO_MANY_REQUESTS:
_LOGGER.warning(
"The vendor's API rate limit has been exceeded. "
"So will cease polling, and will resume after %s seconds.",
(self._params[CONF_SCAN_INTERVAL] * 3).total_seconds()
)
self._timers['statusUpdated'] = datetime.now() + \
self._params[CONF_SCAN_INTERVAL] * 3
else:
raise # we don't expect/handle any other HTTPErrors
# These properties, methods are from the Entity class
async def async_added_to_hass(self):
"""Run when entity about to be added."""
async_dispatcher_connect(self.hass, DISPATCHER_EVOHOME, self._connect)
@property
def should_poll(self) -> bool:
"""Most evohome devices push their state to HA.
Only the Controller should be polled.
"""
return False
@property
def name(self) -> str:
"""Return the name to use in the frontend UI."""
return self._name
@property
def device_state_attributes(self):
"""Return the device state attributes of the evohome device.
This is state data that is not available otherwise, due to the
restrictions placed upon ClimateDevice properties, etc. by HA.
"""
return {'status': self._status}
@property
def icon(self):
"""Return the icon to use in the frontend UI."""
return self._icon
@property
def available(self) -> bool:
"""Return True if the device is currently available."""
return self._available
@property
def supported_features(self):
"""Get the list of supported features of the device."""
return self._supported_features
# These properties are common to ClimateDevice, WaterHeaterDevice classes
@property
def precision(self):
"""Return the temperature precision to use in the frontend UI."""
return PRECISION_HALVES
@property
def temperature_unit(self):
"""Return the temperature unit to use in the frontend UI."""
return TEMP_CELSIUS
@property
def operation_list(self):
"""Return the list of available operations."""
return self._operation_list

View File

@ -4,20 +4,21 @@ import logging
import requests.exceptions import requests.exceptions
import evohomeclient2
from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import ( from homeassistant.components.climate.const import (
STATE_AUTO, STATE_ECO, STATE_MANUAL, SUPPORT_AWAY_MODE, SUPPORT_ON_OFF, STATE_AUTO, STATE_ECO, STATE_MANUAL, SUPPORT_AWAY_MODE, SUPPORT_ON_OFF,
SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE) SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
from homeassistant.const import ( from homeassistant.const import (
CONF_SCAN_INTERVAL, HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS, CONF_SCAN_INTERVAL, STATE_OFF,)
PRECISION_HALVES, STATE_OFF, TEMP_CELSIUS) from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect, dispatcher_send)
from . import ( from . import (
CONF_LOCATION_IDX, DATA_EVOHOME, DISPATCHER_EVOHOME, EVO_CHILD, EVO_PARENT, EvoDevice,
GWS, TCS) CONF_LOCATION_IDX, EVO_CHILD, EVO_PARENT)
from .const import (
DATA_EVOHOME, DISPATCHER_EVOHOME, GWS, TCS)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -103,115 +104,7 @@ async def async_setup_platform(hass, hass_config, async_add_entities,
async_add_entities(entities, update_before_add=False) async_add_entities(entities, update_before_add=False)
class EvoClimateDevice(ClimateDevice): class EvoZone(EvoDevice, ClimateDevice):
"""Base for a Honeywell evohome Climate device."""
# pylint: disable=no-member
def __init__(self, evo_data, client, obj_ref):
"""Initialize the evohome entity."""
self._client = client
self._obj = obj_ref
self._params = evo_data['params']
self._timers = evo_data['timers']
self._status = {}
self._available = False # should become True after first update()
async def async_added_to_hass(self):
"""Run when entity about to be added."""
async_dispatcher_connect(self.hass, DISPATCHER_EVOHOME, self._connect)
@callback
def _connect(self, packet):
if packet['to'] & self._type and packet['signal'] == 'refresh':
self.async_schedule_update_ha_state(force_refresh=True)
def _handle_exception(self, err):
try:
import evohomeclient2
raise err
except evohomeclient2.AuthenticationError:
_LOGGER.error(
"Failed to (re)authenticate with the vendor's server. "
"This may be a temporary error. Message is: %s",
err
)
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."
)
except requests.exceptions.HTTPError:
if err.response.status_code == HTTP_SERVICE_UNAVAILABLE:
_LOGGER.warning(
"Vendor says their server is currently unavailable. "
"This may be temporary; check the vendor's status page."
)
elif err.response.status_code == HTTP_TOO_MANY_REQUESTS:
_LOGGER.warning(
"The vendor's API rate limit has been exceeded. "
"So will cease polling, and will resume after %s seconds.",
(self._params[CONF_SCAN_INTERVAL] * 3).total_seconds()
)
self._timers['statusUpdated'] = datetime.now() + \
self._params[CONF_SCAN_INTERVAL] * 3
else:
raise # we don't expect/handle any other HTTPErrors
@property
def name(self) -> str:
"""Return the name to use in the frontend UI."""
return self._name
@property
def icon(self):
"""Return the icon to use in the frontend UI."""
return self._icon
@property
def device_state_attributes(self):
"""Return the device state attributes of the evohome Climate device.
This is state data that is not available otherwise, due to the
restrictions placed upon ClimateDevice properties, etc. by HA.
"""
return {'status': self._status}
@property
def available(self) -> bool:
"""Return True if the device is currently available."""
return self._available
@property
def supported_features(self):
"""Get the list of supported features of the device."""
return self._supported_features
@property
def operation_list(self):
"""Return the list of available operations."""
return self._operation_list
@property
def temperature_unit(self):
"""Return the temperature unit to use in the frontend UI."""
return TEMP_CELSIUS
@property
def precision(self):
"""Return the temperature precision to use in the frontend UI."""
return PRECISION_HALVES
class EvoZone(EvoClimateDevice):
"""Base for a Honeywell evohome Zone device.""" """Base for a Honeywell evohome Zone device."""
def __init__(self, evo_data, client, obj_ref): def __init__(self, evo_data, client, obj_ref):
@ -235,33 +128,6 @@ class EvoZone(EvoClimateDevice):
SUPPORT_TARGET_TEMPERATURE | \ SUPPORT_TARGET_TEMPERATURE | \
SUPPORT_ON_OFF SUPPORT_ON_OFF
@property
def min_temp(self):
"""Return the minimum target temperature of a evohome Zone.
The default is 5 (in Celsius), but it is configurable within 5-35.
"""
return self._config['setpointCapabilities']['minHeatSetpoint']
@property
def max_temp(self):
"""Return the minimum target temperature of a evohome Zone.
The default is 35 (in Celsius), but it is configurable within 5-35.
"""
return self._config['setpointCapabilities']['maxHeatSetpoint']
@property
def target_temperature(self):
"""Return the target temperature of the evohome Zone."""
return self._status['setpointStatus']['targetHeatTemperature']
@property
def current_temperature(self):
"""Return the current temperature of the evohome Zone."""
return (self._status['temperatureStatus']['temperature']
if self._status['temperatureStatus']['isAvailable'] else None)
@property @property
def current_operation(self): def current_operation(self):
"""Return the current operating mode of the evohome Zone. """Return the current operating mode of the evohome Zone.
@ -285,6 +151,17 @@ class EvoZone(EvoClimateDevice):
return current_operation return current_operation
@property
def current_temperature(self):
"""Return the current temperature of the evohome Zone."""
return (self._status['temperatureStatus']['temperature']
if self._status['temperatureStatus']['isAvailable'] else None)
@property
def target_temperature(self):
"""Return the target temperature of the evohome Zone."""
return self._status['setpointStatus']['targetHeatTemperature']
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return True if the evohome Zone is off. """Return True if the evohome Zone is off.
@ -297,6 +174,22 @@ class EvoZone(EvoClimateDevice):
self._status['setpointStatus']['setpointMode'] == EVO_PERMOVER self._status['setpointStatus']['setpointMode'] == EVO_PERMOVER
return not is_off return not is_off
@property
def min_temp(self):
"""Return the minimum target temperature of a evohome Zone.
The default is 5 (in Celsius), but it is configurable within 5-35.
"""
return self._config['setpointCapabilities']['minHeatSetpoint']
@property
def max_temp(self):
"""Return the maximum target temperature of a evohome Zone.
The default is 35 (in Celsius), but it is configurable within 5-35.
"""
return self._config['setpointCapabilities']['maxHeatSetpoint']
def _set_temperature(self, temperature, until=None): def _set_temperature(self, temperature, until=None):
"""Set the new target temperature of a Zone. """Set the new target temperature of a Zone.
@ -305,7 +198,6 @@ class EvoZone(EvoClimateDevice):
- None for PermanentOverride (i.e. indefinitely) - None for PermanentOverride (i.e. indefinitely)
""" """
try: try:
import evohomeclient2
self._obj.set_temperature(temperature, until) self._obj.set_temperature(temperature, until)
except (requests.exceptions.RequestException, except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err: evohomeclient2.AuthenticationError) as err:
@ -330,6 +222,29 @@ class EvoZone(EvoClimateDevice):
""" """
self._set_temperature(self.min_temp, until=None) self._set_temperature(self.min_temp, until=None)
def _set_operation_mode(self, operation_mode):
if operation_mode == EVO_FOLLOW:
try:
self._obj.cancel_temp_override()
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
self._handle_exception(err)
elif operation_mode == EVO_TEMPOVER:
_LOGGER.error(
"_set_operation_mode(op_mode=%s): mode not yet implemented",
operation_mode
)
elif operation_mode == EVO_PERMOVER:
self._set_temperature(self.target_temperature, until=None)
else:
_LOGGER.error(
"_set_operation_mode(op_mode=%s): mode not valid",
operation_mode
)
def set_operation_mode(self, operation_mode): def set_operation_mode(self, operation_mode):
"""Set an operating mode for a Zone. """Set an operating mode for a Zone.
@ -354,38 +269,6 @@ class EvoZone(EvoClimateDevice):
""" """
self._set_operation_mode(HA_STATE_TO_ZONE.get(operation_mode)) self._set_operation_mode(HA_STATE_TO_ZONE.get(operation_mode))
def _set_operation_mode(self, operation_mode):
if operation_mode == EVO_FOLLOW:
try:
import evohomeclient2
self._obj.cancel_temp_override()
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
self._handle_exception(err)
elif operation_mode == EVO_TEMPOVER:
_LOGGER.error(
"_set_operation_mode(op_mode=%s): mode not yet implemented",
operation_mode
)
elif operation_mode == EVO_PERMOVER:
self._set_temperature(self.target_temperature, until=None)
else:
_LOGGER.error(
"_set_operation_mode(op_mode=%s): mode not valid",
operation_mode
)
@property
def should_poll(self) -> bool:
"""Return False as evohome child devices should never be polled.
The evohome Controller will inform its children when to update().
"""
return False
def update(self): def update(self):
"""Process the evohome Zone's state data.""" """Process the evohome Zone's state data."""
evo_data = self.hass.data[DATA_EVOHOME] evo_data = self.hass.data[DATA_EVOHOME]
@ -398,7 +281,7 @@ class EvoZone(EvoClimateDevice):
self._available = True self._available = True
class EvoController(EvoClimateDevice): class EvoController(EvoDevice, ClimateDevice):
"""Base for a Honeywell evohome hub/Controller device. """Base for a Honeywell evohome hub/Controller device.
The Controller (aka TCS, temperature control system) is the parent of all The Controller (aka TCS, temperature control system) is the parent of all
@ -445,22 +328,18 @@ class EvoController(EvoClimateDevice):
return TCS_STATE_TO_HA.get(self._status['systemModeStatus']['mode']) return TCS_STATE_TO_HA.get(self._status['systemModeStatus']['mode'])
@property @property
def min_temp(self): def current_temperature(self):
"""Return the minimum target temperature of a evohome Controller. """Return the average current temperature of the Heating/DHW zones.
Although evohome Controllers do not have a minimum target temp, one is Although evohome Controllers do not have a target temp, one is
expected by the HA schema; the default for an evohome HR92 is used. expected by the HA schema.
""" """
return 5 tmp_list = [x for x in self._status['zones']
if x['temperatureStatus']['isAvailable']]
temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list]
@property avg_temp = round(sum(temps) / len(temps), 1) if temps else None
def max_temp(self): return avg_temp
"""Return the minimum target temperature of a evohome Controller.
Although evohome Controllers do not have a maximum target temp, one is
expected by the HA schema; the default for an evohome HR92 is used.
"""
return 35
@property @property
def target_temperature(self): def target_temperature(self):
@ -476,18 +355,9 @@ class EvoController(EvoClimateDevice):
return avg_temp return avg_temp
@property @property
def current_temperature(self): def is_away_mode_on(self) -> bool:
"""Return the average current temperature of the Heating/DHW zones. """Return True if away mode is on."""
return self._status['systemModeStatus']['mode'] == EVO_AWAY
Although evohome Controllers do not have a target temp, one is
expected by the HA schema.
"""
tmp_list = [x for x in self._status['zones']
if x['temperatureStatus']['isAvailable'] is True]
temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list]
avg_temp = round(sum(temps) / len(temps), 1) if temps else None
return avg_temp
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
@ -499,9 +369,42 @@ class EvoController(EvoClimateDevice):
return True return True
@property @property
def is_away_mode_on(self) -> bool: def min_temp(self):
"""Return True if away mode is on.""" """Return the minimum target temperature of a evohome Controller.
return self._status['systemModeStatus']['mode'] == EVO_AWAY
Although evohome Controllers do not have a minimum target temp, one is
expected by the HA schema; the default for an evohome HR92 is used.
"""
return 5
@property
def max_temp(self):
"""Return the maximum target temperature of a evohome Controller.
Although evohome Controllers do not have a maximum target temp, one is
expected by the HA schema; the default for an evohome HR92 is used.
"""
return 35
@property
def should_poll(self) -> bool:
"""Return True as the evohome Controller should always be polled."""
return True
def _set_operation_mode(self, operation_mode):
try:
self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
self._handle_exception(err)
def set_operation_mode(self, operation_mode):
"""Set new target operation mode for the TCS.
Currently limited to 'Auto', 'AutoWithEco' & 'HeatingOff'. If 'Away'
mode is needed, it can be enabled via turn_away_mode_on method.
"""
self._set_operation_mode(HA_STATE_TO_TCS.get(operation_mode))
def turn_away_mode_on(self): def turn_away_mode_on(self):
"""Turn away mode on. """Turn away mode on.
@ -519,27 +422,6 @@ class EvoController(EvoClimateDevice):
""" """
self._set_operation_mode(EVO_AUTO) self._set_operation_mode(EVO_AUTO)
def _set_operation_mode(self, operation_mode):
try:
import evohomeclient2
self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
self._handle_exception(err)
def set_operation_mode(self, operation_mode):
"""Set new target operation mode for the TCS.
Currently limited to 'Auto', 'AutoWithEco' & 'HeatingOff'. If 'Away'
mode is needed, it can be enabled via turn_away_mode_on method.
"""
self._set_operation_mode(HA_STATE_TO_TCS.get(operation_mode))
@property
def should_poll(self) -> bool:
"""Return True as the evohome Controller should always be polled."""
return True
def update(self): def update(self):
"""Get the latest state data of the entire evohome Location. """Get the latest state data of the entire evohome Location.
@ -559,7 +441,6 @@ class EvoController(EvoClimateDevice):
loc_idx = self._params[CONF_LOCATION_IDX] loc_idx = self._params[CONF_LOCATION_IDX]
try: try:
import evohomeclient2
self._status.update( self._status.update(
self._client.locations[loc_idx].status()[GWS][0][TCS][0]) self._client.locations[loc_idx].status()[GWS][0][TCS][0])
except (requests.exceptions.RequestException, except (requests.exceptions.RequestException,

View File

@ -0,0 +1,9 @@
"""Provides the constants needed for evohome."""
DOMAIN = 'evohome'
DATA_EVOHOME = 'data_' + DOMAIN
DISPATCHER_EVOHOME = 'dispatcher_' + DOMAIN
# These are used only to help prevent E501 (line too long) violations.
GWS = 'gateways'
TCS = 'temperatureControlSystems'

View File

@ -6,5 +6,5 @@
"evohomeclient==0.3.2" "evohomeclient==0.3.2"
], ],
"dependencies": [], "dependencies": [],
"codeowners": [] "codeowners": ["@zxdavb"]
} }