From fae82731e1aa88b33c6371242482b6b58f9f8b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 29 Aug 2023 15:43:14 +0200 Subject: [PATCH] Simplify and improve AEMET coordinator updates (#99273) --- homeassistant/components/aemet/__init__.py | 11 +- homeassistant/components/aemet/config_flow.py | 2 +- .../aemet/weather_update_coordinator.py | 172 ++++-------------- tests/components/aemet/test_config_flow.py | 2 +- tests/components/aemet/test_coordinator.py | 37 ++++ tests/components/aemet/test_init.py | 28 +++ 6 files changed, 108 insertions(+), 144 deletions(-) create mode 100644 tests/components/aemet/test_coordinator.py diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index 772dcd0276b..c8b3f774a97 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -1,6 +1,8 @@ """The AEMET OpenData component.""" + import logging +from aemet_opendata.exceptions import TownNotFound from aemet_opendata.interface import AEMET, ConnectionOptions from homeassistant.config_entries import ConfigEntry @@ -30,10 +32,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options = ConnectionOptions(api_key, station_updates) aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options) - weather_coordinator = WeatherUpdateCoordinator( - hass, aemet, latitude, longitude, station_updates - ) + try: + await aemet.select_coordinates(latitude, longitude) + except TownNotFound as err: + _LOGGER.error(err) + return False + weather_coordinator = WeatherUpdateCoordinator(hass, aemet) await weather_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index 4f3531b19e7..4df25613803 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -43,7 +43,7 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): options = ConnectionOptions(user_input[CONF_API_KEY], False) aemet = AEMET(aiohttp_client.async_get_clientsession(self.hass), options) try: - await aemet.get_conventional_observation_stations(False) + await aemet.select_coordinates(latitude, longitude) except AuthError: errors["base"] = "invalid_api_key" diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 66a1a2eb891..016dd2ba75c 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -2,9 +2,9 @@ from __future__ import annotations from asyncio import timeout -from dataclasses import dataclass, field from datetime import timedelta import logging +from typing import Any, Final from aemet_opendata.const import ( AEMET_ATTR_DATE, @@ -14,11 +14,8 @@ from aemet_opendata.const import ( AEMET_ATTR_FEEL_TEMPERATURE, AEMET_ATTR_FORECAST, AEMET_ATTR_HUMIDITY, - AEMET_ATTR_ID, - AEMET_ATTR_IDEMA, AEMET_ATTR_MAX, AEMET_ATTR_MIN, - AEMET_ATTR_NAME, AEMET_ATTR_PRECIPITATION, AEMET_ATTR_PRECIPITATION_PROBABILITY, AEMET_ATTR_SKY_STATE, @@ -27,7 +24,6 @@ from aemet_opendata.const import ( AEMET_ATTR_SPEED, AEMET_ATTR_STATION_DATE, AEMET_ATTR_STATION_HUMIDITY, - AEMET_ATTR_STATION_LOCATION, AEMET_ATTR_STATION_PRESSURE, AEMET_ATTR_STATION_PRESSURE_SEA, AEMET_ATTR_STATION_TEMPERATURE, @@ -37,12 +33,15 @@ from aemet_opendata.const import ( AEMET_ATTR_WIND_GUST, ATTR_DATA, ) +from aemet_opendata.exceptions import AemetError from aemet_opendata.helpers import ( get_forecast_day_value, get_forecast_hour_value, get_forecast_interval_value, ) +from aemet_opendata.interface import AEMET +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -83,6 +82,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +API_TIMEOUT: Final[int] = 120 STATION_MAX_DELTA = timedelta(hours=2) WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) @@ -112,130 +112,33 @@ def format_int(value) -> int | None: return None -class TownNotFound(UpdateFailed): - """Raised when town is not found.""" - - class WeatherUpdateCoordinator(DataUpdateCoordinator): """Weather data update coordinator.""" - def __init__(self, hass, aemet, latitude, longitude, station_updates): + def __init__( + self, + hass: HomeAssistant, + aemet: AEMET, + ) -> None: """Initialize coordinator.""" + self.aemet = aemet + super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL + hass, + _LOGGER, + name=DOMAIN, + update_interval=WEATHER_UPDATE_INTERVAL, ) - self._aemet = aemet - self._station = None - self._town = None - self._latitude = latitude - self._longitude = longitude - self._station_updates = station_updates - self._data = { - "daily": None, - "hourly": None, - "station": None, - } - - async def _async_update_data(self): - data = {} - async with timeout(120): - weather_response = await self._get_aemet_weather() - data = self._convert_weather_response(weather_response) - return data - - async def _get_aemet_weather(self): - """Poll weather data from AEMET OpenData.""" - weather = await self._get_weather_and_forecast() - return weather - - async def _get_weather_station(self): - if not self._station: - self._station = ( - await self._aemet.get_conventional_observation_station_by_coordinates( - self._latitude, self._longitude - ) - ) - if self._station: - _LOGGER.debug( - "station found for coordinates [%s, %s]: %s", - self._latitude, - self._longitude, - self._station, - ) - if not self._station: - _LOGGER.debug( - "station not found for coordinates [%s, %s]", - self._latitude, - self._longitude, - ) - return self._station - - async def _get_weather_town(self): - if not self._town: - self._town = await self._aemet.get_town_by_coordinates( - self._latitude, self._longitude - ) - if self._town: - _LOGGER.debug( - "Town found for coordinates [%s, %s]: %s", - self._latitude, - self._longitude, - self._town, - ) - if not self._town: - _LOGGER.error( - "Town not found for coordinates [%s, %s]", - self._latitude, - self._longitude, - ) - raise TownNotFound - return self._town - - async def _get_weather_and_forecast(self): - """Get weather and forecast data from AEMET OpenData.""" - - await self._get_weather_town() - - daily = await self._aemet.get_specific_forecast_town_daily( - self._town[AEMET_ATTR_ID] - ) - if not daily: - _LOGGER.error( - 'Error fetching daily data for town "%s"', self._town[AEMET_ATTR_ID] - ) - - hourly = await self._aemet.get_specific_forecast_town_hourly( - self._town[AEMET_ATTR_ID] - ) - if not hourly: - _LOGGER.error( - 'Error fetching hourly data for town "%s"', self._town[AEMET_ATTR_ID] - ) - - station = None - if self._station_updates and await self._get_weather_station(): - station = await self._aemet.get_conventional_observation_station_data( - self._station[AEMET_ATTR_IDEMA] - ) - if not station: - _LOGGER.error( - 'Error fetching data for station "%s"', - self._station[AEMET_ATTR_IDEMA], - ) - - if daily: - self._data["daily"] = daily - if hourly: - self._data["hourly"] = hourly - if station: - self._data["station"] = station - - return AemetWeather( - self._data["daily"], - self._data["hourly"], - self._data["station"], - ) + async def _async_update_data(self) -> dict[str, Any]: + """Update coordinator data.""" + async with timeout(API_TIMEOUT): + try: + await self.aemet.update() + except AemetError as error: + raise UpdateFailed(error) from error + weather_response = self.aemet.legacy_weather() + return self._convert_weather_response(weather_response) def _convert_weather_response(self, weather_response): """Format the weather response correctly.""" @@ -520,14 +423,14 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _get_station_id(self): """Get station ID from weather data.""" - if self._station: - return self._station[AEMET_ATTR_IDEMA] + if self.aemet.station: + return self.aemet.station.get_id() return None def _get_station_name(self): """Get station name from weather data.""" - if self._station: - return self._station[AEMET_ATTR_STATION_LOCATION] + if self.aemet.station: + return self.aemet.station.get_name() return None @staticmethod @@ -568,14 +471,14 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _get_town_id(self): """Get town ID from weather data.""" - if self._town: - return self._town[AEMET_ATTR_ID] + if self.aemet.town: + return self.aemet.town.get_id() return None def _get_town_name(self): """Get town name from weather data.""" - if self._town: - return self._town[AEMET_ATTR_NAME] + if self.aemet.town: + return self.aemet.town.get_name() return None @staticmethod @@ -625,12 +528,3 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): if val: return format_int(val) return None - - -@dataclass -class AemetWeather: - """Class to harmonize weather data model.""" - - daily: dict = field(default_factory=dict) - hourly: dict = field(default_factory=dict) - station: dict = field(default_factory=dict) diff --git a/tests/components/aemet/test_config_flow.py b/tests/components/aemet/test_config_flow.py index b311cfd4a54..8c3264d8975 100644 --- a/tests/components/aemet/test_config_flow.py +++ b/tests/components/aemet/test_config_flow.py @@ -142,7 +142,7 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None: async def test_form_auth_error(hass: HomeAssistant) -> None: """Test setting up with api auth error.""" mocked_aemet = MagicMock() - mocked_aemet.get_conventional_observation_stations.side_effect = AuthError + mocked_aemet.select_coordinates.side_effect = AuthError with patch( "homeassistant.components.aemet.config_flow.AEMET", diff --git a/tests/components/aemet/test_coordinator.py b/tests/components/aemet/test_coordinator.py new file mode 100644 index 00000000000..067fc30a2c0 --- /dev/null +++ b/tests/components/aemet/test_coordinator.py @@ -0,0 +1,37 @@ +"""Define tests for the AEMET OpenData coordinator.""" +from unittest.mock import patch + +from aemet_opendata.exceptions import AemetError +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.aemet.weather_update_coordinator import ( + WEATHER_UPDATE_INTERVAL, +) +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +from tests.common import async_fire_time_changed + + +async def test_coordinator_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test error on coordinator update.""" + + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await async_init_integration(hass) + + with patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=AemetError, + ): + freezer.tick(WEATHER_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("weather.aemet") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/aemet/test_init.py b/tests/components/aemet/test_init.py index 24c16ba3ef3..f9eac318c6c 100644 --- a/tests/components/aemet/test_init.py +++ b/tests/components/aemet/test_init.py @@ -1,6 +1,8 @@ """Define tests for the AEMET OpenData init.""" from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.aemet.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME @@ -41,3 +43,29 @@ async def test_unload_entry(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_init_town_not_found( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test TownNotFound when loading the AEMET integration.""" + + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + with patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=mock_api_call, + ): + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "api-key", + CONF_LATITUDE: "0.0", + CONF_LONGITUDE: "0.0", + CONF_NAME: "AEMET", + }, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) is False