From 16670a38a432e556cdd406becef9ac6b084fbc86 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 25 Mar 2020 19:13:28 +0100 Subject: [PATCH] Dynamic update interval for Airly integration (#31459) * Initial commit * dynamic update * Don't update when add entities * Cleaning * Fix MAX_REQUESTS_PER_DAY const * Fix pylint errors * Fix comment * Migrate to DataUpdateCoordinator * Cleaning * Suggested change * Change try..except as suggested * Remove unnecessary self._attrs variable * Cleaning * Fix typo * Change update_interval method * Add comments * Add ConfigEntryNotReady --- homeassistant/components/airly/__init__.py | 132 +++++++++++------- homeassistant/components/airly/air_quality.py | 69 ++++----- homeassistant/components/airly/const.py | 2 +- homeassistant/components/airly/sensor.py | 36 +++-- 4 files changed, 138 insertions(+), 101 deletions(-) diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index bad5a48c05f..85071925357 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta import logging +from math import ceil from aiohttp.client_exceptions import ClientConnectorError from airly import Airly @@ -10,28 +11,40 @@ import async_timeout from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import Config, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( ATTR_API_ADVICE, ATTR_API_CAQI, ATTR_API_CAQI_DESCRIPTION, ATTR_API_CAQI_LEVEL, - DATA_CLIENT, DOMAIN, + MAX_REQUESTS_PER_DAY, NO_AIRLY_SENSORS, ) +PLATFORMS = ["air_quality", "sensor"] + _LOGGER = logging.getLogger(__name__) -DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) + +def set_update_interval(hass, instances): + """Set update_interval to another configured Airly instances.""" + # We check how many Airly configured instances are and calculate interval to not + # exceed allowed numbers of requests. + interval = timedelta(minutes=ceil(24 * 60 / MAX_REQUESTS_PER_DAY) * instances) + + if hass.data.get(DOMAIN): + for instance in hass.data[DOMAIN].values(): + instance.update_interval = interval + + return interval async def async_setup(hass: HomeAssistant, config: Config) -> bool: """Set up configured Airly.""" - hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA_CLIENT] = {} return True @@ -48,70 +61,85 @@ async def async_setup_entry(hass, config_entry): ) websession = async_get_clientsession(hass) - - airly = AirlyData(websession, api_key, latitude, longitude) - - await airly.async_update() - - hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = airly - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(config_entry, "air_quality") + # Change update_interval for other Airly instances + update_interval = set_update_interval( + hass, len(hass.config_entries.async_entries(DOMAIN)) ) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(config_entry, "sensor") + + coordinator = AirlyDataUpdateCoordinator( + hass, websession, api_key, latitude, longitude, update_interval ) + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config_entry.entry_id] = coordinator + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + return True async def async_unload_entry(hass, config_entry): """Unload a config entry.""" - hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) - await hass.config_entries.async_forward_entry_unload(config_entry, "air_quality") - await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") - return True + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + # Change update_interval for other Airly instances + set_update_interval(hass, len(hass.data[DOMAIN])) + + return unload_ok -class AirlyData: +class AirlyDataUpdateCoordinator(DataUpdateCoordinator): """Define an object to hold Airly data.""" - def __init__(self, session, api_key, latitude, longitude): + def __init__(self, hass, session, api_key, latitude, longitude, update_interval): """Initialize.""" self.latitude = latitude self.longitude = longitude self.airly = Airly(api_key, session) - self.data = {} - @Throttle(DEFAULT_SCAN_INTERVAL) - async def async_update(self): - """Update Airly data.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - try: - with async_timeout.timeout(20): - measurements = self.airly.create_measurements_session_point( - self.latitude, self.longitude - ) + async def _async_update_data(self): + """Update data via library.""" + data = {} + with async_timeout.timeout(20): + measurements = self.airly.create_measurements_session_point( + self.latitude, self.longitude + ) + try: await measurements.update() + except (AirlyError, ClientConnectorError) as error: + raise UpdateFailed(error) - values = measurements.current["values"] - index = measurements.current["indexes"][0] - standards = measurements.current["standards"] + values = measurements.current["values"] + index = measurements.current["indexes"][0] + standards = measurements.current["standards"] - if index["description"] == NO_AIRLY_SENSORS: - _LOGGER.error("Can't retrieve data: no Airly sensors in this area") - return - for value in values: - self.data[value["name"]] = value["value"] - for standard in standards: - self.data[f"{standard['pollutant']}_LIMIT"] = standard["limit"] - self.data[f"{standard['pollutant']}_PERCENT"] = standard["percent"] - self.data[ATTR_API_CAQI] = index["value"] - self.data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ") - self.data[ATTR_API_CAQI_DESCRIPTION] = index["description"] - self.data[ATTR_API_ADVICE] = index["advice"] - _LOGGER.debug("Data retrieved from Airly") - except asyncio.TimeoutError: - _LOGGER.error("Asyncio Timeout Error") - except (ValueError, AirlyError, ClientConnectorError) as error: - _LOGGER.error(error) - self.data = {} + if index["description"] == NO_AIRLY_SENSORS: + raise UpdateFailed("Can't retrieve data: no Airly sensors in this area") + for value in values: + data[value["name"]] = value["value"] + for standard in standards: + data[f"{standard['pollutant']}_LIMIT"] = standard["limit"] + data[f"{standard['pollutant']}_PERCENT"] = standard["percent"] + data[ATTR_API_CAQI] = index["value"] + data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ") + data[ATTR_API_CAQI_DESCRIPTION] = index["description"] + data[ATTR_API_ADVICE] = index["advice"] + return data diff --git a/homeassistant/components/airly/air_quality.py b/homeassistant/components/airly/air_quality.py index 45b4dfa3a37..fa42e58e9ad 100644 --- a/homeassistant/components/airly/air_quality.py +++ b/homeassistant/components/airly/air_quality.py @@ -18,13 +18,13 @@ from .const import ( ATTR_API_PM25, ATTR_API_PM25_LIMIT, ATTR_API_PM25_PERCENT, - DATA_CLIENT, DOMAIN, ) ATTRIBUTION = "Data provided by Airly" LABEL_ADVICE = "advice" +LABEL_AQI_DESCRIPTION = f"{ATTR_AQI}_description" LABEL_AQI_LEVEL = f"{ATTR_AQI}_level" LABEL_PM_2_5_LIMIT = f"{ATTR_PM_2_5}_limit" LABEL_PM_2_5_PERCENT = f"{ATTR_PM_2_5}_percent_of_limit" @@ -36,9 +36,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Airly air_quality entity based on a config entry.""" name = config_entry.data[CONF_NAME] - data = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([AirlyAirQuality(data, name, config_entry.unique_id)], True) + async_add_entities( + [AirlyAirQuality(coordinator, name, config_entry.unique_id)], False + ) def round_state(func): @@ -56,23 +58,23 @@ def round_state(func): class AirlyAirQuality(AirQualityEntity): """Define an Airly air quality.""" - def __init__(self, airly, name, unique_id): + def __init__(self, coordinator, name, unique_id): """Initialize.""" - self.airly = airly - self.data = airly.data + self.coordinator = coordinator self._name = name self._unique_id = unique_id - self._pm_2_5 = None - self._pm_10 = None - self._aqi = None self._icon = "mdi:blur" - self._attrs = {} @property def name(self): """Return the name.""" return self._name + @property + def should_poll(self): + """Return the polling requirement of the entity.""" + return False + @property def icon(self): """Return the icon.""" @@ -82,30 +84,25 @@ class AirlyAirQuality(AirQualityEntity): @round_state def air_quality_index(self): """Return the air quality index.""" - return self._aqi + return self.coordinator.data[ATTR_API_CAQI] @property @round_state def particulate_matter_2_5(self): """Return the particulate matter 2.5 level.""" - return self._pm_2_5 + return self.coordinator.data[ATTR_API_PM25] @property @round_state def particulate_matter_10(self): """Return the particulate matter 10 level.""" - return self._pm_10 + return self.coordinator.data[ATTR_API_PM10] @property def attribution(self): """Return the attribution.""" return ATTRIBUTION - @property - def state(self): - """Return the CAQI description.""" - return self.data[ATTR_API_CAQI_DESCRIPTION] - @property def unique_id(self): """Return a unique_id for this entity.""" @@ -114,25 +111,29 @@ class AirlyAirQuality(AirQualityEntity): @property def available(self): """Return True if entity is available.""" - return bool(self.data) + return self.coordinator.last_update_success @property def device_state_attributes(self): """Return the state attributes.""" - self._attrs[LABEL_ADVICE] = self.data[ATTR_API_ADVICE] - self._attrs[LABEL_AQI_LEVEL] = self.data[ATTR_API_CAQI_LEVEL] - self._attrs[LABEL_PM_2_5_LIMIT] = self.data[ATTR_API_PM25_LIMIT] - self._attrs[LABEL_PM_2_5_PERCENT] = round(self.data[ATTR_API_PM25_PERCENT]) - self._attrs[LABEL_PM_10_LIMIT] = self.data[ATTR_API_PM10_LIMIT] - self._attrs[LABEL_PM_10_PERCENT] = round(self.data[ATTR_API_PM10_PERCENT]) - return self._attrs + return { + LABEL_AQI_DESCRIPTION: self.coordinator.data[ATTR_API_CAQI_DESCRIPTION], + LABEL_ADVICE: self.coordinator.data[ATTR_API_ADVICE], + LABEL_AQI_LEVEL: self.coordinator.data[ATTR_API_CAQI_LEVEL], + LABEL_PM_2_5_LIMIT: self.coordinator.data[ATTR_API_PM25_LIMIT], + LABEL_PM_2_5_PERCENT: round(self.coordinator.data[ATTR_API_PM25_PERCENT]), + LABEL_PM_10_LIMIT: self.coordinator.data[ATTR_API_PM10_LIMIT], + LABEL_PM_10_PERCENT: round(self.coordinator.data[ATTR_API_PM10_PERCENT]), + } + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.coordinator.async_add_listener(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """Disconnect from update signal.""" + self.coordinator.async_remove_listener(self.async_write_ha_state) async def async_update(self): - """Update the entity.""" - await self.airly.async_update() - - if self.airly.data: - self.data = self.airly.data - self._pm_10 = self.data[ATTR_API_PM10] - self._pm_2_5 = self.data[ATTR_API_PM25] - self._aqi = self.data[ATTR_API_CAQI] + """Update Airly entity.""" + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index 2040faea6b6..d7f8fc12797 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -13,7 +13,7 @@ ATTR_API_PM25_LIMIT = "PM25_LIMIT" ATTR_API_PM25_PERCENT = "PM25_PERCENT" ATTR_API_PRESSURE = "PRESSURE" ATTR_API_TEMPERATURE = "TEMPERATURE" -DATA_CLIENT = "client" DEFAULT_NAME = "Airly" DOMAIN = "airly" +MAX_REQUESTS_PER_DAY = 100 NO_AIRLY_SENSORS = "There are no Airly sensors in this area yet." diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index a6754b4a00d..0ee9fb3aac5 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -18,7 +18,6 @@ from .const import ( ATTR_API_PM1, ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, - DATA_CLIENT, DOMAIN, ) @@ -60,14 +59,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Airly sensor entities based on a config entry.""" name = config_entry.data[CONF_NAME] - data = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id] sensors = [] for sensor in SENSOR_TYPES: unique_id = f"{config_entry.unique_id}-{sensor.lower()}" - sensors.append(AirlySensor(data, name, sensor, unique_id)) + sensors.append(AirlySensor(coordinator, name, sensor, unique_id)) - async_add_entities(sensors, True) + async_add_entities(sensors, False) def round_state(func): @@ -85,10 +84,9 @@ def round_state(func): class AirlySensor(Entity): """Define an Airly sensor.""" - def __init__(self, airly, name, kind, unique_id): + def __init__(self, coordinator, name, kind, unique_id): """Initialize.""" - self.airly = airly - self.data = airly.data + self.coordinator = coordinator self._name = name self._unique_id = unique_id self.kind = kind @@ -103,10 +101,15 @@ class AirlySensor(Entity): """Return the name.""" return f"{self._name} {SENSOR_TYPES[self.kind][ATTR_LABEL]}" + @property + def should_poll(self): + """Return the polling requirement of the entity.""" + return False + @property def state(self): """Return the state.""" - self._state = self.data[self.kind] + self._state = self.coordinator.data[self.kind] if self.kind in [ATTR_API_PM1, ATTR_API_PRESSURE]: self._state = round(self._state) if self.kind in [ATTR_API_TEMPERATURE, ATTR_API_HUMIDITY]: @@ -142,11 +145,16 @@ class AirlySensor(Entity): @property def available(self): """Return True if entity is available.""" - return bool(self.data) + return self.coordinator.last_update_success + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.coordinator.async_add_listener(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """Disconnect from update signal.""" + self.coordinator.async_remove_listener(self.async_write_ha_state) async def async_update(self): - """Update the sensor.""" - await self.airly.async_update() - - if self.airly.data: - self.data = self.airly.data + """Update Airly entity.""" + await self.coordinator.async_request_refresh()