Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bugfix evohome #26810

Merged
merged 29 commits into from Oct 1, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ecddf78
address issues #25984, #25985
zxdavb Sep 21, 2019
e644c6b
Merge remote-tracking branch 'upstream/dev' into evohome_bugfix
zxdavb Sep 21, 2019
148e3c6
small tweak
zxdavb Sep 21, 2019
4fd3e99
refactor - fix bugs, coding erros, consolidate
zxdavb Sep 22, 2019
5dff8cf
some zones don't have schedules
zxdavb Sep 23, 2019
6c78f36
some zones don't have schedules 2
zxdavb Sep 23, 2019
53e582a
some zones don't have schedules 3
zxdavb Sep 23, 2019
c2e0f84
fix water_heater, add away mode
zxdavb Sep 23, 2019
8b6c53a
Merge remote-tracking branch 'upstream/dev' into evohome_bugfix
zxdavb Sep 23, 2019
8e9e97d
readbility tweak
zxdavb Sep 23, 2019
67d1e33
bugfix: no refesh after state change
zxdavb Sep 24, 2019
5034bf6
bugfix: no refesh after state change 2
zxdavb Sep 24, 2019
efb924a
temove dodgy wrappers (protected-access), fix until logic
zxdavb Sep 24, 2019
8acd0cb
remove dodgy _set_zone_mode wrapper
zxdavb Sep 24, 2019
d63e69e
tweak
zxdavb Sep 24, 2019
ef047ab
Merge remote-tracking branch 'upstream/dev' into evohome_bugfix
zxdavb Sep 24, 2019
42a4b3d
Merge remote-tracking branch 'upstream/dev' into evohome_bugfix
zxdavb Sep 24, 2019
473c5bf
Merge remote-tracking branch 'upstream/dev' into evohome_bugfix
zxdavb Sep 24, 2019
0d0fe42
tweak docstrings
zxdavb Sep 24, 2019
5f27d0a
Merge remote-tracking branch 'upstream/dev' into evohome_bugfix
zxdavb Sep 25, 2019
ec1df91
refactor as per PR review
zxdavb Sep 25, 2019
6b5a210
refactor as per PR review 3
zxdavb Sep 26, 2019
f30d98d
refactor to use dt_util
zxdavb Sep 26, 2019
9109bc6
small tweak
zxdavb Sep 26, 2019
d50a889
tweak doc strings
zxdavb Sep 26, 2019
d451922
remove packet from _refresh
zxdavb Sep 27, 2019
6592180
set_temp() don't have until
zxdavb Sep 28, 2019
11b2bde
add unique_id
zxdavb Sep 30, 2019
2fab37d
add unique_id 2
zxdavb Sep 30, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
247 changes: 148 additions & 99 deletions homeassistant/components/evohome/__init__.py
Expand Up @@ -4,6 +4,7 @@
"""
from datetime import datetime, timedelta
import logging
import re
from typing import Any, Dict, Optional, Tuple

import aiohttp.client_exceptions
Expand All @@ -25,9 +26,9 @@
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from homeassistant.util.dt import parse_datetime, utcnow
import homeassistant.util.dt as dt_util

from .const import DOMAIN, STORAGE_VERSION, STORAGE_KEY, GWS, TCS
from .const import DOMAIN, EVO_FOLLOW, STORAGE_VERSION, STORAGE_KEY, GWS, TCS

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -55,20 +56,45 @@
)


def _local_dt_to_utc(dt_naive: datetime) -> datetime:
dt_aware = utcnow() + (dt_naive - datetime.now())
def _local_dt_to_aware(dt_naive: datetime) -> datetime:
dt_aware = dt_util.now() + (dt_naive - datetime.now())
if dt_aware.microsecond >= 500000:
dt_aware += timedelta(seconds=1)
return dt_aware.replace(microsecond=0)


def _utc_to_local_dt(dt_aware: datetime) -> datetime:
dt_naive = datetime.now() + (dt_aware - utcnow())
def _dt_to_local_naive(dt_aware: datetime) -> datetime:
dt_naive = datetime.now() + (dt_aware - dt_util.now())
if dt_naive.microsecond >= 500000:
dt_naive += timedelta(seconds=1)
return dt_naive.replace(microsecond=0)


def convert_until(status_dict, until_key) -> str:
"""Convert datetime string from "%Y-%m-%dT%H:%M:%SZ" to local/aware/isoformat."""
if until_key in status_dict: # only present for certain modes
dt_utc_naive = dt_util.parse_datetime(status_dict[until_key])
status_dict[until_key] = dt_util.as_local(dt_utc_naive).isoformat()


def convert_dict(dictionary: Dict[str, Any]) -> Dict[str, Any]:
"""Recursively convert a dict's keys to snake_case."""

def convert_key(key: str) -> str:
"""Convert a string to snake_case."""
string = re.sub(r"[\-\.\s]", "_", str(key))
return (string[0]).lower() + re.sub(
r"[A-Z]", lambda matched: "_" + matched.group(0).lower(), string[1:]
)

return {
(convert_key(k) if isinstance(k, str) else k): (
convert_dict(v) if isinstance(v, dict) else v
)
for k, v in dictionary.items()
}


def _handle_exception(err) -> bool:
try:
raise err
Expand Down Expand Up @@ -135,7 +161,7 @@ class EvoBroker:
"""Container for evohome client and data."""

def __init__(self, hass, params) -> None:
"""Initialize the evohome client and data structure."""
"""Initialize the evohome client and its data structure."""
self.hass = hass
self.params = params
self.config = {}
Expand All @@ -157,7 +183,7 @@ async def init_client(self) -> bool:

# evohomeasync2 uses naive/local datetimes
if access_token_expires is not None:
access_token_expires = _utc_to_local_dt(access_token_expires)
access_token_expires = _dt_to_local_naive(access_token_expires)

client = self.client = evohomeasync2.EvohomeClient(
self.params[CONF_USERNAME],
Expand Down Expand Up @@ -220,7 +246,7 @@ async def _load_auth_tokens(
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 = parse_datetime(at_expires_str)
at_expires_dt = dt_util.parse_datetime(at_expires_str)
else:
at_expires_dt = None

Expand All @@ -230,7 +256,7 @@ async def _load_auth_tokens(

async def _save_auth_tokens(self, *args) -> None:
# evohomeasync2 uses naive/local datetimes
access_token_expires = _local_dt_to_utc(self.client.access_token_expires)
access_token_expires = _local_dt_to_aware(self.client.access_token_expires)

self._app_storage[CONF_USERNAME] = self.params[CONF_USERNAME]
self._app_storage[CONF_REFRESH_TOKEN] = self.client.refresh_token
Expand All @@ -246,11 +272,11 @@ async def _save_auth_tokens(self, *args) -> None:
)

async def update(self, *args, **kwargs) -> None:
"""Get the latest state data of the entire evohome Location.
"""Get the latest state data of an 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).
This includes state data for a 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]

Expand All @@ -260,18 +286,16 @@ async def update(self, *args, **kwargs) -> None:
_handle_exception(err)
else:
# inform the evohome devices that state data has been updated
self.hass.helpers.dispatcher.async_dispatcher_send(
DOMAIN, {"signal": "refresh"}
)
self.hass.helpers.dispatcher.async_dispatcher_send(DOMAIN)

_LOGGER.debug("Status = %s", status[GWS][0][TCS][0])


class EvoDevice(Entity):
"""Base for any evohome device.

This includes the Controller, (up to 12) Heating Zones and
(optionally) a DHW controller.
This includes the Controller, (up to 12) Heating Zones and (optionally) a
DHW controller.
"""

def __init__(self, evo_broker, evo_device) -> None:
Expand All @@ -280,72 +304,26 @@ def __init__(self, evo_broker, evo_device) -> None:
self._evo_broker = evo_broker
self._evo_tcs = evo_broker.tcs

self._name = self._icon = self._precision = None
self._state_attributes = []
self._unique_id = self._name = self._icon = self._precision = None

self._device_state_attrs = {}
self._state_attributes = []
self._supported_features = None
self._schedule = {}

@callback
def _refresh(self, packet):
if packet["signal"] == "refresh":
self.async_schedule_update_ha_state(force_refresh=True)

@property
def setpoints(self) -> Dict[str, Any]:
"""Return the current/next setpoints from the schedule.

Only Zones & DHW controllers (but not the TCS) can have schedules.
"""
if not self._schedule["DailySchedules"]:
return {}

switchpoints = {}

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 = self._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 = self._schedule["DailySchedules"][(day_of_week + offset) % 7]
switchpoint = day["Switchpoints"][idx]

dt_naive = datetime.strptime(
f"{sp_date}T{switchpoint['TimeOfDay']}", "%Y-%m-%dT%H:%M:%S"
)

spt["from"] = _local_dt_to_utc(dt_naive).isoformat()
try:
spt["temperature"] = switchpoint["heatSetpoint"]
except KeyError:
spt["state"] = switchpoint["DhwState"]

return switchpoints
def _refresh(self) -> None:
self.async_schedule_update_ha_state(force_refresh=True)

@property
def should_poll(self) -> bool:
"""Evohome entities should not be polled."""
return False

@property
def unique_id(self) -> Optional[str]:
"""Return a unique ID."""
return self._unique_id

@property
def name(self) -> str:
"""Return the name of the Evohome entity."""
Expand All @@ -354,15 +332,15 @@ def name(self) -> str:
@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
status = self._device_state_attrs
if "systemModeStatus" in status:
convert_until(status["systemModeStatus"], "timeUntil")
if "setpointStatus" in status:
convert_until(status["setpointStatus"], "until")
if "stateStatus" in status:
convert_until(status["stateStatus"], "until")

return {"status": status}
return {"status": convert_dict(status)}

@property
def icon(self) -> str:
Expand All @@ -388,27 +366,98 @@ def temperature_unit(self) -> str:
"""Return the temperature unit to use in the frontend UI."""
return TEMP_CELSIUS

async def _call_client_api(self, api_function) -> None:
async def _call_client_api(self, api_function, refresh=True) -> Any:
try:
await api_function
result = await api_function
except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err:
_handle_exception(err)
if not _handle_exception(err):
return

self.hass.helpers.event.async_call_later(
2, self._evo_broker.update()
) # call update() in 2 seconds
if refresh is True:
self.hass.helpers.event.async_call_later(1, self._evo_broker.update())

async def _update_schedule(self) -> None:
"""Get the latest state data."""
if (
not self._schedule.get("DailySchedules")
or parse_datetime(self.setpoints["next"]["from"]) < utcnow()
):
return result


class EvoChild(EvoDevice):
"""Base for any evohome child.

This includes (up to 12) Heating Zones and (optionally) a DHW controller.
"""

def __init__(self, evo_broker, evo_device) -> None:
"""Initialize a evohome Controller (hub)."""
super().__init__(evo_broker, evo_device)
self._schedule = {}
self._setpoints = {}

@property
def current_temperature(self) -> Optional[float]:
"""Return the current temperature of a Zone."""
if self._evo_device.temperatureStatus["isAvailable"]:
return self._evo_device.temperatureStatus["temperature"]
return None

@property
def setpoints(self) -> Dict[str, Any]:
"""Return the current/next setpoints from the schedule.

Only Zones & DHW controllers (but not the TCS) can have schedules.
"""
if not self._schedule["DailySchedules"]:
zxdavb marked this conversation as resolved.
Show resolved Hide resolved
return {} # no schedule {'DailySchedules': []}, so no scheduled setpoints

day_time = dt_util.now()
day_of_week = int(day_time.strftime("%w")) # 0 is Sunday
time_of_day = day_time.strftime("%H:%M:%S")

# Iterate today's switchpoints until past the current time of day...
day = self._schedule["DailySchedules"][day_of_week]
sp_idx = -1 # last switchpoint of the day before
for i, tmp in enumerate(day["Switchpoints"]):
if time_of_day > tmp["TimeOfDay"]:
sp_idx = i # current setpoint
else:
break

# Did the current SP start yesterday? Does the next start SP tomorrow?
this_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 [
("this", this_sp_day, sp_idx),
("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)),
]:
sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d")
day = self._schedule["DailySchedules"][(day_of_week + offset) % 7]
switchpoint = day["Switchpoints"][idx]

dt_local_aware = _local_dt_to_aware(
dt_util.parse_datetime(f"{sp_date}T{switchpoint['TimeOfDay']}")
)

self._setpoints[f"{key}_sp_from"] = dt_local_aware.isoformat()
try:
self._schedule = await self._evo_device.schedule()
except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err:
_handle_exception(err)
self._setpoints[f"{key}_sp_temp"] = switchpoint["heatSetpoint"]
except KeyError:
self._setpoints[f"{key}_sp_state"] = switchpoint["DhwState"]

return self._setpoints

async def _update_schedule(self) -> None:
"""Get the latest schedule."""
if "DailySchedules" in self._schedule and not self._schedule["DailySchedules"]:
if not self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW:
return # avoid unnecessary I/O - there's nothing to update

self._schedule = await self._call_client_api(
self._evo_device.schedule(), refresh=False
)

async def async_update(self) -> None:
"""Get the latest state data."""
await self._update_schedule()
next_sp_from = self._setpoints.get("next_sp_from", "2000-01-01T00:00:00+00:00")
if dt_util.now() >= dt_util.parse_datetime(next_sp_from):
await self._update_schedule() # no schedule, or it's out-of-date

self._device_state_attrs = {"setpoints": self.setpoints}