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 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,