diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 34157769b97..840d4d917f7 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -2,21 +2,18 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable from dataclasses import dataclass import datetime import logging -from typing import TYPE_CHECKING -from pynws import SimpleNWS +from pynws import 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, callback +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.event import async_track_point_in_utc_time from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from homeassistant.util.dt import utcnow @@ -27,8 +24,10 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR, Platform.WEATHER] DEFAULT_SCAN_INTERVAL = datetime.timedelta(minutes=10) -FAILED_SCAN_INTERVAL = datetime.timedelta(minutes=1) -DEBOUNCE_TIME = 60 # in seconds +RETRY_INTERVAL = datetime.timedelta(minutes=1) +RETRY_STOP = datetime.timedelta(minutes=10) + +DEBOUNCE_TIME = 10 * 60 # in seconds def base_unique_id(latitude: float, longitude: float) -> str: @@ -41,62 +40,9 @@ class NWSData: """Data for the National Weather Service integration.""" api: SimpleNWS - coordinator_observation: NwsDataUpdateCoordinator - coordinator_forecast: NwsDataUpdateCoordinator - coordinator_forecast_hourly: NwsDataUpdateCoordinator - - -class NwsDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module - """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: Callable[[], Awaitable[None]] | None = None, - request_refresh_debouncer: debounce.Debouncer | None = None, - ) -> 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 - - @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: - if TYPE_CHECKING: - # the base class allows None, but this one doesn't - assert self.update_interval is not None - update_interval = self.update_interval - 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, - ) + coordinator_observation: TimestampDataUpdateCoordinator[None] + coordinator_forecast: TimestampDataUpdateCoordinator[None] + coordinator_forecast_hourly: TimestampDataUpdateCoordinator[None] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -114,39 +60,57 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_observation() -> None: """Retrieve recent observations.""" - await nws_data.update_observation(start_time=utcnow() - UPDATE_TIME_PERIOD) + await call_with_retry( + nws_data.update_observation, + RETRY_INTERVAL, + RETRY_STOP, + start_time=utcnow() - UPDATE_TIME_PERIOD, + ) - coordinator_observation = NwsDataUpdateCoordinator( + async def update_forecast() -> None: + """Retrieve twice-daily forecsat.""" + await call_with_retry( + nws_data.update_forecast, + RETRY_INTERVAL, + RETRY_STOP, + ) + + async def update_forecast_hourly() -> None: + """Retrieve hourly forecast.""" + await call_with_retry( + nws_data.update_forecast_hourly, + RETRY_INTERVAL, + RETRY_STOP, + ) + + coordinator_observation = TimestampDataUpdateCoordinator( hass, _LOGGER, name=f"NWS observation station {station}", update_method=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 = NwsDataUpdateCoordinator( + coordinator_forecast = TimestampDataUpdateCoordinator( hass, _LOGGER, name=f"NWS forecast station {station}", - update_method=nws_data.update_forecast, + update_method=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 = NwsDataUpdateCoordinator( + coordinator_forecast_hourly = TimestampDataUpdateCoordinator( hass, _LOGGER, name=f"NWS forecast hourly station {station}", - update_method=nws_data.update_forecast_hourly, + update_method=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/manifest.json b/homeassistant/components/nws/manifest.json index 4006a145db4..f68d76ee95b 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["metar", "pynws"], "quality_scale": "platinum", - "requirements": ["pynws==1.6.0"] + "requirements": ["pynws[retry]==1.7.0"] } diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 1d8c5ab045e..447c2dc5cf8 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -25,7 +25,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + TimestampDataUpdateCoordinator, +) from homeassistant.util.dt import utcnow from homeassistant.util.unit_conversion import ( DistanceConverter, @@ -34,7 +37,7 @@ from homeassistant.util.unit_conversion import ( ) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import NWSData, NwsDataUpdateCoordinator, base_unique_id, device_info +from . import NWSData, base_unique_id, device_info from .const import ATTRIBUTION, CONF_STATION, DOMAIN, OBSERVATION_VALID_TIME PARALLEL_UPDATES = 0 @@ -158,7 +161,7 @@ async def async_setup_entry( ) -class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): +class NWSSensor(CoordinatorEntity[TimestampDataUpdateCoordinator[None]], SensorEntity): """An NWS Sensor Entity.""" entity_description: NWSSensorEntityDescription diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 89414f5acf1..c017d579c3a 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -2,6 +2,7 @@ from __future__ import annotations +from functools import partial from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast @@ -34,7 +35,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.dt import utcnow from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter from . import NWSData, base_unique_id, device_info @@ -46,7 +46,6 @@ from .const import ( DOMAIN, FORECAST_VALID_TIME, HOURLY, - OBSERVATION_VALID_TIME, ) PARALLEL_UPDATES = 0 @@ -140,96 +139,69 @@ class NWSWeather(CoordinatorWeatherEntity): self.nws = nws_data.api latitude = entry_data[CONF_LATITUDE] longitude = entry_data[CONF_LONGITUDE] - self.coordinator_forecast_legacy = nws_data.coordinator_forecast - self.station = self.nws.station - self.observation: dict[str, Any] | None = None - self._forecast_hourly: list[dict[str, Any]] | None = None - self._forecast_legacy: list[dict[str, Any]] | None = None - self._forecast_twice_daily: list[dict[str, Any]] | None = None + self.station = self.nws.station self._attr_unique_id = _calculate_unique_id(entry_data, DAYNIGHT) self._attr_device_info = device_info(latitude, longitude) self._attr_name = self.station async def async_added_to_hass(self) -> None: - """Set up a listener and load data.""" + """When entity is added to hass.""" await super().async_added_to_hass() - self.async_on_remove( - self.coordinator_forecast_legacy.async_add_listener( - self._handle_legacy_forecast_coordinator_update + self.async_on_remove(partial(self._remove_forecast_listener, "daily")) + self.async_on_remove(partial(self._remove_forecast_listener, "hourly")) + self.async_on_remove(partial(self._remove_forecast_listener, "twice_daily")) + + for forecast_type in ("twice_daily", "hourly"): + if (coordinator := self.forecast_coordinators[forecast_type]) is None: + continue + self.unsub_forecast[forecast_type] = coordinator.async_add_listener( + partial(self._handle_forecast_update, forecast_type) ) - ) - # Load initial data from coordinators - self._handle_coordinator_update() - self._handle_hourly_forecast_coordinator_update() - self._handle_twice_daily_forecast_coordinator_update() - self._handle_legacy_forecast_coordinator_update() - - @callback - def _handle_coordinator_update(self) -> None: - """Load data from integration.""" - self.observation = self.nws.observation - self.async_write_ha_state() - - @callback - def _handle_hourly_forecast_coordinator_update(self) -> None: - """Handle updated data from the hourly forecast coordinator.""" - self._forecast_hourly = self.nws.forecast_hourly - - @callback - def _handle_twice_daily_forecast_coordinator_update(self) -> None: - """Handle updated data from the twice daily forecast coordinator.""" - self._forecast_twice_daily = self.nws.forecast - - @callback - def _handle_legacy_forecast_coordinator_update(self) -> None: - """Handle updated data from the legacy forecast coordinator.""" - self._forecast_legacy = self.nws.forecast - self.async_write_ha_state() @property def native_temperature(self) -> float | None: """Return the current temperature.""" - if self.observation: - return self.observation.get("temperature") + if observation := self.nws.observation: + return observation.get("temperature") return None @property def native_pressure(self) -> int | None: """Return the current pressure.""" - if self.observation: - return self.observation.get("seaLevelPressure") + if observation := self.nws.observation: + return observation.get("seaLevelPressure") return None @property def humidity(self) -> float | None: """Return the name of the sensor.""" - if self.observation: - return self.observation.get("relativeHumidity") + if observation := self.nws.observation: + return observation.get("relativeHumidity") return None @property def native_wind_speed(self) -> float | None: """Return the current windspeed.""" - if self.observation: - return self.observation.get("windSpeed") + if observation := self.nws.observation: + return observation.get("windSpeed") return None @property def wind_bearing(self) -> int | None: """Return the current wind bearing (degrees).""" - if self.observation: - return self.observation.get("windDirection") + if observation := self.nws.observation: + return observation.get("windDirection") return None @property def condition(self) -> str | None: """Return current condition.""" weather = None - if self.observation: - weather = self.observation.get("iconWeather") - time = cast(str, self.observation.get("iconTime")) + if observation := self.nws.observation: + weather = observation.get("iconWeather") + time = cast(str, observation.get("iconTime")) if weather: return convert_condition(time, weather) @@ -238,8 +210,8 @@ class NWSWeather(CoordinatorWeatherEntity): @property def native_visibility(self) -> int | None: """Return visibility.""" - if self.observation: - return self.observation.get("visibility") + if observation := self.nws.observation: + return observation.get("visibility") return None def _forecast( @@ -302,33 +274,12 @@ class NWSWeather(CoordinatorWeatherEntity): @callback def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" - return self._forecast(self._forecast_hourly, HOURLY) + return self._forecast(self.nws.forecast_hourly, HOURLY) @callback def _async_forecast_twice_daily(self) -> list[Forecast] | None: """Return the twice daily forecast in native units.""" - return self._forecast(self._forecast_twice_daily, DAYNIGHT) - - @property - def available(self) -> bool: - """Return if state is available.""" - last_success = ( - self.coordinator.last_update_success - and self.coordinator_forecast_legacy.last_update_success - ) - if ( - self.coordinator.last_update_success_time - and self.coordinator_forecast_legacy.last_update_success_time - ): - last_success_time = ( - utcnow() - self.coordinator.last_update_success_time - < OBSERVATION_VALID_TIME - and utcnow() - self.coordinator_forecast_legacy.last_update_success_time - < FORECAST_VALID_TIME - ) - else: - last_success_time = False - return last_success or last_success_time + return self._forecast(self.nws.forecast, DAYNIGHT) async def async_update(self) -> None: """Update the entity. @@ -336,4 +287,7 @@ class NWSWeather(CoordinatorWeatherEntity): Only used by the generic entity update service. """ await self.coordinator.async_request_refresh() - await self.coordinator_forecast_legacy.async_request_refresh() + + for forecast_type in ("twice_daily", "hourly"): + if (coordinator := self.forecast_coordinators[forecast_type]) is not None: + await coordinator.async_request_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index 264bd84354b..368eb14894c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2004,7 +2004,7 @@ pynobo==1.8.1 pynuki==1.6.3 # homeassistant.components.nws -pynws==1.6.0 +pynws[retry]==1.7.0 # homeassistant.components.nx584 pynx584==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba91ead59bc..274e874636e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1567,7 +1567,7 @@ pynobo==1.8.1 pynuki==1.6.3 # homeassistant.components.nws -pynws==1.6.0 +pynws[retry]==1.7.0 # homeassistant.components.nx584 pynx584==0.5 diff --git a/tests/components/nws/conftest.py b/tests/components/nws/conftest.py index ac2c281c57b..48401fe87ba 100644 --- a/tests/components/nws/conftest.py +++ b/tests/components/nws/conftest.py @@ -11,6 +11,7 @@ from .const import DEFAULT_FORECAST, DEFAULT_OBSERVATION @pytest.fixture def mock_simple_nws(): """Mock pynws SimpleNWS with default values.""" + with patch("homeassistant.components.nws.SimpleNWS") as mock_nws: instance = mock_nws.return_value instance.set_station = AsyncMock(return_value=None) diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index ad40b576a8a..87aae18be60 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -13,7 +13,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, DOMAIN as WEATHER_DOMAIN, - LEGACY_SERVICE_GET_FORECAST, SERVICE_GET_FORECASTS, ) from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN @@ -181,7 +180,7 @@ async def test_entity_refresh(hass: HomeAssistant, mock_simple_nws, no_sensor) - await hass.async_block_till_done() assert instance.update_observation.call_count == 2 assert instance.update_forecast.call_count == 2 - instance.update_forecast_hourly.assert_called_once() + assert instance.update_forecast_hourly.call_count == 2 async def test_error_observation( @@ -189,18 +188,8 @@ async def test_error_observation( ) -> None: """Test error during update observation.""" utc_time = dt_util.utcnow() - with ( - patch("homeassistant.components.nws.utcnow") as mock_utc, - patch("homeassistant.components.nws.weather.utcnow") as mock_utc_weather, - ): - - def increment_time(time): - mock_utc.return_value += time - mock_utc_weather.return_value += time - async_fire_time_changed(hass, mock_utc.return_value) - + with patch("homeassistant.components.nws.utcnow") as mock_utc: 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 @@ -219,68 +208,6 @@ async def test_error_observation( assert state assert state.state == STATE_UNAVAILABLE - # second update happens faster and succeeds - instance.update_observation.side_effect = None - increment_time(timedelta(minutes=1)) - await hass.async_block_till_done() - - assert instance.update_observation.call_count == 2 - - state = hass.states.get("weather.abc") - assert state - assert state.state == ATTR_CONDITION_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") - assert state - assert state.state == ATTR_CONDITION_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") - assert state - assert state.state == STATE_UNAVAILABLE - - -async def test_error_forecast(hass: HomeAssistant, mock_simple_nws, no_sensor) -> None: - """Test error during update forecast.""" - instance = mock_simple_nws.return_value - instance.update_forecast.side_effect = aiohttp.ClientError - - 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() - - instance.update_forecast.assert_called_once() - - state = hass.states.get("weather.abc") - assert state - assert state.state == STATE_UNAVAILABLE - - instance.update_forecast.side_effect = None - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1)) - await hass.async_block_till_done() - - assert instance.update_forecast.call_count == 2 - - state = hass.states.get("weather.abc") - assert state - assert state.state == ATTR_CONDITION_SUNNY - async def test_new_config_entry(hass: HomeAssistant, no_sensor) -> None: """Test the expected entities are created.""" @@ -304,7 +231,6 @@ async def test_new_config_entry(hass: HomeAssistant, no_sensor) -> None: ("service"), [ SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, ], ) async def test_forecast_service( @@ -355,7 +281,7 @@ async def test_forecast_service( assert instance.update_observation.call_count == 2 assert instance.update_forecast.call_count == 2 - assert instance.update_forecast_hourly.call_count == 1 + assert instance.update_forecast_hourly.call_count == 2 for forecast_type in ("twice_daily", "hourly"): response = await hass.services.async_call(