diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index b0aa6179952..f855b30db48 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -11,6 +11,7 @@ import async_timeout from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import ( ATTR_API_ADVICE, @@ -19,7 +20,8 @@ from .const import ( ATTR_API_CAQI_LEVEL, CONF_USE_NEAREST, DOMAIN, - MAX_REQUESTS_PER_DAY, + MAX_UPDATE_INTERVAL, + MIN_UPDATE_INTERVAL, NO_AIRLY_SENSORS, ) @@ -28,15 +30,30 @@ PLATFORMS = ["air_quality", "sensor"] _LOGGER = logging.getLogger(__name__) -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) +def set_update_interval(instances, requests_remaining): + """ + Return data update interval. - if hass.data.get(DOMAIN): - for instance in hass.data[DOMAIN].values(): - instance.update_interval = interval + The number of requests is reset at midnight UTC so we calculate the update + interval based on number of minutes until midnight, the number of Airly instances + and the number of remaining requests. + """ + now = dt_util.utcnow() + midnight = dt_util.find_next_time_expression_time( + now, seconds=[0], minutes=[0], hours=[0] + ) + minutes_to_midnight = (midnight - now).total_seconds() / 60 + interval = timedelta( + minutes=min( + max( + ceil(minutes_to_midnight / requests_remaining * instances), + MIN_UPDATE_INTERVAL, + ), + MAX_UPDATE_INTERVAL, + ) + ) + + _LOGGER.debug("Data will be update every %s", interval) return interval @@ -55,10 +72,8 @@ async def async_setup_entry(hass, config_entry): ) websession = async_get_clientsession(hass) - # Change update_interval for other Airly instances - update_interval = set_update_interval( - hass, len(hass.config_entries.async_entries(DOMAIN)) - ) + + update_interval = timedelta(minutes=MIN_UPDATE_INTERVAL) coordinator = AirlyDataUpdateCoordinator( hass, websession, api_key, latitude, longitude, update_interval, use_nearest @@ -82,9 +97,6 @@ async def async_unload_entry(hass, config_entry): 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 @@ -132,6 +144,14 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator): self.airly.requests_per_day, ) + # Airly API sometimes returns None for requests remaining so we update + # update_interval only if we have valid value. + if self.airly.requests_remaining: + self.update_interval = set_update_interval( + len(self.hass.config_entries.async_entries(DOMAIN)), + self.airly.requests_remaining, + ) + values = measurements.current["values"] index = measurements.current["indexes"][0] standards = measurements.current["standards"] diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index b8d2270c3c4..df4818ef949 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -24,5 +24,6 @@ DEFAULT_NAME = "Airly" DOMAIN = "airly" LABEL_ADVICE = "advice" MANUFACTURER = "Airly sp. z o.o." -MAX_REQUESTS_PER_DAY = 100 +MAX_UPDATE_INTERVAL = 90 +MIN_UPDATE_INTERVAL = 5 NO_AIRLY_SENSORS = "There are no Airly sensors in this area yet." diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index 2898bd5c6f6..c2785d6f3e7 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -1,6 +1,7 @@ """Test init of Airly integration.""" -from datetime import timedelta +from unittest.mock import patch +from homeassistant.components.airly import set_update_interval from homeassistant.components.airly.const import DOMAIN from homeassistant.config_entries import ( ENTRY_STATE_LOADED, @@ -8,10 +9,11 @@ from homeassistant.config_entries import ( ENTRY_STATE_SETUP_RETRY, ) from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.util.dt import utcnow from . import API_POINT_URL -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture from tests.components.airly import init_integration @@ -88,37 +90,83 @@ async def test_config_with_turned_off_station(hass, aioclient_mock): async def test_update_interval(hass, aioclient_mock): """Test correct update interval when the number of configured instances changes.""" - entry = await init_integration(hass, aioclient_mock) - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state == ENTRY_STATE_LOADED - for instance in hass.data[DOMAIN].values(): - assert instance.update_interval == timedelta(minutes=15) + REMAINING_RQUESTS = 15 + HEADERS = { + "X-RateLimit-Limit-day": "100", + "X-RateLimit-Remaining-day": str(REMAINING_RQUESTS), + } entry = MockConfigEntry( domain=DOMAIN, - title="Work", - unique_id="66.66-111.11", + title="Home", + unique_id="123-456", data={ "api_key": "foo", - "latitude": 66.66, - "longitude": 111.11, - "name": "Work", + "latitude": 123, + "longitude": 456, + "name": "Home", }, ) aioclient_mock.get( - "https://airapi.airly.eu/v2/measurements/point?lat=66.660000&lng=111.110000", + API_POINT_URL, text=load_fixture("airly_valid_station.json"), + headers=HEADERS, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + instances = 1 - assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + assert aioclient_mock.call_count == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state == ENTRY_STATE_LOADED - for instance in hass.data[DOMAIN].values(): - assert instance.update_interval == timedelta(minutes=30) + + update_interval = set_update_interval(instances, REMAINING_RQUESTS) + future = utcnow() + update_interval + with patch("homeassistant.util.dt.utcnow") as mock_utcnow: + mock_utcnow.return_value = future + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + # call_count should increase by one because we have one instance configured + assert aioclient_mock.call_count == 2 + + # Now we add the second Airly instance + entry = MockConfigEntry( + domain=DOMAIN, + title="Work", + unique_id="66.66-111.11", + data={ + "api_key": "foo", + "latitude": 66.66, + "longitude": 111.11, + "name": "Work", + }, + ) + + aioclient_mock.get( + "https://airapi.airly.eu/v2/measurements/point?lat=66.660000&lng=111.110000", + text=load_fixture("airly_valid_station.json"), + headers=HEADERS, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + instances = 2 + + assert aioclient_mock.call_count == 3 + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + assert entry.state == ENTRY_STATE_LOADED + + update_interval = set_update_interval(instances, REMAINING_RQUESTS) + future = utcnow() + update_interval + mock_utcnow.return_value = future + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + # call_count should increase by two because we have two instances configured + assert aioclient_mock.call_count == 5 async def test_unload_entry(hass, aioclient_mock):