Home Assistant Unofficial Reference 2024.12.1
entity.py
Go to the documentation of this file.
1 """Base for evohome entity."""
2 
3 from datetime import datetime, timedelta, timezone
4 import logging
5 from typing import Any
6 
7 import evohomeasync2 as evo
8 from evohomeasync2.schema.const import (
9  SZ_HEAT_SETPOINT,
10  SZ_SETPOINT_STATUS,
11  SZ_STATE_STATUS,
12  SZ_SYSTEM_MODE_STATUS,
13  SZ_TIME_UNTIL,
14  SZ_UNTIL,
15 )
16 
17 from homeassistant.helpers.dispatcher import async_dispatcher_connect
18 from homeassistant.helpers.entity import Entity
19 import homeassistant.util.dt as dt_util
20 
21 from . import EvoBroker, EvoService
22 from .const import DOMAIN
23 from .helpers import convert_dict, convert_until
24 
25 _LOGGER = logging.getLogger(__name__)
26 
27 
29  """Base for any evohome-compatible entity (controller, DHW, zone).
30 
31  This includes the controller, (1 to 12) heating zones and (optionally) a
32  DHW controller.
33  """
34 
35  _attr_should_poll = False
36 
37  def __init__(
38  self,
39  evo_broker: EvoBroker,
40  evo_device: evo.ControlSystem | evo.HotWater | evo.Zone,
41  ) -> None:
42  """Initialize an evohome-compatible entity (TCS, DHW, zone)."""
43  self._evo_device_evo_device = evo_device
44  self._evo_broker_evo_broker = evo_broker
45 
46  self._device_state_attrs: dict[str, Any] = {}
47 
48  async def async_refresh(self, payload: dict | None = None) -> None:
49  """Process any signals."""
50  if payload is None:
51  self.async_schedule_update_ha_stateasync_schedule_update_ha_state(force_refresh=True)
52  return
53  if payload["unique_id"] != self._attr_unique_id:
54  return
55  if payload["service"] in (
56  EvoService.SET_ZONE_OVERRIDE,
57  EvoService.RESET_ZONE_OVERRIDE,
58  ):
59  await self.async_zone_svc_requestasync_zone_svc_request(payload["service"], payload["data"])
60  return
61  await self.async_tcs_svc_requestasync_tcs_svc_request(payload["service"], payload["data"])
62 
63  async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> None:
64  """Process a service request (system mode) for a controller."""
65  raise NotImplementedError
66 
67  async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None:
68  """Process a service request (setpoint override) for a zone."""
69  raise NotImplementedError
70 
71  @property
72  def extra_state_attributes(self) -> dict[str, Any]:
73  """Return the evohome-specific state attributes."""
74  status = self._device_state_attrs
75  if SZ_SYSTEM_MODE_STATUS in status:
76  convert_until(status[SZ_SYSTEM_MODE_STATUS], SZ_TIME_UNTIL)
77  if SZ_SETPOINT_STATUS in status:
78  convert_until(status[SZ_SETPOINT_STATUS], SZ_UNTIL)
79  if SZ_STATE_STATUS in status:
80  convert_until(status[SZ_STATE_STATUS], SZ_UNTIL)
81 
82  return {"status": convert_dict(status)}
83 
84  async def async_added_to_hass(self) -> None:
85  """Run when entity about to be added to hass."""
86  async_dispatcher_connect(self.hasshass, DOMAIN, self.async_refreshasync_refresh)
87 
88 
90  """Base for any evohome-compatible child entity (DHW, zone).
91 
92  This includes (1 to 12) heating zones and (optionally) a DHW controller.
93  """
94 
95  _evo_id: str # mypy hint
96 
97  def __init__(
98  self, evo_broker: EvoBroker, evo_device: evo.HotWater | evo.Zone
99  ) -> None:
100  """Initialize an evohome-compatible child entity (DHW, zone)."""
101  super().__init__(evo_broker, evo_device)
102 
103  self._evo_tcs_evo_tcs = evo_device.tcs
104 
105  self._schedule_schedule: dict[str, Any] = {}
106  self._setpoints_setpoints: dict[str, Any] = {}
107 
108  @property
109  def current_temperature(self) -> float | None:
110  """Return the current temperature of a Zone."""
111 
112  assert isinstance(self._evo_device_evo_device, evo.HotWater | evo.Zone) # mypy check
113 
114  if (temp := self._evo_broker_evo_broker.temps.get(self._evo_id)) is not None:
115  # use high-precision temps if available
116  return temp
117  return self._evo_device_evo_device.temperature
118 
119  @property
120  def setpoints(self) -> dict[str, Any]:
121  """Return the current/next setpoints from the schedule.
122 
123  Only Zones & DHW controllers (but not the TCS) can have schedules.
124  """
125 
126  def _dt_evo_to_aware(dt_naive: datetime, utc_offset: timedelta) -> datetime:
127  dt_aware = dt_naive.replace(tzinfo=dt_util.UTC) - utc_offset
128  return dt_util.as_local(dt_aware)
129 
130  if not (schedule := self._schedule_schedule.get("DailySchedules")):
131  return {} # no scheduled setpoints when {'DailySchedules': []}
132 
133  # get dt in the same TZ as the TCS location, so we can compare schedule times
134  day_time = dt_util.now().astimezone(timezone(self._evo_broker_evo_broker.loc_utc_offset))
135  day_of_week = day_time.weekday() # for evohome, 0 is Monday
136  time_of_day = day_time.strftime("%H:%M:%S")
137 
138  try:
139  # Iterate today's switchpoints until past the current time of day...
140  day = schedule[day_of_week]
141  sp_idx = -1 # last switchpoint of the day before
142  for i, tmp in enumerate(day["Switchpoints"]):
143  if time_of_day > tmp["TimeOfDay"]:
144  sp_idx = i # current setpoint
145  else:
146  break
147 
148  # Did this setpoint start yesterday? Does the next setpoint start tomorrow?
149  this_sp_day = -1 if sp_idx == -1 else 0
150  next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0
151 
152  for key, offset, idx in (
153  ("this", this_sp_day, sp_idx),
154  ("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)),
155  ):
156  sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d")
157  day = schedule[(day_of_week + offset) % 7]
158  switchpoint = day["Switchpoints"][idx]
159 
160  switchpoint_time_of_day = dt_util.parse_datetime(
161  f"{sp_date}T{switchpoint['TimeOfDay']}"
162  )
163  assert switchpoint_time_of_day is not None # mypy check
164  dt_aware = _dt_evo_to_aware(
165  switchpoint_time_of_day, self._evo_broker_evo_broker.loc_utc_offset
166  )
167 
168  self._setpoints_setpoints[f"{key}_sp_from"] = dt_aware.isoformat()
169  try:
170  self._setpoints_setpoints[f"{key}_sp_temp"] = switchpoint[SZ_HEAT_SETPOINT]
171  except KeyError:
172  self._setpoints_setpoints[f"{key}_sp_state"] = switchpoint["DhwState"]
173 
174  except IndexError:
175  self._setpoints_setpoints = {}
176  _LOGGER.warning(
177  "Failed to get setpoints, report as an issue if this error persists",
178  exc_info=True,
179  )
180 
181  return self._setpoints_setpoints
182 
183  async def _update_schedule(self) -> None:
184  """Get the latest schedule, if any."""
185 
186  assert isinstance(self._evo_device_evo_device, evo.HotWater | evo.Zone) # mypy check
187 
188  try:
189  schedule = await self._evo_broker_evo_broker.call_client_api(
190  self._evo_device_evo_device.get_schedule(), update_state=False
191  )
192  except evo.InvalidSchedule as err:
193  _LOGGER.warning(
194  "%s: Unable to retrieve a valid schedule: %s",
195  self._evo_device_evo_device,
196  err,
197  )
198  self._schedule_schedule = {}
199  else:
200  self._schedule_schedule = schedule or {}
201 
202  _LOGGER.debug("Schedule['%s'] = %s", self.namename, self._schedule_schedule)
203 
204  async def async_update(self) -> None:
205  """Get the latest state data."""
206  next_sp_from = self._setpoints_setpoints.get("next_sp_from", "2000-01-01T00:00:00+00:00")
207  next_sp_from_dt = dt_util.parse_datetime(next_sp_from)
208  if next_sp_from_dt is None or dt_util.now() >= next_sp_from_dt:
209  await self._update_schedule_update_schedule() # no schedule, or it's out-of-date
210 
211  self._device_state_attrs_device_state_attrs = {"setpoints": self.setpointssetpoints}
None __init__(self, EvoBroker evo_broker, evo.HotWater|evo.Zone evo_device)
Definition: entity.py:99
None async_tcs_svc_request(self, str service, dict[str, Any] data)
Definition: entity.py:63
None async_zone_svc_request(self, str service, dict[str, Any] data)
Definition: entity.py:67
None async_refresh(self, dict|None payload=None)
Definition: entity.py:48
None __init__(self, EvoBroker evo_broker, evo.ControlSystem|evo.HotWater|evo.Zone evo_device)
Definition: entity.py:41
None async_schedule_update_ha_state(self, bool force_refresh=False)
Definition: entity.py:1265
str|UndefinedType|None name(self)
Definition: entity.py:738
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
dict[str, Any] convert_dict(dict[str, Any] dictionary)
Definition: helpers.py:43
None convert_until(dict status_dict, str until_key)
Definition: helpers.py:35
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103