Bugfix evohome converting non-UTC timezones (#32120)
* bugfix: correctly handle non-UTC TZs * bugfix: system mode is always permanent * bugfix: handle where until is none * tweak: improve logging to support above fixes
This commit is contained in:
parent
4717d072c9
commit
ae0ea0f088
4 changed files with 54 additions and 33 deletions
homeassistant/components/evohome
|
@ -34,7 +34,7 @@ from homeassistant.helpers.service import verify_domain_control
|
|||
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import DOMAIN, EVO_FOLLOW, GWS, STORAGE_KEY, STORAGE_VERSION, TCS
|
||||
from .const import DOMAIN, EVO_FOLLOW, GWS, STORAGE_KEY, STORAGE_VER, TCS, UTC_OFFSET
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -93,22 +93,22 @@ SET_ZONE_OVERRIDE_SCHEMA = vol.Schema(
|
|||
# system mode schemas are built dynamically, below
|
||||
|
||||
|
||||
def _local_dt_to_aware(dt_naive: dt) -> dt:
|
||||
def _dt_local_to_aware(dt_naive: dt) -> dt:
|
||||
dt_aware = dt_util.now() + (dt_naive - dt.now())
|
||||
if dt_aware.microsecond >= 500000:
|
||||
dt_aware += timedelta(seconds=1)
|
||||
return dt_aware.replace(microsecond=0)
|
||||
|
||||
|
||||
def _dt_to_local_naive(dt_aware: dt) -> dt:
|
||||
def _dt_aware_to_naive(dt_aware: dt) -> dt:
|
||||
dt_naive = dt.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."""
|
||||
def convert_until(status_dict: dict, until_key: str) -> str:
|
||||
"""Reformat a dt str from "%Y-%m-%dT%H:%M:%SZ" as 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()
|
||||
|
@ -190,14 +190,14 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
|
|||
|
||||
# evohomeasync2 requires naive/local datetimes as strings
|
||||
if tokens.get(ACCESS_TOKEN_EXPIRES) is not None:
|
||||
tokens[ACCESS_TOKEN_EXPIRES] = _dt_to_local_naive(
|
||||
tokens[ACCESS_TOKEN_EXPIRES] = _dt_aware_to_naive(
|
||||
dt_util.parse_datetime(tokens[ACCESS_TOKEN_EXPIRES])
|
||||
)
|
||||
|
||||
user_data = tokens.pop(USER_DATA, None)
|
||||
return (tokens, user_data)
|
||||
|
||||
store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
store = hass.helpers.storage.Store(STORAGE_VER, STORAGE_KEY)
|
||||
tokens, user_data = await load_auth_tokens(store)
|
||||
|
||||
client_v2 = evohomeasync2.EvohomeClient(
|
||||
|
@ -217,7 +217,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
|
|||
|
||||
loc_idx = config[DOMAIN][CONF_LOCATION_IDX]
|
||||
try:
|
||||
loc_config = client_v2.installation_info[loc_idx][GWS][0][TCS][0]
|
||||
loc_config = client_v2.installation_info[loc_idx]
|
||||
except IndexError:
|
||||
_LOGGER.error(
|
||||
"Config error: '%s' = %s, but the valid range is 0-%s. "
|
||||
|
@ -228,7 +228,11 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
|
|||
)
|
||||
return False
|
||||
|
||||
_LOGGER.debug("Config = %s", loc_config)
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_config = {"locationInfo": {"timeZone": None}, GWS: [{TCS: None}]}
|
||||
_config["locationInfo"]["timeZone"] = loc_config["locationInfo"]["timeZone"]
|
||||
_config[GWS][0][TCS] = loc_config[GWS][0][TCS]
|
||||
_LOGGER.debug("Config = %s", _config)
|
||||
|
||||
client_v1 = evohomeasync.EvohomeClient(
|
||||
client_v2.username,
|
||||
|
@ -393,12 +397,15 @@ class EvoBroker:
|
|||
loc_idx = params[CONF_LOCATION_IDX]
|
||||
self.config = client.installation_info[loc_idx][GWS][0][TCS][0]
|
||||
self.tcs = client.locations[loc_idx]._gateways[0]._control_systems[0]
|
||||
self.tcs_utc_offset = timedelta(
|
||||
minutes=client.locations[loc_idx].timeZone[UTC_OFFSET]
|
||||
)
|
||||
self.temps = {}
|
||||
|
||||
async def save_auth_tokens(self) -> None:
|
||||
"""Save access tokens and session IDs to the store for later use."""
|
||||
# evohomeasync2 uses naive/local datetimes
|
||||
access_token_expires = _local_dt_to_aware(self.client.access_token_expires)
|
||||
access_token_expires = _dt_local_to_aware(self.client.access_token_expires)
|
||||
|
||||
app_storage = {CONF_USERNAME: self.client.username}
|
||||
app_storage[REFRESH_TOKEN] = self.client.refresh_token
|
||||
|
@ -481,7 +488,7 @@ class EvoBroker:
|
|||
else:
|
||||
async_dispatcher_send(self.hass, DOMAIN)
|
||||
|
||||
_LOGGER.debug("Status = %s", status[GWS][0][TCS][0])
|
||||
_LOGGER.debug("Status = %s", status)
|
||||
|
||||
if access_token != self.client.access_token:
|
||||
await self.save_auth_tokens()
|
||||
|
@ -621,6 +628,11 @@ class EvoChild(EvoDevice):
|
|||
|
||||
Only Zones & DHW controllers (but not the TCS) can have schedules.
|
||||
"""
|
||||
|
||||
def _dt_evo_to_aware(dt_naive: dt, utc_offset: timedelta) -> dt:
|
||||
dt_aware = dt_naive.replace(tzinfo=dt_util.UTC) - utc_offset
|
||||
return dt_util.as_local(dt_aware)
|
||||
|
||||
if not self._schedule["DailySchedules"]:
|
||||
return {} # no schedule {'DailySchedules': []}, so no scheduled setpoints
|
||||
|
||||
|
@ -650,11 +662,12 @@ class EvoChild(EvoDevice):
|
|||
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']}")
|
||||
dt_aware = _dt_evo_to_aware(
|
||||
dt_util.parse_datetime(f"{sp_date}T{switchpoint['TimeOfDay']}"),
|
||||
self._evo_broker.tcs_utc_offset,
|
||||
)
|
||||
|
||||
self._setpoints[f"{key}_sp_from"] = dt_local_aware.isoformat()
|
||||
self._setpoints[f"{key}_sp_from"] = dt_aware.isoformat()
|
||||
try:
|
||||
self._setpoints[f"{key}_sp_temp"] = switchpoint["heatSetpoint"]
|
||||
except KeyError:
|
||||
|
|
|
@ -20,7 +20,7 @@ from homeassistant.components.climate.const import (
|
|||
)
|
||||
from homeassistant.const import PRECISION_TENTHS
|
||||
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
||||
from homeassistant.util.dt import parse_datetime
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from . import (
|
||||
ATTR_DURATION_DAYS,
|
||||
|
@ -170,21 +170,21 @@ class EvoZone(EvoChild, EvoClimateDevice):
|
|||
return
|
||||
|
||||
# otherwise it is SVC_SET_ZONE_OVERRIDE
|
||||
temp = round(data[ATTR_ZONE_TEMP] * self.precision) / self.precision
|
||||
temp = max(min(temp, self.max_temp), self.min_temp)
|
||||
temperature = max(min(data[ATTR_ZONE_TEMP], self.max_temp), self.min_temp)
|
||||
|
||||
if ATTR_DURATION_UNTIL in data:
|
||||
duration = data[ATTR_DURATION_UNTIL]
|
||||
if duration.total_seconds() == 0:
|
||||
await self._update_schedule()
|
||||
until = parse_datetime(str(self.setpoints.get("next_sp_from")))
|
||||
until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", ""))
|
||||
else:
|
||||
until = dt.now() + data[ATTR_DURATION_UNTIL]
|
||||
until = dt_util.now() + data[ATTR_DURATION_UNTIL]
|
||||
else:
|
||||
until = None # indefinitely
|
||||
|
||||
until = dt_util.as_utc(until) if until else None
|
||||
await self._evo_broker.call_client_api(
|
||||
self._evo_device.set_temperature(temperature=temp, until=until)
|
||||
self._evo_device.set_temperature(temperature, until=until)
|
||||
)
|
||||
|
||||
@property
|
||||
|
@ -244,12 +244,13 @@ class EvoZone(EvoChild, EvoClimateDevice):
|
|||
if until is None:
|
||||
if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW:
|
||||
await self._update_schedule()
|
||||
until = parse_datetime(str(self.setpoints.get("next_sp_from")))
|
||||
until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", ""))
|
||||
elif self._evo_device.setpointStatus["setpointMode"] == EVO_TEMPOVER:
|
||||
until = parse_datetime(self._evo_device.setpointStatus["until"])
|
||||
until = dt_util.parse_datetime(self._evo_device.setpointStatus["until"])
|
||||
|
||||
until = dt_util.as_utc(until) if until else None
|
||||
await self._evo_broker.call_client_api(
|
||||
self._evo_device.set_temperature(temperature, until)
|
||||
self._evo_device.set_temperature(temperature, until=until)
|
||||
)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
|
||||
|
@ -292,12 +293,13 @@ class EvoZone(EvoChild, EvoClimateDevice):
|
|||
|
||||
if evo_preset_mode == EVO_TEMPOVER:
|
||||
await self._update_schedule()
|
||||
until = parse_datetime(str(self.setpoints.get("next_sp_from")))
|
||||
until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", ""))
|
||||
else: # EVO_PERMOVER
|
||||
until = None
|
||||
|
||||
until = dt_util.as_utc(until) if until else None
|
||||
await self._evo_broker.call_client_api(
|
||||
self._evo_device.set_temperature(temperature, until)
|
||||
self._evo_device.set_temperature(temperature, until=until)
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
|
@ -345,11 +347,11 @@ class EvoController(EvoClimateDevice):
|
|||
mode = EVO_RESET
|
||||
|
||||
if ATTR_DURATION_DAYS in data:
|
||||
until = dt.combine(dt.now().date(), dt.min.time())
|
||||
until = dt_util.start_of_local_day()
|
||||
until += data[ATTR_DURATION_DAYS]
|
||||
|
||||
elif ATTR_DURATION_HOURS in data:
|
||||
until = dt.now() + data[ATTR_DURATION_HOURS]
|
||||
until = dt_util.now() + data[ATTR_DURATION_HOURS]
|
||||
|
||||
else:
|
||||
until = None
|
||||
|
@ -358,7 +360,10 @@ class EvoController(EvoClimateDevice):
|
|||
|
||||
async def _set_tcs_mode(self, mode: str, until: Optional[dt] = None) -> None:
|
||||
"""Set a Controller to any of its native EVO_* operating modes."""
|
||||
await self._evo_broker.call_client_api(self._evo_tcs.set_status(mode))
|
||||
until = dt_util.as_utc(until) if until else None
|
||||
await self._evo_broker.call_client_api(
|
||||
self._evo_tcs.set_status(mode, until=until)
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> str:
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
"""Support for (EMEA/EU-based) Honeywell TCC climate systems."""
|
||||
DOMAIN = "evohome"
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_VER = 1
|
||||
STORAGE_KEY = DOMAIN
|
||||
|
||||
# The Parent's (i.e. TCS, Controller's) operating mode is one of:
|
||||
|
@ -21,3 +21,5 @@ EVO_PERMOVER = "PermanentOverride"
|
|||
# These are used only to help prevent E501 (line too long) violations
|
||||
GWS = "gateways"
|
||||
TCS = "temperatureControlSystems"
|
||||
|
||||
UTC_OFFSET = "currentOffsetMinutes"
|
||||
|
|
|
@ -9,7 +9,7 @@ from homeassistant.components.water_heater import (
|
|||
)
|
||||
from homeassistant.const import PRECISION_TENTHS, PRECISION_WHOLE, STATE_OFF, STATE_ON
|
||||
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
||||
from homeassistant.util.dt import parse_datetime
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from . import EvoChild
|
||||
from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER
|
||||
|
@ -90,15 +90,16 @@ class EvoDHW(EvoChild, WaterHeaterDevice):
|
|||
await self._evo_broker.call_client_api(self._evo_device.set_dhw_auto())
|
||||
else:
|
||||
await self._update_schedule()
|
||||
until = parse_datetime(str(self.setpoints.get("next_sp_from")))
|
||||
until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", ""))
|
||||
until = dt_util.as_utc(until) if until else None
|
||||
|
||||
if operation_mode == STATE_ON:
|
||||
await self._evo_broker.call_client_api(
|
||||
self._evo_device.set_dhw_on(until)
|
||||
self._evo_device.set_dhw_on(until=until)
|
||||
)
|
||||
else: # STATE_OFF
|
||||
await self._evo_broker.call_client_api(
|
||||
self._evo_device.set_dhw_off(until)
|
||||
self._evo_device.set_dhw_off(until=until)
|
||||
)
|
||||
|
||||
async def async_turn_away_mode_on(self):
|
||||
|
|
Loading…
Add table
Reference in a new issue