"""Support for entities of the Evohome integration.""" from collections.abc import Mapping from datetime import datetime from enum import StrEnum import logging from typing import Any, override import evohomeasync2 as evo from evohomeasync2.const import ( SZ_SINCE, SZ_TIME_UNTIL, SZ_UNTIL, ZoneModelType as EvoZoneModelType, ZoneType as EvoZoneType, ) from evohomeasync2.typedefs import EvoDhwScheduleDayOfWeekT, EvoZonScheduleDayOfWeekT from homeassistant.core import callback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .coordinator import EvoDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) def _recurse_and_revert(val: Any, _key: str | None = None) -> Any: """Recursively revert any values to native format.""" if isinstance(val, dict): return {k: _recurse_and_revert(v, k) for k, v in val.items()} if isinstance(val, (list, tuple)): return type(val)(_recurse_and_revert(v) for v in val) if isinstance(val, datetime) and _key in (SZ_SINCE, SZ_TIME_UNTIL, SZ_UNTIL): return val.isoformat() if isinstance(val, StrEnum): return "".join(word.capitalize() for word in val.value.split("_")) return val def is_valid_zone(zone: evo.Zone) -> bool: """Check if an Evohome zone should have climate and button entities.""" return ( zone.model == EvoZoneModelType.HEATING_ZONE or zone.type == EvoZoneType.THERMOSTAT ) def unique_zone_id(evo_device: evo.Zone) -> str: """Return a unique identifier for a zone-based entity. Some systems assign the zone the same ID as its parent TCS; in that case we append 'z' so the zone entity doesn't collide with the controller entity. """ if evo_device.id == evo_device.tcs.id: return f"{evo_device.id}z" return evo_device.id class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]): """Base for Evohome's Climate & WaterHeater entities.""" _evo_device: evo.ControlSystem | evo.HotWater | evo.Zone _evo_id_attr: str _evo_state_attr_names: tuple[str, ...] def __init__( self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.ControlSystem | evo.HotWater | evo.Zone, ) -> None: """Initialize an evohome-compatible entity (TCS, DHW, zone).""" super().__init__(coordinator, context=evo_device.id) self._evo_device = evo_device self._device_state_attrs: dict[str, Any] = {} @property @override def extra_state_attributes(self) -> Mapping[str, Any]: """Return the evohome-specific state attributes.""" return {"status": self._device_state_attrs} @callback @override def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self._device_state_attrs[self._evo_id_attr] = self._evo_device.id for attr in self._evo_state_attr_names: self._device_state_attrs[attr] = getattr(self._evo_device, attr) self._device_state_attrs = _recurse_and_revert(self._device_state_attrs) super()._handle_coordinator_update() async def update_attrs(self) -> None: """Update the entity's extra state attrs.""" self._handle_coordinator_update() class EvoChild(EvoEntity): """Base for any evohome-compatible child entity (DHW, zone). This includes (1 to 12) heating zones and (optionally) a DHW controller. """ _evo_device: evo.HotWater | evo.Zone _evo_id: str def __init__( self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.HotWater | evo.Zone ) -> None: """Initialize an evohome-compatible child entity (DHW, zone).""" super().__init__(coordinator, evo_device) self._evo_id = evo_device.id self._evo_tcs = evo_device.tcs self._schedule: ( list[EvoDhwScheduleDayOfWeekT] | list[EvoZonScheduleDayOfWeekT] | None ) = None self._setpoints: dict[str, Any] = {} @property def current_temperature(self) -> float | None: """Return the current temperature of a Zone.""" assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check if (temp := self.coordinator.temps.get(self._evo_id)) is not None: # use high-precision temps if available return temp return self._evo_device.temperature @property def setpoints(self) -> Mapping[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: return self._setpoints this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint key = "temp" if isinstance(self._evo_device, evo.Zone) else "state" self._setpoints = { "this_sp_from": this_sp_dtm, f"this_sp_{key}": this_sp_val, "next_sp_from": next_sp_dtm, f"next_sp_{key}": next_sp_val, } return self._setpoints async def _update_schedule(self, force_refresh: bool = False) -> None: """Get the latest schedule, if any.""" async def get_schedule() -> None: try: schedule = await self.coordinator.call_client_api( self._evo_device.get_schedule(), # type: ignore[arg-type] request_refresh=False, ) except evo.InvalidScheduleError as err: _LOGGER.warning( "%s: Unable to retrieve a valid schedule: %s", self._evo_device, err, ) self._schedule = [] return else: self._schedule = schedule # type: ignore[assignment] _LOGGER.debug("Schedule['%s'] = %s", self.name, schedule) if ( force_refresh or self._schedule is None or ( (until := self._setpoints.get("next_sp_from")) is not None and until < dt_util.utcnow() ) ): # must use self._setpoints, not self.setpoints await get_schedule() _ = self.setpoints # update the setpoints attr @callback @override def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self._device_state_attrs = { "activeFaults": self._evo_device.active_faults, "setpoints": self.setpoints, } super()._handle_coordinator_update() @override async def update_attrs(self) -> None: """Update the entity's extra state attrs.""" await self._update_schedule() await super().update_attrs()