Add AEMET OpenData integration (#45074)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Álvaro Fernández Rojas 2021-02-13 21:53:28 +01:00 committed by GitHub
parent 2f40f44670
commit eecf07d7df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 4398 additions and 0 deletions

View File

@ -23,6 +23,8 @@ omit =
homeassistant/components/adguard/sensor.py
homeassistant/components/adguard/switch.py
homeassistant/components/ads/*
homeassistant/components/aemet/abstract_aemet_sensor.py
homeassistant/components/aemet/weather_update_coordinator.py
homeassistant/components/aftership/sensor.py
homeassistant/components/agent_dvr/__init__.py
homeassistant/components/agent_dvr/alarm_control_panel.py

View File

@ -24,6 +24,7 @@ homeassistant/components/accuweather/* @bieniu
homeassistant/components/acmeda/* @atmurray
homeassistant/components/adguard/* @frenck
homeassistant/components/advantage_air/* @Bre77
homeassistant/components/aemet/* @noltari
homeassistant/components/agent_dvr/* @ispysoftware
homeassistant/components/airly/* @bieniu
homeassistant/components/airnow/* @asymworks

View File

@ -0,0 +1,61 @@
"""The AEMET OpenData component."""
import asyncio
import logging
from aemet_opendata.interface import AEMET
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import HomeAssistant
from .const import COMPONENTS, DOMAIN, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR
from .weather_update_coordinator import WeatherUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the AEMET OpenData component."""
hass.data.setdefault(DOMAIN, {})
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry):
"""Set up AEMET OpenData as config entry."""
name = config_entry.data[CONF_NAME]
api_key = config_entry.data[CONF_API_KEY]
latitude = config_entry.data[CONF_LATITUDE]
longitude = config_entry.data[CONF_LONGITUDE]
aemet = AEMET(api_key)
weather_coordinator = WeatherUpdateCoordinator(hass, aemet, latitude, longitude)
await weather_coordinator.async_refresh()
hass.data[DOMAIN][config_entry.entry_id] = {
ENTRY_NAME: name,
ENTRY_WEATHER_COORDINATOR: weather_coordinator,
}
for component in COMPONENTS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, component)
)
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(config_entry, component)
for component in COMPONENTS
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok

View File

@ -0,0 +1,57 @@
"""Abstraction form AEMET OpenData sensors."""
from homeassistant.const import ATTR_ATTRIBUTION
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, SENSOR_DEVICE_CLASS, SENSOR_NAME, SENSOR_UNIT
from .weather_update_coordinator import WeatherUpdateCoordinator
class AbstractAemetSensor(CoordinatorEntity):
"""Abstract class for an AEMET OpenData sensor."""
def __init__(
self,
name,
unique_id,
sensor_type,
sensor_configuration,
coordinator: WeatherUpdateCoordinator,
):
"""Initialize the sensor."""
super().__init__(coordinator)
self._name = name
self._unique_id = unique_id
self._sensor_type = sensor_type
self._sensor_name = sensor_configuration[SENSOR_NAME]
self._unit_of_measurement = sensor_configuration.get(SENSOR_UNIT)
self._device_class = sensor_configuration.get(SENSOR_DEVICE_CLASS)
@property
def name(self):
"""Return the name of the sensor."""
return f"{self._name} {self._sensor_name}"
@property
def unique_id(self):
"""Return a unique_id for this entity."""
return self._unique_id
@property
def attribution(self):
"""Return the attribution."""
return ATTRIBUTION
@property
def device_class(self):
"""Return the device_class."""
return self._device_class
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
return self._unit_of_measurement
@property
def device_state_attributes(self):
"""Return the state attributes."""
return {ATTR_ATTRIBUTION: ATTRIBUTION}

View File

@ -0,0 +1,58 @@
"""Config flow for AEMET OpenData."""
from aemet_opendata import AEMET
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
import homeassistant.helpers.config_validation as cv
from .const import DEFAULT_NAME
from .const import DOMAIN # pylint:disable=unused-import
class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for AEMET OpenData."""
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
latitude = user_input[CONF_LATITUDE]
longitude = user_input[CONF_LONGITUDE]
await self.async_set_unique_id(f"{latitude}-{longitude}")
self._abort_if_unique_id_configured()
api_online = await _is_aemet_api_online(self.hass, user_input[CONF_API_KEY])
if not api_online:
errors["base"] = "invalid_api_key"
if not errors:
return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input
)
schema = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
vol.Optional(
CONF_LATITUDE, default=self.hass.config.latitude
): cv.latitude,
vol.Optional(
CONF_LONGITUDE, default=self.hass.config.longitude
): cv.longitude,
}
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def _is_aemet_api_online(hass, api_key):
aemet = AEMET(api_key)
return await hass.async_add_executor_job(
aemet.get_conventional_observation_stations, False
)

View File

@ -0,0 +1,326 @@
"""Constant values for the AEMET OpenData component."""
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_FOG,
ATTR_CONDITION_LIGHTNING,
ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_POURING,
ATTR_CONDITION_RAINY,
ATTR_CONDITION_SNOWY,
ATTR_CONDITION_SUNNY,
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_PRECIPITATION,
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TEMP,
ATTR_FORECAST_TEMP_LOW,
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
ATTR_FORECAST_WIND_SPEED,
)
from homeassistant.const import (
DEGREE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TIMESTAMP,
PERCENTAGE,
PRECIPITATION_MILLIMETERS_PER_HOUR,
PRESSURE_HPA,
SPEED_KILOMETERS_PER_HOUR,
TEMP_CELSIUS,
)
ATTRIBUTION = "Powered by AEMET OpenData"
COMPONENTS = ["sensor", "weather"]
DEFAULT_NAME = "AEMET"
DOMAIN = "aemet"
ENTRY_NAME = "name"
ENTRY_WEATHER_COORDINATOR = "weather_coordinator"
UPDATE_LISTENER = "update_listener"
SENSOR_NAME = "sensor_name"
SENSOR_UNIT = "sensor_unit"
SENSOR_DEVICE_CLASS = "sensor_device_class"
ATTR_API_CONDITION = "condition"
ATTR_API_FORECAST_DAILY = "forecast-daily"
ATTR_API_FORECAST_HOURLY = "forecast-hourly"
ATTR_API_HUMIDITY = "humidity"
ATTR_API_PRESSURE = "pressure"
ATTR_API_RAIN = "rain"
ATTR_API_RAIN_PROB = "rain-probability"
ATTR_API_SNOW = "snow"
ATTR_API_SNOW_PROB = "snow-probability"
ATTR_API_STATION_ID = "station-id"
ATTR_API_STATION_NAME = "station-name"
ATTR_API_STATION_TIMESTAMP = "station-timestamp"
ATTR_API_STORM_PROB = "storm-probability"
ATTR_API_TEMPERATURE = "temperature"
ATTR_API_TEMPERATURE_FEELING = "temperature-feeling"
ATTR_API_TOWN_ID = "town-id"
ATTR_API_TOWN_NAME = "town-name"
ATTR_API_TOWN_TIMESTAMP = "town-timestamp"
ATTR_API_WIND_BEARING = "wind-bearing"
ATTR_API_WIND_MAX_SPEED = "wind-max-speed"
ATTR_API_WIND_SPEED = "wind-speed"
CONDITIONS_MAP = {
ATTR_CONDITION_CLEAR_NIGHT: {
"11n", # Despejado (de noche)
},
ATTR_CONDITION_CLOUDY: {
"14", # Nuboso
"14n", # Nuboso (de noche)
"15", # Muy nuboso
"15n", # Muy nuboso (de noche)
"16", # Cubierto
"16n", # Cubierto (de noche)
"17", # Nubes altas
"17n", # Nubes altas (de noche)
},
ATTR_CONDITION_FOG: {
"81", # Niebla
"81n", # Niebla (de noche)
"82", # Bruma - Neblina
"82n", # Bruma - Neblina (de noche)
},
ATTR_CONDITION_LIGHTNING: {
"51", # Intervalos nubosos con tormenta
"51n", # Intervalos nubosos con tormenta (de noche)
"52", # Nuboso con tormenta
"52n", # Nuboso con tormenta (de noche)
"53", # Muy nuboso con tormenta
"53n", # Muy nuboso con tormenta (de noche)
"54", # Cubierto con tormenta
"54n", # Cubierto con tormenta (de noche)
},
ATTR_CONDITION_LIGHTNING_RAINY: {
"61", # Intervalos nubosos con tormenta y lluvia escasa
"61n", # Intervalos nubosos con tormenta y lluvia escasa (de noche)
"62", # Nuboso con tormenta y lluvia escasa
"62n", # Nuboso con tormenta y lluvia escasa (de noche)
"63", # Muy nuboso con tormenta y lluvia escasa
"63n", # Muy nuboso con tormenta y lluvia escasa (de noche)
"64", # Cubierto con tormenta y lluvia escasa
"64n", # Cubierto con tormenta y lluvia escasa (de noche)
},
ATTR_CONDITION_PARTLYCLOUDY: {
"12", # Poco nuboso
"12n", # Poco nuboso (de noche)
"13", # Intervalos nubosos
"13n", # Intervalos nubosos (de noche)
},
ATTR_CONDITION_POURING: {
"27", # Chubascos
"27n", # Chubascos (de noche)
},
ATTR_CONDITION_RAINY: {
"23", # Intervalos nubosos con lluvia
"23n", # Intervalos nubosos con lluvia (de noche)
"24", # Nuboso con lluvia
"24n", # Nuboso con lluvia (de noche)
"25", # Muy nuboso con lluvia
"25n", # Muy nuboso con lluvia (de noche)
"26", # Cubierto con lluvia
"26n", # Cubierto con lluvia (de noche)
"43", # Intervalos nubosos con lluvia escasa
"43n", # Intervalos nubosos con lluvia escasa (de noche)
"44", # Nuboso con lluvia escasa
"44n", # Nuboso con lluvia escasa (de noche)
"45", # Muy nuboso con lluvia escasa
"45n", # Muy nuboso con lluvia escasa (de noche)
"46", # Cubierto con lluvia escasa
"46n", # Cubierto con lluvia escasa (de noche)
},
ATTR_CONDITION_SNOWY: {
"33", # Intervalos nubosos con nieve
"33n", # Intervalos nubosos con nieve (de noche)
"34", # Nuboso con nieve
"34n", # Nuboso con nieve (de noche)
"35", # Muy nuboso con nieve
"35n", # Muy nuboso con nieve (de noche)
"36", # Cubierto con nieve
"36n", # Cubierto con nieve (de noche)
"71", # Intervalos nubosos con nieve escasa
"71n", # Intervalos nubosos con nieve escasa (de noche)
"72", # Nuboso con nieve escasa
"72n", # Nuboso con nieve escasa (de noche)
"73", # Muy nuboso con nieve escasa
"73n", # Muy nuboso con nieve escasa (de noche)
"74", # Cubierto con nieve escasa
"74n", # Cubierto con nieve escasa (de noche)
},
ATTR_CONDITION_SUNNY: {
"11", # Despejado
},
}
FORECAST_MONITORED_CONDITIONS = [
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_PRECIPITATION,
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TEMP,
ATTR_FORECAST_TEMP_LOW,
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
ATTR_FORECAST_WIND_SPEED,
]
MONITORED_CONDITIONS = [
ATTR_API_CONDITION,
ATTR_API_HUMIDITY,
ATTR_API_PRESSURE,
ATTR_API_RAIN,
ATTR_API_RAIN_PROB,
ATTR_API_SNOW,
ATTR_API_SNOW_PROB,
ATTR_API_STATION_ID,
ATTR_API_STATION_NAME,
ATTR_API_STATION_TIMESTAMP,
ATTR_API_STORM_PROB,
ATTR_API_TEMPERATURE,
ATTR_API_TEMPERATURE_FEELING,
ATTR_API_TOWN_ID,
ATTR_API_TOWN_NAME,
ATTR_API_TOWN_TIMESTAMP,
ATTR_API_WIND_BEARING,
ATTR_API_WIND_MAX_SPEED,
ATTR_API_WIND_SPEED,
]
FORECAST_MODE_DAILY = "daily"
FORECAST_MODE_HOURLY = "hourly"
FORECAST_MODES = [
FORECAST_MODE_DAILY,
FORECAST_MODE_HOURLY,
]
FORECAST_MODE_ATTR_API = {
FORECAST_MODE_DAILY: ATTR_API_FORECAST_DAILY,
FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY,
}
FORECAST_SENSOR_TYPES = {
ATTR_FORECAST_CONDITION: {
SENSOR_NAME: "Condition",
},
ATTR_FORECAST_PRECIPITATION: {
SENSOR_NAME: "Precipitation",
SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR,
},
ATTR_FORECAST_PRECIPITATION_PROBABILITY: {
SENSOR_NAME: "Precipitation probability",
SENSOR_UNIT: PERCENTAGE,
},
ATTR_FORECAST_TEMP: {
SENSOR_NAME: "Temperature",
SENSOR_UNIT: TEMP_CELSIUS,
SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
},
ATTR_FORECAST_TEMP_LOW: {
SENSOR_NAME: "Temperature Low",
SENSOR_UNIT: TEMP_CELSIUS,
SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
},
ATTR_FORECAST_TIME: {
SENSOR_NAME: "Time",
SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP,
},
ATTR_FORECAST_WIND_BEARING: {
SENSOR_NAME: "Wind bearing",
SENSOR_UNIT: DEGREE,
},
ATTR_FORECAST_WIND_SPEED: {
SENSOR_NAME: "Wind speed",
SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR,
},
}
WEATHER_SENSOR_TYPES = {
ATTR_API_CONDITION: {
SENSOR_NAME: "Condition",
},
ATTR_API_HUMIDITY: {
SENSOR_NAME: "Humidity",
SENSOR_UNIT: PERCENTAGE,
SENSOR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
},
ATTR_API_PRESSURE: {
SENSOR_NAME: "Pressure",
SENSOR_UNIT: PRESSURE_HPA,
SENSOR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE,
},
ATTR_API_RAIN: {
SENSOR_NAME: "Rain",
SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR,
},
ATTR_API_RAIN_PROB: {
SENSOR_NAME: "Rain probability",
SENSOR_UNIT: PERCENTAGE,
},
ATTR_API_SNOW: {
SENSOR_NAME: "Snow",
SENSOR_UNIT: PRECIPITATION_MILLIMETERS_PER_HOUR,
},
ATTR_API_SNOW_PROB: {
SENSOR_NAME: "Snow probability",
SENSOR_UNIT: PERCENTAGE,
},
ATTR_API_STATION_ID: {
SENSOR_NAME: "Station ID",
},
ATTR_API_STATION_NAME: {
SENSOR_NAME: "Station name",
},
ATTR_API_STATION_TIMESTAMP: {
SENSOR_NAME: "Station timestamp",
SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP,
},
ATTR_API_STORM_PROB: {
SENSOR_NAME: "Storm probability",
SENSOR_UNIT: PERCENTAGE,
},
ATTR_API_TEMPERATURE: {
SENSOR_NAME: "Temperature",
SENSOR_UNIT: TEMP_CELSIUS,
SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
},
ATTR_API_TEMPERATURE_FEELING: {
SENSOR_NAME: "Temperature feeling",
SENSOR_UNIT: TEMP_CELSIUS,
SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
},
ATTR_API_TOWN_ID: {
SENSOR_NAME: "Town ID",
},
ATTR_API_TOWN_NAME: {
SENSOR_NAME: "Town name",
},
ATTR_API_TOWN_TIMESTAMP: {
SENSOR_NAME: "Town timestamp",
SENSOR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP,
},
ATTR_API_WIND_BEARING: {
SENSOR_NAME: "Wind bearing",
SENSOR_UNIT: DEGREE,
},
ATTR_API_WIND_MAX_SPEED: {
SENSOR_NAME: "Wind max speed",
SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR,
},
ATTR_API_WIND_SPEED: {
SENSOR_NAME: "Wind speed",
SENSOR_UNIT: SPEED_KILOMETERS_PER_HOUR,
},
}
WIND_BEARING_MAP = {
"C": None,
"N": 0.0,
"NE": 45.0,
"E": 90.0,
"SE": 135.0,
"S": 180.0,
"SO": 225.0,
"O": 270.0,
"NO": 315.0,
}

View File

@ -0,0 +1,8 @@
{
"domain": "aemet",
"name": "AEMET OpenData",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aemet",
"requirements": ["AEMET-OpenData==0.1.8"],
"codeowners": ["@noltari"]
}

View File

@ -0,0 +1,114 @@
"""Support for the AEMET OpenData service."""
from .abstract_aemet_sensor import AbstractAemetSensor
from .const import (
DOMAIN,
ENTRY_NAME,
ENTRY_WEATHER_COORDINATOR,
FORECAST_MODE_ATTR_API,
FORECAST_MODE_DAILY,
FORECAST_MODES,
FORECAST_MONITORED_CONDITIONS,
FORECAST_SENSOR_TYPES,
MONITORED_CONDITIONS,
WEATHER_SENSOR_TYPES,
)
from .weather_update_coordinator import WeatherUpdateCoordinator
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up AEMET OpenData sensor entities based on a config entry."""
domain_data = hass.data[DOMAIN][config_entry.entry_id]
name = domain_data[ENTRY_NAME]
weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR]
weather_sensor_types = WEATHER_SENSOR_TYPES
forecast_sensor_types = FORECAST_SENSOR_TYPES
entities = []
for sensor_type in MONITORED_CONDITIONS:
unique_id = f"{config_entry.unique_id}-{sensor_type}"
entities.append(
AemetSensor(
name,
unique_id,
sensor_type,
weather_sensor_types[sensor_type],
weather_coordinator,
)
)
for mode in FORECAST_MODES:
name = f"{domain_data[ENTRY_NAME]} {mode}"
for sensor_type in FORECAST_MONITORED_CONDITIONS:
unique_id = f"{config_entry.unique_id}-forecast-{mode}-{sensor_type}"
entities.append(
AemetForecastSensor(
f"{name} Forecast",
unique_id,
sensor_type,
forecast_sensor_types[sensor_type],
weather_coordinator,
mode,
)
)
async_add_entities(entities)
class AemetSensor(AbstractAemetSensor):
"""Implementation of an AEMET OpenData sensor."""
def __init__(
self,
name,
unique_id,
sensor_type,
sensor_configuration,
weather_coordinator: WeatherUpdateCoordinator,
):
"""Initialize the sensor."""
super().__init__(
name, unique_id, sensor_type, sensor_configuration, weather_coordinator
)
self._weather_coordinator = weather_coordinator
@property
def state(self):
"""Return the state of the device."""
return self._weather_coordinator.data.get(self._sensor_type)
class AemetForecastSensor(AbstractAemetSensor):
"""Implementation of an AEMET OpenData forecast sensor."""
def __init__(
self,
name,
unique_id,
sensor_type,
sensor_configuration,
weather_coordinator: WeatherUpdateCoordinator,
forecast_mode,
):
"""Initialize the sensor."""
super().__init__(
name, unique_id, sensor_type, sensor_configuration, weather_coordinator
)
self._weather_coordinator = weather_coordinator
self._forecast_mode = forecast_mode
@property
def entity_registry_enabled_default(self) -> bool:
"""Return if the entity should be enabled when first added to the entity registry."""
return self._forecast_mode == FORECAST_MODE_DAILY
@property
def state(self):
"""Return the state of the device."""
forecasts = self._weather_coordinator.data.get(
FORECAST_MODE_ATTR_API[self._forecast_mode]
)
if forecasts:
return forecasts[0].get(self._sensor_type)
return None

View File

@ -0,0 +1,22 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]"
},
"error": {
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]"
},
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
"name": "Name of the integration"
},
"description": "Set up AEMET OpenData integration. To generate API key go to https://opendata.aemet.es/centrodedescargas/altaUsuario",
"title": "AEMET OpenData"
}
}
}
}

View File

@ -0,0 +1,22 @@
{
"config": {
"abort": {
"already_configured": "Location is already configured"
},
"error": {
"invalid_api_key": "Invalid API key"
},
"step": {
"user": {
"data": {
"api_key": "API Key",
"latitude": "Latitude",
"longitude": "Longitude",
"name": "Name of the integration"
},
"description": "Set up AEMET OpenData integration. To generate API key go to https://opendata.aemet.es/centrodedescargas/altaUsuario",
"title": "AEMET OpenData"
}
}
}
}

View File

@ -0,0 +1,113 @@
"""Support for the AEMET OpenData service."""
from homeassistant.components.weather import WeatherEntity
from homeassistant.const import TEMP_CELSIUS
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTR_API_CONDITION,
ATTR_API_HUMIDITY,
ATTR_API_PRESSURE,
ATTR_API_TEMPERATURE,
ATTR_API_WIND_BEARING,
ATTR_API_WIND_SPEED,
ATTRIBUTION,
DOMAIN,
ENTRY_NAME,
ENTRY_WEATHER_COORDINATOR,
FORECAST_MODE_ATTR_API,
FORECAST_MODE_DAILY,
FORECAST_MODES,
)
from .weather_update_coordinator import WeatherUpdateCoordinator
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up AEMET OpenData weather entity based on a config entry."""
domain_data = hass.data[DOMAIN][config_entry.entry_id]
weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR]
entities = []
for mode in FORECAST_MODES:
name = f"{domain_data[ENTRY_NAME]} {mode}"
unique_id = f"{config_entry.unique_id} {mode}"
entities.append(AemetWeather(name, unique_id, weather_coordinator, mode))
if entities:
async_add_entities(entities, False)
class AemetWeather(CoordinatorEntity, WeatherEntity):
"""Implementation of an AEMET OpenData sensor."""
def __init__(
self,
name,
unique_id,
coordinator: WeatherUpdateCoordinator,
forecast_mode,
):
"""Initialize the sensor."""
super().__init__(coordinator)
self._name = name
self._unique_id = unique_id
self._forecast_mode = forecast_mode
@property
def attribution(self):
"""Return the attribution."""
return ATTRIBUTION
@property
def condition(self):
"""Return the current condition."""
return self.coordinator.data[ATTR_API_CONDITION]
@property
def entity_registry_enabled_default(self) -> bool:
"""Return if the entity should be enabled when first added to the entity registry."""
return self._forecast_mode == FORECAST_MODE_DAILY
@property
def forecast(self):
"""Return the forecast array."""
return self.coordinator.data[FORECAST_MODE_ATTR_API[self._forecast_mode]]
@property
def humidity(self):
"""Return the humidity."""
return self.coordinator.data[ATTR_API_HUMIDITY]
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def pressure(self):
"""Return the pressure."""
return self.coordinator.data[ATTR_API_PRESSURE]
@property
def temperature(self):
"""Return the temperature."""
return self.coordinator.data[ATTR_API_TEMPERATURE]
@property
def temperature_unit(self):
"""Return the unit of measurement."""
return TEMP_CELSIUS
@property
def unique_id(self):
"""Return a unique_id for this entity."""
return self._unique_id
@property
def wind_bearing(self):
"""Return the temperature."""
return self.coordinator.data[ATTR_API_WIND_BEARING]
@property
def wind_speed(self):
"""Return the temperature."""
return self.coordinator.data[ATTR_API_WIND_SPEED]

View File

@ -0,0 +1,637 @@
"""Weather data coordinator for the AEMET OpenData service."""
from dataclasses import dataclass, field
from datetime import timedelta
import logging
from aemet_opendata.const import (
AEMET_ATTR_DATE,
AEMET_ATTR_DAY,
AEMET_ATTR_DIRECTION,
AEMET_ATTR_ELABORATED,
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,
AEMET_ATTR_SNOW,
AEMET_ATTR_SNOW_PROBABILITY,
AEMET_ATTR_SPEED,
AEMET_ATTR_STATION_DATE,
AEMET_ATTR_STATION_HUMIDITY,
AEMET_ATTR_STATION_LOCATION,
AEMET_ATTR_STATION_PRESSURE_SEA,
AEMET_ATTR_STATION_TEMPERATURE,
AEMET_ATTR_STORM_PROBABILITY,
AEMET_ATTR_TEMPERATURE,
AEMET_ATTR_TEMPERATURE_FEELING,
AEMET_ATTR_WIND,
AEMET_ATTR_WIND_GUST,
ATTR_DATA,
)
from aemet_opendata.helpers import (
get_forecast_day_value,
get_forecast_hour_value,
get_forecast_interval_value,
)
import async_timeout
from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_PRECIPITATION,
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TEMP,
ATTR_FORECAST_TEMP_LOW,
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
ATTR_FORECAST_WIND_SPEED,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import (
ATTR_API_CONDITION,
ATTR_API_FORECAST_DAILY,
ATTR_API_FORECAST_HOURLY,
ATTR_API_HUMIDITY,
ATTR_API_PRESSURE,
ATTR_API_RAIN,
ATTR_API_RAIN_PROB,
ATTR_API_SNOW,
ATTR_API_SNOW_PROB,
ATTR_API_STATION_ID,
ATTR_API_STATION_NAME,
ATTR_API_STATION_TIMESTAMP,
ATTR_API_STORM_PROB,
ATTR_API_TEMPERATURE,
ATTR_API_TEMPERATURE_FEELING,
ATTR_API_TOWN_ID,
ATTR_API_TOWN_NAME,
ATTR_API_TOWN_TIMESTAMP,
ATTR_API_WIND_BEARING,
ATTR_API_WIND_MAX_SPEED,
ATTR_API_WIND_SPEED,
CONDITIONS_MAP,
DOMAIN,
WIND_BEARING_MAP,
)
_LOGGER = logging.getLogger(__name__)
WEATHER_UPDATE_INTERVAL = timedelta(minutes=10)
def format_condition(condition: str) -> str:
"""Return condition from dict CONDITIONS_MAP."""
for key, value in CONDITIONS_MAP.items():
if condition in value:
return key
_LOGGER.error('condition "%s" not found in CONDITIONS_MAP', condition)
return condition
def format_float(value) -> float:
"""Try converting string to float."""
try:
return float(value)
except ValueError:
return None
def format_int(value) -> int:
"""Try converting string to int."""
try:
return int(value)
except ValueError:
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):
"""Initialize coordinator."""
super().__init__(
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._data = {
"daily": None,
"hourly": None,
"station": None,
}
async def _async_update_data(self):
data = {}
with async_timeout.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.hass.async_add_executor_job(self._get_weather_and_forecast)
return weather
def _get_weather_station(self):
if not self._station:
self._station = (
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
def _get_weather_town(self):
if not self._town:
self._town = 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
def _get_weather_and_forecast(self):
"""Get weather and forecast data from AEMET OpenData."""
self._get_weather_town()
daily = 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 = 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._get_weather_station():
station = 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):
"""Format the weather response correctly."""
if not weather_response or not weather_response.hourly:
return None
elaborated = dt_util.parse_datetime(
weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_ELABORATED]
)
now = dt_util.now()
hour = now.hour
# Get current day
day = None
for cur_day in weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_FORECAST][
AEMET_ATTR_DAY
]:
cur_day_date = dt_util.parse_datetime(cur_day[AEMET_ATTR_DATE])
if now.date() == cur_day_date.date():
day = cur_day
break
# Get station data
station_data = None
if weather_response.station:
station_data = weather_response.station[ATTR_DATA][-1]
condition = None
humidity = None
pressure = None
rain = None
rain_prob = None
snow = None
snow_prob = None
station_id = None
station_name = None
station_timestamp = None
storm_prob = None
temperature = None
temperature_feeling = None
town_id = None
town_name = None
town_timestamp = dt_util.as_utc(elaborated)
wind_bearing = None
wind_max_speed = None
wind_speed = None
# Get weather values
if day:
condition = self._get_condition(day, hour)
humidity = self._get_humidity(day, hour)
rain = self._get_rain(day, hour)
rain_prob = self._get_rain_prob(day, hour)
snow = self._get_snow(day, hour)
snow_prob = self._get_snow_prob(day, hour)
station_id = self._get_station_id()
station_name = self._get_station_name()
storm_prob = self._get_storm_prob(day, hour)
temperature = self._get_temperature(day, hour)
temperature_feeling = self._get_temperature_feeling(day, hour)
town_id = self._get_town_id()
town_name = self._get_town_name()
wind_bearing = self._get_wind_bearing(day, hour)
wind_max_speed = self._get_wind_max_speed(day, hour)
wind_speed = self._get_wind_speed(day, hour)
# Overwrite weather values with closest station data (if present)
if station_data:
if AEMET_ATTR_STATION_DATE in station_data:
station_dt = dt_util.parse_datetime(
station_data[AEMET_ATTR_STATION_DATE] + "Z"
)
station_timestamp = dt_util.as_utc(station_dt).isoformat()
if AEMET_ATTR_STATION_HUMIDITY in station_data:
humidity = format_float(station_data[AEMET_ATTR_STATION_HUMIDITY])
if AEMET_ATTR_STATION_PRESSURE_SEA in station_data:
pressure = format_float(station_data[AEMET_ATTR_STATION_PRESSURE_SEA])
if AEMET_ATTR_STATION_TEMPERATURE in station_data:
temperature = format_float(station_data[AEMET_ATTR_STATION_TEMPERATURE])
# Get forecast from weather data
forecast_daily = self._get_daily_forecast_from_weather_response(
weather_response, now
)
forecast_hourly = self._get_hourly_forecast_from_weather_response(
weather_response, now
)
return {
ATTR_API_CONDITION: condition,
ATTR_API_FORECAST_DAILY: forecast_daily,
ATTR_API_FORECAST_HOURLY: forecast_hourly,
ATTR_API_HUMIDITY: humidity,
ATTR_API_TEMPERATURE: temperature,
ATTR_API_TEMPERATURE_FEELING: temperature_feeling,
ATTR_API_PRESSURE: pressure,
ATTR_API_RAIN: rain,
ATTR_API_RAIN_PROB: rain_prob,
ATTR_API_SNOW: snow,
ATTR_API_SNOW_PROB: snow_prob,
ATTR_API_STATION_ID: station_id,
ATTR_API_STATION_NAME: station_name,
ATTR_API_STATION_TIMESTAMP: station_timestamp,
ATTR_API_STORM_PROB: storm_prob,
ATTR_API_TOWN_ID: town_id,
ATTR_API_TOWN_NAME: town_name,
ATTR_API_TOWN_TIMESTAMP: town_timestamp,
ATTR_API_WIND_BEARING: wind_bearing,
ATTR_API_WIND_MAX_SPEED: wind_max_speed,
ATTR_API_WIND_SPEED: wind_speed,
}
def _get_daily_forecast_from_weather_response(self, weather_response, now):
if weather_response.daily:
parse = False
forecast = []
for day in weather_response.daily[ATTR_DATA][0][AEMET_ATTR_FORECAST][
AEMET_ATTR_DAY
]:
day_date = dt_util.parse_datetime(day[AEMET_ATTR_DATE])
if now.date() == day_date.date():
parse = True
if parse:
cur_forecast = self._convert_forecast_day(day_date, day)
if cur_forecast:
forecast.append(cur_forecast)
return forecast
return None
def _get_hourly_forecast_from_weather_response(self, weather_response, now):
if weather_response.hourly:
parse = False
hour = now.hour
forecast = []
for day in weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_FORECAST][
AEMET_ATTR_DAY
]:
day_date = dt_util.parse_datetime(day[AEMET_ATTR_DATE])
hour_start = 0
if now.date() == day_date.date():
parse = True
hour_start = now.hour
if parse:
for hour in range(hour_start, 24):
cur_forecast = self._convert_forecast_hour(day_date, day, hour)
if cur_forecast:
forecast.append(cur_forecast)
return forecast
return None
def _convert_forecast_day(self, date, day):
condition = self._get_condition_day(day)
if not condition:
return None
return {
ATTR_FORECAST_CONDITION: condition,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: self._get_precipitation_prob_day(
day
),
ATTR_FORECAST_TEMP: self._get_temperature_day(day),
ATTR_FORECAST_TEMP_LOW: self._get_temperature_low_day(day),
ATTR_FORECAST_TIME: dt_util.as_utc(date),
ATTR_FORECAST_WIND_SPEED: self._get_wind_speed_day(day),
ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing_day(day),
}
def _convert_forecast_hour(self, date, day, hour):
condition = self._get_condition(day, hour)
if not condition:
return None
forecast_dt = date.replace(hour=hour, minute=0, second=0)
return {
ATTR_FORECAST_CONDITION: condition,
ATTR_FORECAST_PRECIPITATION: self._calc_precipitation(day, hour),
ATTR_FORECAST_PRECIPITATION_PROBABILITY: self._calc_precipitation_prob(
day, hour
),
ATTR_FORECAST_TEMP: self._get_temperature(day, hour),
ATTR_FORECAST_TIME: dt_util.as_utc(forecast_dt),
ATTR_FORECAST_WIND_SPEED: self._get_wind_speed(day, hour),
ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing(day, hour),
}
def _calc_precipitation(self, day, hour):
"""Calculate the precipitation."""
rain_value = self._get_rain(day, hour)
if not rain_value:
rain_value = 0
snow_value = self._get_snow(day, hour)
if not snow_value:
snow_value = 0
if round(rain_value + snow_value, 1) == 0:
return None
return round(rain_value + snow_value, 1)
def _calc_precipitation_prob(self, day, hour):
"""Calculate the precipitation probability (hour)."""
rain_value = self._get_rain_prob(day, hour)
if not rain_value:
rain_value = 0
snow_value = self._get_snow_prob(day, hour)
if not snow_value:
snow_value = 0
if rain_value == 0 and snow_value == 0:
return None
return max(rain_value, snow_value)
@staticmethod
def _get_condition(day_data, hour):
"""Get weather condition (hour) from weather data."""
val = get_forecast_hour_value(day_data[AEMET_ATTR_SKY_STATE], hour)
if val:
return format_condition(val)
return None
@staticmethod
def _get_condition_day(day_data):
"""Get weather condition (day) from weather data."""
val = get_forecast_day_value(day_data[AEMET_ATTR_SKY_STATE])
if val:
return format_condition(val)
return None
@staticmethod
def _get_humidity(day_data, hour):
"""Get humidity from weather data."""
val = get_forecast_hour_value(day_data[AEMET_ATTR_HUMIDITY], hour)
if val:
return format_int(val)
return None
@staticmethod
def _get_precipitation_prob_day(day_data):
"""Get humidity from weather data."""
val = get_forecast_day_value(day_data[AEMET_ATTR_PRECIPITATION_PROBABILITY])
if val:
return format_int(val)
return None
@staticmethod
def _get_rain(day_data, hour):
"""Get rain from weather data."""
val = get_forecast_hour_value(day_data[AEMET_ATTR_PRECIPITATION], hour)
if val:
return format_float(val)
return None
@staticmethod
def _get_rain_prob(day_data, hour):
"""Get rain probability from weather data."""
val = get_forecast_interval_value(
day_data[AEMET_ATTR_PRECIPITATION_PROBABILITY], hour
)
if val:
return format_int(val)
return None
@staticmethod
def _get_snow(day_data, hour):
"""Get snow from weather data."""
val = get_forecast_hour_value(day_data[AEMET_ATTR_SNOW], hour)
if val:
return format_float(val)
return None
@staticmethod
def _get_snow_prob(day_data, hour):
"""Get snow probability from weather data."""
val = get_forecast_interval_value(day_data[AEMET_ATTR_SNOW_PROBABILITY], hour)
if val:
return format_int(val)
return None
def _get_station_id(self):
"""Get station ID from weather data."""
if self._station:
return self._station[AEMET_ATTR_IDEMA]
return None
def _get_station_name(self):
"""Get station name from weather data."""
if self._station:
return self._station[AEMET_ATTR_STATION_LOCATION]
return None
@staticmethod
def _get_storm_prob(day_data, hour):
"""Get storm probability from weather data."""
val = get_forecast_interval_value(day_data[AEMET_ATTR_STORM_PROBABILITY], hour)
if val:
return format_int(val)
return None
@staticmethod
def _get_temperature(day_data, hour):
"""Get temperature (hour) from weather data."""
val = get_forecast_hour_value(day_data[AEMET_ATTR_TEMPERATURE], hour)
if val:
return format_int(val)
return None
@staticmethod
def _get_temperature_day(day_data):
"""Get temperature (day) from weather data."""
val = get_forecast_day_value(
day_data[AEMET_ATTR_TEMPERATURE], key=AEMET_ATTR_MAX
)
if val:
return format_int(val)
return None
@staticmethod
def _get_temperature_low_day(day_data):
"""Get temperature (day) from weather data."""
val = get_forecast_day_value(
day_data[AEMET_ATTR_TEMPERATURE], key=AEMET_ATTR_MIN
)
if val:
return format_int(val)
return None
@staticmethod
def _get_temperature_feeling(day_data, hour):
"""Get temperature from weather data."""
val = get_forecast_hour_value(day_data[AEMET_ATTR_TEMPERATURE_FEELING], hour)
if val:
return format_int(val)
return None
def _get_town_id(self):
"""Get town ID from weather data."""
if self._town:
return self._town[AEMET_ATTR_ID]
return None
def _get_town_name(self):
"""Get town name from weather data."""
if self._town:
return self._town[AEMET_ATTR_NAME]
return None
@staticmethod
def _get_wind_bearing(day_data, hour):
"""Get wind bearing (hour) from weather data."""
val = get_forecast_hour_value(
day_data[AEMET_ATTR_WIND_GUST], hour, key=AEMET_ATTR_DIRECTION
)[0]
if val in WIND_BEARING_MAP:
return WIND_BEARING_MAP[val]
_LOGGER.error("%s not found in Wind Bearing map", val)
return None
@staticmethod
def _get_wind_bearing_day(day_data):
"""Get wind bearing (day) from weather data."""
val = get_forecast_day_value(
day_data[AEMET_ATTR_WIND], key=AEMET_ATTR_DIRECTION
)
if val in WIND_BEARING_MAP:
return WIND_BEARING_MAP[val]
_LOGGER.error("%s not found in Wind Bearing map", val)
return None
@staticmethod
def _get_wind_max_speed(day_data, hour):
"""Get wind max speed from weather data."""
val = get_forecast_hour_value(day_data[AEMET_ATTR_WIND_GUST], hour)
if val:
return format_int(val)
return None
@staticmethod
def _get_wind_speed(day_data, hour):
"""Get wind speed (hour) from weather data."""
val = get_forecast_hour_value(
day_data[AEMET_ATTR_WIND_GUST], hour, key=AEMET_ATTR_SPEED
)[0]
if val:
return format_int(val)
return None
@staticmethod
def _get_wind_speed_day(day_data):
"""Get wind speed (day) from weather data."""
val = get_forecast_day_value(day_data[AEMET_ATTR_WIND], key=AEMET_ATTR_SPEED)
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)

View File

@ -11,6 +11,7 @@ FLOWS = [
"acmeda",
"adguard",
"advantage_air",
"aemet",
"agent_dvr",
"airly",
"airnow",

View File

@ -1,6 +1,9 @@
# Home Assistant Core, full dependency set
-r requirements.txt
# homeassistant.components.aemet
AEMET-OpenData==0.1.8
# homeassistant.components.dht
# Adafruit-DHT==1.4.0

View File

@ -3,6 +3,9 @@
-r requirements_test.txt
# homeassistant.components.aemet
AEMET-OpenData==0.1.8
# homeassistant.components.homekit
HAP-python==3.3.0

View File

@ -0,0 +1 @@
"""Tests for the AEMET OpenData integration."""

View File

@ -0,0 +1,100 @@
"""Define tests for the AEMET OpenData config flow."""
from unittest.mock import MagicMock, patch
import requests_mock
from homeassistant import data_entry_flow
from homeassistant.components.aemet.const import DOMAIN
from homeassistant.config_entries import ENTRY_STATE_LOADED, SOURCE_USER
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
import homeassistant.util.dt as dt_util
from .util import aemet_requests_mock
from tests.common import MockConfigEntry
CONFIG = {
CONF_NAME: "aemet",
CONF_API_KEY: "foo",
CONF_LATITUDE: 40.30403754,
CONF_LONGITUDE: -3.72935236,
}
async def test_form(hass):
"""Test that the form is served with valid input."""
with patch(
"homeassistant.components.aemet.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.aemet.async_setup_entry",
return_value=True,
) as mock_setup_entry, requests_mock.mock() as _m:
aemet_requests_mock(_m)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == SOURCE_USER
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], CONFIG
)
await hass.async_block_till_done()
conf_entries = hass.config_entries.async_entries(DOMAIN)
entry = conf_entries[0]
assert entry.state == ENTRY_STATE_LOADED
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == CONFIG[CONF_NAME]
assert result["data"][CONF_LATITUDE] == CONFIG[CONF_LATITUDE]
assert result["data"][CONF_LONGITUDE] == CONFIG[CONF_LONGITUDE]
assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY]
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_duplicated_id(hass):
"""Test that the options form."""
now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00")
with patch("homeassistant.util.dt.now", return_value=now), patch(
"homeassistant.util.dt.utcnow", return_value=now
), requests_mock.mock() as _m:
aemet_requests_mock(_m)
entry = MockConfigEntry(
domain=DOMAIN, unique_id="40.30403754--3.72935236", data=CONFIG
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
)
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
async def test_form_api_offline(hass):
"""Test setting up with api call error."""
mocked_aemet = MagicMock()
mocked_aemet.get_conventional_observation_stations.return_value = None
with patch(
"homeassistant.components.aemet.config_flow.AEMET",
return_value=mocked_aemet,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
)
assert result["errors"] == {"base": "invalid_api_key"}

View File

@ -0,0 +1,44 @@
"""Define tests for the AEMET OpenData init."""
from unittest.mock import patch
import requests_mock
from homeassistant.components.aemet.const import DOMAIN
from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
import homeassistant.util.dt as dt_util
from .util import aemet_requests_mock
from tests.common import MockConfigEntry
CONFIG = {
CONF_NAME: "aemet",
CONF_API_KEY: "foo",
CONF_LATITUDE: 40.30403754,
CONF_LONGITUDE: -3.72935236,
}
async def test_unload_entry(hass):
"""Test that the options form."""
now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00")
with patch("homeassistant.util.dt.now", return_value=now), patch(
"homeassistant.util.dt.utcnow", return_value=now
), requests_mock.mock() as _m:
aemet_requests_mock(_m)
config_entry = MockConfigEntry(
domain=DOMAIN, unique_id="aemet_unique_id", data=CONFIG
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ENTRY_STATE_LOADED
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ENTRY_STATE_NOT_LOADED

View File

@ -0,0 +1,137 @@
"""The sensor tests for the AEMET OpenData platform."""
from unittest.mock import patch
from homeassistant.components.weather import (
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_SNOWY,
)
from homeassistant.const import STATE_UNKNOWN
import homeassistant.util.dt as dt_util
from .util import async_init_integration
async def test_aemet_forecast_create_sensors(hass):
"""Test creation of forecast sensors."""
now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00")
with patch("homeassistant.util.dt.now", return_value=now), patch(
"homeassistant.util.dt.utcnow", return_value=now
):
await async_init_integration(hass)
state = hass.states.get("sensor.aemet_daily_forecast_condition")
assert state.state == ATTR_CONDITION_PARTLYCLOUDY
state = hass.states.get("sensor.aemet_daily_forecast_precipitation")
assert state.state == STATE_UNKNOWN
state = hass.states.get("sensor.aemet_daily_forecast_precipitation_probability")
assert state.state == "30"
state = hass.states.get("sensor.aemet_daily_forecast_temperature")
assert state.state == "4"
state = hass.states.get("sensor.aemet_daily_forecast_temperature_low")
assert state.state == "-4"
state = hass.states.get("sensor.aemet_daily_forecast_time")
assert state.state == "2021-01-10 00:00:00+00:00"
state = hass.states.get("sensor.aemet_daily_forecast_wind_bearing")
assert state.state == "45.0"
state = hass.states.get("sensor.aemet_daily_forecast_wind_speed")
assert state.state == "20"
state = hass.states.get("sensor.aemet_hourly_forecast_condition")
assert state is None
state = hass.states.get("sensor.aemet_hourly_forecast_precipitation")
assert state is None
state = hass.states.get("sensor.aemet_hourly_forecast_precipitation_probability")
assert state is None
state = hass.states.get("sensor.aemet_hourly_forecast_temperature")
assert state is None
state = hass.states.get("sensor.aemet_hourly_forecast_temperature_low")
assert state is None
state = hass.states.get("sensor.aemet_hourly_forecast_time")
assert state is None
state = hass.states.get("sensor.aemet_hourly_forecast_wind_bearing")
assert state is None
state = hass.states.get("sensor.aemet_hourly_forecast_wind_speed")
assert state is None
async def test_aemet_weather_create_sensors(hass):
"""Test creation of weather sensors."""
now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00")
with patch("homeassistant.util.dt.now", return_value=now), patch(
"homeassistant.util.dt.utcnow", return_value=now
):
await async_init_integration(hass)
state = hass.states.get("sensor.aemet_condition")
assert state.state == ATTR_CONDITION_SNOWY
state = hass.states.get("sensor.aemet_humidity")
assert state.state == "99.0"
state = hass.states.get("sensor.aemet_pressure")
assert state.state == "1004.4"
state = hass.states.get("sensor.aemet_rain")
assert state.state == "1.8"
state = hass.states.get("sensor.aemet_rain_probability")
assert state.state == "100"
state = hass.states.get("sensor.aemet_snow")
assert state.state == "1.8"
state = hass.states.get("sensor.aemet_snow_probability")
assert state.state == "100"
state = hass.states.get("sensor.aemet_station_id")
assert state.state == "3195"
state = hass.states.get("sensor.aemet_station_name")
assert state.state == "MADRID RETIRO"
state = hass.states.get("sensor.aemet_station_timestamp")
assert state.state == "2021-01-09T12:00:00+00:00"
state = hass.states.get("sensor.aemet_storm_probability")
assert state.state == "0"
state = hass.states.get("sensor.aemet_temperature")
assert state.state == "-0.7"
state = hass.states.get("sensor.aemet_temperature_feeling")
assert state.state == "-4"
state = hass.states.get("sensor.aemet_town_id")
assert state.state == "id28065"
state = hass.states.get("sensor.aemet_town_name")
assert state.state == "Getafe"
state = hass.states.get("sensor.aemet_town_timestamp")
assert state.state == "2021-01-09 11:47:45+00:00"
state = hass.states.get("sensor.aemet_wind_bearing")
assert state.state == "90.0"
state = hass.states.get("sensor.aemet_wind_max_speed")
assert state.state == "24"
state = hass.states.get("sensor.aemet_wind_speed")
assert state.state == "15"

View File

@ -0,0 +1,61 @@
"""The sensor tests for the AEMET OpenData platform."""
from unittest.mock import patch
from homeassistant.components.aemet.const import ATTRIBUTION
from homeassistant.components.weather import (
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_SNOWY,
ATTR_FORECAST,
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_PRECIPITATION,
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TEMP,
ATTR_FORECAST_TEMP_LOW,
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
ATTR_FORECAST_WIND_SPEED,
ATTR_WEATHER_HUMIDITY,
ATTR_WEATHER_PRESSURE,
ATTR_WEATHER_TEMPERATURE,
ATTR_WEATHER_WIND_BEARING,
ATTR_WEATHER_WIND_SPEED,
)
from homeassistant.const import ATTR_ATTRIBUTION
import homeassistant.util.dt as dt_util
from .util import async_init_integration
async def test_aemet_weather(hass):
"""Test states of the weather."""
now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00")
with patch("homeassistant.util.dt.now", return_value=now), patch(
"homeassistant.util.dt.utcnow", return_value=now
):
await async_init_integration(hass)
state = hass.states.get("weather.aemet_daily")
assert state
assert state.state == ATTR_CONDITION_SNOWY
assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 99.0
assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4
assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7
assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0
assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15
forecast = state.attributes.get(ATTR_FORECAST)[0]
assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY
assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None
assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 30
assert forecast.get(ATTR_FORECAST_TEMP) == 4
assert forecast.get(ATTR_FORECAST_TEMP_LOW) == -4
assert forecast.get(ATTR_FORECAST_TIME) == dt_util.parse_datetime(
"2021-01-10 00:00:00+00:00"
)
assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 45.0
assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 20
state = hass.states.get("weather.aemet_hourly")
assert state is None

View File

@ -0,0 +1,93 @@
"""Tests for the AEMET OpenData integration."""
import requests_mock
from homeassistant.components.aemet import DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_fixture
def aemet_requests_mock(mock):
"""Mock requests performed to AEMET OpenData API."""
station_3195_fixture = "aemet/station-3195.json"
station_3195_data_fixture = "aemet/station-3195-data.json"
station_list_fixture = "aemet/station-list.json"
station_list_data_fixture = "aemet/station-list-data.json"
town_28065_forecast_daily_fixture = "aemet/town-28065-forecast-daily.json"
town_28065_forecast_daily_data_fixture = "aemet/town-28065-forecast-daily-data.json"
town_28065_forecast_hourly_fixture = "aemet/town-28065-forecast-hourly.json"
town_28065_forecast_hourly_data_fixture = (
"aemet/town-28065-forecast-hourly-data.json"
)
town_id28065_fixture = "aemet/town-id28065.json"
town_list_fixture = "aemet/town-list.json"
mock.get(
"https://opendata.aemet.es/opendata/api/observacion/convencional/datos/estacion/3195",
text=load_fixture(station_3195_fixture),
)
mock.get(
"https://opendata.aemet.es/opendata/sh/208c3ca3",
text=load_fixture(station_3195_data_fixture),
)
mock.get(
"https://opendata.aemet.es/opendata/api/observacion/convencional/todas",
text=load_fixture(station_list_fixture),
)
mock.get(
"https://opendata.aemet.es/opendata/sh/2c55192f",
text=load_fixture(station_list_data_fixture),
)
mock.get(
"https://opendata.aemet.es/opendata/api/prediccion/especifica/municipio/diaria/28065",
text=load_fixture(town_28065_forecast_daily_fixture),
)
mock.get(
"https://opendata.aemet.es/opendata/sh/64e29abb",
text=load_fixture(town_28065_forecast_daily_data_fixture),
)
mock.get(
"https://opendata.aemet.es/opendata/api/prediccion/especifica/municipio/horaria/28065",
text=load_fixture(town_28065_forecast_hourly_fixture),
)
mock.get(
"https://opendata.aemet.es/opendata/sh/18ca1886",
text=load_fixture(town_28065_forecast_hourly_data_fixture),
)
mock.get(
"https://opendata.aemet.es/opendata/api/maestro/municipio/id28065",
text=load_fixture(town_id28065_fixture),
)
mock.get(
"https://opendata.aemet.es/opendata/api/maestro/municipios",
text=load_fixture(town_list_fixture),
)
async def async_init_integration(
hass: HomeAssistant,
skip_setup: bool = False,
):
"""Set up the AEMET OpenData integration in Home Assistant."""
with requests_mock.mock() as _m:
aemet_requests_mock(_m)
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_API_KEY: "mock",
CONF_LATITUDE: "40.30403754",
CONF_LONGITUDE: "-3.72935236",
CONF_NAME: "AEMET",
},
)
entry.add_to_hass(hass)
if not skip_setup:
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()

View File

@ -0,0 +1,369 @@
[ {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-08T14:00:00",
"prec" : 1.2,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 929.9,
"hr" : 97.0,
"pres_nmar" : 1009.9,
"tamin" : -0.1,
"ta" : 0.1,
"tamax" : 0.2,
"tpr" : -0.3,
"rviento" : 132.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-08T15:00:00",
"prec" : 1.5,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 929.0,
"hr" : 98.0,
"pres_nmar" : 1008.9,
"tamin" : 0.1,
"ta" : 0.2,
"tamax" : 0.3,
"tpr" : 0.0,
"rviento" : 154.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-08T16:00:00",
"prec" : 0.7,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 928.8,
"hr" : 98.0,
"pres_nmar" : 1008.6,
"tamin" : 0.2,
"ta" : 0.3,
"tamax" : 0.3,
"tpr" : 0.0,
"rviento" : 177.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-08T17:00:00",
"prec" : 1.7,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 928.6,
"hr" : 99.0,
"pres_nmar" : 1008.5,
"tamin" : 0.1,
"ta" : 0.1,
"tamax" : 0.3,
"tpr" : 0.0,
"rviento" : 174.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-08T18:00:00",
"prec" : 1.9,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 928.2,
"hr" : 99.0,
"pres_nmar" : 1008.1,
"tamin" : -0.1,
"ta" : -0.1,
"tamax" : 0.1,
"tpr" : -0.3,
"rviento" : 163.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-08T19:00:00",
"prec" : 3.0,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 928.4,
"hr" : 99.0,
"pres_nmar" : 1008.4,
"tamin" : -0.3,
"ta" : -0.3,
"tamax" : 0.0,
"tpr" : -0.5,
"rviento" : 79.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-08T20:00:00",
"prec" : 3.5,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 928.4,
"hr" : 99.0,
"pres_nmar" : 1008.5,
"tamin" : -0.6,
"ta" : -0.6,
"tamax" : -0.3,
"tpr" : -0.7,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-08T21:00:00",
"prec" : 2.6,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 928.1,
"hr" : 99.0,
"pres_nmar" : 1008.2,
"tamin" : -0.7,
"ta" : -0.7,
"tamax" : -0.5,
"tpr" : -0.7,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-08T22:00:00",
"prec" : 3.0,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 927.6,
"hr" : 99.0,
"pres_nmar" : 1007.7,
"tamin" : -0.8,
"ta" : -0.8,
"tamax" : -0.7,
"tpr" : -1.0,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-08T23:00:00",
"prec" : 2.9,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 926.9,
"hr" : 99.0,
"pres_nmar" : 1007.0,
"tamin" : -0.9,
"ta" : -0.9,
"tamax" : -0.7,
"tpr" : -1.0,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-09T00:00:00",
"prec" : 1.4,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 926.5,
"hr" : 99.0,
"pres_nmar" : 1006.6,
"tamin" : -1.0,
"ta" : -1.0,
"tamax" : -0.8,
"tpr" : -1.2,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-09T01:00:00",
"prec" : 2.0,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 925.9,
"hr" : 99.0,
"pres_nmar" : 1006.0,
"tamin" : -1.3,
"ta" : -1.3,
"tamax" : -1.0,
"tpr" : -1.4,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-09T02:00:00",
"prec" : 1.5,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 925.7,
"hr" : 99.0,
"pres_nmar" : 1005.8,
"tamin" : -1.5,
"ta" : -1.4,
"tamax" : -1.3,
"tpr" : -1.4,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-09T03:00:00",
"prec" : 1.2,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 925.6,
"hr" : 99.0,
"pres_nmar" : 1005.7,
"tamin" : -1.5,
"ta" : -1.4,
"tamax" : -1.4,
"tpr" : -1.4,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-09T04:00:00",
"prec" : 1.1,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 924.9,
"hr" : 99.0,
"pres_nmar" : 1005.0,
"tamin" : -1.5,
"ta" : -1.5,
"tamax" : -1.4,
"tpr" : -1.7,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-09T05:00:00",
"prec" : 0.7,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 924.6,
"hr" : 99.0,
"pres_nmar" : 1004.7,
"tamin" : -1.5,
"ta" : -1.5,
"tamax" : -1.4,
"tpr" : -1.7,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-09T06:00:00",
"prec" : 0.2,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 924.4,
"hr" : 99.0,
"pres_nmar" : 1004.5,
"tamin" : -1.6,
"ta" : -1.6,
"tamax" : -1.5,
"tpr" : -1.7,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-09T07:00:00",
"prec" : 0.0,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 924.4,
"hr" : 99.0,
"pres_nmar" : 1004.5,
"tamin" : -1.6,
"ta" : -1.6,
"tamax" : -1.6,
"tpr" : -1.7,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-09T08:00:00",
"prec" : 0.1,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 924.8,
"hr" : 99.0,
"pres_nmar" : 1004.9,
"tamin" : -1.6,
"ta" : -1.6,
"tamax" : -1.5,
"tpr" : -1.7,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-09T09:00:00",
"prec" : 0.0,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 925.0,
"hr" : 99.0,
"pres_nmar" : 1005.0,
"tamin" : -1.6,
"ta" : -1.3,
"tamax" : -1.3,
"tpr" : -1.4,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-09T10:00:00",
"prec" : 0.0,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 925.3,
"hr" : 99.0,
"pres_nmar" : 1005.3,
"tamin" : -1.3,
"ta" : -1.2,
"tamax" : -1.1,
"tpr" : -1.4,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-09T11:00:00",
"prec" : 4.4,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 925.4,
"hr" : 99.0,
"pres_nmar" : 1005.4,
"tamin" : -1.2,
"ta" : -1.0,
"tamax" : -1.0,
"tpr" : -1.2,
"rviento" : 0.0
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-09T12:00:00",
"prec" : 7.0,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 924.6,
"hr" : 99.0,
"pres_nmar" : 1004.4,
"tamin" : -1.0,
"ta" : -0.7,
"tamax" : -0.6,
"tpr" : -0.7,
"rviento" : 0.0
} ]

View File

@ -0,0 +1,6 @@
{
"descripcion" : "exito",
"estado" : 200,
"datos" : "https://opendata.aemet.es/opendata/sh/208c3ca3",
"metadatos" : "https://opendata.aemet.es/opendata/sh/55c2971b"
}

View File

@ -0,0 +1,42 @@
[ {
"idema" : "3194U",
"lon" : -3.724167,
"fint" : "2021-01-08T14:00:00",
"prec" : 1.3,
"alt" : 664.0,
"lat" : 40.45167,
"ubi" : "MADRID C. UNIVERSITARIA",
"hr" : 98.0,
"tamin" : 0.6,
"ta" : 0.9,
"tamax" : 1.0,
"tpr" : 0.6
}, {
"idema" : "3194Y",
"lon" : -3.813369,
"fint" : "2021-01-08T14:00:00",
"prec" : 0.2,
"alt" : 665.0,
"lat" : 40.448437,
"ubi" : "POZUELO DE ALARCON (AUTOM<4F>TICA)",
"hr" : 93.0,
"tamin" : 0.5,
"ta" : 0.6,
"tamax" : 0.6
}, {
"idema" : "3195",
"lon" : -3.678095,
"fint" : "2021-01-08T14:00:00",
"prec" : 1.2,
"alt" : 667.0,
"lat" : 40.411804,
"ubi" : "MADRID RETIRO",
"pres" : 929.9,
"hr" : 97.0,
"pres_nmar" : 1009.9,
"tamin" : -0.1,
"ta" : 0.1,
"tamax" : 0.2,
"tpr" : -0.3,
"rviento" : 132.0
} ]

View File

@ -0,0 +1,6 @@
{
"descripcion" : "exito",
"estado" : 200,
"datos" : "https://opendata.aemet.es/opendata/sh/2c55192f",
"metadatos" : "https://opendata.aemet.es/opendata/sh/55c2971b"
}

View File

@ -0,0 +1,625 @@
[ {
"origen" : {
"productor" : "Agencia Estatal de Meteorolog<6F>a - AEMET. Gobierno de Espa<70>a",
"web" : "http://www.aemet.es",
"enlace" : "http://www.aemet.es/es/eltiempo/prediccion/municipios/getafe-id28065",
"language" : "es",
"copyright" : "<22> AEMET. Autorizado el uso de la informaci<63>n y su reproducci<63>n citando a AEMET como autora de la misma.",
"notaLegal" : "http://www.aemet.es/es/nota_legal"
},
"elaborado" : "2021-01-09T11:54:00",
"nombre" : "Getafe",
"provincia" : "Madrid",
"prediccion" : {
"dia" : [ {
"probPrecipitacion" : [ {
"value" : 0,
"periodo" : "00-24"
}, {
"value" : 0,
"periodo" : "00-12"
}, {
"value" : 100,
"periodo" : "12-24"
}, {
"value" : 0,
"periodo" : "00-06"
}, {
"value" : 100,
"periodo" : "06-12"
}, {
"value" : 100,
"periodo" : "12-18"
}, {
"value" : 100,
"periodo" : "18-24"
} ],
"cotaNieveProv" : [ {
"value" : "",
"periodo" : "00-24"
}, {
"value" : "",
"periodo" : "00-12"
}, {
"value" : "500",
"periodo" : "12-24"
}, {
"value" : "",
"periodo" : "00-06"
}, {
"value" : "400",
"periodo" : "06-12"
}, {
"value" : "500",
"periodo" : "12-18"
}, {
"value" : "600",
"periodo" : "18-24"
} ],
"estadoCielo" : [ {
"value" : "",
"periodo" : "00-24",
"descripcion" : ""
}, {
"value" : "",
"periodo" : "00-12",
"descripcion" : ""
}, {
"value" : "36",
"periodo" : "12-24",
"descripcion" : "Cubierto con nieve"
}, {
"value" : "",
"periodo" : "00-06",
"descripcion" : ""
}, {
"value" : "36",
"periodo" : "06-12",
"descripcion" : "Cubierto con nieve"
}, {
"value" : "36",
"periodo" : "12-18",
"descripcion" : "Cubierto con nieve"
}, {
"value" : "34n",
"periodo" : "18-24",
"descripcion" : "Nuboso con nieve"
} ],
"viento" : [ {
"direccion" : "",
"velocidad" : 0,
"periodo" : "00-24"
}, {
"direccion" : "",
"velocidad" : 0,
"periodo" : "00-12"
}, {
"direccion" : "E",
"velocidad" : 15,
"periodo" : "12-24"
}, {
"direccion" : "NE",
"velocidad" : 30,
"periodo" : "00-06"
}, {
"direccion" : "E",
"velocidad" : 15,
"periodo" : "06-12"
}, {
"direccion" : "E",
"velocidad" : 5,
"periodo" : "12-18"
}, {
"direccion" : "NE",
"velocidad" : 5,
"periodo" : "18-24"
} ],
"rachaMax" : [ {
"value" : "",
"periodo" : "00-24"
}, {
"value" : "",
"periodo" : "00-12"
}, {
"value" : "",
"periodo" : "12-24"
}, {
"value" : "40",
"periodo" : "00-06"
}, {
"value" : "",
"periodo" : "06-12"
}, {
"value" : "",
"periodo" : "12-18"
}, {
"value" : "",
"periodo" : "18-24"
} ],
"temperatura" : {
"maxima" : 2,
"minima" : -1,
"dato" : [ {
"value" : -1,
"hora" : 6
}, {
"value" : 0,
"hora" : 12
}, {
"value" : 1,
"hora" : 18
}, {
"value" : 1,
"hora" : 24
} ]
},
"sensTermica" : {
"maxima" : 1,
"minima" : -9,
"dato" : [ {
"value" : -1,
"hora" : 6
}, {
"value" : -4,
"hora" : 12
}, {
"value" : 1,
"hora" : 18
}, {
"value" : 1,
"hora" : 24
} ]
},
"humedadRelativa" : {
"maxima" : 100,
"minima" : 75,
"dato" : [ {
"value" : 100,
"hora" : 6
}, {
"value" : 100,
"hora" : 12
}, {
"value" : 95,
"hora" : 18
}, {
"value" : 75,
"hora" : 24
} ]
},
"uvMax" : 1,
"fecha" : "2021-01-09T00:00:00"
}, {
"probPrecipitacion" : [ {
"value" : 30,
"periodo" : "00-24"
}, {
"value" : 25,
"periodo" : "00-12"
}, {
"value" : 5,
"periodo" : "12-24"
}, {
"value" : 5,
"periodo" : "00-06"
}, {
"value" : 15,
"periodo" : "06-12"
}, {
"value" : 5,
"periodo" : "12-18"
}, {
"value" : 0,
"periodo" : "18-24"
} ],
"cotaNieveProv" : [ {
"value" : "600",
"periodo" : "00-24"
}, {
"value" : "600",
"periodo" : "00-12"
}, {
"value" : "",
"periodo" : "12-24"
}, {
"value" : "",
"periodo" : "00-06"
}, {
"value" : "600",
"periodo" : "06-12"
}, {
"value" : "",
"periodo" : "12-18"
}, {
"value" : "",
"periodo" : "18-24"
} ],
"estadoCielo" : [ {
"value" : "13",
"periodo" : "00-24",
"descripcion" : "Intervalos nubosos"
}, {
"value" : "15",
"periodo" : "00-12",
"descripcion" : "Muy nuboso"
}, {
"value" : "12",
"periodo" : "12-24",
"descripcion" : "Poco nuboso"
}, {
"value" : "14n",
"periodo" : "00-06",
"descripcion" : "Nuboso"
}, {
"value" : "15",
"periodo" : "06-12",
"descripcion" : "Muy nuboso"
}, {
"value" : "12",
"periodo" : "12-18",
"descripcion" : "Poco nuboso"
}, {
"value" : "12n",
"periodo" : "18-24",
"descripcion" : "Poco nuboso"
} ],
"viento" : [ {
"direccion" : "NE",
"velocidad" : 20,
"periodo" : "00-24"
}, {
"direccion" : "NE",
"velocidad" : 20,
"periodo" : "00-12"
}, {
"direccion" : "NE",
"velocidad" : 20,
"periodo" : "12-24"
}, {
"direccion" : "N",
"velocidad" : 10,
"periodo" : "00-06"
}, {
"direccion" : "NE",
"velocidad" : 20,
"periodo" : "06-12"
}, {
"direccion" : "NE",
"velocidad" : 15,
"periodo" : "12-18"
}, {
"direccion" : "NE",
"velocidad" : 20,
"periodo" : "18-24"
} ],
"rachaMax" : [ {
"value" : "30",
"periodo" : "00-24"
}, {
"value" : "30",
"periodo" : "00-12"
}, {
"value" : "30",
"periodo" : "12-24"
}, {
"value" : "",
"periodo" : "00-06"
}, {
"value" : "30",
"periodo" : "06-12"
}, {
"value" : "",
"periodo" : "12-18"
}, {
"value" : "",
"periodo" : "18-24"
} ],
"temperatura" : {
"maxima" : 4,
"minima" : -4,
"dato" : [ {
"value" : -1,
"hora" : 6
}, {
"value" : 3,
"hora" : 12
}, {
"value" : 1,
"hora" : 18
}, {
"value" : -1,
"hora" : 24
} ]
},
"sensTermica" : {
"maxima" : 1,
"minima" : -7,
"dato" : [ {
"value" : -4,
"hora" : 6
}, {
"value" : -2,
"hora" : 12
}, {
"value" : -4,
"hora" : 18
}, {
"value" : -6,
"hora" : 24
} ]
},
"humedadRelativa" : {
"maxima" : 100,
"minima" : 70,
"dato" : [ {
"value" : 90,
"hora" : 6
}, {
"value" : 75,
"hora" : 12
}, {
"value" : 80,
"hora" : 18
}, {
"value" : 80,
"hora" : 24
} ]
},
"uvMax" : 1,
"fecha" : "2021-01-10T00:00:00"
}, {
"probPrecipitacion" : [ {
"value" : 0,
"periodo" : "00-24"
}, {
"value" : 0,
"periodo" : "00-12"
}, {
"value" : 0,
"periodo" : "12-24"
} ],
"cotaNieveProv" : [ {
"value" : "",
"periodo" : "00-24"
}, {
"value" : "",
"periodo" : "00-12"
}, {
"value" : "",
"periodo" : "12-24"
} ],
"estadoCielo" : [ {
"value" : "12",
"periodo" : "00-24",
"descripcion" : "Poco nuboso"
}, {
"value" : "12",
"periodo" : "00-12",
"descripcion" : "Poco nuboso"
}, {
"value" : "12",
"periodo" : "12-24",
"descripcion" : "Poco nuboso"
} ],
"viento" : [ {
"direccion" : "N",
"velocidad" : 5,
"periodo" : "00-24"
}, {
"direccion" : "NE",
"velocidad" : 20,
"periodo" : "00-12"
}, {
"direccion" : "NO",
"velocidad" : 10,
"periodo" : "12-24"
} ],
"rachaMax" : [ {
"value" : "",
"periodo" : "00-24"
}, {
"value" : "",
"periodo" : "00-12"
}, {
"value" : "",
"periodo" : "12-24"
} ],
"temperatura" : {
"maxima" : 3,
"minima" : -7,
"dato" : [ ]
},
"sensTermica" : {
"maxima" : 3,
"minima" : -8,
"dato" : [ ]
},
"humedadRelativa" : {
"maxima" : 85,
"minima" : 60,
"dato" : [ ]
},
"uvMax" : 1,
"fecha" : "2021-01-11T00:00:00"
}, {
"probPrecipitacion" : [ {
"value" : 0,
"periodo" : "00-24"
}, {
"value" : 0,
"periodo" : "00-12"
}, {
"value" : 0,
"periodo" : "12-24"
} ],
"cotaNieveProv" : [ {
"value" : "",
"periodo" : "00-24"
}, {
"value" : "",
"periodo" : "00-12"
}, {
"value" : "",
"periodo" : "12-24"
} ],
"estadoCielo" : [ {
"value" : "12",
"periodo" : "00-24",
"descripcion" : "Poco nuboso"
}, {
"value" : "12",
"periodo" : "00-12",
"descripcion" : "Poco nuboso"
}, {
"value" : "12",
"periodo" : "12-24",
"descripcion" : "Poco nuboso"
} ],
"viento" : [ {
"direccion" : "C",
"velocidad" : 0,
"periodo" : "00-24"
}, {
"direccion" : "E",
"velocidad" : 5,
"periodo" : "00-12"
}, {
"direccion" : "C",
"velocidad" : 0,
"periodo" : "12-24"
} ],
"rachaMax" : [ {
"value" : "",
"periodo" : "00-24"
}, {
"value" : "",
"periodo" : "00-12"
}, {
"value" : "",
"periodo" : "12-24"
} ],
"temperatura" : {
"maxima" : -1,
"minima" : -13,
"dato" : [ ]
},
"sensTermica" : {
"maxima" : -1,
"minima" : -13,
"dato" : [ ]
},
"humedadRelativa" : {
"maxima" : 100,
"minima" : 65,
"dato" : [ ]
},
"uvMax" : 2,
"fecha" : "2021-01-12T00:00:00"
}, {
"probPrecipitacion" : [ {
"value" : 0
} ],
"cotaNieveProv" : [ {
"value" : ""
} ],
"estadoCielo" : [ {
"value" : "11",
"descripcion" : "Despejado"
} ],
"viento" : [ {
"direccion" : "C",
"velocidad" : 0
} ],
"rachaMax" : [ {
"value" : ""
} ],
"temperatura" : {
"maxima" : 6,
"minima" : -11,
"dato" : [ ]
},
"sensTermica" : {
"maxima" : 6,
"minima" : -11,
"dato" : [ ]
},
"humedadRelativa" : {
"maxima" : 100,
"minima" : 65,
"dato" : [ ]
},
"uvMax" : 2,
"fecha" : "2021-01-13T00:00:00"
}, {
"probPrecipitacion" : [ {
"value" : 0
} ],
"cotaNieveProv" : [ {
"value" : ""
} ],
"estadoCielo" : [ {
"value" : "12",
"descripcion" : "Poco nuboso"
} ],
"viento" : [ {
"direccion" : "C",
"velocidad" : 0
} ],
"rachaMax" : [ {
"value" : ""
} ],
"temperatura" : {
"maxima" : 6,
"minima" : -7,
"dato" : [ ]
},
"sensTermica" : {
"maxima" : 6,
"minima" : -7,
"dato" : [ ]
},
"humedadRelativa" : {
"maxima" : 100,
"minima" : 80,
"dato" : [ ]
},
"fecha" : "2021-01-14T00:00:00"
}, {
"probPrecipitacion" : [ {
"value" : 0
} ],
"cotaNieveProv" : [ {
"value" : ""
} ],
"estadoCielo" : [ {
"value" : "14",
"descripcion" : "Nuboso"
} ],
"viento" : [ {
"direccion" : "C",
"velocidad" : 0
} ],
"rachaMax" : [ {
"value" : ""
} ],
"temperatura" : {
"maxima" : 5,
"minima" : -4,
"dato" : [ ]
},
"sensTermica" : {
"maxima" : 5,
"minima" : -4,
"dato" : [ ]
},
"humedadRelativa" : {
"maxima" : 100,
"minima" : 55,
"dato" : [ ]
},
"fecha" : "2021-01-15T00:00:00"
} ]
},
"id" : 28065,
"version" : 1.0
} ]

View File

@ -0,0 +1,6 @@
{
"descripcion" : "exito",
"estado" : 200,
"datos" : "https://opendata.aemet.es/opendata/sh/64e29abb",
"metadatos" : "https://opendata.aemet.es/opendata/sh/dfd88b22"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
{
"descripcion" : "exito",
"estado" : 200,
"datos" : "https://opendata.aemet.es/opendata/sh/18ca1886",
"metadatos" : "https://opendata.aemet.es/opendata/sh/93a7c63d"
}

15
tests/fixtures/aemet/town-id28065.json vendored Normal file
View File

@ -0,0 +1,15 @@
[ {
"latitud" : "40<34>18'14.535144\"",
"id_old" : "28325",
"url" : "getafe-id28065",
"latitud_dec" : "40.30403754",
"altitud" : "622",
"capital" : "Getafe",
"num_hab" : "173057",
"zona_comarcal" : "722802",
"destacada" : "1",
"nombre" : "Getafe",
"longitud_dec" : "-3.72935236",
"id" : "id28065",
"longitud" : "-3<>43'45.668496\""
} ]

43
tests/fixtures/aemet/town-list.json vendored Normal file
View File

@ -0,0 +1,43 @@
[ {
"latitud" : "40<34>18'14.535144\"",
"id_old" : "28325",
"url" : "getafe-id28065",
"latitud_dec" : "40.30403754",
"altitud" : "622",
"capital" : "Getafe",
"num_hab" : "173057",
"zona_comarcal" : "722802",
"destacada" : "1",
"nombre" : "Getafe",
"longitud_dec" : "-3.72935236",
"id" : "id28065",
"longitud" : "-3<>43'45.668496\""
}, {
"latitud" : "40<34>19'54.277752\"",
"id_old" : "28370",
"url" : "leganes-id28074",
"latitud_dec" : "40.33174382",
"altitud" : "667",
"capital" : "Legan<61>s",
"num_hab" : "186696",
"zona_comarcal" : "722802",
"destacada" : "1",
"nombre" : "Legan<61>s",
"longitud_dec" : "-3.76655557",
"id" : "id28074",
"longitud" : "-3<>45'59.600052\""
}, {
"latitud" : "40<34>24'30.282876\"",
"id_old" : "28001",
"url" : "madrid-id28079",
"latitud_dec" : "40.40841191",
"altitud" : "657",
"capital" : "Madrid",
"num_hab" : "3165235",
"zona_comarcal" : "722802",
"destacada" : "1",
"nombre" : "Madrid",
"longitud_dec" : "-3.68760088",
"id" : "id28079",
"longitud" : "-3<>41'15.363168\""
} ]