weatherkit: use stale data for up to an hour if updates fail (#130398)

This commit is contained in:
TJ Horner 2024-12-18 10:21:03 -08:00 committed by GitHub
parent bb2d027532
commit 53ef96c63e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 126 additions and 35 deletions

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import datetime, timedelta
from apple_weatherkit import DataSetType from apple_weatherkit import DataSetType
from apple_weatherkit.client import WeatherKitApiClient, WeatherKitApiClientError from apple_weatherkit.client import WeatherKitApiClient, WeatherKitApiClientError
@ -20,12 +20,15 @@ REQUESTED_DATA_SETS = [
DataSetType.HOURLY_FORECAST, DataSetType.HOURLY_FORECAST,
] ]
STALE_DATA_THRESHOLD = timedelta(hours=1)
class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching data from the API.""" """Class to manage fetching data from the API."""
config_entry: ConfigEntry config_entry: ConfigEntry
supported_data_sets: list[DataSetType] | None = None supported_data_sets: list[DataSetType] | None = None
last_updated_at: datetime | None = None
def __init__( def __init__(
self, self,
@ -62,10 +65,20 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator):
if not self.supported_data_sets: if not self.supported_data_sets:
await self.update_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_LATITUDE],
self.config_entry.data[CONF_LONGITUDE], self.config_entry.data[CONF_LONGITUDE],
self.supported_data_sets, self.supported_data_sets,
) )
except WeatherKitApiClientError as exception: except WeatherKitApiClientError as 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 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

View File

@ -1,5 +1,6 @@
"""Tests for the Apple WeatherKit integration.""" """Tests for the Apple WeatherKit integration."""
from contextlib import contextmanager
from unittest.mock import patch from unittest.mock import patch
from apple_weatherkit import DataSetType from apple_weatherkit import DataSetType
@ -26,20 +27,13 @@ EXAMPLE_CONFIG_DATA = {
} }
async def init_integration( @contextmanager
hass: HomeAssistant, def mock_weather_response(
is_night_time: bool = False, is_night_time: bool = False,
has_hourly_forecast: bool = True, has_hourly_forecast: bool = True,
has_daily_forecast: bool = True, has_daily_forecast: bool = True,
) -> MockConfigEntry: ):
"""Set up the WeatherKit integration in Home Assistant.""" """Mock a successful WeatherKit API response."""
entry = MockConfigEntry(
domain=DOMAIN,
title="Home",
unique_id="0123456",
data=EXAMPLE_CONFIG_DATA,
)
weather_response = load_json_object_fixture("weatherkit/weather_response.json") weather_response = load_json_object_fixture("weatherkit/weather_response.json")
available_data_sets = [DataSetType.CURRENT_WEATHER] available_data_sets = [DataSetType.CURRENT_WEATHER]
@ -68,6 +62,20 @@ async def init_integration(
return_value=available_data_sets, return_value=available_data_sets,
), ),
): ):
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) entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id) await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()

View File

@ -4,30 +4,93 @@ from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch
from apple_weatherkit.client import WeatherKitApiClientError from apple_weatherkit.client import WeatherKitApiClientError
from freezegun.api import FrozenDateTimeFactory
from homeassistant.const import STATE_UNAVAILABLE from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant 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 from tests.common import async_fire_time_changed
async def test_failed_updates(hass: HomeAssistant) -> None: async def test_update_uses_stale_data_before_threshold(
"""Test that we properly handle failed updates.""" 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) 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( with patch(
"homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data",
side_effect=WeatherKitApiClientError, side_effect=WeatherKitApiClientError,
): ):
async_fire_time_changed( freezer.tick(timedelta(minutes=59))
hass, async_fire_time_changed(hass)
utcnow() + timedelta(minutes=5), 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() await hass.async_block_till_done()
state = hass.states.get("weather.home") state = hass.states.get("weather.home")
assert state assert state
assert state.state == STATE_UNAVAILABLE 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

View File

@ -6,7 +6,7 @@ import pytest
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import init_integration from . import init_integration, mock_weather_response
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -20,6 +20,7 @@ async def test_sensor_values(
hass: HomeAssistant, entity_name: str, expected_value: Any hass: HomeAssistant, entity_name: str, expected_value: Any
) -> None: ) -> None:
"""Test that various sensor values match what we expect.""" """Test that various sensor values match what we expect."""
with mock_weather_response():
await init_integration(hass) await init_integration(hass)
state = hass.states.get(entity_name) state = hass.states.get(entity_name)

View File

@ -23,11 +23,12 @@ from homeassistant.components.weatherkit.const import ATTRIBUTION
from homeassistant.const import ATTR_ATTRIBUTION, ATTR_SUPPORTED_FEATURES from homeassistant.const import ATTR_ATTRIBUTION, ATTR_SUPPORTED_FEATURES
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import init_integration from . import init_integration, mock_weather_response
async def test_current_weather(hass: HomeAssistant) -> None: async def test_current_weather(hass: HomeAssistant) -> None:
"""Test states of the current weather.""" """Test states of the current weather."""
with mock_weather_response():
await init_integration(hass) await init_integration(hass)
state = hass.states.get("weather.home") state = hass.states.get("weather.home")
@ -49,7 +50,8 @@ async def test_current_weather(hass: HomeAssistant) -> None:
async def test_current_weather_nighttime(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.""" """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") state = hass.states.get("weather.home")
assert state assert state
@ -58,7 +60,8 @@ async def test_current_weather_nighttime(hass: HomeAssistant) -> None:
async def test_daily_forecast_missing(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.""" """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") state = hass.states.get("weather.home")
assert state assert state
@ -69,7 +72,8 @@ async def test_daily_forecast_missing(hass: HomeAssistant) -> None:
async def test_hourly_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.""" """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") state = hass.states.get("weather.home")
assert state assert state
@ -86,6 +90,7 @@ async def test_hourly_forecast(
hass: HomeAssistant, snapshot: SnapshotAssertion, service: str hass: HomeAssistant, snapshot: SnapshotAssertion, service: str
) -> None: ) -> None:
"""Test states of the hourly forecast.""" """Test states of the hourly forecast."""
with mock_weather_response():
await init_integration(hass) await init_integration(hass)
response = await hass.services.async_call( response = await hass.services.async_call(
@ -109,6 +114,7 @@ async def test_daily_forecast(
hass: HomeAssistant, snapshot: SnapshotAssertion, service: str hass: HomeAssistant, snapshot: SnapshotAssertion, service: str
) -> None: ) -> None:
"""Test states of the daily forecast.""" """Test states of the daily forecast."""
with mock_weather_response():
await init_integration(hass) await init_integration(hass)
response = await hass.services.async_call( response = await hass.services.async_call(