mirror of
https://github.com/home-assistant/core.git
synced 2025-07-10 06:47:09 +00:00
weatherkit: use stale data for up to an hour if updates fail (#130398)
This commit is contained in:
parent
bb2d027532
commit
53ef96c63e
@ -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
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user