Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 9 commits into from
May 22, 2024
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
bdraco marked this conversation as resolved.
Show resolved Hide resolved