Keep observation data valid for 60 min and retry with no data for nws (#117109)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
MatthewFlamm 2024-05-22 04:14:05 -04:00 committed by GitHub
parent cddb057eae
commit 52bb02b376
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 260 additions and 67 deletions

View file

@ -5,10 +5,9 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
import datetime import datetime
from functools import partial
import logging import logging
from pynws import SimpleNWS, call_with_retry from pynws import NwsNoDataError, SimpleNWS, call_with_retry
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform
@ -16,21 +15,25 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import debounce from homeassistant.helpers import debounce
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from homeassistant.helpers.update_coordinator import (
from homeassistant.util.dt import utcnow TimestampDataUpdateCoordinator,
UpdateFailed,
)
from .const import CONF_STATION, DOMAIN, UPDATE_TIME_PERIOD from .const import (
CONF_STATION,
DEBOUNCE_TIME,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
RETRY_INTERVAL,
RETRY_STOP,
)
from .coordinator import NWSObservationDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR, Platform.WEATHER] PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
DEFAULT_SCAN_INTERVAL = datetime.timedelta(minutes=10)
RETRY_INTERVAL = datetime.timedelta(minutes=1)
RETRY_STOP = datetime.timedelta(minutes=10)
DEBOUNCE_TIME = 10 * 60 # in seconds
type NWSConfigEntry = ConfigEntry[NWSData] type NWSConfigEntry = ConfigEntry[NWSData]
@ -44,7 +47,7 @@ class NWSData:
"""Data for the National Weather Service integration.""" """Data for the National Weather Service integration."""
api: SimpleNWS api: SimpleNWS
coordinator_observation: TimestampDataUpdateCoordinator[None] coordinator_observation: NWSObservationDataUpdateCoordinator
coordinator_forecast: TimestampDataUpdateCoordinator[None] coordinator_forecast: TimestampDataUpdateCoordinator[None]
coordinator_forecast_hourly: TimestampDataUpdateCoordinator[None] coordinator_forecast_hourly: TimestampDataUpdateCoordinator[None]
@ -62,55 +65,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool:
nws_data = SimpleNWS(latitude, longitude, api_key, client_session) nws_data = SimpleNWS(latitude, longitude, api_key, client_session)
await nws_data.set_station(station) await nws_data.set_station(station)
def async_setup_update_observation(
retry_interval: datetime.timedelta | float,
retry_stop: datetime.timedelta | float,
) -> Callable[[], Awaitable[None]]:
async def update_observation() -> None:
"""Retrieve recent observations."""
await call_with_retry(
nws_data.update_observation,
retry_interval,
retry_stop,
start_time=utcnow() - UPDATE_TIME_PERIOD,
)
return update_observation
def async_setup_update_forecast( def async_setup_update_forecast(
retry_interval: datetime.timedelta | float, retry_interval: datetime.timedelta | float,
retry_stop: datetime.timedelta | float, retry_stop: datetime.timedelta | float,
) -> Callable[[], Awaitable[None]]: ) -> Callable[[], Awaitable[None]]:
return partial( async def update_forecast() -> None:
call_with_retry, """Retrieve forecast."""
nws_data.update_forecast, try:
retry_interval, await call_with_retry(
retry_stop, nws_data.update_forecast,
) retry_interval,
retry_stop,
retry_no_data=True,
)
except NwsNoDataError as err:
raise UpdateFailed("No data returned.") from err
return update_forecast
def async_setup_update_forecast_hourly( def async_setup_update_forecast_hourly(
retry_interval: datetime.timedelta | float, retry_interval: datetime.timedelta | float,
retry_stop: datetime.timedelta | float, retry_stop: datetime.timedelta | float,
) -> Callable[[], Awaitable[None]]: ) -> Callable[[], Awaitable[None]]:
return partial( async def update_forecast_hourly() -> None:
call_with_retry, """Retrieve forecast hourly."""
nws_data.update_forecast_hourly, try:
retry_interval, await call_with_retry(
retry_stop, nws_data.update_forecast_hourly,
) retry_interval,
retry_stop,
retry_no_data=True,
)
except NwsNoDataError as err:
raise UpdateFailed("No data returned.") from err
# Don't use retries in setup return update_forecast_hourly
coordinator_observation = TimestampDataUpdateCoordinator(
coordinator_observation = NWSObservationDataUpdateCoordinator(
hass, hass,
_LOGGER, nws_data,
name=f"NWS observation station {station}",
update_method=async_setup_update_observation(0, 0),
update_interval=DEFAULT_SCAN_INTERVAL,
request_refresh_debouncer=debounce.Debouncer(
hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True
),
) )
# Don't use retries in setup
coordinator_forecast = TimestampDataUpdateCoordinator( coordinator_forecast = TimestampDataUpdateCoordinator(
hass, hass,
_LOGGER, _LOGGER,
@ -145,9 +141,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool:
await coordinator_forecast_hourly.async_refresh() await coordinator_forecast_hourly.async_refresh()
# Use retries # Use retries
coordinator_observation.update_method = async_setup_update_observation(
RETRY_INTERVAL, RETRY_STOP
)
coordinator_forecast.update_method = async_setup_update_forecast( coordinator_forecast.update_method = async_setup_update_forecast(
RETRY_INTERVAL, RETRY_STOP RETRY_INTERVAL, RETRY_STOP
) )

View file

@ -76,7 +76,12 @@ CONDITION_CLASSES: dict[str, list[str]] = {
DAYNIGHT = "daynight" DAYNIGHT = "daynight"
HOURLY = "hourly" HOURLY = "hourly"
OBSERVATION_VALID_TIME = timedelta(minutes=20) OBSERVATION_VALID_TIME = timedelta(minutes=60)
FORECAST_VALID_TIME = timedelta(minutes=45) FORECAST_VALID_TIME = timedelta(minutes=45)
# A lot of stations update once hourly plus some wiggle room # A lot of stations update once hourly plus some wiggle room
UPDATE_TIME_PERIOD = timedelta(minutes=70) UPDATE_TIME_PERIOD = timedelta(minutes=70)
DEBOUNCE_TIME = 10 * 60 # in seconds
DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)
RETRY_INTERVAL = timedelta(minutes=1)
RETRY_STOP = timedelta(minutes=10)

View file

@ -0,0 +1,93 @@
"""The NWS coordinator."""
from datetime import datetime
import logging
from aiohttp import ClientResponseError
from pynws import NwsNoDataError, SimpleNWS, call_with_retry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import debounce
from homeassistant.helpers.update_coordinator import (
TimestampDataUpdateCoordinator,
UpdateFailed,
)
from homeassistant.util.dt import utcnow
from .const import (
DEBOUNCE_TIME,
DEFAULT_SCAN_INTERVAL,
OBSERVATION_VALID_TIME,
RETRY_INTERVAL,
RETRY_STOP,
UPDATE_TIME_PERIOD,
)
_LOGGER = logging.getLogger(__name__)
class NWSObservationDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]):
"""Class to manage fetching NWS observation data."""
def __init__(
self,
hass: HomeAssistant,
nws: SimpleNWS,
) -> None:
"""Initialize."""
self.nws = nws
self.last_api_success_time: datetime | None = None
self.initialized: bool = False
super().__init__(
hass,
_LOGGER,
name=f"NWS observation station {nws.station}",
update_interval=DEFAULT_SCAN_INTERVAL,
request_refresh_debouncer=debounce.Debouncer(
hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True
),
)
async def _async_update_data(self) -> None:
"""Update data via library."""
if not self.initialized:
await self._async_first_update_data()
else:
await self._async_subsequent_update_data()
async def _async_first_update_data(self):
"""Update data without retries first."""
try:
await self.nws.update_observation(
raise_no_data=True,
start_time=utcnow() - UPDATE_TIME_PERIOD,
)
except (NwsNoDataError, ClientResponseError) as err:
raise UpdateFailed(err) from err
else:
self.last_api_success_time = utcnow()
finally:
self.initialized = True
async def _async_subsequent_update_data(self) -> None:
"""Update data with retries and caching data over multiple failed rounds."""
try:
await call_with_retry(
self.nws.update_observation,
RETRY_INTERVAL,
RETRY_STOP,
retry_no_data=True,
start_time=utcnow() - UPDATE_TIME_PERIOD,
)
except (NwsNoDataError, ClientResponseError) as err:
if not self.last_api_success_time or (
utcnow() - self.last_api_success_time > OBSERVATION_VALID_TIME
):
raise UpdateFailed(err) from err
_LOGGER.debug(
"NWS observation update failed, but data still valid. Last success: %s",
self.last_api_success_time,
)
else:
self.last_api_success_time = utcnow()

View file

@ -28,7 +28,6 @@ from homeassistant.helpers.update_coordinator import (
CoordinatorEntity, CoordinatorEntity,
TimestampDataUpdateCoordinator, TimestampDataUpdateCoordinator,
) )
from homeassistant.util.dt import utcnow
from homeassistant.util.unit_conversion import ( from homeassistant.util.unit_conversion import (
DistanceConverter, DistanceConverter,
PressureConverter, PressureConverter,
@ -37,7 +36,7 @@ from homeassistant.util.unit_conversion import (
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from . import NWSConfigEntry, NWSData, base_unique_id, device_info from . import NWSConfigEntry, NWSData, base_unique_id, device_info
from .const import ATTRIBUTION, CONF_STATION, OBSERVATION_VALID_TIME from .const import ATTRIBUTION, CONF_STATION
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@ -225,15 +224,3 @@ class NWSSensor(CoordinatorEntity[TimestampDataUpdateCoordinator[None]], SensorE
if unit_of_measurement == PERCENTAGE: if unit_of_measurement == PERCENTAGE:
return round(value) return round(value)
return value return value
@property
def available(self) -> bool:
"""Return if state is available."""
if self.coordinator.last_update_success_time:
last_success_time = (
utcnow() - self.coordinator.last_update_success_time
< OBSERVATION_VALID_TIME
)
else:
last_success_time = False
return self.coordinator.last_update_success or last_success_time

View file

@ -5,10 +5,15 @@ from unittest.mock import patch
import aiohttp import aiohttp
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
from pynws import NwsNoDataError
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components import nws from homeassistant.components import nws
from homeassistant.components.nws.const import (
DEFAULT_SCAN_INTERVAL,
OBSERVATION_VALID_TIME,
)
from homeassistant.components.weather import ( from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_SUNNY, ATTR_CONDITION_SUNNY,
@ -114,6 +119,116 @@ async def test_none_values(hass: HomeAssistant, mock_simple_nws, no_sensor) -> N
assert data.get(key) is None assert data.get(key) is None
async def test_data_caching_error_observation(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_simple_nws,
no_sensor,
caplog,
) -> None:
"""Test caching of data with errors."""
with (
patch("homeassistant.components.nws.coordinator.RETRY_STOP", 0),
patch("homeassistant.components.nws.coordinator.RETRY_INTERVAL", 0),
):
instance = mock_simple_nws.return_value
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")
assert state.state == "sunny"
# data is still valid even when update fails
instance.update_observation.side_effect = NwsNoDataError("Test")
freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=100))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("weather.abc")
assert state.state == "sunny"
assert (
"NWS observation update failed, but data still valid. Last success: "
in caplog.text
)
# data is no longer valid after OBSERVATION_VALID_TIME
freezer.tick(OBSERVATION_VALID_TIME + timedelta(seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("weather.abc")
assert state.state == STATE_UNAVAILABLE
assert "Error fetching NWS observation station ABC data: Test" in caplog.text
async def test_no_data_error_observation(
hass: HomeAssistant, mock_simple_nws, no_sensor, caplog
) -> None:
"""Test catching NwsNoDataDrror."""
instance = mock_simple_nws.return_value
instance.update_observation.side_effect = NwsNoDataError("Test")
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()
assert "Error fetching NWS observation station ABC data: Test" in caplog.text
async def test_no_data_error_forecast(
hass: HomeAssistant, mock_simple_nws, no_sensor, caplog
) -> None:
"""Test catching NwsNoDataDrror."""
instance = mock_simple_nws.return_value
instance.update_forecast.side_effect = NwsNoDataError("Test")
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()
assert (
"Error fetching NWS forecast station ABC data: No data returned" in caplog.text
)
async def test_no_data_error_forecast_hourly(
hass: HomeAssistant, mock_simple_nws, no_sensor, caplog
) -> None:
"""Test catching NwsNoDataDrror."""
instance = mock_simple_nws.return_value
instance.update_forecast_hourly.side_effect = NwsNoDataError("Test")
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()
assert (
"Error fetching NWS forecast hourly station ABC data: No data returned"
in caplog.text
)
async def test_none(hass: HomeAssistant, mock_simple_nws, no_sensor) -> None: async def test_none(hass: HomeAssistant, mock_simple_nws, no_sensor) -> None:
"""Test with None as observation and forecast.""" """Test with None as observation and forecast."""
instance = mock_simple_nws.return_value instance = mock_simple_nws.return_value
@ -188,7 +303,7 @@ async def test_error_observation(
) -> None: ) -> None:
"""Test error during update observation.""" """Test error during update observation."""
utc_time = dt_util.utcnow() utc_time = dt_util.utcnow()
with patch("homeassistant.components.nws.utcnow") as mock_utc: with patch("homeassistant.components.nws.coordinator.utcnow") as mock_utc:
mock_utc.return_value = utc_time mock_utc.return_value = utc_time
instance = mock_simple_nws.return_value instance = mock_simple_nws.return_value
# first update fails # first update fails