Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The AirVisual component."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping
6 from datetime import timedelta
7 from math import ceil
8 from typing import Any
9 
10 from pyairvisual.cloud_api import (
11  CloudAPI,
12  InvalidKeyError,
13  KeyExpiredError,
14  UnauthorizedError,
15 )
16 from pyairvisual.errors import AirVisualError
17 
18 from homeassistant.components import automation
19 from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
20 from homeassistant.const import (
21  CONF_API_KEY,
22  CONF_COUNTRY,
23  CONF_IP_ADDRESS,
24  CONF_LATITUDE,
25  CONF_LONGITUDE,
26  CONF_SHOW_ON_MAP,
27  CONF_STATE,
28  Platform,
29 )
30 from homeassistant.core import HomeAssistant, callback
31 from homeassistant.exceptions import ConfigEntryAuthFailed
32 from homeassistant.helpers import (
33  aiohttp_client,
34  device_registry as dr,
35  entity_registry as er,
36 )
37 from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
38 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
39 
40 from .const import (
41  CONF_CITY,
42  CONF_GEOGRAPHIES,
43  CONF_INTEGRATION_TYPE,
44  DOMAIN,
45  INTEGRATION_TYPE_GEOGRAPHY_COORDS,
46  INTEGRATION_TYPE_GEOGRAPHY_NAME,
47  INTEGRATION_TYPE_NODE_PRO,
48  LOGGER,
49 )
50 
51 type AirVisualConfigEntry = ConfigEntry[DataUpdateCoordinator]
52 
53 # We use a raw string for the airvisual_pro domain (instead of importing the actual
54 # constant) so that we can avoid listing it as a dependency:
55 DOMAIN_AIRVISUAL_PRO = "airvisual_pro"
56 
57 PLATFORMS = [Platform.SENSOR]
58 
59 DEFAULT_ATTRIBUTION = "Data provided by AirVisual"
60 
61 
62 @callback
64  hass: HomeAssistant, api_key: str, num_consumers: int
65 ) -> timedelta:
66  """Get a leveled scan interval for a particular cloud API key.
67 
68  This will shift based on the number of active consumers, thus keeping the user
69  under the monthly API limit.
70  """
71  # Assuming 10,000 calls per month and a "largest possible month" of 31 days; note
72  # that we give a buffer of 1500 API calls for any drift, restarts, etc.:
73  minutes_between_api_calls = ceil(num_consumers * 31 * 24 * 60 / 8500)
74 
75  LOGGER.debug(
76  "Leveling API key usage (%s): %s consumers, %s minutes between updates",
77  api_key,
78  num_consumers,
79  minutes_between_api_calls,
80  )
81 
82  return timedelta(minutes=minutes_between_api_calls)
83 
84 
85 @callback
87  hass: HomeAssistant, api_key: str
88 ) -> list[DataUpdateCoordinator]:
89  """Get all DataUpdateCoordinator objects related to a particular API key."""
90  return [
91  entry.runtime_data
92  for entry in hass.config_entries.async_entries(DOMAIN)
93  if entry.data.get(CONF_API_KEY) == api_key and hasattr(entry, "runtime_data")
94  ]
95 
96 
97 @callback
98 def async_get_geography_id(geography_dict: Mapping[str, Any]) -> str:
99  """Generate a unique ID from a geography dict."""
100  if CONF_CITY in geography_dict:
101  return ", ".join(
102  (
103  geography_dict[CONF_CITY],
104  geography_dict[CONF_STATE],
105  geography_dict[CONF_COUNTRY],
106  )
107  )
108  return ", ".join(
109  (str(geography_dict[CONF_LATITUDE]), str(geography_dict[CONF_LONGITUDE]))
110  )
111 
112 
113 @callback
115  hass: HomeAssistant, api_key: str
116 ) -> None:
117  """Sync the update interval for geography-based data coordinators (by API key)."""
118  coordinators = async_get_cloud_coordinators_by_api_key(hass, api_key)
119 
120  if not coordinators:
121  return
122 
123  update_interval = async_get_cloud_api_update_interval(
124  hass, api_key, len(coordinators)
125  )
126 
127  for coordinator in coordinators:
128  LOGGER.debug(
129  "Updating interval for coordinator: %s, %s",
130  coordinator.name,
131  update_interval,
132  )
133  coordinator.update_interval = update_interval
134 
135 
136 @callback
138  hass: HomeAssistant, entry: ConfigEntry
139 ) -> None:
140  """Ensure that geography config entries have appropriate properties."""
141  entry_updates = {}
142 
143  if not entry.unique_id:
144  # If the config entry doesn't already have a unique ID, set one:
145  entry_updates["unique_id"] = entry.data[CONF_API_KEY]
146  if not entry.options:
147  # If the config entry doesn't already have any options set, set defaults:
148  entry_updates["options"] = {CONF_SHOW_ON_MAP: True}
149  if entry.data.get(CONF_INTEGRATION_TYPE) not in [
150  INTEGRATION_TYPE_GEOGRAPHY_COORDS,
151  INTEGRATION_TYPE_GEOGRAPHY_NAME,
152  ]:
153  # If the config entry data doesn't contain an integration type that we know
154  # about, infer it from the data we have:
155  entry_updates["data"] = {**entry.data}
156  if CONF_CITY in entry.data:
157  entry_updates["data"][CONF_INTEGRATION_TYPE] = (
158  INTEGRATION_TYPE_GEOGRAPHY_NAME
159  )
160  else:
161  entry_updates["data"][CONF_INTEGRATION_TYPE] = (
162  INTEGRATION_TYPE_GEOGRAPHY_COORDS
163  )
164 
165  if not entry_updates:
166  return
167 
168  hass.config_entries.async_update_entry(entry, **entry_updates)
169 
170 
171 async def async_setup_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) -> bool:
172  """Set up AirVisual as config entry."""
173  if CONF_API_KEY not in entry.data:
174  # If this is a migrated AirVisual Pro entry, there's no actual setup to do;
175  # that will be handled by the `airvisual_pro` domain:
176  return False
177 
179 
180  websession = aiohttp_client.async_get_clientsession(hass)
181  cloud_api = CloudAPI(entry.data[CONF_API_KEY], session=websession)
182 
183  async def async_update_data() -> dict[str, Any]:
184  """Get new data from the API."""
185  if CONF_CITY in entry.data:
186  api_coro = cloud_api.air_quality.city(
187  entry.data[CONF_CITY],
188  entry.data[CONF_STATE],
189  entry.data[CONF_COUNTRY],
190  )
191  else:
192  api_coro = cloud_api.air_quality.nearest_city(
193  entry.data[CONF_LATITUDE],
194  entry.data[CONF_LONGITUDE],
195  )
196 
197  try:
198  return await api_coro
199  except (InvalidKeyError, KeyExpiredError, UnauthorizedError) as ex:
200  raise ConfigEntryAuthFailed from ex
201  except AirVisualError as err:
202  raise UpdateFailed(f"Error while retrieving data: {err}") from err
203 
204  coordinator = DataUpdateCoordinator(
205  hass,
206  LOGGER,
207  config_entry=entry,
208  name=async_get_geography_id(entry.data),
209  # We give a placeholder update interval in order to create the coordinator;
210  # then, below, we use the coordinator's presence (along with any other
211  # coordinators using the same API key) to calculate an actual, leveled
212  # update interval:
213  update_interval=timedelta(minutes=5),
214  update_method=async_update_data,
215  )
216 
217  entry.async_on_unload(entry.add_update_listener(async_reload_entry))
218 
219  await coordinator.async_config_entry_first_refresh()
220  entry.runtime_data = coordinator
221 
222  # Reassess the interval between 2 server requests
223  async_sync_geo_coordinator_update_intervals(hass, entry.data[CONF_API_KEY])
224 
225  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
226 
227  return True
228 
229 
230 async def async_migrate_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) -> bool:
231  """Migrate an old config entry."""
232  version = entry.version
233 
234  LOGGER.debug("Migrating from version %s", version)
235 
236  # 1 -> 2: One geography per config entry
237  if version == 1:
238  version = 2
239 
240  # Update the config entry to only include the first geography (there is always
241  # guaranteed to be at least one):
242  geographies = list(entry.data[CONF_GEOGRAPHIES])
243  first_geography = geographies.pop(0)
244  first_id = async_get_geography_id(first_geography)
245 
246  hass.config_entries.async_update_entry(
247  entry,
248  unique_id=first_id,
249  title=f"Cloud API ({first_id})",
250  data={CONF_API_KEY: entry.data[CONF_API_KEY], **first_geography},
251  version=version,
252  )
253 
254  # For any geographies that remain, create a new config entry for each one:
255  for geography in geographies:
256  if CONF_LATITUDE in geography:
257  source = "geography_by_coords"
258  else:
259  source = "geography_by_name"
260  hass.async_create_task(
261  hass.config_entries.flow.async_init(
262  DOMAIN,
263  context={"source": SOURCE_IMPORT},
264  data={
265  "import_source": source,
266  CONF_API_KEY: entry.data[CONF_API_KEY],
267  **geography,
268  },
269  )
270  )
271 
272  # 2 -> 3: Moving AirVisual Pro to its own domain
273  elif version == 2:
274  version = 3
275 
276  if entry.data[CONF_INTEGRATION_TYPE] == INTEGRATION_TYPE_NODE_PRO:
277  device_registry = dr.async_get(hass)
278  entity_registry = er.async_get(hass)
279  ip_address = entry.data[CONF_IP_ADDRESS]
280 
281  # Store the existing Pro device before the migration removes it:
282  old_device_entry = next(
283  entry
284  for entry in dr.async_entries_for_config_entry(
285  device_registry, entry.entry_id
286  )
287  )
288 
289  # Store the existing Pro entity entries (mapped by unique ID) before the
290  # migration removes it:
291  old_entity_entries: dict[str, er.RegistryEntry] = {
292  entry.unique_id: entry
293  for entry in er.async_entries_for_device(
294  entity_registry, old_device_entry.id, include_disabled_entities=True
295  )
296  }
297 
298  # Remove this config entry and create a new one under the `airvisual_pro`
299  # domain:
300  new_entry_data = {**entry.data}
301  new_entry_data.pop(CONF_INTEGRATION_TYPE)
302 
303  # Schedule the removal in a task to avoid a deadlock
304  # since we cannot remove a config entry that is in
305  # the process of being setup.
306  hass.async_create_background_task(
307  hass.config_entries.async_remove(entry.entry_id),
308  name="remove config legacy airvisual entry {entry.title}",
309  )
310  await hass.config_entries.flow.async_init(
311  DOMAIN_AIRVISUAL_PRO,
312  context={"source": SOURCE_IMPORT},
313  data=new_entry_data,
314  )
315 
316  # After the migration has occurred, grab the new config and device entries
317  # (now under the `airvisual_pro` domain):
318  new_config_entry = next(
319  entry
320  for entry in hass.config_entries.async_entries(DOMAIN_AIRVISUAL_PRO)
321  if entry.data[CONF_IP_ADDRESS] == ip_address
322  )
323  new_device_entry = next(
324  entry
325  for entry in dr.async_entries_for_config_entry(
326  device_registry, new_config_entry.entry_id
327  )
328  )
329 
330  # Update the new device entry with any customizations from the old one:
331  device_registry.async_update_device(
332  new_device_entry.id,
333  area_id=old_device_entry.area_id,
334  disabled_by=old_device_entry.disabled_by,
335  name_by_user=old_device_entry.name_by_user,
336  )
337 
338  # Update the new entity entries with any customizations from the old ones:
339  for new_entity_entry in er.async_entries_for_device(
340  entity_registry, new_device_entry.id, include_disabled_entities=True
341  ):
342  if old_entity_entry := old_entity_entries.get(
343  new_entity_entry.unique_id
344  ):
345  entity_registry.async_update_entity(
346  new_entity_entry.entity_id,
347  area_id=old_entity_entry.area_id,
348  device_class=old_entity_entry.device_class,
349  disabled_by=old_entity_entry.disabled_by,
350  hidden_by=old_entity_entry.hidden_by,
351  icon=old_entity_entry.icon,
352  name=old_entity_entry.name,
353  new_entity_id=old_entity_entry.entity_id,
354  unit_of_measurement=old_entity_entry.unit_of_measurement,
355  )
356 
357  # If any automations are using the old device ID, create a Repairs issues
358  # with instructions on how to update it:
359  if device_automations := automation.automations_with_device(
360  hass, old_device_entry.id
361  ):
363  hass,
364  DOMAIN,
365  f"airvisual_pro_migration_{entry.entry_id}",
366  is_fixable=False,
367  is_persistent=True,
368  severity=IssueSeverity.WARNING,
369  translation_key="airvisual_pro_migration",
370  translation_placeholders={
371  "ip_address": ip_address,
372  "old_device_id": old_device_entry.id,
373  "new_device_id": new_device_entry.id,
374  "device_automations_string": ", ".join(
375  f"`{automation}`" for automation in device_automations
376  ),
377  },
378  )
379  else:
380  hass.config_entries.async_update_entry(entry, version=version)
381 
382  LOGGER.info("Migration to version %s successful", version)
383 
384  return True
385 
386 
387 async def async_unload_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) -> bool:
388  """Unload an AirVisual config entry."""
389  unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
390 
391  if unload_ok and CONF_API_KEY in entry.data:
392  # Re-calculate the update interval period for any remaining consumers of
393  # this API key:
394  async_sync_geo_coordinator_update_intervals(hass, entry.data[CONF_API_KEY])
395 
396  return unload_ok
397 
398 
399 async def async_reload_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) -> None:
400  """Handle an options update."""
401  await hass.config_entries.async_reload(entry.entry_id)
None _standardize_geography_config_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:139
str async_get_geography_id(Mapping[str, Any] geography_dict)
Definition: __init__.py:98
timedelta async_get_cloud_api_update_interval(HomeAssistant hass, str api_key, int num_consumers)
Definition: __init__.py:65
None async_reload_entry(HomeAssistant hass, AirVisualConfigEntry entry)
Definition: __init__.py:399
bool async_migrate_entry(HomeAssistant hass, AirVisualConfigEntry entry)
Definition: __init__.py:230
bool async_unload_entry(HomeAssistant hass, AirVisualConfigEntry entry)
Definition: __init__.py:387
list[DataUpdateCoordinator] async_get_cloud_coordinators_by_api_key(HomeAssistant hass, str api_key)
Definition: __init__.py:88
bool async_setup_entry(HomeAssistant hass, AirVisualConfigEntry entry)
Definition: __init__.py:171
None async_sync_geo_coordinator_update_intervals(HomeAssistant hass, str api_key)
Definition: __init__.py:116
None async_create_issue(HomeAssistant hass, str entry_id)
Definition: repairs.py:69