Simplify and improve AEMET coordinator updates (#99273)

This commit is contained in:
Álvaro Fernández Rojas 2023-08-29 15:43:14 +02:00 committed by GitHub
parent 98cb5b4b5d
commit fae82731e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 108 additions and 144 deletions

View File

@ -1,6 +1,8 @@
"""The AEMET OpenData component.""" """The AEMET OpenData component."""
import logging import logging
from aemet_opendata.exceptions import TownNotFound
from aemet_opendata.interface import AEMET, ConnectionOptions from aemet_opendata.interface import AEMET, ConnectionOptions
from homeassistant.config_entries import ConfigEntry 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) options = ConnectionOptions(api_key, station_updates)
aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options) aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options)
weather_coordinator = WeatherUpdateCoordinator( try:
hass, aemet, latitude, longitude, station_updates 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() await weather_coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})

View File

@ -43,7 +43,7 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
options = ConnectionOptions(user_input[CONF_API_KEY], False) options = ConnectionOptions(user_input[CONF_API_KEY], False)
aemet = AEMET(aiohttp_client.async_get_clientsession(self.hass), options) aemet = AEMET(aiohttp_client.async_get_clientsession(self.hass), options)
try: try:
await aemet.get_conventional_observation_stations(False) await aemet.select_coordinates(latitude, longitude)
except AuthError: except AuthError:
errors["base"] = "invalid_api_key" errors["base"] = "invalid_api_key"

View File

@ -2,9 +2,9 @@
from __future__ import annotations from __future__ import annotations
from asyncio import timeout from asyncio import timeout
from dataclasses import dataclass, field
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any, Final
from aemet_opendata.const import ( from aemet_opendata.const import (
AEMET_ATTR_DATE, AEMET_ATTR_DATE,
@ -14,11 +14,8 @@ from aemet_opendata.const import (
AEMET_ATTR_FEEL_TEMPERATURE, AEMET_ATTR_FEEL_TEMPERATURE,
AEMET_ATTR_FORECAST, AEMET_ATTR_FORECAST,
AEMET_ATTR_HUMIDITY, AEMET_ATTR_HUMIDITY,
AEMET_ATTR_ID,
AEMET_ATTR_IDEMA,
AEMET_ATTR_MAX, AEMET_ATTR_MAX,
AEMET_ATTR_MIN, AEMET_ATTR_MIN,
AEMET_ATTR_NAME,
AEMET_ATTR_PRECIPITATION, AEMET_ATTR_PRECIPITATION,
AEMET_ATTR_PRECIPITATION_PROBABILITY, AEMET_ATTR_PRECIPITATION_PROBABILITY,
AEMET_ATTR_SKY_STATE, AEMET_ATTR_SKY_STATE,
@ -27,7 +24,6 @@ from aemet_opendata.const import (
AEMET_ATTR_SPEED, AEMET_ATTR_SPEED,
AEMET_ATTR_STATION_DATE, AEMET_ATTR_STATION_DATE,
AEMET_ATTR_STATION_HUMIDITY, AEMET_ATTR_STATION_HUMIDITY,
AEMET_ATTR_STATION_LOCATION,
AEMET_ATTR_STATION_PRESSURE, AEMET_ATTR_STATION_PRESSURE,
AEMET_ATTR_STATION_PRESSURE_SEA, AEMET_ATTR_STATION_PRESSURE_SEA,
AEMET_ATTR_STATION_TEMPERATURE, AEMET_ATTR_STATION_TEMPERATURE,
@ -37,12 +33,15 @@ from aemet_opendata.const import (
AEMET_ATTR_WIND_GUST, AEMET_ATTR_WIND_GUST,
ATTR_DATA, ATTR_DATA,
) )
from aemet_opendata.exceptions import AemetError
from aemet_opendata.helpers import ( from aemet_opendata.helpers import (
get_forecast_day_value, get_forecast_day_value,
get_forecast_hour_value, get_forecast_hour_value,
get_forecast_interval_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.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@ -83,6 +82,7 @@ from .const import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
API_TIMEOUT: Final[int] = 120
STATION_MAX_DELTA = timedelta(hours=2) STATION_MAX_DELTA = timedelta(hours=2)
WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) WEATHER_UPDATE_INTERVAL = timedelta(minutes=10)
@ -112,130 +112,33 @@ def format_int(value) -> int | None:
return None return None
class TownNotFound(UpdateFailed):
"""Raised when town is not found."""
class WeatherUpdateCoordinator(DataUpdateCoordinator): class WeatherUpdateCoordinator(DataUpdateCoordinator):
"""Weather data update coordinator.""" """Weather data update coordinator."""
def __init__(self, hass, aemet, latitude, longitude, station_updates): def __init__(
self,
hass: HomeAssistant,
aemet: AEMET,
) -> None:
"""Initialize coordinator.""" """Initialize coordinator."""
self.aemet = aemet
super().__init__( super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL hass,
_LOGGER,
name=DOMAIN,
update_interval=WEATHER_UPDATE_INTERVAL,
) )
self._aemet = aemet async def _async_update_data(self) -> dict[str, Any]:
self._station = None """Update coordinator data."""
self._town = None async with timeout(API_TIMEOUT):
self._latitude = latitude try:
self._longitude = longitude await self.aemet.update()
self._station_updates = station_updates except AemetError as error:
self._data = { raise UpdateFailed(error) from error
"daily": None, weather_response = self.aemet.legacy_weather()
"hourly": None, return self._convert_weather_response(weather_response)
"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"],
)
def _convert_weather_response(self, weather_response): def _convert_weather_response(self, weather_response):
"""Format the weather response correctly.""" """Format the weather response correctly."""
@ -520,14 +423,14 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
def _get_station_id(self): def _get_station_id(self):
"""Get station ID from weather data.""" """Get station ID from weather data."""
if self._station: if self.aemet.station:
return self._station[AEMET_ATTR_IDEMA] return self.aemet.station.get_id()
return None return None
def _get_station_name(self): def _get_station_name(self):
"""Get station name from weather data.""" """Get station name from weather data."""
if self._station: if self.aemet.station:
return self._station[AEMET_ATTR_STATION_LOCATION] return self.aemet.station.get_name()
return None return None
@staticmethod @staticmethod
@ -568,14 +471,14 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
def _get_town_id(self): def _get_town_id(self):
"""Get town ID from weather data.""" """Get town ID from weather data."""
if self._town: if self.aemet.town:
return self._town[AEMET_ATTR_ID] return self.aemet.town.get_id()
return None return None
def _get_town_name(self): def _get_town_name(self):
"""Get town name from weather data.""" """Get town name from weather data."""
if self._town: if self.aemet.town:
return self._town[AEMET_ATTR_NAME] return self.aemet.town.get_name()
return None return None
@staticmethod @staticmethod
@ -625,12 +528,3 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
if val: if val:
return format_int(val) return format_int(val)
return None 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)

View File

@ -142,7 +142,7 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None:
async def test_form_auth_error(hass: HomeAssistant) -> None: async def test_form_auth_error(hass: HomeAssistant) -> None:
"""Test setting up with api auth error.""" """Test setting up with api auth error."""
mocked_aemet = MagicMock() mocked_aemet = MagicMock()
mocked_aemet.get_conventional_observation_stations.side_effect = AuthError mocked_aemet.select_coordinates.side_effect = AuthError
with patch( with patch(
"homeassistant.components.aemet.config_flow.AEMET", "homeassistant.components.aemet.config_flow.AEMET",

View File

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

View File

@ -1,6 +1,8 @@
"""Define tests for the AEMET OpenData init.""" """Define tests for the AEMET OpenData init."""
from unittest.mock import patch from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
from homeassistant.components.aemet.const import DOMAIN from homeassistant.components.aemet.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME 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.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED 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