Add zones to evohome component (#18428)

* Added Zones, and removed available() logic

flesh out Zones

tidy up init

some more tidying up

Nearly there - full functionality

passed txo - ready to send PR

Ready to PR, except to remove logging

Add Zones and associated functionality to evohome component

Add Zones to evohome (some more tidying up)

Add Zones to evohome (Nearly there - full functionality)

Add Zones to evohome (passed tox)

Add Zones to evohome (except to remove logging)

Add Zones and associated functionality to evohome component

Revert _LOGGER.warn to .debug, as it should be

Cleanup stupid REBASE

* removed a duplicate/unwanted code block

* tidy up comment

* use async_added_to_hass instead of bus.listen

* Pass evo_data instead of hass when instntiating

* switch to async version of setup_platform/add_entities

* Remove workaround for bug in client library
 - using github version for now, as awaiting new PyPi package

* Avoid invalid-name lint - use 'zone_idx' instead of 'z'

* Fix line too long error

* remove commented-out line of code

* fix a logic error, improve REDACTION of potentially-sensitive infomation

* restore use of EVENT_HOMEASSISTANT_START to improve HA startup time

* added a docstring to _flatten_json

* Switch instantiation from component to platform

* Use v0.2.8 of client api (resolves logging bug)

* import rather than duplicate, and de-lint

* We use evohomeclient v0.2.8 now

* remove all the api logging

* Changed scan_interal to Throttle

* added a configurable scan_interval

* small code tidy-up, removed sub-function

* tidy up update() code

* minimize use of self.hass.data[]

* remove lint

* remove unwanted logging

* remove debug code

* correct a small coding error

* small tidyup of code

* remove flatten_json

* add @callback to _first_update()

* switch back to load_platform

* adhere to standards fro logging

* use new format string formatting

* minor change to comments

* convert scan_interval to timedelta from int

* restore rounding up of scan_interval

* code tidy up

* sync when in sync context

* fix typo

* remove raises not needed

* tidy up typos, etc.

* remove invalid-name lint

* tidy up exception handling

* de-lint/pretty-fy

* move 'status' to a JSON node, so theirs room for 'config', 'schedule' in the future
This commit is contained in:
David Bonnes 2018-11-27 11:17:22 +00:00 committed by Martin Hjelmare
parent a03cb12c61
commit 9a25054a0d
5 changed files with 502 additions and 275 deletions

View File

@ -1,7 +1,7 @@
"""Support for Honeywell evohome (EMEA/EU-based systems only). """Support for Climate devices of (EMEA/EU-based) Honeywell evohome systems.
Support for a temperature control system (TCS, controller) with 0+ heating Support for a temperature control system (TCS, controller) with 0+ heating
zones (e.g. TRVs, relays) and, optionally, a DHW controller. zones (e.g. TRVs, relays).
For more details about this platform, please refer to the documentation at For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/climate.evohome/ https://home-assistant.io/components/climate.evohome/
@ -13,29 +13,34 @@ import logging
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from homeassistant.components.climate import ( from homeassistant.components.climate import (
ClimateDevice, STATE_AUTO, STATE_ECO, STATE_MANUAL, STATE_OFF,
STATE_AUTO,
STATE_ECO,
STATE_OFF,
SUPPORT_OPERATION_MODE,
SUPPORT_AWAY_MODE, SUPPORT_AWAY_MODE,
SUPPORT_ON_OFF,
SUPPORT_OPERATION_MODE,
SUPPORT_TARGET_TEMPERATURE,
ClimateDevice
) )
from homeassistant.components.evohome import ( from homeassistant.components.evohome import (
CONF_LOCATION_IDX, DATA_EVOHOME, DISPATCHER_EVOHOME,
DATA_EVOHOME, CONF_LOCATION_IDX, SCAN_INTERVAL_DEFAULT,
MAX_TEMP, EVO_PARENT, EVO_CHILD,
MIN_TEMP, GWS, TCS,
SCAN_INTERVAL_MAX
) )
from homeassistant.const import ( from homeassistant.const import (
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
PRECISION_TENTHS,
TEMP_CELSIUS,
HTTP_TOO_MANY_REQUESTS, HTTP_TOO_MANY_REQUESTS,
PRECISION_HALVES,
TEMP_CELSIUS
) )
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import (
dispatcher_send,
async_dispatcher_connect
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# these are for the controller's opmode/state and the zone's state # the Controller's opmode/state and the zone's (inherited) state
EVO_RESET = 'AutoWithReset' EVO_RESET = 'AutoWithReset'
EVO_AUTO = 'Auto' EVO_AUTO = 'Auto'
EVO_AUTOECO = 'AutoWithEco' EVO_AUTOECO = 'AutoWithEco'
@ -44,7 +49,14 @@ EVO_DAYOFF = 'DayOff'
EVO_CUSTOM = 'Custom' EVO_CUSTOM = 'Custom'
EVO_HEATOFF = 'HeatingOff' EVO_HEATOFF = 'HeatingOff'
EVO_STATE_TO_HA = { # these are for Zones' opmode, and state
EVO_FOLLOW = 'FollowSchedule'
EVO_TEMPOVER = 'TemporaryOverride'
EVO_PERMOVER = 'PermanentOverride'
# for the Controller. NB: evohome treats Away mode as a mode in/of itself,
# where HA considers it to 'override' the exising operating mode
TCS_STATE_TO_HA = {
EVO_RESET: STATE_AUTO, EVO_RESET: STATE_AUTO,
EVO_AUTO: STATE_AUTO, EVO_AUTO: STATE_AUTO,
EVO_AUTOECO: STATE_ECO, EVO_AUTOECO: STATE_ECO,
@ -53,171 +65,150 @@ EVO_STATE_TO_HA = {
EVO_CUSTOM: STATE_AUTO, EVO_CUSTOM: STATE_AUTO,
EVO_HEATOFF: STATE_OFF EVO_HEATOFF: STATE_OFF
} }
HA_STATE_TO_TCS = {
HA_STATE_TO_EVO = {
STATE_AUTO: EVO_AUTO, STATE_AUTO: EVO_AUTO,
STATE_ECO: EVO_AUTOECO, STATE_ECO: EVO_AUTOECO,
STATE_OFF: EVO_HEATOFF STATE_OFF: EVO_HEATOFF
} }
TCS_OP_LIST = list(HA_STATE_TO_TCS)
HA_OP_LIST = list(HA_STATE_TO_EVO) # the Zones' opmode; their state is usually 'inherited' from the TCS
EVO_FOLLOW = 'FollowSchedule'
EVO_TEMPOVER = 'TemporaryOverride'
EVO_PERMOVER = 'PermanentOverride'
# these are used to help prevent E501 (line too long) violations # for the Zones...
GWS = 'gateways' ZONE_STATE_TO_HA = {
TCS = 'temperatureControlSystems' EVO_FOLLOW: STATE_AUTO,
EVO_TEMPOVER: STATE_MANUAL,
# debug codes - these happen occasionally, but the cause is unknown EVO_PERMOVER: STATE_MANUAL
EVO_DEBUG_NO_RECENT_UPDATES = '0x01' }
EVO_DEBUG_NO_STATUS = '0x02' HA_STATE_TO_ZONE = {
STATE_AUTO: EVO_FOLLOW,
STATE_MANUAL: EVO_PERMOVER
}
ZONE_OP_LIST = list(HA_STATE_TO_ZONE)
def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_platform(hass, hass_config, async_add_entities,
"""Create a Honeywell (EMEA/EU) evohome CH/DHW system. discovery_info=None):
"""Create the evohome Controller, and its Zones, if any."""
An evohome system consists of: a controller, with 0-12 heating zones (e.g.
TRVs, relays) and, optionally, a DHW controller (a HW boiler).
Here, we add the controller only.
"""
evo_data = hass.data[DATA_EVOHOME] evo_data = hass.data[DATA_EVOHOME]
client = evo_data['client'] client = evo_data['client']
loc_idx = evo_data['params'][CONF_LOCATION_IDX] loc_idx = evo_data['params'][CONF_LOCATION_IDX]
# evohomeclient has no defined way of accessing non-default location other # evohomeclient has exposed no means of accessing non-default location
# than using a protected member, such as below # (i.e. loc_idx > 0) other than using a protected member, such as below
tcs_obj_ref = client.locations[loc_idx]._gateways[0]._control_systems[0] # noqa E501; pylint: disable=protected-access tcs_obj_ref = client.locations[loc_idx]._gateways[0]._control_systems[0] # noqa E501; pylint: disable=protected-access
_LOGGER.debug( _LOGGER.debug(
"setup_platform(): Found Controller: id: %s [%s], type: %s", "setup_platform(): Found Controller, id=%s [%s], "
"name=%s (location_idx=%s)",
tcs_obj_ref.systemId, tcs_obj_ref.systemId,
tcs_obj_ref.modelType,
tcs_obj_ref.location.name, tcs_obj_ref.location.name,
tcs_obj_ref.modelType loc_idx
) )
parent = EvoController(evo_data, client, tcs_obj_ref)
add_entities([parent], update_before_add=True) controller = EvoController(evo_data, client, tcs_obj_ref)
zones = []
for zone_idx in tcs_obj_ref.zones:
zone_obj_ref = tcs_obj_ref.zones[zone_idx]
_LOGGER.debug(
"setup_platform(): Found Zone, id=%s [%s], "
"name=%s",
zone_obj_ref.zoneId,
zone_obj_ref.zone_type,
zone_obj_ref.name
)
zones.append(EvoZone(evo_data, client, zone_obj_ref))
entities = [controller] + zones
async_add_entities(entities, update_before_add=False)
class EvoController(ClimateDevice): class EvoClimateDevice(ClimateDevice):
"""Base for a Honeywell evohome hub/Controller device. """Base for a Honeywell evohome Climate device."""
The Controller (aka TCS, temperature control system) is the parent of all # pylint: disable=no-member
the child (CH/DHW) devices.
"""
def __init__(self, evo_data, client, obj_ref): def __init__(self, evo_data, client, obj_ref):
"""Initialize the evohome entity. """Initialize the evohome entity."""
self._client = client
Most read-only properties are set here. So are pseudo read-only,
for example name (which _could_ change between update()s).
"""
self.client = client
self._obj = obj_ref self._obj = obj_ref
self._id = obj_ref.systemId
self._name = evo_data['config']['locationInfo']['name']
self._config = evo_data['config'][GWS][0][TCS][0]
self._params = evo_data['params'] self._params = evo_data['params']
self._timers = evo_data['timers'] self._timers = evo_data['timers']
self._timers['statusUpdated'] = datetime.min
self._status = {} self._status = {}
self._available = False # should become True after first update() self._available = False # should become True after first update()
def _handle_requests_exceptions(self, err): async def async_added_to_hass(self):
# evohomeclient v2 api (>=0.2.7) exposes requests exceptions, incl.: """Run when entity about to be added."""
# - HTTP_BAD_REQUEST, is usually Bad user credentials async_dispatcher_connect(self.hass, DISPATCHER_EVOHOME, self._connect)
# - HTTP_TOO_MANY_REQUESTS, is api usuage limit exceeded
# - HTTP_SERVICE_UNAVAILABLE, is often Vendor's fault
@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_requests_exceptions(self, err):
if err.response.status_code == HTTP_TOO_MANY_REQUESTS: if err.response.status_code == HTTP_TOO_MANY_REQUESTS:
# execute a back off: pause, and reduce rate # execute a backoff: pause, and also reduce rate
old_scan_interval = self._params[CONF_SCAN_INTERVAL] old_interval = self._params[CONF_SCAN_INTERVAL]
new_scan_interval = min(old_scan_interval * 2, SCAN_INTERVAL_MAX) new_interval = min(old_interval, SCAN_INTERVAL_DEFAULT) * 2
self._params[CONF_SCAN_INTERVAL] = new_scan_interval self._params[CONF_SCAN_INTERVAL] = new_interval
_LOGGER.warning( _LOGGER.warning(
"API rate limit has been exceeded: increasing '%s' from %s to " "API rate limit has been exceeded. Suspending polling for %s "
"%s seconds, and suspending polling for %s seconds.", "seconds, and increasing '%s' from %s to %s seconds.",
new_interval * 3,
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
old_scan_interval, old_interval,
new_scan_interval, new_interval,
new_scan_interval * 3
) )
self._timers['statusUpdated'] = datetime.now() + \ self._timers['statusUpdated'] = datetime.now() + new_interval * 3
timedelta(seconds=new_scan_interval * 3)
else: else:
raise err raise err # we dont handle any other HTTPErrors
@property @property
def name(self): def name(self) -> str:
"""Return the name to use in the frontend UI.""" """Return the name to use in the frontend UI."""
return self._name return self._name
@property @property
def available(self): def icon(self):
"""Return True if the device is available. """Return the icon to use in the frontend UI."""
return self._icon
All evohome entities are initially unavailable. Once HA has started, @property
state data is then retrieved by the Controller, and then the children def device_state_attributes(self):
will get a state (e.g. operating_mode, current_temperature). """Return the device state attributes of the evohome Climate device.
However, evohome entities can become unavailable for other reasons. 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 return self._available
@property @property
def supported_features(self): def supported_features(self):
"""Get the list of supported features of the Controller.""" """Get the list of supported features of the device."""
return SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE return self._supported_features
@property
def device_state_attributes(self):
"""Return the device state attributes of the controller.
This is operating mode state data that is not available otherwise, due
to the restrictions placed upon ClimateDevice properties, etc by HA.
"""
data = {}
data['systemMode'] = self._status['systemModeStatus']['mode']
data['isPermanent'] = self._status['systemModeStatus']['isPermanent']
if 'timeUntil' in self._status['systemModeStatus']:
data['timeUntil'] = self._status['systemModeStatus']['timeUntil']
data['activeFaults'] = self._status['activeFaults']
return data
@property @property
def operation_list(self): def operation_list(self):
"""Return the list of available operations.""" """Return the list of available operations."""
return HA_OP_LIST return self._operation_list
@property
def current_operation(self):
"""Return the operation mode of the evohome entity."""
return EVO_STATE_TO_HA.get(self._status['systemModeStatus']['mode'])
@property
def target_temperature(self):
"""Return the average target temperature of the Heating/DHW zones."""
temps = [zone['setpointStatus']['targetHeatTemperature']
for zone in self._status['zones']]
avg_temp = round(sum(temps) / len(temps), 1) if temps else None
return avg_temp
@property
def current_temperature(self):
"""Return the average current temperature of the Heating/DHW zones."""
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 temperature_unit(self): def temperature_unit(self):
@ -227,47 +218,313 @@ class EvoController(ClimateDevice):
@property @property
def precision(self): def precision(self):
"""Return the temperature precision to use in the frontend UI.""" """Return the temperature precision to use in the frontend UI."""
return PRECISION_TENTHS return PRECISION_HALVES
class EvoZone(EvoClimateDevice):
"""Base for a Honeywell evohome Zone device."""
def __init__(self, evo_data, client, obj_ref):
"""Initialize the evohome Zone."""
super().__init__(evo_data, client, obj_ref)
self._id = obj_ref.zoneId
self._name = obj_ref.name
self._icon = "mdi:radiator"
self._type = EVO_CHILD
for _zone in evo_data['config'][GWS][0][TCS][0]['zones']:
if _zone['zoneId'] == self._id:
self._config = _zone
break
self._status = {}
self._operation_list = ZONE_OP_LIST
self._supported_features = \
SUPPORT_OPERATION_MODE | \
SUPPORT_TARGET_TEMPERATURE | \
SUPPORT_ON_OFF
@property @property
def min_temp(self): def min_temp(self):
"""Return the minimum target temp (setpoint) of a evohome entity.""" """Return the minimum target temperature of a evohome Zone.
return MIN_TEMP
The default is 5 (in Celsius), but it is configurable within 5-35.
"""
return self._config['setpointCapabilities']['minHeatSetpoint']
@property @property
def max_temp(self): def max_temp(self):
"""Return the maximum target temp (setpoint) of a evohome entity.""" """Return the minimum target temperature of a evohome Zone.
return MAX_TEMP
The default is 35 (in Celsius), but it is configurable within 5-35.
"""
return self._config['setpointCapabilities']['maxHeatSetpoint']
@property @property
def is_on(self): def target_temperature(self):
"""Return true as evohome controllers are always on. """Return the target temperature of the evohome Zone."""
return self._status['setpointStatus']['targetHeatTemperature']
Operating modes can include 'HeatingOff', but (for example) DHW would @property
remain on. def current_temperature(self):
"""Return the current temperature of the evohome Zone."""
return self._status['temperatureStatus']['temperature']
@property
def current_operation(self):
"""Return the current operating mode of the evohome Zone.
The evohome Zones that are in 'FollowSchedule' mode inherit their
actual operating mode from the Controller.
"""
evo_data = self.hass.data[DATA_EVOHOME]
system_mode = evo_data['status']['systemModeStatus']['mode']
setpoint_mode = self._status['setpointStatus']['setpointMode']
if setpoint_mode == EVO_FOLLOW:
# then inherit state from the controller
if system_mode == EVO_RESET:
current_operation = TCS_STATE_TO_HA.get(EVO_AUTO)
else:
current_operation = TCS_STATE_TO_HA.get(system_mode)
else:
current_operation = ZONE_STATE_TO_HA.get(setpoint_mode)
return current_operation
@property
def is_on(self) -> bool:
"""Return True if the evohome Zone is off.
A Zone is considered off if its target temp is set to its minimum, and
it is not following its schedule (i.e. not in 'FollowSchedule' mode).
"""
is_off = \
self.target_temperature == self.min_temp and \
self._status['setpointStatus']['setpointMode'] == EVO_PERMOVER
return not is_off
def _set_temperature(self, temperature, until=None):
"""Set the new target temperature of a Zone.
temperature is required, until can be:
- strftime('%Y-%m-%dT%H:%M:%SZ') for TemporaryOverride, or
- None for PermanentOverride (i.e. indefinitely)
"""
try:
self._obj.set_temperature(temperature, until)
except HTTPError as err:
self._handle_exception("HTTPError", str(err)) # noqa: E501; pylint: disable=no-member
def set_temperature(self, **kwargs):
"""Set new target temperature, indefinitely."""
self._set_temperature(kwargs['temperature'], until=None)
def turn_on(self):
"""Turn the evohome Zone on.
This is achieved by setting the Zone to its 'FollowSchedule' mode.
"""
self._set_operation_mode(EVO_FOLLOW)
def turn_off(self):
"""Turn the evohome Zone off.
This is achieved by setting the Zone to its minimum temperature,
indefinitely (i.e. 'PermanentOverride' mode).
"""
self._set_temperature(self.min_temp, until=None)
def set_operation_mode(self, operation_mode):
"""Set an operating mode for a Zone.
Currently limited to 'Auto' & 'Manual'. If 'Off' is needed, it can be
enabled via turn_off method.
NB: evohome Zones do not have an operating mode as understood by HA.
Instead they usually 'inherit' an operating mode from their controller.
More correctly, these Zones are in a follow mode, 'FollowSchedule',
where their setpoint temperatures are a function of their schedule, and
the Controller's operating_mode, e.g. Economy mode is their scheduled
setpoint less (usually) 3C.
Thus, you cannot set a Zone to Away mode, but the location (i.e. the
Controller) is set to Away and each Zones's setpoints are adjusted
accordingly to some lower temperature.
However, Zones can override these setpoints, either for a specified
period of time, 'TemporaryOverride', after which they will revert back
to 'FollowSchedule' mode, or indefinitely, 'PermanentOverride'.
"""
self._set_operation_mode(HA_STATE_TO_ZONE.get(operation_mode))
def _set_operation_mode(self, operation_mode):
if operation_mode == EVO_FOLLOW:
try:
self._obj.cancel_temp_override(self._obj)
except HTTPError as err:
self._handle_exception("HTTPError", str(err)) # noqa: E501; pylint: disable=no-member
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):
"""Process the evohome Zone's state data."""
evo_data = self.hass.data[DATA_EVOHOME]
for _zone in evo_data['status']['zones']:
if _zone['zoneId'] == self._id:
self._status = _zone
break
self._available = True
class EvoController(EvoClimateDevice):
"""Base for a Honeywell evohome hub/Controller device.
The Controller (aka TCS, temperature control system) is the parent of all
the child (CH/DHW) devices. It is also a Climate device.
"""
def __init__(self, evo_data, client, obj_ref):
"""Initialize the evohome Controller (hub)."""
super().__init__(evo_data, client, obj_ref)
self._id = obj_ref.systemId
self._name = '_{}'.format(obj_ref.location.name)
self._icon = "mdi:thermostat"
self._type = EVO_PARENT
self._config = evo_data['config'][GWS][0][TCS][0]
self._status = evo_data['status']
self._timers['statusUpdated'] = datetime.min
self._operation_list = TCS_OP_LIST
self._supported_features = \
SUPPORT_OPERATION_MODE | \
SUPPORT_AWAY_MODE
@property
def device_state_attributes(self):
"""Return the device state attributes of the evohome Controller.
This is state data that is not available otherwise, due to the
restrictions placed upon ClimateDevice properties, etc. by HA.
"""
status = dict(self._status)
if 'zones' in status:
del status['zones']
if 'dhw' in status:
del status['dhw']
return {'status': status}
@property
def current_operation(self):
"""Return the current operating mode of the evohome Controller."""
return TCS_STATE_TO_HA.get(self._status['systemModeStatus']['mode'])
@property
def min_temp(self):
"""Return the minimum target temperature of a evohome Controller.
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 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
def target_temperature(self):
"""Return the average target temperature of the Heating/DHW zones.
Although evohome Controllers do not have a target temp, one is
expected by the HA schema.
"""
temps = [zone['setpointStatus']['targetHeatTemperature']
for zone in self._status['zones']]
avg_temp = round(sum(temps) / len(temps), 1) if temps else None
return avg_temp
@property
def current_temperature(self):
"""Return the average current temperature of the Heating/DHW zones.
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
def is_on(self) -> bool:
"""Return True as evohome Controllers are always on.
For example, evohome Controllers have a 'HeatingOff' mode, but even
then the DHW would remain on.
""" """
return True return True
@property @property
def is_away_mode_on(self): def is_away_mode_on(self) -> bool:
"""Return true if away mode is on.""" """Return True if away mode is on."""
return self._status['systemModeStatus']['mode'] == EVO_AWAY return self._status['systemModeStatus']['mode'] == EVO_AWAY
def turn_away_mode_on(self): def turn_away_mode_on(self):
"""Turn away mode on.""" """Turn away mode on.
The evohome Controller will not remember is previous operating mode.
"""
self._set_operation_mode(EVO_AWAY) self._set_operation_mode(EVO_AWAY)
def turn_away_mode_off(self): def turn_away_mode_off(self):
"""Turn away mode off.""" """Turn away mode off.
The evohome Controller can not recall its previous operating mode (as
intimated by the HA schema), so this method is achieved by setting the
Controller's mode back to Auto.
"""
self._set_operation_mode(EVO_AUTO) self._set_operation_mode(EVO_AUTO)
def _set_operation_mode(self, operation_mode): def _set_operation_mode(self, operation_mode):
# Set new target operation mode for the TCS.
_LOGGER.debug(
"_set_operation_mode(): API call [1 request(s)]: "
"tcs._set_status(%s)...",
operation_mode
)
try: try:
self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access
except HTTPError as err: except HTTPError as err:
@ -279,93 +536,45 @@ class EvoController(ClimateDevice):
Currently limited to 'Auto', 'AutoWithEco' & 'HeatingOff'. If 'Away' Currently limited to 'Auto', 'AutoWithEco' & 'HeatingOff'. If 'Away'
mode is needed, it can be enabled via turn_away_mode_on method. mode is needed, it can be enabled via turn_away_mode_on method.
""" """
self._set_operation_mode(HA_STATE_TO_EVO.get(operation_mode)) self._set_operation_mode(HA_STATE_TO_TCS.get(operation_mode))
def _update_state_data(self, evo_data): @property
client = evo_data['client'] def should_poll(self) -> bool:
loc_idx = evo_data['params'][CONF_LOCATION_IDX] """Return True as the evohome Controller should always be polled."""
return True
_LOGGER.debug(
"_update_state_data(): API call [1 request(s)]: "
"client.locations[loc_idx].status()..."
)
try:
evo_data['status'].update(
client.locations[loc_idx].status()[GWS][0][TCS][0])
except HTTPError as err: # check if we've exceeded the api rate limit
self._handle_requests_exceptions(err)
else:
evo_data['timers']['statusUpdated'] = datetime.now()
_LOGGER.debug(
"_update_state_data(): evo_data['status'] = %s",
evo_data['status']
)
def update(self): def update(self):
"""Get the latest state data of the installation. """Get the latest state data of the entire evohome Location.
This includes state data for the Controller and its child devices, such This includes state data for the Controller and all its child devices,
as the operating_mode of the Controller and the current_temperature such as the operating mode of the Controller and the current temp of
of its children. its children (e.g. Zones, DHW controller).
This is not asyncio-friendly due to the underlying client api.
""" """
evo_data = self.hass.data[DATA_EVOHOME] # should the latest evohome state data be retreived this cycle?
timeout = datetime.now() + timedelta(seconds=55) timeout = datetime.now() + timedelta(seconds=55)
expired = timeout > self._timers['statusUpdated'] + \ expired = timeout > self._timers['statusUpdated'] + \
timedelta(seconds=evo_data['params'][CONF_SCAN_INTERVAL]) self._params[CONF_SCAN_INTERVAL]
if not expired: if not expired:
return return
was_available = self._available or \ # Retreive the latest state data via the client api
self._timers['statusUpdated'] == datetime.min loc_idx = self._params[CONF_LOCATION_IDX]
self._update_state_data(evo_data)
self._status = evo_data['status']
if _LOGGER.isEnabledFor(logging.DEBUG):
tmp_dict = dict(self._status)
if 'zones' in tmp_dict:
tmp_dict['zones'] = '...'
if 'dhw' in tmp_dict:
tmp_dict['dhw'] = '...'
_LOGGER.debug(
"update(%s), self._status = %s",
self._id + " [" + self._name + "]",
tmp_dict
)
no_recent_updates = self._timers['statusUpdated'] < datetime.now() - \
timedelta(seconds=self._params[CONF_SCAN_INTERVAL] * 3.1)
if no_recent_updates:
self._available = False
debug_code = EVO_DEBUG_NO_RECENT_UPDATES
elif not self._status:
# unavailable because no status (but how? other than at startup?)
self._available = False
debug_code = EVO_DEBUG_NO_STATUS
try:
self._status.update(
self._client.locations[loc_idx].status()[GWS][0][TCS][0])
except HTTPError as err: # check if we've exceeded the api rate limit
self._handle_requests_exceptions(err)
else: else:
self._timers['statusUpdated'] = datetime.now()
self._available = True self._available = True
if not self._available and was_available: _LOGGER.debug(
# only warn if available went from True to False "_update_state_data(): self._status = %s",
_LOGGER.warning( self._status
"The entity, %s, has become unavailable, debug code is: %s", )
self._id + " [" + self._name + "]",
debug_code
)
elif self._available and not was_available: # inform the child devices that state data has been updated
# this isn't the first re-available (e.g. _after_ STARTUP) pkt = {'sender': 'controller', 'signal': 'refresh', 'to': EVO_CHILD}
_LOGGER.debug( dispatcher_send(self.hass, DISPATCHER_EVOHOME, pkt)
"The entity, %s, has become available",
self._id + " [" + self._name + "]"
)

View File

@ -20,7 +20,7 @@ from homeassistant.const import (
CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT,
ATTR_TEMPERATURE, CONF_REGION) ATTR_TEMPERATURE, CONF_REGION)
REQUIREMENTS = ['evohomeclient==0.2.7', 'somecomfort==0.5.2'] REQUIREMENTS = ['evohomeclient==0.2.8', 'somecomfort==0.5.2']
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@ -1,4 +1,4 @@
"""Support for Honeywell evohome (EMEA/EU-based systems only). """Support for (EMEA/EU-based) Honeywell evohome systems.
Support for a temperature control system (TCS, controller) with 0+ heating Support for a temperature control system (TCS, controller) with 0+ heating
zones (e.g. TRVs, relays) and, optionally, a DHW controller. zones (e.g. TRVs, relays) and, optionally, a DHW controller.
@ -8,46 +8,48 @@ https://home-assistant.io/components/evohome/
""" """
# Glossary: # Glossary:
# TCS - temperature control system (a.k.a. Controller, Parent), which can # TCS - temperature control system (a.k.a. Controller, Parent), which can
# have up to 13 Children: # have up to 13 Children:
# 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
from datetime import timedelta
import logging import logging
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ( from homeassistant.const import (
CONF_USERNAME, CONF_SCAN_INTERVAL, CONF_USERNAME, CONF_PASSWORD,
CONF_PASSWORD, EVENT_HOMEASSISTANT_START,
CONF_SCAN_INTERVAL, HTTP_BAD_REQUEST, HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS
HTTP_BAD_REQUEST
) )
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
REQUIREMENTS = ['evohomeclient==0.2.7'] REQUIREMENTS = ['evohomeclient==0.2.8']
# If ever > 0.2.7, re-check the work-around wrapper is still required when
# instantiating the client, below.
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = 'evohome' DOMAIN = 'evohome'
DATA_EVOHOME = 'data_' + DOMAIN DATA_EVOHOME = 'data_' + DOMAIN
DISPATCHER_EVOHOME = 'dispatcher_' + DOMAIN
CONF_LOCATION_IDX = 'location_idx' CONF_LOCATION_IDX = 'location_idx'
MAX_TEMP = 28 SCAN_INTERVAL_DEFAULT = timedelta(seconds=300)
MIN_TEMP = 5 SCAN_INTERVAL_MINIMUM = timedelta(seconds=180)
SCAN_INTERVAL_DEFAULT = 180
SCAN_INTERVAL_MAX = 300
CONFIG_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({ DOMAIN: vol.Schema({
vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_LOCATION_IDX, default=0): cv.positive_int, vol.Optional(CONF_LOCATION_IDX, default=0):
cv.positive_int,
vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL_DEFAULT):
vol.All(cv.time_period, vol.Range(min=SCAN_INTERVAL_MINIMUM)),
}), }),
}, extra=vol.ALLOW_EXTRA) }, extra=vol.ALLOW_EXTRA)
@ -55,91 +57,107 @@ CONFIG_SCHEMA = vol.Schema({
GWS = 'gateways' GWS = 'gateways'
TCS = 'temperatureControlSystems' TCS = 'temperatureControlSystems'
# bit masks for dispatcher packets
EVO_PARENT = 0x01
EVO_CHILD = 0x02
def setup(hass, config):
"""Create a Honeywell (EMEA/EU) evohome CH/DHW system.
One controller with 0+ heating zones (e.g. TRVs, relays) and, optionally, a def setup(hass, hass_config):
DHW controller. Does not work for US-based systems. """Create a (EMEA/EU-based) Honeywell evohome system.
Currently, only the Controller and the Zones are implemented here.
""" """
evo_data = hass.data[DATA_EVOHOME] = {} evo_data = hass.data[DATA_EVOHOME] = {}
evo_data['timers'] = {} evo_data['timers'] = {}
evo_data['params'] = dict(config[DOMAIN]) # use a copy, since scan_interval is rounded up to nearest 60s
evo_data['params'][CONF_SCAN_INTERVAL] = SCAN_INTERVAL_DEFAULT evo_data['params'] = dict(hass_config[DOMAIN])
scan_interval = evo_data['params'][CONF_SCAN_INTERVAL]
scan_interval = timedelta(
minutes=(scan_interval.total_seconds() + 59) // 60)
from evohomeclient2 import EvohomeClient from evohomeclient2 import EvohomeClient
_LOGGER.debug("setup(): API call [4 request(s)]: client.__init__()...")
try: try:
# There's a bug in evohomeclient2 v0.2.7: the client.__init__() sets
# the root loglevel when EvohomeClient(debug=?), so remember it now...
log_level = logging.getLogger().getEffectiveLevel()
client = EvohomeClient( client = EvohomeClient(
evo_data['params'][CONF_USERNAME], evo_data['params'][CONF_USERNAME],
evo_data['params'][CONF_PASSWORD], evo_data['params'][CONF_PASSWORD],
debug=False debug=False
) )
# ...then restore it to what it was before instantiating the client
logging.getLogger().setLevel(log_level)
except HTTPError as err: except HTTPError as err:
if err.response.status_code == HTTP_BAD_REQUEST: if err.response.status_code == HTTP_BAD_REQUEST:
_LOGGER.error( _LOGGER.error(
"Failed to establish a connection with evohome web servers, " "setup(): Failed to connect with the vendor's web servers. "
"Check your username (%s), and password are correct." "Check your username (%s), and password are correct."
"Unable to continue. Resolve any errors and restart HA.", "Unable to continue. Resolve any errors and restart HA.",
evo_data['params'][CONF_USERNAME] evo_data['params'][CONF_USERNAME]
) )
return False # unable to continue
raise # we dont handle any other HTTPErrors elif err.response.status_code == HTTP_SERVICE_UNAVAILABLE:
_LOGGER.error(
"setup(): Failed to connect with the vendor's web servers. "
"The server is not contactable. Unable to continue. "
"Resolve any errors and restart HA."
)
finally: # Redact username, password as no longer needed. elif err.response.status_code == HTTP_TOO_MANY_REQUESTS:
_LOGGER.error(
"setup(): Failed to connect with the vendor's web servers. "
"You have exceeded the api rate limit. Unable to continue. "
"Wait a while (say 10 minutes) and restart HA."
)
else:
raise # we dont expect/handle any other HTTPErrors
return False # unable to continue
finally: # Redact username, password as no longer needed
evo_data['params'][CONF_USERNAME] = 'REDACTED' evo_data['params'][CONF_USERNAME] = 'REDACTED'
evo_data['params'][CONF_PASSWORD] = 'REDACTED' evo_data['params'][CONF_PASSWORD] = 'REDACTED'
evo_data['client'] = client evo_data['client'] = client
evo_data['status'] = {}
# Redact any installation data we'll never need. # Redact any installation data we'll never need
if client.installation_info[0]['locationInfo']['locationId'] != 'REDACTED': for loc in client.installation_info:
for loc in client.installation_info: loc['locationInfo']['locationId'] = 'REDACTED'
loc['locationInfo']['streetAddress'] = 'REDACTED' loc['locationInfo']['locationOwner'] = 'REDACTED'
loc['locationInfo']['city'] = 'REDACTED' loc['locationInfo']['streetAddress'] = 'REDACTED'
loc['locationInfo']['locationOwner'] = 'REDACTED' loc['locationInfo']['city'] = 'REDACTED'
loc[GWS][0]['gatewayInfo'] = 'REDACTED' loc[GWS][0]['gatewayInfo'] = 'REDACTED'
# Pull down the installation configuration. # Pull down the installation configuration
loc_idx = evo_data['params'][CONF_LOCATION_IDX] loc_idx = evo_data['params'][CONF_LOCATION_IDX]
try: try:
evo_data['config'] = client.installation_info[loc_idx] evo_data['config'] = client.installation_info[loc_idx]
except IndexError: except IndexError:
_LOGGER.warning( _LOGGER.warning(
"setup(): Parameter '%s' = %s , is outside its range (0-%s)", "setup(): Parameter '%s'=%s, is outside its range (0-%s)",
CONF_LOCATION_IDX, CONF_LOCATION_IDX,
loc_idx, loc_idx,
len(client.installation_info) - 1 len(client.installation_info) - 1
) )
return False # unable to continue return False # unable to continue
evo_data['status'] = {}
if _LOGGER.isEnabledFor(logging.DEBUG): if _LOGGER.isEnabledFor(logging.DEBUG):
tmp_loc = dict(evo_data['config']) tmp_loc = dict(evo_data['config'])
tmp_loc['locationInfo']['postcode'] = 'REDACTED' tmp_loc['locationInfo']['postcode'] = 'REDACTED'
tmp_tcs = tmp_loc[GWS][0][TCS][0] if 'dhw' in tmp_loc[GWS][0][TCS][0]: # if this location has DHW...
if 'zones' in tmp_tcs: tmp_loc[GWS][0][TCS][0]['dhw'] = '...'
tmp_tcs['zones'] = '...'
if 'dhw' in tmp_tcs:
tmp_tcs['dhw'] = '...'
_LOGGER.debug("setup(), location = %s", tmp_loc) _LOGGER.debug("setup(): evo_data['config']=%s", tmp_loc)
load_platform(hass, 'climate', DOMAIN, {}, config) load_platform(hass, 'climate', DOMAIN, {}, hass_config)
@callback
def _first_update(event):
# When HA has started, the hub knows to retreive it's first update
pkt = {'sender': 'setup()', 'signal': 'refresh', 'to': EVO_PARENT}
async_dispatcher_send(hass, DISPATCHER_EVOHOME, pkt)
hass.bus.listen(EVENT_HOMEASSISTANT_START, _first_update)
return True return True

View File

@ -358,7 +358,7 @@ eternalegypt==0.0.5
# homeassistant.components.evohome # homeassistant.components.evohome
# homeassistant.components.climate.honeywell # homeassistant.components.climate.honeywell
evohomeclient==0.2.7 evohomeclient==0.2.8
# homeassistant.components.image_processing.dlib_face_detect # homeassistant.components.image_processing.dlib_face_detect
# homeassistant.components.image_processing.dlib_face_identify # homeassistant.components.image_processing.dlib_face_identify

View File

@ -63,7 +63,7 @@ ephem==3.7.6.0
# homeassistant.components.evohome # homeassistant.components.evohome
# homeassistant.components.climate.honeywell # homeassistant.components.climate.honeywell
evohomeclient==0.2.7 evohomeclient==0.2.8
# homeassistant.components.feedreader # homeassistant.components.feedreader
feedparser==5.2.1 feedparser==5.2.1