"""Provides handling of TCC temperature control systems.""" from __future__ import annotations import json from functools import cached_property from typing import TYPE_CHECKING, Final, NoReturn from _evohome.helpers import as_local_time, camel_to_snake from . import exceptions as exc from .const import ( API_STRFTIME, SZ_ALLOWED_SYSTEM_MODES, SZ_CAN_BE_TEMPORARY, SZ_DAILY_SCHEDULES, SZ_DHW, SZ_DHW_ID, SZ_IS_PERMANENT, SZ_MODE, SZ_MODEL_TYPE, SZ_NAME, SZ_SYSTEM_ID, SZ_SYSTEM_MODE, SZ_SYSTEM_MODE_STATUS, SZ_TIME_UNTIL, SZ_ZONE_ID, SZ_ZONES, ) from .hotwater import HotWater from .schemas import ( S2_PERMANENT, S2_SYSTEM_MODE, S2_TIME_UNTIL, EntityType, SystemMode, TccSetTcsModeT, TcsModelType, ) from .schemas.status import factory_tcs_status from .zone import ActiveFaultsBase, EntityBase, Zone if TYPE_CHECKING: import logging from datetime import datetime as dt import voluptuous as vol from . import Gateway, Location from .auth import Auth from .schemas import ( DayOfWeekDhwT, DayOfWeekZoneT, EvoAllowedSystemModesResponseT, EvoScheduleDhwT, EvoScheduleZoneT, EvoSystemModeStatusResponseT, EvoTcsConfigEntryT, EvoTcsConfigResponseT, EvoTcsStatusResponseT, ) class ControlSystem(ActiveFaultsBase, EntityBase): """Instance of a gateway's TCS (temperatureControlSystem).""" SCH_STATUS: vol.Schema = factory_tcs_status(camel_to_snake) _TYPE = EntityType.TCS _STATUS_EXCLUDES = (SZ_DHW, SZ_ZONES) def __init__(self, gateway: Gateway, config: EvoTcsConfigResponseT) -> None: super().__init__(config[SZ_SYSTEM_ID]) self.gateway = gateway # parent self.location: Location = gateway.location # children self.zones: list[Zone] = [] self.zone_by_id: dict[str, Zone] = {} self.hotwater: HotWater | None = None # break the config TypedDict into its parts... self._config: Final[EvoTcsConfigEntryT] = { # type: ignore[assignment, misc] k: v for k, v in config.items() if k not in (SZ_DHW, SZ_ZONES) } for zon_entry in config[SZ_ZONES]: try: zone = Zone(self, zon_entry) except exc.ConfigError as err: self._logger.warning( f"{self}: zone_id='{zon_entry[SZ_ZONE_ID]}' ignored: {err}" ) else: self.zones.append(zone) self.zone_by_id[zone.id] = zone if dhw_entry := config.get(SZ_DHW): self.hotwater = HotWater(self, dhw_entry) self._status: EvoTcsStatusResponseT | None = None @cached_property def _auth(self) -> Auth: return self.location.client.auth @cached_property def _logger(self) -> logging.Logger: return self.location.client.logger @property # not strictly static, but library largely assumes so def config(self) -> EvoTcsConfigEntryT: """Return the latest config of the entity.""" return self._config @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} @property def status(self) -> EvoTcsStatusResponseT: """Return the latest status of the entity.""" return super().status # type: ignore[return-value] # Config attrs... @cached_property def model(self) -> TcsModelType: return self._config[SZ_MODEL_TYPE] @cached_property def allowed_system_modes(self) -> tuple[EvoAllowedSystemModesResponseT, ...]: """ Some systems support a subset of these modes: "allowed_system_modes": [ {"system_mode": "HeatingOff", "can_be_permanent": true, "can_be_temporary": false}, {"system_mode": "Auto", "can_be_permanent": true, "can_be_temporary": false}, {"system_mode": "AutoWithReset", "can_be_permanent": true, "can_be_temporary": false}, {"system_mode": "AutoWithEco", "can_be_permanent": true, "can_be_temporary": true, "maxDuration": "1.00:00:00", "timingResolution": "01:00:00", "timingMode": "Duration"}, {"system_mode": "Away", "can_be_permanent": true, "can_be_temporary": true, "maxDuration": "99.00:00:00", "timingResolution": "1.00:00:00", "timingMode": "Period"}, {"system_mode": "DayOff", "can_be_permanent": true, "can_be_temporary": true, "maxDuration": "99.00:00:00", "timingResolution": "1.00:00:00", "timingMode": "Period"}, {"system_mode": "Custom", "can_be_permanent": true, "can_be_temporary": true, "maxDuration": "99.00:00:00", "timingResolution": "1.00:00:00", "timingMode": "Period"} ] Other systems support only these: "allowed_system_modes": [ {"system_mode": "Off", "can_be_permanent": true, "can_be_temporary": false}, {"system_mode": "Heat", "can_be_permanent": true, "can_be_temporary": false} ] """ return tuple(self._config[SZ_ALLOWED_SYSTEM_MODES]) @cached_property # a convenience attr, derived from allowed_system_modes def allowed_modes(self) -> tuple[SystemMode, ...]: return tuple(d[SZ_SYSTEM_MODE] for d in self.allowed_system_modes) # Status (state) attrs & methods... async def _get_status(self) -> NoReturn: """Get the latest state of the control system and update its status attrs. It is more efficient to call Location.update() as all descendants are updated with a single GET. Returns the raw JSON of the latest state. """ raise NotImplementedError("Use Location.update() to update status") def _update_status(self, status: EvoTcsStatusResponseT) -> None: """Update the TCS's status and cascade to its descendants.""" self._update_faults(status["active_faults"]) # cascade the child status to descendants... for zon_status in status[SZ_ZONES]: if zone := self.zone_by_id.get(zon_status[SZ_ZONE_ID]): zone._update_status(zon_status) # noqa: SLF001 else: self._logger.warning( f"{self}: zone_id='{zon_status[SZ_ZONE_ID]}' not known" ", (has the system configuration been changed?)" ) if dhw_status := status.get(SZ_DHW): if self.hotwater and self.hotwater.id == dhw_status[SZ_DHW_ID]: self.hotwater._update_status(dhw_status) # noqa: SLF001 else: self._logger.warning( f"{self}: dhw_id='{dhw_status[SZ_DHW_ID]}' not known" ", (has the system configuration been changed?)" ) self._status = { k: v for k, v in status.items() if k not in self._STATUS_EXCLUDES } # type: ignore[assignment] @property def system_mode_status(self) -> EvoSystemModeStatusResponseT: """ "systemModeStatus": {"mode": "AutoWithEco", "isPermanent": true} "systemModeStatus": {'mode': 'AutoWithEco', 'isPermanent': false, 'timeUntil': '2024-12-21T15:55:00Z'}} """ return self.status[SZ_SYSTEM_MODE_STATUS] @property # could have a setter in future def is_permanent(self) -> bool: return self.system_mode_status[SZ_IS_PERMANENT] @property # could have a setter in future def mode(self) -> SystemMode: return self.system_mode_status[SZ_MODE] @property # could have a setter in future def until(self) -> dt | None: # aka timeUntil if (until := self.system_mode_status.get(SZ_TIME_UNTIL)) is None: return None return as_local_time(until, self.location.tzinfo) async def _set_mode(self, tcs_mode: TccSetTcsModeT, /) -> None: """Set the TCS mode.""" # Issue a warning if we fail some basic sanity checks... if tcs_mode[S2_SYSTEM_MODE] not in self.allowed_modes: self._logger.warning( f"{self}: Attempting unsupported {S2_SYSTEM_MODE}: {tcs_mode}..." ) await self._auth.put(f"{self._TYPE}/{self.id}/mode", json=dict(tcs_mode)) async def set_mode( self, system_mode: SystemMode, /, *, until: dt | None = None, ) -> None: """Set the TCS to a mode, either indefinitely, or for a set time.""" if system_mode not in self.allowed_modes: raise exc.InvalidSystemModeError( f"{self}: Unsupported system_mode: {system_mode}" ) tcs_mode: TccSetTcsModeT mode_entry = next( d for d in self.allowed_system_modes if d[SZ_SYSTEM_MODE] == system_mode ) if until is None: # all known modes can be permanent tcs_mode = { S2_SYSTEM_MODE: system_mode, S2_PERMANENT: True, } else: if mode_entry[SZ_CAN_BE_TEMPORARY] is False: raise exc.InvalidSystemModeError( f"{self}: For {system_mode}, until must be None" ) tcs_mode = { # NOTE: TIME_UNTIL, not UNTIL_TIME S2_SYSTEM_MODE: system_mode, S2_PERMANENT: False, S2_TIME_UNTIL: until.strftime(API_STRFTIME), } await self._set_mode(tcs_mode) # most, but not all, TCC-compatible systems support these modes... async def reset(self) -> None: """Set the TCS to auto mode (and set DHW/all zones to FollowSchedule mode). Some systems do not support 'AutoWithReset' mode. """ # some systems have "AutoWithReset" mode... if SystemMode.AUTO_WITH_RESET in self.allowed_modes: await self.set_mode(SystemMode.AUTO_WITH_RESET) return self._logger.debug( f"{self}: Emulating {S2_SYSTEM_MODE}: {SystemMode.AUTO_WITH_RESET}..." ) await self.set_auto() for zone in self.zones: await zone.reset() if self.hotwater: await self.hotwater.reset() async def set_auto(self) -> None: """Set the TCS to auto mode. Some systems use 'Heat' instead of 'Auto' for this mode. """ if ( SystemMode.AUTO in self.allowed_modes or SystemMode.HEAT not in self.allowed_modes ): await self.set_mode(SystemMode.AUTO) # ?raise InvalidSystemModeError return # some systems have "Heat" mode instead of "Auto"... self._logger.debug( f"{self}: Emulating {S2_SYSTEM_MODE}: {SystemMode.AUTO} as {SystemMode.HEAT}" ) await self.set_mode(SystemMode.HEAT) async def set_away(self, /, *, until: dt | None = None) -> None: """Set the TCS to away mode. Some systems do not support this 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. Some systems do not support this mode. """ await self.set_mode(SystemMode.CUSTOM, until=until) async def set_dayoff(self, /, *, until: dt | None = None) -> None: """Set the TCS to day_off mode. Some systems do not support this 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. Some systems do not support this mode. """ await self.set_mode(SystemMode.AUTO_WITH_ECO, until=until) async def set_heatingoff(self) -> None: """Set the TCS to heating_off mode. Some systems use 'Off' instead of 'HeatingOff' for this mode. """ if ( SystemMode.HEATING_OFF in self.allowed_modes or SystemMode.OFF not in self.allowed_modes ): await self.set_mode(SystemMode.HEATING_OFF) # ?raise InvalidSystemModeError return # some systems have "Off" mode instead of "HeatingOff"... self._logger.debug( f"{self}: Emulating {S2_SYSTEM_MODE}: {SystemMode.HEATING_OFF} as {SystemMode.OFF}" ) await self.set_mode(SystemMode.OFF) # these are convenience methods async def get_schedules(self) -> list[EvoScheduleDhwT | EvoScheduleZoneT]: """Backup all schedules from the TCS.""" async def get_schedule( child: HotWater | Zone, ) -> list[DayOfWeekDhwT] | list[DayOfWeekZoneT]: try: return await child.get_schedule() except exc.InvalidScheduleError: self._logger.warning( f"Ignoring {child.id} ({child.name}): missing/invalid schedule" ) return [] self._logger.info( f"Schedules: Backing up from {self.id} ({self.location.name})" ) schedules: list[EvoScheduleDhwT | EvoScheduleZoneT] = [] for zone in self.zones: schedules.append( # noqa: PERF401 { SZ_ZONE_ID: zone.id, SZ_NAME: zone.name, SZ_DAILY_SCHEDULES: await get_schedule(zone), # type: ignore[typeddict-item] } ) if self.hotwater: schedules.append( { SZ_ZONE_ID: self.hotwater.id, SZ_NAME: self.hotwater.name, SZ_DAILY_SCHEDULES: await get_schedule(self.hotwater), # type: ignore[typeddict-item] } ) return schedules async def set_schedules( self, schedules: list[EvoScheduleDhwT | EvoScheduleZoneT], *, match_by_name: bool | None = None, ) -> bool: """Restore all schedules to the TCS and return True if success. The default is to match a schedule to its zone/dhw by id. """ async def restore_by_id(sched: EvoScheduleDhwT | EvoScheduleZoneT) -> bool: """Restore a schedule by id and return False if there was no match.""" id_: str = sched.get("zone_id") or sched["dhw_id"] # type: ignore[assignment,typeddict-item] if self.hotwater and self.hotwater.id == id_: await self.hotwater.set_schedule(json.dumps(sched["daily_schedules"])) elif zone := self.zone_by_id.get(id_): await zone.set_schedule(json.dumps(sched["daily_schedules"])) else: self._logger.warning( f"Ignoring schedule of {id_} ({sched.get('name')}): unknown id" ", consider matching by name rather than by id" ) return False return True async def restore_by_name(sched: EvoScheduleDhwT | EvoScheduleZoneT) -> bool: """Restore a schedule by name and return False if there was no match.""" name: str | None = sched.get("name") # name is NotRequired[str] if name and self.hotwater and name == self.hotwater.name: await self.hotwater.set_schedule(json.dumps(sched["daily_schedules"])) elif name and (zone := self.zone_by_name.get(name)): await zone.set_schedule(json.dumps(sched["daily_schedules"])) else: id_: str = sched.get("zone_id") or sched["dhw_id"] # type: ignore[assignment,typeddict-item] self._logger.warning( f"Ignoring schedule of {id_} ({name}): unknown name" ", consider matching by id rather than by name" ) return False return True self._logger.info( f"Schedules: Restoring (matched by {'name' if match_by_name else 'id'})" f" to {self.id} ({self.location.name})" ) fnc = restore_by_name if match_by_name else restore_by_id all_restored = all([await fnc(sch) for sch in schedules]) same_count = len(schedules) == len(self.zones) + (1 if self.hotwater else 0) if not (success := same_count and all_restored): self._logger.debug("Some schedules not restored (DHW?)") return success