Skip to content

Commit

Permalink
Keep observation data valid for 60 min and retry with no data for nws (
Browse files Browse the repository at this point in the history
…#117109)

Co-authored-by: J. Nick Koston <nick@koston.org>
  • Loading branch information
MatthewFlamm and bdraco committed May 22, 2024
1 parent cddb057 commit 52bb02b
Show file tree
Hide file tree
Showing 5 changed files with 262 additions and 69 deletions.
99 changes: 46 additions & 53 deletions homeassistant/components/nws/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,35 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import datetime
from functools import partial
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.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import debounce
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
from homeassistant.util.dt import utcnow

from .const import CONF_STATION, DOMAIN, UPDATE_TIME_PERIOD
from homeassistant.helpers.update_coordinator import (
TimestampDataUpdateCoordinator,
UpdateFailed,
)

from .const import (
CONF_STATION,
DEBOUNCE_TIME,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
RETRY_INTERVAL,
RETRY_STOP,
)
from .coordinator import NWSObservationDataUpdateCoordinator

_LOGGER = logging.getLogger(__name__)

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]


Expand All @@ -44,7 +47,7 @@ class NWSData:
"""Data for the National Weather Service integration."""

api: SimpleNWS
coordinator_observation: TimestampDataUpdateCoordinator[None]
coordinator_observation: NWSObservationDataUpdateCoordinator
coordinator_forecast: TimestampDataUpdateCoordinator[None]
coordinator_forecast_hourly: TimestampDataUpdateCoordinator[None]

Expand All @@ -62,55 +65,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool:
nws_data = SimpleNWS(latitude, longitude, api_key, client_session)
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(
retry_interval: datetime.timedelta | float,
retry_stop: datetime.timedelta | float,
) -> Callable[[], Awaitable[None]]:
return partial(
call_with_retry,
nws_data.update_forecast,
retry_interval,
retry_stop,
)
async def update_forecast() -> None:
"""Retrieve forecast."""
try:
await call_with_retry(
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(
retry_interval: datetime.timedelta | float,
retry_stop: datetime.timedelta | float,
) -> Callable[[], Awaitable[None]]:
return partial(
call_with_retry,
nws_data.update_forecast_hourly,
retry_interval,
retry_stop,
)

# Don't use retries in setup
coordinator_observation = TimestampDataUpdateCoordinator(
async def update_forecast_hourly() -> None:
"""Retrieve forecast hourly."""
try:
await call_with_retry(
nws_data.update_forecast_hourly,
retry_interval,
retry_stop,
retry_no_data=True,
)
except NwsNoDataError as err:
raise UpdateFailed("No data returned.") from err

return update_forecast_hourly

coordinator_observation = NWSObservationDataUpdateCoordinator(
hass,
_LOGGER,
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
),
nws_data,
)

# Don't use retries in setup
coordinator_forecast = TimestampDataUpdateCoordinator(
hass,
_LOGGER,
Expand Down Expand Up @@ -145,9 +141,6 @@ def async_setup_update_forecast_hourly(
await coordinator_forecast_hourly.async_refresh()

# Use retries
coordinator_observation.update_method = async_setup_update_observation(
RETRY_INTERVAL, RETRY_STOP
)
coordinator_forecast.update_method = async_setup_update_forecast(
RETRY_INTERVAL, RETRY_STOP
)
Expand Down
7 changes: 6 additions & 1 deletion homeassistant/components/nws/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@
DAYNIGHT = "daynight"
HOURLY = "hourly"

OBSERVATION_VALID_TIME = timedelta(minutes=20)
OBSERVATION_VALID_TIME = timedelta(minutes=60)
FORECAST_VALID_TIME = timedelta(minutes=45)
# A lot of stations update once hourly plus some wiggle room
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)
93 changes: 93 additions & 0 deletions homeassistant/components/nws/coordinator.py
Original file line number Diff line number Diff line change
@@ -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()
15 changes: 1 addition & 14 deletions homeassistant/components/nws/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
CoordinatorEntity,
TimestampDataUpdateCoordinator,
)
from homeassistant.util.dt import utcnow
from homeassistant.util.unit_conversion import (
DistanceConverter,
PressureConverter,
Expand All @@ -37,7 +36,7 @@
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM

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

Expand Down Expand Up @@ -225,15 +224,3 @@ def native_value(self) -> float | None:
if unit_of_measurement == PERCENTAGE:
return round(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
Loading

0 comments on commit 52bb02b

Please sign in to comment.