Home Assistant Unofficial Reference 2024.12.1
coordinator.py
Go to the documentation of this file.
1 """The Aprilaire coordinator."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Awaitable, Callable
6 import logging
7 from typing import Any
8 
9 import pyaprilaire.client
10 from pyaprilaire.const import MODELS, Attribute, FunctionalDomain
11 
12 from homeassistant.config_entries import ConfigEntry
13 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
15 from homeassistant.helpers.device_registry import DeviceInfo
16 from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol
17 
18 from .const import DOMAIN
19 
20 RECONNECT_INTERVAL = 60 * 60
21 RETRY_CONNECTION_INTERVAL = 10
22 WAIT_TIMEOUT = 30
23 
24 _LOGGER = logging.getLogger(__name__)
25 
26 type AprilaireConfigEntry = ConfigEntry[AprilaireCoordinator]
27 
28 
30  """Coordinator for interacting with the thermostat."""
31 
32  def __init__(
33  self,
34  hass: HomeAssistant,
35  unique_id: str | None,
36  host: str,
37  port: int,
38  ) -> None:
39  """Initialize the coordinator."""
40 
41  self.hasshass = hass
42  self.unique_idunique_id = unique_id
43  self.datadata: dict[str, Any] = {}
44 
45  self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
46 
47  self.clientclient = pyaprilaire.client.AprilaireClient(
48  host,
49  port,
50  self.async_set_updated_dataasync_set_updated_data,
51  _LOGGER,
52  RECONNECT_INTERVAL,
53  RETRY_CONNECTION_INTERVAL,
54  )
55 
56  if hasattr(self.clientclient, "data") and self.clientclient.data:
57  self.datadata = self.clientclient.data
58 
59  @callback
61  self, update_callback: CALLBACK_TYPE, context: Any = None
62  ) -> Callable[[], None]:
63  """Listen for data updates."""
64 
65  @callback
66  def remove_listener() -> None:
67  """Remove update listener."""
68  self._listeners.pop(remove_listener)
69 
70  self._listeners[remove_listener] = (update_callback, context)
71 
72  return remove_listener
73 
74  @callback
75  def async_update_listeners(self) -> None:
76  """Update all registered listeners."""
77  for update_callback, _ in list(self._listeners.values()):
78  update_callback()
79 
80  def async_set_updated_data(self, data: Any) -> None:
81  """Manually update data, notify listeners and reset refresh interval."""
82 
83  old_device_info = self.create_device_infocreate_device_info(self.datadata)
84 
85  self.datadata = self.datadata | data
86 
87  self.async_update_listenersasync_update_listeners()
88 
89  new_device_info = self.create_device_infocreate_device_info(data)
90 
91  if (
92  old_device_info is not None
93  and new_device_info is not None
94  and old_device_info != new_device_info
95  ):
96  device_registry = dr.async_get(self.hasshass)
97 
98  device = device_registry.async_get_device(old_device_info["identifiers"])
99 
100  if device is not None:
101  new_device_info.pop("identifiers", None)
102  new_device_info.pop("connections", None)
103 
104  device_registry.async_update_device(
105  device_id=device.id,
106  **new_device_info, # type: ignore[misc]
107  )
108 
109  async def start_listen(self):
110  """Start listening for data."""
111  await self.clientclient.start_listen()
112 
113  def stop_listen(self):
114  """Stop listening for data."""
115  self.clientclient.stop_listen()
116 
117  async def wait_for_ready(
118  self, ready_callback: Callable[[bool], Awaitable[None]]
119  ) -> bool:
120  """Wait for the client to be ready."""
121 
122  if not self.datadata or Attribute.MAC_ADDRESS not in self.datadata:
123  data = await self.clientclient.wait_for_response(
124  FunctionalDomain.IDENTIFICATION, 2, WAIT_TIMEOUT
125  )
126 
127  if not data or Attribute.MAC_ADDRESS not in data:
128  _LOGGER.error("Missing MAC address")
129  await ready_callback(False)
130 
131  return False
132 
133  if not self.datadata or Attribute.NAME not in self.datadata:
134  await self.clientclient.wait_for_response(
135  FunctionalDomain.IDENTIFICATION, 4, WAIT_TIMEOUT
136  )
137 
138  if not self.datadata or Attribute.THERMOSTAT_MODES not in self.datadata:
139  await self.clientclient.wait_for_response(
140  FunctionalDomain.CONTROL, 7, WAIT_TIMEOUT
141  )
142 
143  if (
144  not self.datadata
145  or Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_STATUS not in self.datadata
146  ):
147  await self.clientclient.wait_for_response(
148  FunctionalDomain.SENSORS, 2, WAIT_TIMEOUT
149  )
150 
151  await ready_callback(True)
152 
153  return True
154 
155  @property
156  def device_name(self) -> str:
157  """Get the name of the thermostat."""
158 
159  return self.create_device_namecreate_device_name(self.datadata)
160 
161  def create_device_name(self, data: dict[str, Any] | None) -> str:
162  """Create the name of the thermostat."""
163 
164  name = data.get(Attribute.NAME) if data else None
165 
166  return name if name else "Aprilaire"
167 
168  def get_hw_version(self, data: dict[str, Any]) -> str:
169  """Get the hardware version."""
170 
171  if hardware_revision := data.get(Attribute.HARDWARE_REVISION):
172  return (
173  f"Rev. {chr(hardware_revision)}"
174  if hardware_revision > ord("A")
175  else str(hardware_revision)
176  )
177 
178  return "Unknown"
179 
180  @property
181  def device_info(self) -> DeviceInfo | None:
182  """Get the device info for the thermostat."""
183  return self.create_device_infocreate_device_info(self.datadata)
184 
185  def create_device_info(self, data: dict[str, Any]) -> DeviceInfo | None:
186  """Create the device info for the thermostat."""
187 
188  if data is None or Attribute.MAC_ADDRESS not in data or self.unique_idunique_id is None:
189  return None
190 
191  device_info = DeviceInfo(
192  identifiers={(DOMAIN, self.unique_idunique_id)},
193  name=self.create_device_namecreate_device_name(data),
194  manufacturer="Aprilaire",
195  )
196 
197  model_number = data.get(Attribute.MODEL_NUMBER)
198  if model_number is not None:
199  device_info["model"] = MODELS.get(model_number, f"Unknown ({model_number})")
200 
201  device_info["hw_version"] = self.get_hw_versionget_hw_version(data)
202 
203  firmware_major_revision = data.get(Attribute.FIRMWARE_MAJOR_REVISION)
204  firmware_minor_revision = data.get(Attribute.FIRMWARE_MINOR_REVISION)
205  if firmware_major_revision is not None:
206  device_info["sw_version"] = (
207  str(firmware_major_revision)
208  if firmware_minor_revision is None
209  else f"{firmware_major_revision}.{firmware_minor_revision:02}"
210  )
211 
212  return device_info
None __init__(self, HomeAssistant hass, str|None unique_id, str host, int port)
Definition: coordinator.py:38
bool wait_for_ready(self, Callable[[bool], Awaitable[None]] ready_callback)
Definition: coordinator.py:119
Callable[[], None] async_add_listener(self, CALLBACK_TYPE update_callback, Any context=None)
Definition: coordinator.py:62
DeviceInfo|None create_device_info(self, dict[str, Any] data)
Definition: coordinator.py:185