1 """The AirVisual component."""
3 from __future__
import annotations
5 from collections.abc
import Mapping
6 from datetime
import timedelta
10 from pyairvisual.cloud_api
import (
16 from pyairvisual.errors
import AirVisualError
34 device_registry
as dr,
35 entity_registry
as er,
43 CONF_INTEGRATION_TYPE,
45 INTEGRATION_TYPE_GEOGRAPHY_COORDS,
46 INTEGRATION_TYPE_GEOGRAPHY_NAME,
47 INTEGRATION_TYPE_NODE_PRO,
51 type AirVisualConfigEntry = ConfigEntry[DataUpdateCoordinator]
55 DOMAIN_AIRVISUAL_PRO =
"airvisual_pro"
57 PLATFORMS = [Platform.SENSOR]
59 DEFAULT_ATTRIBUTION =
"Data provided by AirVisual"
64 hass: HomeAssistant, api_key: str, num_consumers: int
66 """Get a leveled scan interval for a particular cloud API key.
68 This will shift based on the number of active consumers, thus keeping the user
69 under the monthly API limit.
73 minutes_between_api_calls = ceil(num_consumers * 31 * 24 * 60 / 8500)
76 "Leveling API key usage (%s): %s consumers, %s minutes between updates",
79 minutes_between_api_calls,
82 return timedelta(minutes=minutes_between_api_calls)
87 hass: HomeAssistant, api_key: str
88 ) -> list[DataUpdateCoordinator]:
89 """Get all DataUpdateCoordinator objects related to a particular API key."""
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")
99 """Generate a unique ID from a geography dict."""
100 if CONF_CITY
in geography_dict:
103 geography_dict[CONF_CITY],
104 geography_dict[CONF_STATE],
105 geography_dict[CONF_COUNTRY],
109 (
str(geography_dict[CONF_LATITUDE]),
str(geography_dict[CONF_LONGITUDE]))
115 hass: HomeAssistant, api_key: str
117 """Sync the update interval for geography-based data coordinators (by API key)."""
124 hass, api_key, len(coordinators)
127 for coordinator
in coordinators:
129 "Updating interval for coordinator: %s, %s",
133 coordinator.update_interval = update_interval
138 hass: HomeAssistant, entry: ConfigEntry
140 """Ensure that geography config entries have appropriate properties."""
143 if not entry.unique_id:
145 entry_updates[
"unique_id"] = entry.data[CONF_API_KEY]
146 if not entry.options:
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,
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
161 entry_updates[
"data"][CONF_INTEGRATION_TYPE] = (
162 INTEGRATION_TYPE_GEOGRAPHY_COORDS
165 if not entry_updates:
168 hass.config_entries.async_update_entry(entry, **entry_updates)
172 """Set up AirVisual as config entry."""
173 if CONF_API_KEY
not in entry.data:
180 websession = aiohttp_client.async_get_clientsession(hass)
181 cloud_api = CloudAPI(entry.data[CONF_API_KEY], session=websession)
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],
192 api_coro = cloud_api.air_quality.nearest_city(
193 entry.data[CONF_LATITUDE],
194 entry.data[CONF_LONGITUDE],
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
214 update_method=async_update_data,
217 entry.async_on_unload(entry.add_update_listener(async_reload_entry))
219 await coordinator.async_config_entry_first_refresh()
220 entry.runtime_data = coordinator
225 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
231 """Migrate an old config entry."""
232 version = entry.version
234 LOGGER.debug(
"Migrating from version %s", version)
242 geographies =
list(entry.data[CONF_GEOGRAPHIES])
243 first_geography = geographies.pop(0)
246 hass.config_entries.async_update_entry(
249 title=f
"Cloud API ({first_id})",
250 data={CONF_API_KEY: entry.data[CONF_API_KEY], **first_geography},
255 for geography
in geographies:
256 if CONF_LATITUDE
in geography:
257 source =
"geography_by_coords"
259 source =
"geography_by_name"
260 hass.async_create_task(
261 hass.config_entries.flow.async_init(
263 context={
"source": SOURCE_IMPORT},
265 "import_source": source,
266 CONF_API_KEY: entry.data[CONF_API_KEY],
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]
282 old_device_entry = next(
284 for entry
in dr.async_entries_for_config_entry(
285 device_registry, entry.entry_id
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
300 new_entry_data = {**entry.data}
301 new_entry_data.pop(CONF_INTEGRATION_TYPE)
306 hass.async_create_background_task(
307 hass.config_entries.async_remove(entry.entry_id),
308 name=
"remove config legacy airvisual entry {entry.title}",
310 await hass.config_entries.flow.async_init(
311 DOMAIN_AIRVISUAL_PRO,
312 context={
"source": SOURCE_IMPORT},
318 new_config_entry = next(
320 for entry
in hass.config_entries.async_entries(DOMAIN_AIRVISUAL_PRO)
321 if entry.data[CONF_IP_ADDRESS] == ip_address
323 new_device_entry = next(
325 for entry
in dr.async_entries_for_config_entry(
326 device_registry, new_config_entry.entry_id
331 device_registry.async_update_device(
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,
339 for new_entity_entry
in er.async_entries_for_device(
340 entity_registry, new_device_entry.id, include_disabled_entities=
True
342 if old_entity_entry := old_entity_entries.get(
343 new_entity_entry.unique_id
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,
359 if device_automations := automation.automations_with_device(
360 hass, old_device_entry.id
365 f
"airvisual_pro_migration_{entry.entry_id}",
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
380 hass.config_entries.async_update_entry(entry, version=version)
382 LOGGER.info(
"Migration to version %s successful", version)
388 """Unload an AirVisual config entry."""
389 unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
391 if unload_ok
and CONF_API_KEY
in entry.data:
400 """Handle an options update."""
401 await hass.config_entries.async_reload(entry.entry_id)
None _standardize_geography_config_entry(HomeAssistant hass, ConfigEntry entry)
str async_get_geography_id(Mapping[str, Any] geography_dict)
timedelta async_get_cloud_api_update_interval(HomeAssistant hass, str api_key, int num_consumers)
None async_reload_entry(HomeAssistant hass, AirVisualConfigEntry entry)
bool async_migrate_entry(HomeAssistant hass, AirVisualConfigEntry entry)
bool async_unload_entry(HomeAssistant hass, AirVisualConfigEntry entry)
list[DataUpdateCoordinator] async_get_cloud_coordinators_by_api_key(HomeAssistant hass, str api_key)
bool async_setup_entry(HomeAssistant hass, AirVisualConfigEntry entry)
None async_sync_geo_coordinator_update_intervals(HomeAssistant hass, str api_key)
None async_create_issue(HomeAssistant hass, str entry_id)