diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index ddabba2fc1f..6438d7503db 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta from apple_weatherkit import DataSetType from apple_weatherkit.client import WeatherKitApiClient, WeatherKitApiClientError @@ -20,12 +20,15 @@ REQUESTED_DATA_SETS = [ DataSetType.HOURLY_FORECAST, ] +STALE_DATA_THRESHOLD = timedelta(hours=1) + class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" config_entry: ConfigEntry supported_data_sets: list[DataSetType] | None = None + last_updated_at: datetime | None = None def __init__( self, @@ -62,10 +65,20 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): if not self.supported_data_sets: await self.update_supported_data_sets() - return await self.client.get_weather_data( + updated_data = await self.client.get_weather_data( self.config_entry.data[CONF_LATITUDE], self.config_entry.data[CONF_LONGITUDE], self.supported_data_sets, ) except WeatherKitApiClientError as exception: - raise UpdateFailed(exception) from exception + if self.data is None or ( + self.last_updated_at is not None + and datetime.now() - self.last_updated_at > STALE_DATA_THRESHOLD + ): + raise UpdateFailed(exception) from exception + + LOGGER.debug("Using stale data because update failed: %s", exception) + return self.data + else: + self.last_updated_at = datetime.now() + return updated_data diff --git a/tests/components/weatherkit/__init__.py b/tests/components/weatherkit/__init__.py index 99c856a7e37..bc7c31fe8d8 100644 --- a/tests/components/weatherkit/__init__.py +++ b/tests/components/weatherkit/__init__.py @@ -1,5 +1,6 @@ """Tests for the Apple WeatherKit integration.""" +from contextlib import contextmanager from unittest.mock import patch from apple_weatherkit import DataSetType @@ -26,20 +27,13 @@ EXAMPLE_CONFIG_DATA = { } -async def init_integration( - hass: HomeAssistant, +@contextmanager +def mock_weather_response( is_night_time: bool = False, has_hourly_forecast: bool = True, has_daily_forecast: bool = True, -) -> MockConfigEntry: - """Set up the WeatherKit integration in Home Assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="Home", - unique_id="0123456", - data=EXAMPLE_CONFIG_DATA, - ) - +): + """Mock a successful WeatherKit API response.""" weather_response = load_json_object_fixture("weatherkit/weather_response.json") available_data_sets = [DataSetType.CURRENT_WEATHER] @@ -68,8 +62,22 @@ async def init_integration( return_value=available_data_sets, ), ): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + yield + + +async def init_integration( + hass: HomeAssistant, +) -> MockConfigEntry: + """Set up the WeatherKit integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="0123456", + data=EXAMPLE_CONFIG_DATA, + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/weatherkit/test_coordinator.py b/tests/components/weatherkit/test_coordinator.py index eff142f3d94..7cc78179f44 100644 --- a/tests/components/weatherkit/test_coordinator.py +++ b/tests/components/weatherkit/test_coordinator.py @@ -4,30 +4,93 @@ from datetime import timedelta from unittest.mock import patch from apple_weatherkit.client import WeatherKitApiClientError +from freezegun.api import FrozenDateTimeFactory from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util.dt import utcnow -from . import init_integration +from . import init_integration, mock_weather_response from tests.common import async_fire_time_changed -async def test_failed_updates(hass: HomeAssistant) -> None: - """Test that we properly handle failed updates.""" - await init_integration(hass) +async def test_update_uses_stale_data_before_threshold( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that stale data from the last successful update is used if an update failure occurs before the threshold.""" + with mock_weather_response(): + await init_integration(hass) + + state = hass.states.get("weather.home") + assert state + assert state.state != STATE_UNAVAILABLE + + initial_state = state.state + + # Expect stale data to be used before one hour with patch( "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", side_effect=WeatherKitApiClientError, ): - async_fire_time_changed( - hass, - utcnow() + timedelta(minutes=5), - ) + freezer.tick(timedelta(minutes=59)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("weather.home") + assert state + assert state.state == initial_state + + +async def test_update_becomes_unavailable_after_threshold( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the entity becomes unavailable if an update failure occurs after the threshold.""" + with mock_weather_response(): + await init_integration(hass) + + # Expect state to be unavailable after one hour + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + side_effect=WeatherKitApiClientError, + ): + freezer.tick(timedelta(hours=1, minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("weather.home") assert state assert state.state == STATE_UNAVAILABLE + + +async def test_update_recovers_after_failure( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a successful update after repeated failures recovers the entity's state.""" + with mock_weather_response(): + await init_integration(hass) + + # Trigger a failure after threshold + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + side_effect=WeatherKitApiClientError, + ): + freezer.tick(timedelta(hours=1, minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Expect that a successful update recovers the entity + + with mock_weather_response(): + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("weather.home") + assert state + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/weatherkit/test_sensor.py b/tests/components/weatherkit/test_sensor.py index 6c6999c6bfd..6ded9a779d5 100644 --- a/tests/components/weatherkit/test_sensor.py +++ b/tests/components/weatherkit/test_sensor.py @@ -6,7 +6,7 @@ import pytest from homeassistant.core import HomeAssistant -from . import init_integration +from . import init_integration, mock_weather_response @pytest.mark.parametrize( @@ -20,7 +20,8 @@ async def test_sensor_values( hass: HomeAssistant, entity_name: str, expected_value: Any ) -> None: """Test that various sensor values match what we expect.""" - await init_integration(hass) + with mock_weather_response(): + await init_integration(hass) state = hass.states.get(entity_name) assert state diff --git a/tests/components/weatherkit/test_weather.py b/tests/components/weatherkit/test_weather.py index ba20276c22e..ec4ce2ba3b3 100644 --- a/tests/components/weatherkit/test_weather.py +++ b/tests/components/weatherkit/test_weather.py @@ -23,12 +23,13 @@ from homeassistant.components.weatherkit.const import ATTRIBUTION from homeassistant.const import ATTR_ATTRIBUTION, ATTR_SUPPORTED_FEATURES from homeassistant.core import HomeAssistant -from . import init_integration +from . import init_integration, mock_weather_response async def test_current_weather(hass: HomeAssistant) -> None: """Test states of the current weather.""" - await init_integration(hass) + with mock_weather_response(): + await init_integration(hass) state = hass.states.get("weather.home") assert state @@ -49,7 +50,8 @@ async def test_current_weather(hass: HomeAssistant) -> None: async def test_current_weather_nighttime(hass: HomeAssistant) -> None: """Test that the condition is clear-night when it's sunny and night time.""" - await init_integration(hass, is_night_time=True) + with mock_weather_response(is_night_time=True): + await init_integration(hass) state = hass.states.get("weather.home") assert state @@ -58,7 +60,8 @@ async def test_current_weather_nighttime(hass: HomeAssistant) -> None: async def test_daily_forecast_missing(hass: HomeAssistant) -> None: """Test that daily forecast is not supported when WeatherKit doesn't support it.""" - await init_integration(hass, has_daily_forecast=False) + with mock_weather_response(has_daily_forecast=False): + await init_integration(hass) state = hass.states.get("weather.home") assert state @@ -69,7 +72,8 @@ async def test_daily_forecast_missing(hass: HomeAssistant) -> None: async def test_hourly_forecast_missing(hass: HomeAssistant) -> None: """Test that hourly forecast is not supported when WeatherKit doesn't support it.""" - await init_integration(hass, has_hourly_forecast=False) + with mock_weather_response(has_hourly_forecast=False): + await init_integration(hass) state = hass.states.get("weather.home") assert state @@ -86,7 +90,8 @@ async def test_hourly_forecast( hass: HomeAssistant, snapshot: SnapshotAssertion, service: str ) -> None: """Test states of the hourly forecast.""" - await init_integration(hass) + with mock_weather_response(): + await init_integration(hass) response = await hass.services.async_call( WEATHER_DOMAIN, @@ -109,7 +114,8 @@ async def test_daily_forecast( hass: HomeAssistant, snapshot: SnapshotAssertion, service: str ) -> None: """Test states of the daily forecast.""" - await init_integration(hass) + with mock_weather_response(): + await init_integration(hass) response = await hass.services.async_call( WEATHER_DOMAIN,