1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-24 12:59:34 +00:00

Add hourly forecast for AccuWeather integration (#152178)

This commit is contained in:
Maciej Bieniek
2025-09-12 18:34:56 +02:00
committed by GitHub
parent 3713c03c07
commit 09381abf46
9 changed files with 1638 additions and 37 deletions

View File

@@ -2,21 +2,23 @@
from __future__ import annotations
import asyncio
import logging
from accuweather import AccuWeather
from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM
from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, UPDATE_INTERVAL_DAILY_FORECAST, UPDATE_INTERVAL_OBSERVATION
from .const import DOMAIN
from .coordinator import (
AccuWeatherConfigEntry,
AccuWeatherDailyForecastDataUpdateCoordinator,
AccuWeatherData,
AccuWeatherHourlyForecastDataUpdateCoordinator,
AccuWeatherObservationDataUpdateCoordinator,
)
@@ -28,7 +30,6 @@ PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
async def async_setup_entry(hass: HomeAssistant, entry: AccuWeatherConfigEntry) -> bool:
"""Set up AccuWeather as config entry."""
api_key: str = entry.data[CONF_API_KEY]
name: str = entry.data[CONF_NAME]
location_key = entry.unique_id
@@ -41,26 +42,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: AccuWeatherConfigEntry)
hass,
entry,
accuweather,
name,
"observation",
UPDATE_INTERVAL_OBSERVATION,
)
coordinator_daily_forecast = AccuWeatherDailyForecastDataUpdateCoordinator(
hass,
entry,
accuweather,
name,
"daily forecast",
UPDATE_INTERVAL_DAILY_FORECAST,
)
coordinator_hourly_forecast = AccuWeatherHourlyForecastDataUpdateCoordinator(
hass,
entry,
accuweather,
)
await coordinator_observation.async_config_entry_first_refresh()
await coordinator_daily_forecast.async_config_entry_first_refresh()
await asyncio.gather(
coordinator_observation.async_config_entry_first_refresh(),
coordinator_daily_forecast.async_config_entry_first_refresh(),
coordinator_hourly_forecast.async_config_entry_first_refresh(),
)
entry.runtime_data = AccuWeatherData(
coordinator_observation=coordinator_observation,
coordinator_daily_forecast=coordinator_daily_forecast,
coordinator_hourly_forecast=coordinator_hourly_forecast,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -71,3 +71,4 @@ POLLEN_CATEGORY_MAP = {
}
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10)
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(hours=30)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from asyncio import timeout
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
@@ -12,6 +13,7 @@ from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExcee
from aiohttp.client_exceptions import ClientConnectorError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import (
@@ -20,7 +22,13 @@ from homeassistant.helpers.update_coordinator import (
UpdateFailed,
)
from .const import DOMAIN, MANUFACTURER
from .const import (
DOMAIN,
MANUFACTURER,
UPDATE_INTERVAL_DAILY_FORECAST,
UPDATE_INTERVAL_HOURLY_FORECAST,
UPDATE_INTERVAL_OBSERVATION,
)
EXCEPTIONS = (ApiError, ClientConnectorError, InvalidApiKeyError, RequestsExceededError)
@@ -33,6 +41,7 @@ class AccuWeatherData:
coordinator_observation: AccuWeatherObservationDataUpdateCoordinator
coordinator_daily_forecast: AccuWeatherDailyForecastDataUpdateCoordinator
coordinator_hourly_forecast: AccuWeatherHourlyForecastDataUpdateCoordinator
type AccuWeatherConfigEntry = ConfigEntry[AccuWeatherData]
@@ -48,13 +57,11 @@ class AccuWeatherObservationDataUpdateCoordinator(
hass: HomeAssistant,
config_entry: AccuWeatherConfigEntry,
accuweather: AccuWeather,
name: str,
coordinator_type: str,
update_interval: timedelta,
) -> None:
"""Initialize."""
self.accuweather = accuweather
self.location_key = accuweather.location_key
name = config_entry.data[CONF_NAME]
if TYPE_CHECKING:
assert self.location_key is not None
@@ -65,8 +72,8 @@ class AccuWeatherObservationDataUpdateCoordinator(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{name} ({coordinator_type})",
update_interval=update_interval,
name=f"{name} (observation)",
update_interval=UPDATE_INTERVAL_OBSERVATION,
)
async def _async_update_data(self) -> dict[str, Any]:
@@ -86,23 +93,25 @@ class AccuWeatherObservationDataUpdateCoordinator(
return result
class AccuWeatherDailyForecastDataUpdateCoordinator(
class AccuWeatherForecastDataUpdateCoordinator(
TimestampDataUpdateCoordinator[list[dict[str, Any]]]
):
"""Class to manage fetching AccuWeather data API."""
"""Base class for AccuWeather forecast."""
def __init__(
self,
hass: HomeAssistant,
config_entry: AccuWeatherConfigEntry,
accuweather: AccuWeather,
name: str,
coordinator_type: str,
update_interval: timedelta,
fetch_method: Callable[..., Awaitable[list[dict[str, Any]]]],
) -> None:
"""Initialize."""
self.accuweather = accuweather
self.location_key = accuweather.location_key
self._fetch_method = fetch_method
name = config_entry.data[CONF_NAME]
if TYPE_CHECKING:
assert self.location_key is not None
@@ -118,12 +127,10 @@ class AccuWeatherDailyForecastDataUpdateCoordinator(
)
async def _async_update_data(self) -> list[dict[str, Any]]:
"""Update data via library."""
"""Update forecast data via library."""
try:
async with timeout(10):
result = await self.accuweather.async_get_daily_forecast(
language=self.hass.config.language
)
result = await self._fetch_method(language=self.hass.config.language)
except EXCEPTIONS as error:
raise UpdateFailed(
translation_domain=DOMAIN,
@@ -132,10 +139,53 @@ class AccuWeatherDailyForecastDataUpdateCoordinator(
) from error
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
return result
class AccuWeatherDailyForecastDataUpdateCoordinator(
AccuWeatherForecastDataUpdateCoordinator
):
"""Coordinator for daily forecast."""
def __init__(
self,
hass: HomeAssistant,
config_entry: AccuWeatherConfigEntry,
accuweather: AccuWeather,
) -> None:
"""Initialize."""
super().__init__(
hass,
config_entry,
accuweather,
"daily forecast",
UPDATE_INTERVAL_DAILY_FORECAST,
fetch_method=accuweather.async_get_daily_forecast,
)
class AccuWeatherHourlyForecastDataUpdateCoordinator(
AccuWeatherForecastDataUpdateCoordinator
):
"""Coordinator for hourly forecast."""
def __init__(
self,
hass: HomeAssistant,
config_entry: AccuWeatherConfigEntry,
accuweather: AccuWeather,
) -> None:
"""Initialize."""
super().__init__(
hass,
config_entry,
accuweather,
"hourly forecast",
UPDATE_INTERVAL_HOURLY_FORECAST,
fetch_method=accuweather.async_get_hourly_forecast,
)
def _get_device_info(location_key: str, name: str) -> DeviceInfo:
"""Get device info."""
return DeviceInfo(

View File

@@ -45,6 +45,7 @@ from .coordinator import (
AccuWeatherConfigEntry,
AccuWeatherDailyForecastDataUpdateCoordinator,
AccuWeatherData,
AccuWeatherHourlyForecastDataUpdateCoordinator,
AccuWeatherObservationDataUpdateCoordinator,
)
@@ -64,6 +65,7 @@ class AccuWeatherEntity(
CoordinatorWeatherEntity[
AccuWeatherObservationDataUpdateCoordinator,
AccuWeatherDailyForecastDataUpdateCoordinator,
AccuWeatherHourlyForecastDataUpdateCoordinator,
]
):
"""Define an AccuWeather entity."""
@@ -76,6 +78,7 @@ class AccuWeatherEntity(
super().__init__(
observation_coordinator=accuweather_data.coordinator_observation,
daily_coordinator=accuweather_data.coordinator_daily_forecast,
hourly_coordinator=accuweather_data.coordinator_hourly_forecast,
)
self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
@@ -86,10 +89,13 @@ class AccuWeatherEntity(
self._attr_unique_id = accuweather_data.coordinator_observation.location_key
self._attr_attribution = ATTRIBUTION
self._attr_device_info = accuweather_data.coordinator_observation.device_info
self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY
self._attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
)
self.observation_coordinator = accuweather_data.coordinator_observation
self.daily_coordinator = accuweather_data.coordinator_daily_forecast
self.hourly_coordinator = accuweather_data.coordinator_hourly_forecast
@property
def condition(self) -> str | None:
@@ -207,3 +213,32 @@ class AccuWeatherEntity(
}
for item in self.daily_coordinator.data
]
@callback
def _async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast in native units."""
return [
{
ATTR_FORECAST_TIME: utc_from_timestamp(
item["EpochDateTime"]
).isoformat(),
ATTR_FORECAST_CLOUD_COVERAGE: item["CloudCover"],
ATTR_FORECAST_HUMIDITY: item["RelativeHumidity"],
ATTR_FORECAST_NATIVE_TEMP: item["Temperature"][ATTR_VALUE],
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item["RealFeelTemperature"][
ATTR_VALUE
],
ATTR_FORECAST_NATIVE_PRECIPITATION: item["TotalLiquid"][ATTR_VALUE],
ATTR_FORECAST_PRECIPITATION_PROBABILITY: item[
"PrecipitationProbability"
],
ATTR_FORECAST_NATIVE_WIND_SPEED: item["Wind"][ATTR_SPEED][ATTR_VALUE],
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item["WindGust"][ATTR_SPEED][
ATTR_VALUE
],
ATTR_FORECAST_UV_INDEX: item["UVIndex"],
ATTR_FORECAST_WIND_BEARING: item["Wind"][ATTR_DIRECTION]["Degrees"],
ATTR_FORECAST_CONDITION: CONDITION_MAP.get(item["WeatherIcon"]),
}
for item in self.hourly_coordinator.data
]

View File

@@ -14,7 +14,8 @@ from tests.common import load_json_array_fixture, load_json_object_fixture
def mock_accuweather_client() -> Generator[AsyncMock]:
"""Mock a AccuWeather client."""
current = load_json_object_fixture("current_conditions_data.json", DOMAIN)
forecast = load_json_array_fixture("forecast_data.json", DOMAIN)
daily_forecast = load_json_array_fixture("daily_forecast_data.json", DOMAIN)
hourly_forecast = load_json_array_fixture("hourly_forecast_data.json", DOMAIN)
location = load_json_object_fixture("location_data.json", DOMAIN)
with (
@@ -29,7 +30,8 @@ def mock_accuweather_client() -> Generator[AsyncMock]:
client = mock_client.return_value
client.async_get_location.return_value = location
client.async_get_current_conditions.return_value = current
client.async_get_daily_forecast.return_value = forecast
client.async_get_daily_forecast.return_value = daily_forecast
client.async_get_hourly_forecast.return_value = hourly_forecast
client.location_key = "0123456"
client.requests_remaining = 10

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
# serializer version: 1
# name: test_forecast_service[get_forecasts]
# name: test_forecast_service[daily]
dict({
'weather.home': dict({
'forecast': list([
@@ -82,6 +82,182 @@
}),
})
# ---
# name: test_forecast_service[hourly]
dict({
'weather.home': dict({
'forecast': list([
dict({
'apparent_temperature': 22.6,
'cloud_coverage': 13,
'condition': 'sunny',
'datetime': '2025-09-12T14:00:00+00:00',
'humidity': 48,
'precipitation': 0.0,
'precipitation_probability': 1,
'temperature': 22.5,
'uv_index': 2,
'wind_bearing': 239,
'wind_gust_speed': 24.1,
'wind_speed': 14.8,
}),
dict({
'apparent_temperature': 22.9,
'cloud_coverage': 17,
'condition': 'sunny',
'datetime': '2025-09-12T15:00:00+00:00',
'humidity': 48,
'precipitation': 0.0,
'precipitation_probability': 1,
'temperature': 23.1,
'uv_index': 2,
'wind_bearing': 238,
'wind_gust_speed': 22.2,
'wind_speed': 13.0,
}),
dict({
'apparent_temperature': 20.6,
'cloud_coverage': 23,
'condition': 'sunny',
'datetime': '2025-09-12T16:00:00+00:00',
'humidity': 56,
'precipitation': 0.0,
'precipitation_probability': 1,
'temperature': 21.3,
'uv_index': 1,
'wind_bearing': 232,
'wind_gust_speed': 18.5,
'wind_speed': 13.0,
}),
dict({
'apparent_temperature': 18.2,
'cloud_coverage': 29,
'condition': 'sunny',
'datetime': '2025-09-12T17:00:00+00:00',
'humidity': 62,
'precipitation': 0.0,
'precipitation_probability': 2,
'temperature': 19.5,
'uv_index': 0,
'wind_bearing': 224,
'wind_gust_speed': 16.7,
'wind_speed': 13.0,
}),
dict({
'apparent_temperature': 16.7,
'cloud_coverage': 34,
'condition': 'partlycloudy',
'datetime': '2025-09-12T18:00:00+00:00',
'humidity': 69,
'precipitation': 0.0,
'precipitation_probability': 3,
'temperature': 17.7,
'uv_index': 0,
'wind_bearing': 219,
'wind_gust_speed': 14.8,
'wind_speed': 11.1,
}),
dict({
'apparent_temperature': 14.9,
'cloud_coverage': 30,
'condition': 'partlycloudy',
'datetime': '2025-09-12T19:00:00+00:00',
'humidity': 77,
'precipitation': 0.0,
'precipitation_probability': 3,
'temperature': 15.8,
'uv_index': 0,
'wind_bearing': 230,
'wind_gust_speed': 13.0,
'wind_speed': 11.1,
}),
dict({
'apparent_temperature': 13.8,
'cloud_coverage': 26,
'condition': 'clear-night',
'datetime': '2025-09-12T20:00:00+00:00',
'humidity': 84,
'precipitation': 0.0,
'precipitation_probability': 3,
'temperature': 14.6,
'uv_index': 0,
'wind_bearing': 259,
'wind_gust_speed': 13.0,
'wind_speed': 9.3,
}),
dict({
'apparent_temperature': 13.8,
'cloud_coverage': 22,
'condition': 'clear-night',
'datetime': '2025-09-12T21:00:00+00:00',
'humidity': 86,
'precipitation': 0.0,
'precipitation_probability': 4,
'temperature': 14.4,
'uv_index': 0,
'wind_bearing': 272,
'wind_gust_speed': 13.0,
'wind_speed': 9.3,
}),
dict({
'apparent_temperature': 13.5,
'cloud_coverage': 48,
'condition': 'partlycloudy',
'datetime': '2025-09-12T22:00:00+00:00',
'humidity': 89,
'precipitation': 0.0,
'precipitation_probability': 4,
'temperature': 13.9,
'uv_index': 0,
'wind_bearing': 265,
'wind_gust_speed': 13.0,
'wind_speed': 7.4,
}),
dict({
'apparent_temperature': 13.2,
'cloud_coverage': 74,
'condition': 'partlycloudy',
'datetime': '2025-09-12T23:00:00+00:00',
'humidity': 91,
'precipitation': 0.0,
'precipitation_probability': 4,
'temperature': 13.6,
'uv_index': 0,
'wind_bearing': 256,
'wind_gust_speed': 11.1,
'wind_speed': 7.4,
}),
dict({
'apparent_temperature': 13.5,
'cloud_coverage': 100,
'condition': 'cloudy',
'datetime': '2025-09-13T00:00:00+00:00',
'humidity': 90,
'precipitation': 0.0,
'precipitation_probability': 5,
'temperature': 13.9,
'uv_index': 0,
'wind_bearing': 244,
'wind_gust_speed': 11.1,
'wind_speed': 7.4,
}),
dict({
'apparent_temperature': 13.6,
'cloud_coverage': 98,
'condition': 'cloudy',
'datetime': '2025-09-13T01:00:00+00:00',
'humidity': 89,
'precipitation': 0.0,
'precipitation_probability': 5,
'temperature': 14.0,
'uv_index': 0,
'wind_bearing': 229,
'wind_gust_speed': 9.3,
'wind_speed': 7.4,
}),
]),
}),
})
# ---
# name: test_forecast_subscription
list([
dict({
@@ -269,7 +445,7 @@
'platform': 'accuweather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <WeatherEntityFeature: 1>,
'supported_features': <WeatherEntityFeature: 3>,
'translation_key': None,
'unique_id': '0123456',
'unit_of_measurement': None,
@@ -287,7 +463,7 @@
'precipitation_unit': <UnitOfPrecipitationDepth.MILLIMETERS: 'mm'>,
'pressure': 1012.0,
'pressure_unit': <UnitOfPressure.HPA: 'hPa'>,
'supported_features': <WeatherEntityFeature: 1>,
'supported_features': <WeatherEntityFeature: 3>,
'temperature': 22.6,
'temperature_unit': <UnitOfTemperature.CELSIUS: '°C'>,
'uv_index': 6,

View File

@@ -107,24 +107,24 @@ async def test_unsupported_condition_icon_data(
@pytest.mark.parametrize(
("service"),
[SERVICE_GET_FORECASTS],
("forecast_type"),
["daily", "hourly"],
)
async def test_forecast_service(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_accuweather_client: AsyncMock,
service: str,
forecast_type: str,
) -> None:
"""Test multiple forecast."""
await init_integration(hass)
response = await hass.services.async_call(
WEATHER_DOMAIN,
service,
SERVICE_GET_FORECASTS,
{
"entity_id": "weather.home",
"type": "daily",
"type": forecast_type,
},
blocking=True,
return_response=True,