Files
core/homeassistant/components/evohome/__init__.py
Pascal Vizeli 84cf76ba36 Climate 1.0 (#23899)
* Climate 1.0 / part 1/2/3

* fix flake

* Lint

* Update Google Assistant

* ambiclimate to climate 1.0 (#24911)

* Fix Alexa

* Lint

* Migrate zhong_hong

* Migrate tuya

* Migrate honeywell to new climate schema (#24257)

* Update one

* Fix model climate v2

* Cleanup p4

* Add comfort hold mode

* Fix old code

* Update homeassistant/components/climate/__init__.py

Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io>

* Update homeassistant/components/climate/const.py

Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io>

* First renaming

* Rename operation to hvac for paulus

* Rename hold mode to preset mode

* Cleanup & update comments

* Remove on/off

* Fix supported feature count

* Update services

* Update demo

* Fix tests & use current_hvac

* Update comment

* Fix tests & add typing

* Add more typing

* Update modes

* Fix tests

* Cleanup low/high with range

* Update homematic part 1

* Finish homematic

* Fix lint

* fix hm mapping

* Support simple devices

* convert lcn

* migrate oem

* Fix xs1

* update hive

* update mil

* Update toon

* migrate deconz

* cleanup

* update tesla

* Fix lint

* Fix vera

* Migrate zwave

* Migrate velbus

* Cleanup humity feature

* Cleanup

* Migrate wink

* migrate dyson

* Fix current hvac

* Renaming

* Fix lint

* Migrate tfiac

* migrate tado

* Fix PRESET can be None

* apply PR#23913 from dev

* remove EU component, etc.

* remove EU component, etc.

* ready to test now

* de-linted

* some tweaks

* de-lint

* better handling of edge cases

* delint

* fix set_mode typos

* apply PR#23913 from dev

* remove EU component, etc.

* ready to test now

* de-linted

* some tweaks

* de-lint

* better handling of edge cases

* delint

* fix set_mode typos

* delint, move debug code

* away preset now working

* code tidy-up

* code tidy-up 2

* code tidy-up 3

* address issues #18932, #15063

* address issues #18932, #15063 - 2/2

* refactor MODE_AUTO to MODE_HEAT_COOL and use F not C

* add low/high to set_temp

* add low/high to set_temp 2

* add low/high to set_temp - delint

* run HA scripts

* port changes from PR #24402

* manual rebase

* manual rebase 2

* delint

* minor change

* remove SUPPORT_HVAC_ACTION

* Migrate radiotherm

* Convert touchline

* Migrate flexit

* Migrate nuheat

* Migrate maxcube

* Fix names maxcube const

* Migrate proliphix

* Migrate heatmiser

* Migrate fritzbox

* Migrate opentherm_gw

* Migrate venstar

* Migrate daikin

* Migrate modbus

* Fix elif

* Migrate Homematic IP Cloud to climate-1.0 (#24913)

* hmip climate fix

* Update hvac_mode and preset_mode

* fix lint

* Fix lint

* Migrate generic_thermostat

* Migrate incomfort to new climate schema (#24915)

* initial commit

* Update climate.py

* Migrate eq3btsmart

* Lint

* cleanup PRESET_MANUAL

* Migrate ecobee

* No conditional features

* KNX: Migrate climate component to new climate platform (#24931)

* Migrate climate component

* Remove unused code

* Corrected line length

* Lint

* Lint

* fix tests

* Fix value

* Migrate geniushub to new climate schema (#24191)

* Update one

* Fix model climate v2

* Cleanup p4

* Add comfort hold mode

* Fix old code

* Update homeassistant/components/climate/__init__.py

Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io>

* Update homeassistant/components/climate/const.py

Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io>

* First renaming

* Rename operation to hvac for paulus

* Rename hold mode to preset mode

* Cleanup & update comments

* Remove on/off

* Fix supported feature count

* Update services

* Update demo

* Fix tests & use current_hvac

* Update comment

* Fix tests & add typing

* Add more typing

* Update modes

* Fix tests

* Cleanup low/high with range

* Update homematic part 1

* Finish homematic

* Fix lint

* fix hm mapping

* Support simple devices

* convert lcn

* migrate oem

* Fix xs1

* update hive

* update mil

* Update toon

* migrate deconz

* cleanup

* update tesla

* Fix lint

* Fix vera

* Migrate zwave

* Migrate velbus

* Cleanup humity feature

* Cleanup

* Migrate wink

* migrate dyson

* Fix current hvac

* Renaming

* Fix lint

* Migrate tfiac

* migrate tado

* delinted

* delinted

* use latest client

* clean up mappings

* clean up mappings

* add duration to set_temperature

* add duration to set_temperature

* manual rebase

* tweak

* fix regression

* small fix

* fix rebase mixup

* address comments

* finish refactor

* fix regression

* tweak type hints

* delint

* manual rebase

* WIP: Fixes for honeywell migration to climate-1.0 (#24938)

* add type hints

* code tidy-up

* Fixes for incomfort migration to climate-1.0 (#24936)

* delint type hints

* no async unless await

* revert: no async unless await

* revert: no async unless await 2

* delint

* fix typo

* Fix homekit_controller on climate-1.0 (#24948)

* Fix tests on climate-1.0 branch

* As part of climate-1.0, make state return the heating-cooling.current characteristic

* Fixes from review

* lint

* Fix imports

* Migrate stibel_eltron

* Fix lint

* Migrate coolmaster to climate 1.0 (#24967)

* Migrate coolmaster to climate 1.0

* fix lint errors

* More lint fixes

* Fix demo to work with UI

* Migrate spider

* Demo update

* Updated frontend to 20190705.0

* Fix boost mode (#24980)

* Prepare Netatmo for climate 1.0 (#24973)

* Migration Netatmo

* Address comments

* Update climate.py

* Migrate ephember

* Migrate Sensibo

* Implemented review comments (#24942)

* Migrate ESPHome

* Migrate MQTT

* Migrate Nest

* Migrate melissa

* Initial/partial migration of ST

* Migrate ST

* Remove Away mode (#24995)

* Migrate evohome, cache access tokens (#24491)

* add water_heater, add storage - initial commit

* add water_heater, add storage - initial commit

delint

add missing code

desiderata

update honeywell client library & CODEOWNER

add auth_tokens code, refactor & delint

refactor for broker

delint

* Add Broker, Water Heater & Refactor

add missing code

desiderata

* update honeywell client library & CODEOWNER

add auth_tokens code, refactor & delint

refactor for broker

* bugfix - loc_idx may not be 0

more refactor - ensure pure async

more refactoring

appears all r/o attributes are working

tweak precsion, DHW & delint

remove unused code

remove unused code 2

remove unused code, refactor _save_auth_tokens()

* support RoundThermostat

bugfix opmode, switch to util.dt, add until=1h

revert breaking change

* store at_expires as naive UTC

remove debug code

delint

tidy up exception handling

delint

add water_heater, add storage - initial commit

delint

add missing code

desiderata

update honeywell client library & CODEOWNER

add auth_tokens code, refactor & delint

refactor for broker

add water_heater, add storage - initial commit

delint

add missing code

desiderata

update honeywell client library & CODEOWNER

add auth_tokens code, refactor & delint

refactor for broker

delint

bugfix - loc_idx may not be 0

more refactor - ensure pure async

more refactoring

appears all r/o attributes are working

tweak precsion, DHW & delint

remove unused code

remove unused code 2

remove unused code, refactor _save_auth_tokens()

support RoundThermostat

bugfix opmode, switch to util.dt, add until=1h

revert breaking change

store at_expires as naive UTC

remove debug code

delint

tidy up exception handling

delint

* update CODEOWNERS

* fix regression

* fix requirements

* migrate to climate-1.0

* tweaking

* de-lint

* TCS working? & delint

* tweaking

* TCS code finalised

* remove available() logic

* refactor _switchpoints()

* tidy up switchpoint code

* tweak

* teaking device_state_attributes

* some refactoring

* move PRESET_CUSTOM back to evohome

* move CONF_ACCESS_TOKEN_EXPIRES CONF_REFRESH_TOKEN back to evohome

* refactor SP code and dt conversion

* delinted

* delinted

* remove water_heater

* fix regression

* Migrate homekit

* Cleanup away mode

* Fix tests

* add helpers

* fix tests melissa

* Fix nehueat

* fix zwave

* add more tests

* fix deconz

* Fix climate test emulate_hue

* fix tests

* fix dyson tests

* fix demo with new layout

* fix honeywell

* Switch homekit_controller to use HVAC_MODE_HEAT_COOL instead of HVAC_MODE_AUTO (#25009)

* Lint

* PyLint

* Pylint

* fix fritzbox tests

* Fix google

* Fix all tests

* Fix lint

* Fix auto for homekit like controler

* Fix lint

* fix lint
2019-07-08 14:00:24 +02:00

359 lines
12 KiB
Python

"""Support for (EMEA/EU-based) Honeywell TCC climate systems.
Such systems include evohome (multi-zone), and Round Thermostat (single zone).
"""
from datetime import datetime, timedelta
import logging
from typing import Any, Dict, Tuple
from dateutil.tz import tzlocal
import requests.exceptions
import voluptuous as vol
import evohomeclient2
from homeassistant.const import (
CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME,
HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS, TEMP_CELSIUS)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import load_platform
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
from .const import DOMAIN, EVO_STRFTIME, STORAGE_VERSION, STORAGE_KEY, GWS, TCS
_LOGGER = logging.getLogger(__name__)
CONF_ACCESS_TOKEN_EXPIRES = 'access_token_expires'
CONF_REFRESH_TOKEN = 'refresh_token'
CONF_LOCATION_IDX = 'location_idx'
SCAN_INTERVAL_DEFAULT = timedelta(seconds=300)
SCAN_INTERVAL_MINIMUM = timedelta(seconds=60)
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
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)
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)
def _handle_exception(err):
try:
raise err
except evohomeclient2.AuthenticationError:
_LOGGER.error(
"Failed to (re)authenticate with the vendor's server. "
"Check that your username and password are correct. "
"Message is: %s",
err
)
return False
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."
"Message is: %s",
err
)
return False
except requests.exceptions.HTTPError:
if err.response.status_code == HTTP_SERVICE_UNAVAILABLE:
_LOGGER.warning(
"Vendor says their server is currently unavailable. "
"Check the vendor's status page."
)
return False
if err.response.status_code == HTTP_TOO_MANY_REQUESTS:
_LOGGER.warning(
"The vendor's API rate limit has been exceeded. "
"Consider increasing the %s.", CONF_SCAN_INTERVAL
)
return False
raise # we don't expect/handle any other HTTPErrors
async def async_setup(hass, hass_config):
"""Create a (EMEA/EU-based) Honeywell evohome system."""
broker = EvoBroker(hass, hass_config[DOMAIN])
if not await 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.")
async_track_time_interval(
hass, broker.update, hass_config[DOMAIN][CONF_SCAN_INTERVAL]
)
return True
class EvoBroker:
"""Container for evohome client and data."""
def __init__(self, hass, params) -> None:
"""Initialize the evohome client and data structure."""
self.hass = hass
self.params = params
self.config = self.status = self.timers = {}
self.client = self.tcs = None
self._app_storage = None
hass.data[DOMAIN] = {}
hass.data[DOMAIN]['broker'] = self
async 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()
try:
client = self.client = await self.hass.async_add_executor_job(
evohomeclient2.EvohomeClient,
self.params[CONF_USERNAME],
self.params[CONF_PASSWORD],
False,
refresh_token,
access_token,
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'
loc_idx = self.params[CONF_LOCATION_IDX]
try:
self.config = client.installation_info[loc_idx][GWS][0][TCS][0]
except IndexError:
_LOGGER.error(
"Config error: '%s' = %s, but its valid range is 0-%s. "
"Unable to continue. "
"Fix any configuration errors and restart HA.",
CONF_LOCATION_IDX, loc_idx, len(client.installation_info) - 1
)
return False
else:
self.tcs = \
client.locations[loc_idx]._gateways[0]._control_systems[0] # noqa: E501; pylint: disable=protected-access
_LOGGER.debug("Config = %s", self.config)
return True
async def _load_auth_tokens(self) -> Tuple[str, str, datetime]:
store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
app_storage = self._app_storage = await store.async_load()
if app_storage.get(CONF_USERNAME) == self.params[CONF_USERNAME]:
refresh_token = app_storage.get(CONF_REFRESH_TOKEN)
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)
else:
at_expires_dt = None
return (refresh_token, access_token, at_expires_dt)
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(
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()
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
)
def update(self, *args, **kwargs) -> None:
"""Get the latest state data of the entire evohome Location.
This includes state data for the Controller and all its child devices,
such as the operating mode of the Controller and the current temp of
its children (e.g. Zones, DHW controller).
"""
loc_idx = self.params[CONF_LOCATION_IDX]
try:
status = self.client.locations[loc_idx].status()[GWS][0][TCS][0]
except (requests.exceptions.RequestException,
evohomeclient2.AuthenticationError) as err:
_handle_exception(err)
else:
self.timers['statusUpdated'] = utcnow()
_LOGGER.debug("Status = %s", status)
# inform the evohome devices that state data has been updated
async_dispatcher_send(self.hass, DOMAIN, {'signal': 'refresh'})
class EvoDevice(Entity):
"""Base for any evohome device.
This includes the Controller, (up to 12) Heating Zones and
(optionally) a DHW controller.
"""
def __init__(self, evo_broker, evo_device) -> None:
"""Initialize the evohome entity."""
self._evo_device = evo_device
self._evo_tcs = evo_broker.tcs
self._name = self._icon = self._precision = None
self._state_attributes = []
self._supported_features = None
self._setpoints = None
@callback
def _refresh(self, packet):
if packet['signal'] == 'refresh':
self.async_schedule_update_ha_state(force_refresh=True)
def get_setpoints(self) -> 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()
day_time = datetime.now()
day_of_week = int(day_time.strftime('%w')) # 0 is Sunday
# Iterate today's switchpoints until past the current time of day...
day = schedule['DailySchedules'][day_of_week]
sp_idx = -1 # last switchpoint of the day before
for i, tmp in enumerate(day['Switchpoints']):
if day_time.strftime('%H:%M:%S') > tmp['TimeOfDay']:
sp_idx = i # current setpoint
else:
break
# Did the current SP start yesterday? Does the next start SP tomorrow?
current_sp_day = -1 if sp_idx == -1 else 0
next_sp_day = 1 if sp_idx + 1 == len(day['Switchpoints']) else 0
for key, offset, idx in [
('current', current_sp_day, sp_idx),
('next', next_sp_day, (sp_idx + 1) * (1 - next_sp_day))]:
spt = switchpoints[key] = {}
sp_date = (day_time + timedelta(days=offset)).strftime('%Y-%m-%d')
day = schedule['DailySchedules'][(day_of_week + offset) % 7]
switchpoint = day['Switchpoints'][idx]
dt_naive = datetime.strptime(
'{}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)
return switchpoints
@property
def should_poll(self) -> bool:
"""Evohome entities should not be polled."""
return False
@property
def name(self) -> str:
"""Return the name of the Evohome entity."""
return self._name
@property
def device_state_attributes(self) -> Dict[str, Any]:
"""Return the Evohome-specific state attributes."""
status = {}
for attr in self._state_attributes:
if attr != 'setpoints':
status[attr] = getattr(self._evo_device, attr)
if 'setpoints' in self._state_attributes:
status['setpoints'] = self._setpoints
return {'status': status}
@property
def icon(self) -> str:
"""Return the icon to use in the frontend UI."""
return self._icon
@property
def supported_features(self) -> int:
"""Get the flag of supported features of the device."""
return self._supported_features
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
async_dispatcher_connect(self.hass, DOMAIN, self._refresh)
@property
def precision(self) -> float:
"""Return the temperature precision to use in the frontend UI."""
return self._precision
@property
def temperature_unit(self) -> str:
"""Return the temperature unit to use in the frontend UI."""
return TEMP_CELSIUS
def update(self) -> None:
"""Get the latest state data."""
self._setpoints = self.get_setpoints()