"""Provides handling of TCC v0 entities. The entity hierarchy is flattened, similar to: Location(TCS) -> DHW | Zone But here it is implemented as: Location -> Gateway -> TCS -> DHW | Zone """ from __future__ import annotations import logging from abc import ABC, abstractmethod from functools import cached_property from typing import TYPE_CHECKING, Any, Final from . import exceptions as exc from .auth import Auth from .const import ( SZ_ALLOWED_MODES, SZ_COUNTRY, SZ_DAYLIGHT_SAVING_TIME_ENABLED, SZ_DEVICE_ID, SZ_DEVICES, SZ_GATEWAY_ID, SZ_INDOOR_TEMPERATURE, SZ_INDOOR_TEMPERATURE_STATUS, SZ_INSTANCE, SZ_IS_AVAILABLE, SZ_LOCATION_ID, SZ_MAC_ID, SZ_MAX_HEAT_SETPOINT, SZ_MIN_HEAT_SETPOINT, SZ_ONE_TOUCH_ACTIONS_SUSPENDED, SZ_ONE_TOUCH_BUTTONS, SZ_TEMPERATURE, SZ_THERMOSTAT_MODEL_TYPE, SZ_TIME_ZONE, SZ_WEATHER, ) from .schemas import ( API_STRFTIME, SZ_DHW_OFF, SZ_DHW_ON, SZ_HOLD, SZ_MODE, SZ_NAME, SZ_NEXT_TIME, SZ_QUICK_ACTION, SZ_QUICK_ACTION_NEXT_TIME, SZ_SCHEDULED, SZ_STATUS, SZ_TEMPORARY, SZ_THERMOSTAT, SZ_VALUE, EvoGwyInfoDictT, SystemMode, ) if TYPE_CHECKING: import logging from datetime import datetime as dt # some methods emulate the v2 API... from evohomeasync2.schemas import EvoTemperatureStatusResponseT from . import EvohomeClient from .auth import Auth from .schemas import ( EvoDevInfoDictT, EvoLocInfoDictT, EvoTcsInfoDictT, EvoTimeZoneInfoDictT, EvoWeatherDictT, ) _TEMP_IS_NA: Final = 128 class _EntityBase(ABC): """Base class for all entities.""" _config: EvoDevInfoDictT | EvoGwyInfoDictT | EvoLocInfoDictT | EvoTcsInfoDictT _status: EvoDevInfoDictT | EvoGwyInfoDictT | EvoLocInfoDictT | EvoTcsInfoDictT def __init__(self, entity_id: int) -> None: self._id: Final = entity_id def __str__(self) -> str: return f"{self.__class__.__name__}(id='{self._id}')" @property @abstractmethod def _auth(self) -> Auth: ... @property @abstractmethod def _logger(self) -> logging.Logger: ... @cached_property def id(self) -> str: return str(self._id) @property # not strictly static, but library largely assumes so def config( self, ) -> EvoDevInfoDictT | EvoGwyInfoDictT | EvoLocInfoDictT | EvoTcsInfoDictT: """Return the config of the entity.""" return self._config @property def status( self, ) -> EvoDevInfoDictT | EvoGwyInfoDictT | EvoLocInfoDictT | EvoTcsInfoDictT: """Return the latest config of the entity.""" return self._status class _DeviceBase(_EntityBase): """Base class for all devices.""" _config: EvoDevInfoDictT | EvoGwyInfoDictT _status: EvoDevInfoDictT | EvoGwyInfoDictT def __init__(self, location: Location, config: EvoDevInfoDictT, /) -> None: super().__init__(config[SZ_DEVICE_ID]) self._loc = location # parent self._config = config self._status = config # not a typo, config is a superset of status @cached_property def _auth(self) -> Auth: return self._loc.client.auth @cached_property def _logger(self) -> logging.Logger: return self._loc.client.logger def _update_status(self, status: EvoDevInfoDictT | EvoGwyInfoDictT) -> None: """Update the device's status.""" self._status = status class HotWater(_DeviceBase): # Hotwater version of a Device """Instance of a location's DHW zone.""" _config: Final[EvoDevInfoDictT] # type: ignore[misc] _status: EvoDevInfoDictT # Config attrs... @property def name(self) -> str: return "Domestic Hot Water" @property def idx(self) -> str: return "HW" # Status (state) attrs & methods... @property # emulate the v2 API... def temperature_status(self) -> EvoTemperatureStatusResponseT: """Expose the temperature_status as per the v2 API.""" if self._status is None: raise exc.InvalidStatusError(f"{self} has no state, has it been fetched?") temp = self._status[SZ_THERMOSTAT][SZ_INDOOR_TEMPERATURE] temp_status = self._status[SZ_THERMOSTAT][SZ_INDOOR_TEMPERATURE_STATUS] return { SZ_IS_AVAILABLE: temp_status == "Measured", } | ({} if temp == _TEMP_IS_NA else {SZ_TEMPERATURE: temp}) # type: ignore[return-value] @property def temperature(self) -> float | None: if not (status := self.temperature_status)[SZ_IS_AVAILABLE]: return None assert SZ_TEMPERATURE in status # mypy hint return status[SZ_TEMPERATURE] async def _set_dhw( self, status: str, # "Scheduled" | "Hold" mode: str | None = None, # "DHWOn" | "DHWOff" next_time: dt | None = None, # "%Y-%m-%dT%H:%M:%SZ" ) -> None: """Set DHW to Auto, or On/Off, either indefinitely, or until a set time.""" data = { SZ_STATUS: status, SZ_MODE: mode, # SZ_NEXT_TIME: None, # SZ_SPECIAL_TIMES: None, # SZ_HEAT_SETPOINT: None, # SZ_COOL_SETPOINT: None, } if next_time: data |= {SZ_NEXT_TIME: next_time.strftime(API_STRFTIME)} url = f"devices/{self.id}/thermostat/changeableValues" await self._auth.put(url, json=data) async def set_dhw_on(self, until: dt | None = None) -> None: """Set DHW to On, either indefinitely, or until a specified time. When On, the DHW controller will work to keep its target temperature at/above its target temperature. After the specified time, it will revert to its scheduled behaviour. """ await self._set_dhw(status=SZ_HOLD, mode=SZ_DHW_ON, next_time=until) async def set_dhw_off(self, until: dt | None = None) -> None: """Set DHW to Off, either indefinitely, or until a specified time. When Off, the DHW controller will ignore its target temperature. After the specified time, it will revert to its scheduled behaviour. """ await self._set_dhw(status=SZ_HOLD, mode=SZ_DHW_OFF, next_time=until) async def set_dhw_auto(self) -> None: """Allow DHW to switch between On and Off, according to its schedule.""" await self._set_dhw(status=SZ_SCHEDULED) class Zone(_DeviceBase): # Zone version of a Device """Instance of a location's heating zone.""" _config: Final[EvoDevInfoDictT] # type: ignore[misc] _status: EvoDevInfoDictT # Config attrs... @property def name(self) -> str: return self._config[SZ_NAME] @property # appears to be the zone idx (not in newer API) def idx(self) -> str: return f"{self._config[SZ_INSTANCE]:02X}" @property def max_heat_setpoint(self) -> float: return self._status[SZ_THERMOSTAT][SZ_MAX_HEAT_SETPOINT] @property def min_heat_setpoint(self) -> float: return self._status[SZ_THERMOSTAT][SZ_MIN_HEAT_SETPOINT] @cached_property def allowed_modes(self) -> tuple[str, ...]: """Return the set of modes the zone can be assigned.""" return tuple(self._config[SZ_THERMOSTAT][SZ_ALLOWED_MODES]) # Status (state) attrs & methods... @property # emulate the v2 API... def temperature_status(self) -> EvoTemperatureStatusResponseT: """Expose the temperature_status as per the v2 API.""" if self._status is None: raise exc.InvalidStatusError(f"{self} has no state, has it been fetched?") temp = self._status[SZ_THERMOSTAT][SZ_INDOOR_TEMPERATURE] temp_status = self._status[SZ_THERMOSTAT][SZ_INDOOR_TEMPERATURE_STATUS] return { SZ_IS_AVAILABLE: temp_status == "Measured", } | ({} if temp == _TEMP_IS_NA else {SZ_TEMPERATURE: temp}) # type: ignore[return-value] @property def temperature(self) -> float | None: if not (status := self.temperature_status)[SZ_IS_AVAILABLE]: return None assert SZ_TEMPERATURE in status # mypy hint return status[SZ_TEMPERATURE] async def _set_heat_setpoint( self, status: str, # "Scheduled" | "Temporary" | "Hold" value: float | None = None, next_time: dt | None = None, # "%Y-%m-%dT%H:%M:%SZ" ) -> None: """Set zone setpoint, either indefinitely, or until a set time.""" data: dict[str, float | str] = {SZ_STATUS: status} if value is not None: # NOTE: may have to send {SZ_VALUE: None} instead data[SZ_VALUE] = value if next_time is not None: data[SZ_NEXT_TIME] = next_time.strftime(API_STRFTIME) url = f"devices/{self.id}/thermostat/changeableValues/heatSetpoint" await self._auth.put(url, json=data) async def set_temperature( self, temperature: float, until: dt | None = None ) -> None: """Override the setpoint of a zone, for a period of time, or indefinitely.""" if until: await self._set_heat_setpoint( SZ_TEMPORARY, value=temperature, next_time=until ) else: await self._set_heat_setpoint(SZ_HOLD, value=temperature) async def set_zone_auto(self) -> None: """Set a zone to follow its schedule.""" await self._set_heat_setpoint(status=SZ_SCHEDULED) class ControlSystem(_EntityBase): # TCS portion of a Location """Instance of a TCS (a portion of a location).""" _cli: EvohomeClient # proxy for parent _config: Final[EvoTcsInfoDictT] # type: ignore[misc] _status: EvoTcsInfoDictT def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.hotwater: HotWater | None = None self.zones: list[Zone] = [] self.zone_by_id: dict[str, Zone] = {} # id is fixed to zone self.zone_by_idx: dict[str, Zone] = {} # not sure if fixed, or attr like name @property def zone_by_name(self) -> dict[str, Zone]: """Return the zones by name (names are not fixed attrs).""" return {zone.name: zone for zone in self.zones} # Status (state) attrs & methods... @property def one_touch_actions_suspended(self) -> bool: return bool(self._status[SZ_ONE_TOUCH_ACTIONS_SUSPENDED]) @property def one_touch_buttons(self) -> tuple[str, ...]: return tuple(self._status[SZ_ONE_TOUCH_BUTTONS]) async def _set_mode(self, mode: dict[str, str]) -> None: """Set the TCS mode.""" await self._auth.put(f"evoTouchSystems?locationId={self.id}", json=mode) async def reset(self) -> None: """Set the TCS to auto mode (and DHW/all zones to FollowSchedule mode).""" raise NotImplementedError # TODO: via set_mode() of TCS and its children async def set_mode(self, mode: SystemMode, /, *, until: dt | None = None) -> None: """Set the TCS to a mode, either indefinitely, or for a set time.""" request: dict[str, str] = {SZ_QUICK_ACTION: mode} if until: request |= {SZ_QUICK_ACTION_NEXT_TIME: until.strftime(API_STRFTIME)} await self._set_mode(request) async def set_auto(self) -> None: """Set the TCS to normal mode.""" await self.set_mode(SystemMode.AUTO) async def set_away(self, /, *, until: dt | None = None) -> None: """Set the TCS to away mode.""" await self.set_mode(SystemMode.AWAY, until=until) async def set_custom(self, /, *, until: dt | None = None) -> None: """Set the TCS to custom mode.""" await self.set_mode(SystemMode.CUSTOM, until=until) async def set_dayoff(self, /, *, until: dt | None = None) -> None: """Set the TCS to dayoff mode.""" await self.set_mode(SystemMode.DAY_OFF, until=until) async def set_eco(self, /, *, until: dt | None = None) -> None: """Set the TCS to economy mode.""" await self.set_mode(SystemMode.AUTO_WITH_ECO, until=until) async def set_heatingoff(self, /, *, until: dt | None = None) -> None: """Set the system to heating off mode.""" await self.set_mode(SystemMode.HEATING_OFF, until=until) def _get_zone(self, zon_id: int | str) -> Zone: """Return the TCS's zone by its id, idx or name.""" if isinstance(zon_id, int): zon_id = str(zon_id) dev = self.zone_by_id.get(zon_id) if dev is None: dev = self.zone_by_idx.get(zon_id) if dev is None: dev = self.zone_by_name.get(zon_id) if dev is None: raise exc.ConfigError(f"no zone {zon_id} in {self}") return dev async def get_temperatures( self, /, *, dont_update_status: bool = False, ) -> dict[str, float | None]: """A convenience function to obtain the high-precision temperatures.""" if not dont_update_status: await self._cli.update() return {z.id: z.temperature for z in self.zones} class Gateway(_DeviceBase): # Gateway portion of a Device """Instance of a location's gateway.""" def __init__(self, location: Location, config: EvoDevInfoDictT, /) -> None: _EntityBase.__init__(self, config[SZ_GATEWAY_ID]) # uses gateway_id self._loc = location self._config = config self._status = config _config: Final[EvoGwyInfoDictT] # type: ignore[misc] _status: EvoGwyInfoDictT # initial state # list(EvoGwyInfoDictT.__annotations__.keys()) # Config attrs... @property # not strictly static, but library largely assumes so def config(self) -> EvoGwyInfoDictT: """Return the config of the entity.""" return { k: v for k, v in self._config.items() if k in list(EvoGwyInfoDictT.__annotations__.keys()) } # type: ignore[return-value] @property def status(self) -> EvoGwyInfoDictT: """Return the latest status of the entity.""" return { k: v for k, v in self._status.items() if k in list(EvoGwyInfoDictT.__annotations__.keys()) } # type: ignore[return-value] @cached_property def mac_address(self) -> str: return self._config[SZ_MAC_ID] class Location(ControlSystem, _EntityBase): # assumes 1 TCS per Location """Instance of an account's location/TCS.""" def __init__(self, client: EvohomeClient, config: EvoTcsInfoDictT, /) -> None: super().__init__(config[SZ_LOCATION_ID]) self._cli = client # proxy for parent self._config: Final[EvoTcsInfoDictT] = config # type: ignore[misc] self._status: EvoTcsInfoDictT = config # initial state self.gateways: list[Gateway] = [] self.gateway_by_id: dict[str, Gateway] = {} # process config[SZ_DEVICES]... for dev_config in config[SZ_DEVICES]: if str(dev_config[SZ_GATEWAY_ID]) not in self.gateway_by_id: gwy = Gateway(self, dev_config) self.gateways.append(gwy) self.gateway_by_id[gwy.id] = gwy if (type_ := dev_config[SZ_THERMOSTAT_MODEL_TYPE]) == "DOMESTIC_HOT_WATER": self.hotwater = HotWater(self, dev_config) # check is string to handle edge-case of Honeywell TH9320WF3003 elif isinstance(type_, str) and type_.startswith("EMEA_"): self._add_zone(Zone(self, dev_config)) else: # assume everything else is a zone self._logger.warning( f"{self}: Unknown device type, assuming is a zone: {type_}" ) self._add_zone(Zone(self, dev_config)) @cached_property def _auth(self) -> Auth: return self._cli.auth @cached_property def _logger(self) -> logging.Logger: return self._cli.logger @cached_property def client(self) -> EvohomeClient: return self._cli def _add_zone(self, zone: Zone) -> None: self.zones.append(zone) self.zone_by_id[zone.id] = zone self.zone_by_idx[zone.idx] = zone # Config attrs... @property def country(self) -> str: # e.g. "GB", "NL" return self._config[SZ_COUNTRY] @property def name(self) -> str: return self._config[SZ_NAME] @property def dst_enabled(self) -> bool: """Return True if the location uses daylight saving time.""" return self._config[SZ_DAYLIGHT_SAVING_TIME_ENABLED] @property def time_zone_info(self) -> EvoTimeZoneInfoDictT: return self._config[SZ_TIME_ZONE] # Status (state) attrs & methods... # async def update(self, *args: Any, **kwargs: Any) -> Any: # """Get the latest state of the location and update its status attrs.""" # raise NotImplementedError("Use EvohomeClient.update() instead.") @property def weather(self) -> EvoWeatherDictT: return self._status[SZ_WEATHER] def _update_status(self, status: EvoTcsInfoDictT) -> None: """Update the LOC's status and cascade to its descendants.""" self._status = status for dev_status in status[SZ_DEVICES]: if (gwy_id := str(dev_status[SZ_GATEWAY_ID])) in self.gateway_by_id: self.gateway_by_id[gwy_id]._update_status(dev_status) # noqa: SLF001 else: self._logger.warning( f"{self}: Ignoring gateway_id={gwy_id}" ", (has the location configuration changed?)" ) continue if (dev_id := str(dev_status[SZ_DEVICE_ID])) in self.zone_by_id: self.zone_by_id[dev_id]._update_status(dev_status) # noqa: SLF001 elif self.hotwater and self.hotwater.id == dev_id: self.hotwater._update_status(dev_status) # noqa: SLF001 else: self._logger.warning( f"{self}: Ignoring device_id={dev_id}" ", (has the location configuration changed?)" ) continue