diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 9f0579dc20e35b..a0958be8d9e55c 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -2,15 +2,18 @@ import asyncio import datetime import logging +from typing import Awaitable, Callable, Optional from pynws import SimpleNWS from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import debounce from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.dt import utcnow from .const import ( CONF_STATION, @@ -26,7 +29,7 @@ PLATFORMS = ["weather"] DEFAULT_SCAN_INTERVAL = datetime.timedelta(minutes=10) - +FAILED_SCAN_INTERVAL = datetime.timedelta(minutes=1) DEBOUNCE_TIME = 60 # in seconds @@ -40,6 +43,59 @@ async def async_setup(hass: HomeAssistant, config: dict): return True +class NwsDataUpdateCoordinator(DataUpdateCoordinator): + """ + NWS data update coordinator. + + Implements faster data update intervals for failed updates and exposes a last successful update time. + """ + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + *, + name: str, + update_interval: datetime.timedelta, + failed_update_interval: datetime.timedelta, + update_method: Optional[Callable[[], Awaitable]] = None, + request_refresh_debouncer: Optional[debounce.Debouncer] = None, + ): + """Initialize NWS coordinator.""" + super().__init__( + hass, + logger, + name=name, + update_interval=update_interval, + update_method=update_method, + request_refresh_debouncer=request_refresh_debouncer, + ) + self.failed_update_interval = failed_update_interval + self.last_update_success_time = None + + @callback + def _schedule_refresh(self) -> None: + """Schedule a refresh.""" + if self._unsub_refresh: + self._unsub_refresh() + self._unsub_refresh = None + + # We _floor_ utcnow to create a schedule on a rounded second, + # minimizing the time between the point and the real activation. + # That way we obtain a constant update frequency, + # as long as the update process takes less than a second + if self.last_update_success: + update_interval = self.update_interval + self.last_update_success_time = utcnow() + else: + update_interval = self.failed_update_interval + self._unsub_refresh = async_track_point_in_utc_time( + self.hass, + self._handle_refresh_interval, + utcnow().replace(microsecond=0) + update_interval, + ) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up a National Weather Service entry.""" latitude = entry.data[CONF_LATITUDE] @@ -53,34 +109,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): nws_data = SimpleNWS(latitude, longitude, api_key, client_session) await nws_data.set_station(station) - coordinator_observation = DataUpdateCoordinator( + coordinator_observation = NwsDataUpdateCoordinator( hass, _LOGGER, name=f"NWS observation station {station}", update_method=nws_data.update_observation, update_interval=DEFAULT_SCAN_INTERVAL, + failed_update_interval=FAILED_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True ), ) - coordinator_forecast = DataUpdateCoordinator( + coordinator_forecast = NwsDataUpdateCoordinator( hass, _LOGGER, name=f"NWS forecast station {station}", update_method=nws_data.update_forecast, update_interval=DEFAULT_SCAN_INTERVAL, + failed_update_interval=FAILED_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True ), ) - coordinator_forecast_hourly = DataUpdateCoordinator( + coordinator_forecast_hourly = NwsDataUpdateCoordinator( hass, _LOGGER, name=f"NWS forecast hourly station {station}", update_method=nws_data.update_forecast_hourly, update_interval=DEFAULT_SCAN_INTERVAL, + failed_update_interval=FAILED_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True ), diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 807591e0c2bb7e..7e1ca37ab6bdf6 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -1,4 +1,5 @@ """Support for NWS weather service.""" +from datetime import timedelta import logging from homeassistant.components.weather import ( @@ -24,6 +25,7 @@ from homeassistant.core import callback from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util.distance import convert as convert_distance +from homeassistant.util.dt import utcnow from homeassistant.util.pressure import convert as convert_pressure from homeassistant.util.temperature import convert as convert_temperature @@ -47,6 +49,9 @@ PARALLEL_UPDATES = 0 +OBSERVATION_VALID_TIME = timedelta(minutes=20) +FORECAST_VALID_TIME = timedelta(minutes=45) + def convert_condition(time, weather): """ @@ -287,10 +292,23 @@ def unique_id(self): @property def available(self): """Return if state is available.""" - return ( + last_success = ( self.coordinator_observation.last_update_success and self.coordinator_forecast.last_update_success ) + if ( + self.coordinator_observation.last_update_success_time + and self.coordinator_forecast.last_update_success_time + ): + last_success_time = ( + utcnow() - self.coordinator_observation.last_update_success_time + < OBSERVATION_VALID_TIME + and utcnow() - self.coordinator_forecast.last_update_success_time + < FORECAST_VALID_TIME + ) + else: + last_success_time = False + return last_success or last_success_time async def async_update(self): """Update the entity. diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index 667f40db1379df..1486015d80e191 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -10,6 +10,7 @@ import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from tests.async_mock import patch from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.nws.const import ( EXPECTED_FORECAST_IMPERIAL, @@ -154,39 +155,79 @@ async def test_entity_refresh(hass, mock_simple_nws): async def test_error_observation(hass, mock_simple_nws): """Test error during update observation.""" - instance = mock_simple_nws.return_value - instance.update_observation.side_effect = aiohttp.ClientError + utc_time = dt_util.utcnow() + with patch("homeassistant.components.nws.utcnow") as mock_utc, patch( + "homeassistant.components.nws.weather.utcnow" + ) as mock_utc_weather: - entry = MockConfigEntry(domain=nws.DOMAIN, data=NWS_CONFIG,) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + def increment_time(time): + mock_utc.return_value += time + mock_utc_weather.return_value += time + async_fire_time_changed(hass, mock_utc.return_value) - instance.update_observation.assert_called_once() + mock_utc.return_value = utc_time + mock_utc_weather.return_value = utc_time + instance = mock_simple_nws.return_value + # first update fails + instance.update_observation.side_effect = aiohttp.ClientError - state = hass.states.get("weather.abc_daynight") - assert state - assert state.state == "unavailable" + entry = MockConfigEntry(domain=nws.DOMAIN, data=NWS_CONFIG,) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - state = hass.states.get("weather.abc_hourly") - assert state - assert state.state == "unavailable" + instance.update_observation.assert_called_once() - instance.update_observation.side_effect = None + state = hass.states.get("weather.abc_daynight") + assert state + assert state.state == "unavailable" - future_time = dt_util.utcnow() + timedelta(minutes=15) - async_fire_time_changed(hass, future_time) - await hass.async_block_till_done() + state = hass.states.get("weather.abc_hourly") + assert state + assert state.state == "unavailable" - assert instance.update_observation.call_count == 2 + # second update happens faster and succeeds + instance.update_observation.side_effect = None + increment_time(timedelta(minutes=1)) + await hass.async_block_till_done() - state = hass.states.get("weather.abc_daynight") - assert state - assert state.state == "sunny" + assert instance.update_observation.call_count == 2 - state = hass.states.get("weather.abc_hourly") - assert state - assert state.state == "sunny" + state = hass.states.get("weather.abc_daynight") + assert state + assert state.state == "sunny" + + state = hass.states.get("weather.abc_hourly") + assert state + assert state.state == "sunny" + + # third udate fails, but data is cached + instance.update_observation.side_effect = aiohttp.ClientError + + increment_time(timedelta(minutes=10)) + await hass.async_block_till_done() + + assert instance.update_observation.call_count == 3 + + state = hass.states.get("weather.abc_daynight") + assert state + assert state.state == "sunny" + + state = hass.states.get("weather.abc_hourly") + assert state + assert state.state == "sunny" + + # after 20 minutes data caching expires, data is no longer shown + increment_time(timedelta(minutes=10)) + await hass.async_block_till_done() + + state = hass.states.get("weather.abc_daynight") + assert state + assert state.state == "unavailable" + + state = hass.states.get("weather.abc_hourly") + assert state + assert state.state == "unavailable" async def test_error_forecast(hass, mock_simple_nws): @@ -207,8 +248,7 @@ async def test_error_forecast(hass, mock_simple_nws): instance.update_forecast.side_effect = None - future_time = dt_util.utcnow() + timedelta(minutes=15) - async_fire_time_changed(hass, future_time) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1)) await hass.async_block_till_done() assert instance.update_forecast.call_count == 2 @@ -236,8 +276,7 @@ async def test_error_forecast_hourly(hass, mock_simple_nws): instance.update_forecast_hourly.side_effect = None - future_time = dt_util.utcnow() + timedelta(minutes=15) - async_fire_time_changed(hass, future_time) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1)) await hass.async_block_till_done() assert instance.update_forecast_hourly.call_count == 2