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
This commit is contained in:
Maciej Bieniek 2020-03-25 19:13:28 +01:00 committed by GitHub
parent eb8e8d00a6
commit 16670a38a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 138 additions and 101 deletions

View File

@ -2,6 +2,7 @@
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
from math import ceil
from aiohttp.client_exceptions import ClientConnectorError from aiohttp.client_exceptions import ClientConnectorError
from airly import Airly from airly import Airly
@ -10,28 +11,40 @@ import async_timeout
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import Config, HomeAssistant from homeassistant.core import Config, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession 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 ( from .const import (
ATTR_API_ADVICE, ATTR_API_ADVICE,
ATTR_API_CAQI, ATTR_API_CAQI,
ATTR_API_CAQI_DESCRIPTION, ATTR_API_CAQI_DESCRIPTION,
ATTR_API_CAQI_LEVEL, ATTR_API_CAQI_LEVEL,
DATA_CLIENT,
DOMAIN, DOMAIN,
MAX_REQUESTS_PER_DAY,
NO_AIRLY_SENSORS, NO_AIRLY_SENSORS,
) )
PLATFORMS = ["air_quality", "sensor"]
_LOGGER = logging.getLogger(__name__) _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: async def async_setup(hass: HomeAssistant, config: Config) -> bool:
"""Set up configured Airly.""" """Set up configured Airly."""
hass.data[DOMAIN] = {}
hass.data[DOMAIN][DATA_CLIENT] = {}
return True return True
@ -48,70 +61,85 @@ async def async_setup_entry(hass, config_entry):
) )
websession = async_get_clientsession(hass) websession = async_get_clientsession(hass)
# Change update_interval for other Airly instances
airly = AirlyData(websession, api_key, latitude, longitude) update_interval = set_update_interval(
hass, len(hass.config_entries.async_entries(DOMAIN))
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")
) )
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 return True
async def async_unload_entry(hass, config_entry): async def async_unload_entry(hass, config_entry):
"""Unload a config entry.""" """Unload a config entry."""
hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) unload_ok = all(
await hass.config_entries.async_forward_entry_unload(config_entry, "air_quality") await asyncio.gather(
await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") *[
return True 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.""" """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.""" """Initialize."""
self.latitude = latitude self.latitude = latitude
self.longitude = longitude self.longitude = longitude
self.airly = Airly(api_key, session) self.airly = Airly(api_key, session)
self.data = {}
@Throttle(DEFAULT_SCAN_INTERVAL) super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)
async def async_update(self):
"""Update Airly data."""
try: async def _async_update_data(self):
"""Update data via library."""
data = {}
with async_timeout.timeout(20): with async_timeout.timeout(20):
measurements = self.airly.create_measurements_session_point( measurements = self.airly.create_measurements_session_point(
self.latitude, self.longitude self.latitude, self.longitude
) )
try:
await measurements.update() await measurements.update()
except (AirlyError, ClientConnectorError) as error:
raise UpdateFailed(error)
values = measurements.current["values"] values = measurements.current["values"]
index = measurements.current["indexes"][0] index = measurements.current["indexes"][0]
standards = measurements.current["standards"] standards = measurements.current["standards"]
if index["description"] == NO_AIRLY_SENSORS: if index["description"] == NO_AIRLY_SENSORS:
_LOGGER.error("Can't retrieve data: no Airly sensors in this area") raise UpdateFailed("Can't retrieve data: no Airly sensors in this area")
return
for value in values: for value in values:
self.data[value["name"]] = value["value"] data[value["name"]] = value["value"]
for standard in standards: for standard in standards:
self.data[f"{standard['pollutant']}_LIMIT"] = standard["limit"] data[f"{standard['pollutant']}_LIMIT"] = standard["limit"]
self.data[f"{standard['pollutant']}_PERCENT"] = standard["percent"] data[f"{standard['pollutant']}_PERCENT"] = standard["percent"]
self.data[ATTR_API_CAQI] = index["value"] data[ATTR_API_CAQI] = index["value"]
self.data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ") data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ")
self.data[ATTR_API_CAQI_DESCRIPTION] = index["description"] data[ATTR_API_CAQI_DESCRIPTION] = index["description"]
self.data[ATTR_API_ADVICE] = index["advice"] data[ATTR_API_ADVICE] = index["advice"]
_LOGGER.debug("Data retrieved from Airly") return data
except asyncio.TimeoutError:
_LOGGER.error("Asyncio Timeout Error")
except (ValueError, AirlyError, ClientConnectorError) as error:
_LOGGER.error(error)
self.data = {}

View File

@ -18,13 +18,13 @@ from .const import (
ATTR_API_PM25, ATTR_API_PM25,
ATTR_API_PM25_LIMIT, ATTR_API_PM25_LIMIT,
ATTR_API_PM25_PERCENT, ATTR_API_PM25_PERCENT,
DATA_CLIENT,
DOMAIN, DOMAIN,
) )
ATTRIBUTION = "Data provided by Airly" ATTRIBUTION = "Data provided by Airly"
LABEL_ADVICE = "advice" LABEL_ADVICE = "advice"
LABEL_AQI_DESCRIPTION = f"{ATTR_AQI}_description"
LABEL_AQI_LEVEL = f"{ATTR_AQI}_level" LABEL_AQI_LEVEL = f"{ATTR_AQI}_level"
LABEL_PM_2_5_LIMIT = f"{ATTR_PM_2_5}_limit" LABEL_PM_2_5_LIMIT = f"{ATTR_PM_2_5}_limit"
LABEL_PM_2_5_PERCENT = f"{ATTR_PM_2_5}_percent_of_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.""" """Set up Airly air_quality entity based on a config entry."""
name = config_entry.data[CONF_NAME] 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): def round_state(func):
@ -56,23 +58,23 @@ def round_state(func):
class AirlyAirQuality(AirQualityEntity): class AirlyAirQuality(AirQualityEntity):
"""Define an Airly air quality.""" """Define an Airly air quality."""
def __init__(self, airly, name, unique_id): def __init__(self, coordinator, name, unique_id):
"""Initialize.""" """Initialize."""
self.airly = airly self.coordinator = coordinator
self.data = airly.data
self._name = name self._name = name
self._unique_id = unique_id self._unique_id = unique_id
self._pm_2_5 = None
self._pm_10 = None
self._aqi = None
self._icon = "mdi:blur" self._icon = "mdi:blur"
self._attrs = {}
@property @property
def name(self): def name(self):
"""Return the name.""" """Return the name."""
return self._name return self._name
@property
def should_poll(self):
"""Return the polling requirement of the entity."""
return False
@property @property
def icon(self): def icon(self):
"""Return the icon.""" """Return the icon."""
@ -82,30 +84,25 @@ class AirlyAirQuality(AirQualityEntity):
@round_state @round_state
def air_quality_index(self): def air_quality_index(self):
"""Return the air quality index.""" """Return the air quality index."""
return self._aqi return self.coordinator.data[ATTR_API_CAQI]
@property @property
@round_state @round_state
def particulate_matter_2_5(self): def particulate_matter_2_5(self):
"""Return the particulate matter 2.5 level.""" """Return the particulate matter 2.5 level."""
return self._pm_2_5 return self.coordinator.data[ATTR_API_PM25]
@property @property
@round_state @round_state
def particulate_matter_10(self): def particulate_matter_10(self):
"""Return the particulate matter 10 level.""" """Return the particulate matter 10 level."""
return self._pm_10 return self.coordinator.data[ATTR_API_PM10]
@property @property
def attribution(self): def attribution(self):
"""Return the attribution.""" """Return the attribution."""
return ATTRIBUTION return ATTRIBUTION
@property
def state(self):
"""Return the CAQI description."""
return self.data[ATTR_API_CAQI_DESCRIPTION]
@property @property
def unique_id(self): def unique_id(self):
"""Return a unique_id for this entity.""" """Return a unique_id for this entity."""
@ -114,25 +111,29 @@ class AirlyAirQuality(AirQualityEntity):
@property @property
def available(self): def available(self):
"""Return True if entity is available.""" """Return True if entity is available."""
return bool(self.data) return self.coordinator.last_update_success
@property @property
def device_state_attributes(self): def device_state_attributes(self):
"""Return the state attributes.""" """Return the state attributes."""
self._attrs[LABEL_ADVICE] = self.data[ATTR_API_ADVICE] return {
self._attrs[LABEL_AQI_LEVEL] = self.data[ATTR_API_CAQI_LEVEL] LABEL_AQI_DESCRIPTION: self.coordinator.data[ATTR_API_CAQI_DESCRIPTION],
self._attrs[LABEL_PM_2_5_LIMIT] = self.data[ATTR_API_PM25_LIMIT] LABEL_ADVICE: self.coordinator.data[ATTR_API_ADVICE],
self._attrs[LABEL_PM_2_5_PERCENT] = round(self.data[ATTR_API_PM25_PERCENT]) LABEL_AQI_LEVEL: self.coordinator.data[ATTR_API_CAQI_LEVEL],
self._attrs[LABEL_PM_10_LIMIT] = self.data[ATTR_API_PM10_LIMIT] LABEL_PM_2_5_LIMIT: self.coordinator.data[ATTR_API_PM25_LIMIT],
self._attrs[LABEL_PM_10_PERCENT] = round(self.data[ATTR_API_PM10_PERCENT]) LABEL_PM_2_5_PERCENT: round(self.coordinator.data[ATTR_API_PM25_PERCENT]),
return self._attrs 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): async def async_update(self):
"""Update the entity.""" """Update Airly entity."""
await self.airly.async_update() await self.coordinator.async_request_refresh()
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]

View File

@ -13,7 +13,7 @@ ATTR_API_PM25_LIMIT = "PM25_LIMIT"
ATTR_API_PM25_PERCENT = "PM25_PERCENT" ATTR_API_PM25_PERCENT = "PM25_PERCENT"
ATTR_API_PRESSURE = "PRESSURE" ATTR_API_PRESSURE = "PRESSURE"
ATTR_API_TEMPERATURE = "TEMPERATURE" ATTR_API_TEMPERATURE = "TEMPERATURE"
DATA_CLIENT = "client"
DEFAULT_NAME = "Airly" DEFAULT_NAME = "Airly"
DOMAIN = "airly" DOMAIN = "airly"
MAX_REQUESTS_PER_DAY = 100
NO_AIRLY_SENSORS = "There are no Airly sensors in this area yet." NO_AIRLY_SENSORS = "There are no Airly sensors in this area yet."

View File

@ -18,7 +18,6 @@ from .const import (
ATTR_API_PM1, ATTR_API_PM1,
ATTR_API_PRESSURE, ATTR_API_PRESSURE,
ATTR_API_TEMPERATURE, ATTR_API_TEMPERATURE,
DATA_CLIENT,
DOMAIN, 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.""" """Set up Airly sensor entities based on a config entry."""
name = config_entry.data[CONF_NAME] 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 = [] sensors = []
for sensor in SENSOR_TYPES: for sensor in SENSOR_TYPES:
unique_id = f"{config_entry.unique_id}-{sensor.lower()}" 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): def round_state(func):
@ -85,10 +84,9 @@ def round_state(func):
class AirlySensor(Entity): class AirlySensor(Entity):
"""Define an Airly sensor.""" """Define an Airly sensor."""
def __init__(self, airly, name, kind, unique_id): def __init__(self, coordinator, name, kind, unique_id):
"""Initialize.""" """Initialize."""
self.airly = airly self.coordinator = coordinator
self.data = airly.data
self._name = name self._name = name
self._unique_id = unique_id self._unique_id = unique_id
self.kind = kind self.kind = kind
@ -103,10 +101,15 @@ class AirlySensor(Entity):
"""Return the name.""" """Return the name."""
return f"{self._name} {SENSOR_TYPES[self.kind][ATTR_LABEL]}" 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 @property
def state(self): def state(self):
"""Return the state.""" """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]: if self.kind in [ATTR_API_PM1, ATTR_API_PRESSURE]:
self._state = round(self._state) self._state = round(self._state)
if self.kind in [ATTR_API_TEMPERATURE, ATTR_API_HUMIDITY]: if self.kind in [ATTR_API_TEMPERATURE, ATTR_API_HUMIDITY]:
@ -142,11 +145,16 @@ class AirlySensor(Entity):
@property @property
def available(self): def available(self):
"""Return True if entity is available.""" """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): async def async_update(self):
"""Update the sensor.""" """Update Airly entity."""
await self.airly.async_update() await self.coordinator.async_request_refresh()
if self.airly.data:
self.data = self.airly.data