Skip to content

Commit

Permalink
Cache data and update faster after failed updates in NWS (home-assist…
Browse files Browse the repository at this point in the history
…ant#35722)

* add last_update_success_time  and a failed update interval

* add failed update interval annd valid times to nws

* Revert "add last_update_success_time  and a failed update interval"

This reverts commit 09428c9.

* extend DataUpdateCoordinator
  • Loading branch information
MatthewFlamm authored May 25, 2020
1 parent 8cbee76 commit 3a97d96
Show file tree
Hide file tree
Showing 3 changed files with 150 additions and 34 deletions.
69 changes: 64 additions & 5 deletions homeassistant/components/nws/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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


Expand All @@ -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]
Expand All @@ -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
),
Expand Down
20 changes: 19 additions & 1 deletion homeassistant/components/nws/weather.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Support for NWS weather service."""
from datetime import timedelta
import logging

from homeassistant.components.weather import (
Expand All @@ -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

Expand All @@ -47,6 +49,9 @@

PARALLEL_UPDATES = 0

OBSERVATION_VALID_TIME = timedelta(minutes=20)
FORECAST_VALID_TIME = timedelta(minutes=45)


def convert_condition(time, weather):
"""
Expand Down Expand Up @@ -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.
Expand Down
95 changes: 67 additions & 28 deletions tests/components/nws/test_weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 3a97d96

Please sign in to comment.