Home Assistant Unofficial Reference 2024.12.1
coordinator.py
Go to the documentation of this file.
1 """The Tomorrow.io integration."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from datetime import timedelta
7 from math import ceil
8 from typing import Any
9 
10 from pytomorrowio import TomorrowioV4
11 from pytomorrowio.const import CURRENT, FORECASTS
12 from pytomorrowio.exceptions import (
13  CantConnectException,
14  InvalidAPIKeyException,
15  RateLimitedException,
16  UnknownException,
17 )
18 
19 from homeassistant.config_entries import ConfigEntry
20 from homeassistant.const import (
21  CONF_API_KEY,
22  CONF_LATITUDE,
23  CONF_LOCATION,
24  CONF_LONGITUDE,
25 )
26 from homeassistant.core import HomeAssistant, callback
27 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
28 
29 from .const import (
30  CONF_TIMESTEP,
31  DOMAIN,
32  LOGGER,
33  TMRW_ATTR_CARBON_MONOXIDE,
34  TMRW_ATTR_CHINA_AQI,
35  TMRW_ATTR_CHINA_HEALTH_CONCERN,
36  TMRW_ATTR_CHINA_PRIMARY_POLLUTANT,
37  TMRW_ATTR_CLOUD_BASE,
38  TMRW_ATTR_CLOUD_CEILING,
39  TMRW_ATTR_CLOUD_COVER,
40  TMRW_ATTR_CONDITION,
41  TMRW_ATTR_DEW_POINT,
42  TMRW_ATTR_EPA_AQI,
43  TMRW_ATTR_EPA_HEALTH_CONCERN,
44  TMRW_ATTR_EPA_PRIMARY_POLLUTANT,
45  TMRW_ATTR_FEELS_LIKE,
46  TMRW_ATTR_FIRE_INDEX,
47  TMRW_ATTR_HUMIDITY,
48  TMRW_ATTR_NITROGEN_DIOXIDE,
49  TMRW_ATTR_OZONE,
50  TMRW_ATTR_PARTICULATE_MATTER_10,
51  TMRW_ATTR_PARTICULATE_MATTER_25,
52  TMRW_ATTR_POLLEN_GRASS,
53  TMRW_ATTR_POLLEN_TREE,
54  TMRW_ATTR_POLLEN_WEED,
55  TMRW_ATTR_PRECIPITATION,
56  TMRW_ATTR_PRECIPITATION_PROBABILITY,
57  TMRW_ATTR_PRECIPITATION_TYPE,
58  TMRW_ATTR_PRESSURE,
59  TMRW_ATTR_PRESSURE_SURFACE_LEVEL,
60  TMRW_ATTR_SOLAR_GHI,
61  TMRW_ATTR_SULPHUR_DIOXIDE,
62  TMRW_ATTR_TEMPERATURE,
63  TMRW_ATTR_TEMPERATURE_HIGH,
64  TMRW_ATTR_TEMPERATURE_LOW,
65  TMRW_ATTR_UV_HEALTH_CONCERN,
66  TMRW_ATTR_UV_INDEX,
67  TMRW_ATTR_VISIBILITY,
68  TMRW_ATTR_WIND_DIRECTION,
69  TMRW_ATTR_WIND_GUST,
70  TMRW_ATTR_WIND_SPEED,
71 )
72 
73 
74 @callback
76  hass: HomeAssistant, api_key: str, exclude_entry: ConfigEntry | None = None
77 ) -> list[ConfigEntry]:
78  """Get all entries for a given API key."""
79  return [
80  entry
81  for entry in hass.config_entries.async_entries(DOMAIN)
82  if entry.data[CONF_API_KEY] == api_key
83  and (exclude_entry is None or exclude_entry != entry)
84  ]
85 
86 
87 @callback
89  hass: HomeAssistant, api: TomorrowioV4, exclude_entry: ConfigEntry | None = None
90 ) -> timedelta:
91  """Calculate update_interval."""
92  # We check how many Tomorrow.io configured instances are using the same API key and
93  # calculate interval to not exceed allowed numbers of requests. Divide 90% of
94  # max_requests by the number of API calls because we want a buffer in the
95  # number of API calls left at the end of the day.
96  entries = async_get_entries_by_api_key(hass, api.api_key, exclude_entry)
97  minutes = ceil(
98  (24 * 60 * len(entries) * api.num_api_requests)
99  / (api.max_requests_per_day * 0.9)
100  )
101  LOGGER.debug(
102  (
103  "Number of config entries: %s\n"
104  "Number of API Requests per call: %s\n"
105  "Max requests per day: %s\n"
106  "Update interval: %s minutes"
107  ),
108  len(entries),
109  api.num_api_requests,
110  api.max_requests_per_day,
111  minutes,
112  )
113  return timedelta(minutes=minutes)
114 
115 
117  """Define an object to hold Tomorrow.io data."""
118 
119  def __init__(self, hass: HomeAssistant, api: TomorrowioV4) -> None:
120  """Initialize."""
121  self._api_api = api
122  self.datadatadata = {CURRENT: {}, FORECASTS: {}}
123  self.entry_id_to_location_dict: dict[str, str] = {}
124  self._coordinator_ready_coordinator_ready: asyncio.Event | None = None
125 
126  super().__init__(hass, LOGGER, name=f"{DOMAIN}_{self._api.api_key_masked}")
127 
128  def add_entry_to_location_dict(self, entry: ConfigEntry) -> None:
129  """Add an entry to the location dict."""
130  latitude = entry.data[CONF_LOCATION][CONF_LATITUDE]
131  longitude = entry.data[CONF_LOCATION][CONF_LONGITUDE]
132  self.entry_id_to_location_dict[entry.entry_id] = f"{latitude},{longitude}"
133 
134  async def async_setup_entry(self, entry: ConfigEntry) -> None:
135  """Load config entry into coordinator."""
136  # If we haven't loaded any data yet, register all entries with this API key and
137  # get the initial data for all of them. We do this because another config entry
138  # may start setup before we finish setting the initial data and we don't want
139  # to do multiple refreshes on startup.
140  if self._coordinator_ready_coordinator_ready is None:
141  LOGGER.debug(
142  "Setting up coordinator for API key %s, loading data for all entries",
143  self._api_api.api_key_masked,
144  )
145  self._coordinator_ready_coordinator_ready = asyncio.Event()
146  for entry_ in async_get_entries_by_api_key(self.hasshass, self._api_api.api_key):
147  self.add_entry_to_location_dictadd_entry_to_location_dict(entry_)
148  LOGGER.debug(
149  "Loaded %s entries, initiating first refresh",
150  len(self.entry_id_to_location_dict),
151  )
152  await self.async_config_entry_first_refreshasync_config_entry_first_refresh()
153  self._coordinator_ready_coordinator_ready.set()
154  else:
155  # If we have an event, we need to wait for it to be set before we proceed
156  await self._coordinator_ready_coordinator_ready.wait()
157  # If we're not getting new data because we already know this entry, we
158  # don't need to schedule a refresh
159  if entry.entry_id in self.entry_id_to_location_dict:
160  return
161  LOGGER.debug(
162  (
163  "Adding new entry to existing coordinator for API key %s, doing a "
164  "partial refresh"
165  ),
166  self._api_api.api_key_masked,
167  )
168  # We need a refresh, but it's going to be a partial refresh so we can
169  # minimize repeat API calls
170  self.add_entry_to_location_dictadd_entry_to_location_dict(entry)
171  await self.async_refreshasync_refresh()
172 
174  self._async_unsub_refresh_async_unsub_refresh()
175  if self._listeners:
176  self._schedule_refresh_schedule_refresh()
177 
178  async def async_unload_entry(self, entry: ConfigEntry) -> bool | None:
179  """Unload a config entry from coordinator.
180 
181  Returns whether coordinator can be removed as well because there are no
182  config entries tied to it anymore.
183  """
184  self.entry_id_to_location_dict.pop(entry.entry_id)
186  return not self.entry_id_to_location_dict
187 
188  async def _async_update_data(self) -> dict[str, Any]:
189  """Update data via library."""
190  data: dict[str, Any] = {}
191  # If we are refreshing because of a new config entry that's not already in our
192  # data, we do a partial refresh to avoid wasted API calls.
193  if self.datadatadata and any(
194  entry_id not in self.datadatadata for entry_id in self.entry_id_to_location_dict
195  ):
196  data = self.datadatadata
197 
198  LOGGER.debug(
199  "Fetching data for %s entries",
200  len(set(self.entry_id_to_location_dict) - set(data)),
201  )
202  for entry_id, location in self.entry_id_to_location_dict.items():
203  if entry_id in data:
204  continue
205  entry = self.hasshass.config_entries.async_get_entry(entry_id)
206  assert entry
207  try:
208  data[entry_id] = await self._api_api.realtime_and_all_forecasts(
209  [
210  # Weather
211  TMRW_ATTR_TEMPERATURE,
212  TMRW_ATTR_HUMIDITY,
213  TMRW_ATTR_PRESSURE,
214  TMRW_ATTR_WIND_SPEED,
215  TMRW_ATTR_WIND_DIRECTION,
216  TMRW_ATTR_CONDITION,
217  TMRW_ATTR_VISIBILITY,
218  TMRW_ATTR_OZONE,
219  TMRW_ATTR_WIND_GUST,
220  TMRW_ATTR_CLOUD_COVER,
221  TMRW_ATTR_PRECIPITATION_TYPE,
222  # Sensors
223  TMRW_ATTR_CARBON_MONOXIDE,
224  TMRW_ATTR_CHINA_AQI,
225  TMRW_ATTR_CHINA_HEALTH_CONCERN,
226  TMRW_ATTR_CHINA_PRIMARY_POLLUTANT,
227  TMRW_ATTR_CLOUD_BASE,
228  TMRW_ATTR_CLOUD_CEILING,
229  TMRW_ATTR_CLOUD_COVER,
230  TMRW_ATTR_DEW_POINT,
231  TMRW_ATTR_EPA_AQI,
232  TMRW_ATTR_EPA_HEALTH_CONCERN,
233  TMRW_ATTR_EPA_PRIMARY_POLLUTANT,
234  TMRW_ATTR_FEELS_LIKE,
235  TMRW_ATTR_FIRE_INDEX,
236  TMRW_ATTR_NITROGEN_DIOXIDE,
237  TMRW_ATTR_OZONE,
238  TMRW_ATTR_PARTICULATE_MATTER_10,
239  TMRW_ATTR_PARTICULATE_MATTER_25,
240  TMRW_ATTR_POLLEN_GRASS,
241  TMRW_ATTR_POLLEN_TREE,
242  TMRW_ATTR_POLLEN_WEED,
243  TMRW_ATTR_PRECIPITATION_TYPE,
244  TMRW_ATTR_PRESSURE_SURFACE_LEVEL,
245  TMRW_ATTR_SOLAR_GHI,
246  TMRW_ATTR_SULPHUR_DIOXIDE,
247  TMRW_ATTR_UV_INDEX,
248  TMRW_ATTR_UV_HEALTH_CONCERN,
249  TMRW_ATTR_WIND_GUST,
250  ],
251  [
252  TMRW_ATTR_TEMPERATURE_LOW,
253  TMRW_ATTR_TEMPERATURE_HIGH,
254  TMRW_ATTR_DEW_POINT,
255  TMRW_ATTR_HUMIDITY,
256  TMRW_ATTR_WIND_SPEED,
257  TMRW_ATTR_WIND_DIRECTION,
258  TMRW_ATTR_CONDITION,
259  TMRW_ATTR_PRECIPITATION,
260  TMRW_ATTR_PRECIPITATION_PROBABILITY,
261  ],
262  nowcast_timestep=entry.options[CONF_TIMESTEP],
263  location=location,
264  )
265  except (
266  CantConnectException,
267  InvalidAPIKeyException,
268  RateLimitedException,
269  UnknownException,
270  ) as error:
271  raise UpdateFailed from error
272 
273  return data
list[ConfigEntry] async_get_entries_by_api_key(HomeAssistant hass, str api_key, ConfigEntry|None exclude_entry=None)
Definition: coordinator.py:77
timedelta async_set_update_interval(HomeAssistant hass, TomorrowioV4 api, ConfigEntry|None exclude_entry=None)
Definition: coordinator.py:90