diff --git a/.coveragerc b/.coveragerc index 375d7df4d0e..271e1f0c7ec 100644 --- a/.coveragerc +++ b/.coveragerc @@ -623,6 +623,9 @@ omit = homeassistant/components/openuv/sensor.py homeassistant/components/openweathermap/sensor.py homeassistant/components/openweathermap/weather.py + homeassistant/components/openweathermap/forecast_update_coordinator.py + homeassistant/components/openweathermap/weather_update_coordinator.py + homeassistant/components/openweathermap/abstract_owm_sensor.py homeassistant/components/opnsense/* homeassistant/components/opple/light.py homeassistant/components/orangepi_gpio/* diff --git a/CODEOWNERS b/CODEOWNERS index 0caa4be8671..e3b0f0462c3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -305,7 +305,7 @@ homeassistant/components/openerz/* @misialq homeassistant/components/opengarage/* @danielhiversen homeassistant/components/opentherm_gw/* @mvn23 homeassistant/components/openuv/* @bachya -homeassistant/components/openweathermap/* @fabaff +homeassistant/components/openweathermap/* @fabaff @freekode homeassistant/components/opnsense/* @mtreinish homeassistant/components/orangepi_gpio/* @pascallj homeassistant/components/oru/* @bvlaicu diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index 43cad1520ca..bdda75bae29 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -1 +1,128 @@ """The openweathermap component.""" +import asyncio +import logging + +from pyowm import OWM + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MODE, + CONF_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import ( + COMPONENTS, + CONF_LANGUAGE, + DOMAIN, + ENTRY_FORECAST_COORDINATOR, + ENTRY_NAME, + ENTRY_WEATHER_COORDINATOR, + UPDATE_LISTENER, +) +from .forecast_update_coordinator import ForecastUpdateCoordinator +from .weather_update_coordinator import WeatherUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up the OpenWeatherMap component.""" + hass.data.setdefault(DOMAIN, {}) + + weather_configs = _filter_domain_configs(config.get("weather", []), DOMAIN) + sensor_configs = _filter_domain_configs(config.get("sensor", []), DOMAIN) + + _import_configs(hass, weather_configs + sensor_configs) + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Set up OpenWeatherMap as config entry.""" + name = config_entry.data[CONF_NAME] + api_key = config_entry.data[CONF_API_KEY] + latitude = config_entry.data.get(CONF_LATITUDE, hass.config.latitude) + longitude = config_entry.data.get(CONF_LONGITUDE, hass.config.longitude) + forecast_mode = _get_config_value(config_entry, CONF_MODE) + language = _get_config_value(config_entry, CONF_LANGUAGE) + + owm = OWM(API_key=api_key, language=language) + weather_coordinator = WeatherUpdateCoordinator(owm, latitude, longitude, hass) + forecast_coordinator = ForecastUpdateCoordinator( + owm, latitude, longitude, forecast_mode, hass + ) + + await weather_coordinator.async_refresh() + await forecast_coordinator.async_refresh() + + if ( + not weather_coordinator.last_update_success + and not forecast_coordinator.last_update_success + ): + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config_entry.entry_id] = { + ENTRY_NAME: name, + ENTRY_WEATHER_COORDINATOR: weather_coordinator, + ENTRY_FORECAST_COORDINATOR: forecast_coordinator, + } + + for component in COMPONENTS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + update_listener = config_entry.add_update_listener(async_update_options) + hass.data[DOMAIN][config_entry.entry_id][UPDATE_LISTENER] = update_listener + + return True + + +async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry): + """Update options.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +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: + update_listener = hass.data[DOMAIN][config_entry.entry_id][UPDATE_LISTENER] + update_listener() + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +def _import_configs(hass, configs): + for config in configs: + _LOGGER.debug("Importing OpenWeatherMap %s", config) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + + +def _filter_domain_configs(elements, domain): + return list(filter(lambda elem: elem["platform"] == domain, elements)) + + +def _get_config_value(config_entry, key): + if config_entry.options: + return config_entry.options[key] + return config_entry.data[key] diff --git a/homeassistant/components/openweathermap/abstract_owm_sensor.py b/homeassistant/components/openweathermap/abstract_owm_sensor.py new file mode 100644 index 00000000000..7378324b0a4 --- /dev/null +++ b/homeassistant/components/openweathermap/abstract_owm_sensor.py @@ -0,0 +1,81 @@ +"""Abstraction form OWM sensors.""" +import logging + +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ATTRIBUTION, SENSOR_DEVICE_CLASS, SENSOR_NAME, SENSOR_UNIT + +_LOGGER = logging.getLogger(__name__) + + +class AbstractOpenWeatherMapSensor(Entity): + """Abstract class for an OpenWeatherMap sensor.""" + + def __init__( + self, + name, + unique_id, + sensor_type, + sensor_configuration, + coordinator: DataUpdateCoordinator, + ): + """Initialize the sensor.""" + 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) + self._coordinator = coordinator + + @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 should_poll(self): + """Return the polling requirement of the entity.""" + return False + + @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} + + @property + def available(self): + """Return True if entity is available.""" + return self._coordinator.last_update_success + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self._coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Get the latest data from OWM and updates the states.""" + await self._coordinator.async_request_refresh() diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py new file mode 100644 index 00000000000..365f55c5d44 --- /dev/null +++ b/homeassistant/components/openweathermap/config_flow.py @@ -0,0 +1,138 @@ +"""Config flow for OpenWeatherMap.""" +import logging + +from pyowm import OWM +from pyowm.exceptions.api_call_error import APICallError +from pyowm.exceptions.api_response_error import UnauthorizedError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MODE, + CONF_NAME, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_LANGUAGE, + DEFAULT_FORECAST_MODE, + DEFAULT_LANGUAGE, + DEFAULT_NAME, + FORECAST_MODES, + LANGUAGES, +) +from .const import DOMAIN # pylint:disable=unused-import + +SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_MODE, default=DEFAULT_FORECAST_MODE): vol.In(FORECAST_MODES), + vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(LANGUAGES), + } +) + +_LOGGER = logging.getLogger(__name__) + + +class OpenWeatherMapConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for OpenWeatherMap.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OpenWeatherMapOptionsFlow(config_entry) + + 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() + + try: + api_online = await _is_owm_api_online( + self.hass, user_input[CONF_API_KEY] + ) + if not api_online: + errors["base"] = "auth" + except UnauthorizedError: + errors["base"] = "auth" + except APICallError: + errors["base"] = "connection" + + if not errors: + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + return self.async_show_form(step_id="user", data_schema=SCHEMA, errors=errors) + + async def async_step_import(self, import_input=None): + """Set the config entry up from yaml.""" + config = import_input.copy() + if CONF_NAME not in config: + config[CONF_NAME] = DEFAULT_NAME + if CONF_LATITUDE not in config: + config[CONF_LATITUDE] = self.hass.config.latitude + if CONF_LONGITUDE not in config: + config[CONF_LONGITUDE] = self.hass.config.longitude + if CONF_MODE not in config: + config[CONF_MODE] = DEFAULT_LANGUAGE + if CONF_LANGUAGE not in config: + config[CONF_LANGUAGE] = DEFAULT_LANGUAGE + return await self.async_step_user(config) + + +class OpenWeatherMapOptionsFlow(config_entries.OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self._get_options_schema(), + ) + + def _get_options_schema(self): + return vol.Schema( + { + vol.Optional( + CONF_MODE, + default=self.config_entry.options.get( + CONF_MODE, DEFAULT_FORECAST_MODE + ), + ): vol.In(FORECAST_MODES), + vol.Optional( + CONF_LANGUAGE, + default=self.config_entry.options.get( + CONF_LANGUAGE, DEFAULT_LANGUAGE + ), + ): vol.In(LANGUAGES), + } + ) + + +async def _is_owm_api_online(hass, api_key): + owm = OWM(api_key) + return await hass.async_add_executor_job(owm.is_API_online) diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py new file mode 100644 index 00000000000..f2507527499 --- /dev/null +++ b/homeassistant/components/openweathermap/const.py @@ -0,0 +1,142 @@ +"""Consts for the OpenWeatherMap.""" +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + 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, + PRESSURE_PA, + SPEED_METERS_PER_SECOND, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) + +DOMAIN = "openweathermap" +DEFAULT_NAME = "OpenWeatherMap" +DEFAULT_LANGUAGE = "en" +DEFAULT_FORECAST_MODE = "freedaily" +ATTRIBUTION = "Data provided by OpenWeatherMap" +CONF_LANGUAGE = "language" +ENTRY_NAME = "name" +ENTRY_FORECAST_COORDINATOR = "forecast_coordinator" +ENTRY_WEATHER_COORDINATOR = "weather_coordinator" +ATTR_API_PRECIPITATION = "precipitation" +ATTR_API_DATETIME = "datetime" +ATTR_API_WEATHER = "weather" +ATTR_API_TEMPERATURE = "temperature" +ATTR_API_WIND_SPEED = "wind_speed" +ATTR_API_WIND_BEARING = "wind_bearing" +ATTR_API_HUMIDITY = "humidity" +ATTR_API_PRESSURE = "pressure" +ATTR_API_CONDITION = "condition" +ATTR_API_CLOUDS = "clouds" +ATTR_API_RAIN = "rain" +ATTR_API_SNOW = "snow" +ATTR_API_WEATHER_CODE = "weather_code" +ATTR_API_FORECAST = "forecast" +ATTR_API_THIS_DAY_FORECAST = "this_day_forecast" +SENSOR_NAME = "sensor_name" +SENSOR_UNIT = "sensor_unit" +SENSOR_DEVICE_CLASS = "sensor_device_class" +UPDATE_LISTENER = "update_listener" +COMPONENTS = ["sensor", "weather"] +FORECAST_MODES = ["hourly", "daily", "freedaily"] +MONITORED_CONDITIONS = [ + ATTR_API_WEATHER, + ATTR_API_TEMPERATURE, + ATTR_API_WIND_SPEED, + ATTR_API_WIND_BEARING, + ATTR_API_HUMIDITY, + ATTR_API_PRESSURE, + ATTR_API_CLOUDS, + ATTR_API_RAIN, + ATTR_API_SNOW, + ATTR_API_CONDITION, + ATTR_API_WEATHER_CODE, +] +FORECAST_MONITORED_CONDITIONS = [ + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, +] +LANGUAGES = ["en", "es", "ru", "it"] +CONDITION_CLASSES = { + "cloudy": [803, 804], + "fog": [701, 741], + "hail": [906], + "lightning": [210, 211, 212, 221], + "lightning-rainy": [200, 201, 202, 230, 231, 232], + "partlycloudy": [801, 802], + "pouring": [504, 314, 502, 503, 522], + "rainy": [300, 301, 302, 310, 311, 312, 313, 500, 501, 520, 521], + "snowy": [600, 601, 602, 611, 612, 620, 621, 622], + "snowy-rainy": [511, 615, 616], + "sunny": [800], + "windy": [905, 951, 952, 953, 954, 955, 956, 957], + "windy-variant": [958, 959, 960, 961], + "exceptional": [711, 721, 731, 751, 761, 762, 771, 900, 901, 962, 903, 904], +} +WEATHER_SENSOR_TYPES = { + ATTR_API_WEATHER: {SENSOR_NAME: "Weather"}, + ATTR_API_TEMPERATURE: { + SENSOR_NAME: "Temperature", + SENSOR_UNIT: TEMP_CELSIUS, + SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + }, + ATTR_API_WIND_SPEED: { + SENSOR_NAME: "Wind speed", + SENSOR_UNIT: SPEED_METERS_PER_SECOND, + }, + ATTR_API_WIND_BEARING: {SENSOR_NAME: "Wind bearing", SENSOR_UNIT: DEGREE}, + ATTR_API_HUMIDITY: { + SENSOR_NAME: "Humidity", + SENSOR_UNIT: UNIT_PERCENTAGE, + SENSOR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + }, + ATTR_API_PRESSURE: { + SENSOR_NAME: "Pressure", + SENSOR_UNIT: PRESSURE_PA, + SENSOR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + }, + ATTR_API_CLOUDS: {SENSOR_NAME: "Cloud coverage", SENSOR_UNIT: UNIT_PERCENTAGE}, + ATTR_API_RAIN: {SENSOR_NAME: "Rain", SENSOR_UNIT: "mm"}, + ATTR_API_SNOW: {SENSOR_NAME: "Snow", SENSOR_UNIT: "mm"}, + ATTR_API_CONDITION: {SENSOR_NAME: "Condition"}, + ATTR_API_WEATHER_CODE: {SENSOR_NAME: "Weather Code"}, +} +FORECAST_SENSOR_TYPES = { + ATTR_FORECAST_CONDITION: {SENSOR_NAME: "Condition"}, + ATTR_FORECAST_PRECIPITATION: {SENSOR_NAME: "Precipitation"}, + 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_API_WIND_BEARING: {SENSOR_NAME: "Wind bearing", SENSOR_UNIT: DEGREE}, + ATTR_API_WIND_SPEED: { + SENSOR_NAME: "Wind speed", + SENSOR_UNIT: SPEED_METERS_PER_SECOND, + }, +} diff --git a/homeassistant/components/openweathermap/forecast_update_coordinator.py b/homeassistant/components/openweathermap/forecast_update_coordinator.py new file mode 100644 index 00000000000..66fa7d39ab4 --- /dev/null +++ b/homeassistant/components/openweathermap/forecast_update_coordinator.py @@ -0,0 +1,137 @@ +"""Forecast data coordinator for the OpenWeatherMap (OWM) service.""" +from datetime import timedelta +import logging + +import async_timeout +from pyowm.exceptions.api_call_error import APICallError +from pyowm.exceptions.api_response_error import UnauthorizedError + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + 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 .const import ( + ATTR_API_FORECAST, + ATTR_API_THIS_DAY_FORECAST, + CONDITION_CLASSES, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +FORECAST_UPDATE_INTERVAL = timedelta(minutes=30) + + +class ForecastUpdateCoordinator(DataUpdateCoordinator): + """Forecast data update coordinator.""" + + def __init__(self, owm, latitude, longitude, forecast_mode, hass): + """Initialize coordinator.""" + self._owm_client = owm + self._forecast_mode = forecast_mode + self._latitude = latitude + self._longitude = longitude + self._forecast_limit = 15 + + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=FORECAST_UPDATE_INTERVAL + ) + + async def _async_update_data(self): + data = {} + with async_timeout.timeout(20): + try: + forecast_response = await self._get_owm_forecast() + data = self._convert_forecast_response(forecast_response) + except (APICallError, UnauthorizedError) as error: + raise UpdateFailed(error) from error + + return data + + async def _get_owm_forecast(self): + if self._forecast_mode == "daily": + forecast_response = await self.hass.async_add_executor_job( + self._owm_client.daily_forecast_at_coords, + self._latitude, + self._longitude, + self._forecast_limit, + ) + else: + forecast_response = await self.hass.async_add_executor_job( + self._owm_client.three_hours_forecast_at_coords, + self._latitude, + self._longitude, + ) + return forecast_response.get_forecast() + + def _convert_forecast_response(self, forecast_response): + weathers = self._get_weathers(forecast_response) + + forecast_entries = self._convert_forecast_entries(weathers) + + return { + ATTR_API_FORECAST: forecast_entries, + ATTR_API_THIS_DAY_FORECAST: forecast_entries[0], + } + + def _get_weathers(self, forecast_response): + if self._forecast_mode == "freedaily": + return forecast_response.get_weathers()[::8] + return forecast_response.get_weathers() + + def _convert_forecast_entries(self, entries): + if self._forecast_mode == "daily": + return list(map(self._convert_daily_forecast, entries)) + return list(map(self._convert_forecast, entries)) + + def _convert_daily_forecast(self, entry): + return { + ATTR_FORECAST_TIME: entry.get_reference_time("unix") * 1000, + ATTR_FORECAST_TEMP: entry.get_temperature("celsius").get("day"), + ATTR_FORECAST_TEMP_LOW: entry.get_temperature("celsius").get("night"), + ATTR_FORECAST_PRECIPITATION: self._calc_daily_precipitation( + entry.get_rain().get("all"), entry.get_snow().get("all") + ), + ATTR_FORECAST_WIND_SPEED: entry.get_wind().get("speed"), + ATTR_FORECAST_WIND_BEARING: entry.get_wind().get("deg"), + ATTR_FORECAST_CONDITION: self._get_condition(entry.get_weather_code()), + } + + def _convert_forecast(self, entry): + return { + ATTR_FORECAST_TIME: entry.get_reference_time("unix") * 1000, + ATTR_FORECAST_TEMP: entry.get_temperature("celsius").get("temp"), + ATTR_FORECAST_PRECIPITATION: self._calc_precipitation(entry), + ATTR_FORECAST_WIND_SPEED: entry.get_wind().get("speed"), + ATTR_FORECAST_WIND_BEARING: entry.get_wind().get("deg"), + ATTR_FORECAST_CONDITION: self._get_condition(entry.get_weather_code()), + } + + @staticmethod + def _calc_daily_precipitation(rain, snow): + """Calculate the precipitation.""" + rain_value = 0 if rain is None else rain + snow_value = 0 if snow is None else snow + if round(rain_value + snow_value, 1) == 0: + return None + return round(rain_value + snow_value, 1) + + @staticmethod + def _calc_precipitation(entry): + return ( + round(entry.get_rain().get("1h"), 1) + if entry.get_rain().get("1h") is not None + and (round(entry.get_rain().get("1h"), 1) > 0) + else None + ) + + @staticmethod + def _get_condition(weather_code): + return [k for k, v in CONDITION_CLASSES.items() if weather_code in v][0] diff --git a/homeassistant/components/openweathermap/manifest.json b/homeassistant/components/openweathermap/manifest.json index eafbfbe243c..dcd5d15f18d 100644 --- a/homeassistant/components/openweathermap/manifest.json +++ b/homeassistant/components/openweathermap/manifest.json @@ -1,7 +1,8 @@ { "domain": "openweathermap", - "name": "Openweathermap", + "name": "OpenWeatherMap", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openweathermap", "requirements": ["pyowm==2.10.0"], - "codeowners": ["@fabaff"] + "codeowners": ["@fabaff", "@freekode"] } diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 938fb609158..c6363107d45 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -1,227 +1,105 @@ """Support for the OpenWeatherMap (OWM) service.""" -from datetime import timedelta import logging -from pyowm import OWM -from pyowm.exceptions.api_call_error import APICallError -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_API_KEY, - CONF_MONITORED_CONDITIONS, - CONF_NAME, - DEGREE, - SPEED_METERS_PER_SECOND, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - UNIT_PERCENTAGE, +from .abstract_owm_sensor import AbstractOpenWeatherMapSensor +from .const import ( + ATTR_API_THIS_DAY_FORECAST, + DOMAIN, + ENTRY_FORECAST_COORDINATOR, + ENTRY_NAME, + ENTRY_WEATHER_COORDINATOR, + FORECAST_MONITORED_CONDITIONS, + FORECAST_SENSOR_TYPES, + MONITORED_CONDITIONS, + WEATHER_SENSOR_TYPES, ) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle +from .forecast_update_coordinator import ForecastUpdateCoordinator +from .weather_update_coordinator import WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by OpenWeatherMap" -CONF_FORECAST = "forecast" -CONF_LANGUAGE = "language" +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up OpenWeatherMap 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] + forecast_coordinator = domain_data[ENTRY_FORECAST_COORDINATOR] -DEFAULT_NAME = "OWM" + weather_sensor_types = WEATHER_SENSOR_TYPES + forecast_sensor_types = FORECAST_SENSOR_TYPES -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) - -SENSOR_TYPES = { - "weather": ["Condition", None], - "temperature": ["Temperature", None], - "wind_speed": ["Wind speed", SPEED_METERS_PER_SECOND], - "wind_bearing": ["Wind bearing", DEGREE], - "humidity": ["Humidity", UNIT_PERCENTAGE], - "pressure": ["Pressure", "mbar"], - "clouds": ["Cloud coverage", UNIT_PERCENTAGE], - "rain": ["Rain", "mm"], - "snow": ["Snow", "mm"], - "weather_code": ["Weather code", None], -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_FORECAST, default=False): cv.boolean, - vol.Optional(CONF_LANGUAGE): cv.string, - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the OpenWeatherMap sensor.""" - - if None in (hass.config.latitude, hass.config.longitude): - _LOGGER.error("Latitude or longitude not set in Home Assistant config") - return - - SENSOR_TYPES["temperature"][1] = hass.config.units.temperature_unit - - name = config.get(CONF_NAME) - forecast = config.get(CONF_FORECAST) - language = config.get(CONF_LANGUAGE) - if isinstance(language, str): - language = language.lower()[:2] - - owm = OWM(API_key=config.get(CONF_API_KEY), language=language) - - if not owm: - _LOGGER.error("Unable to connect to OpenWeatherMap") - return - - data = WeatherData(owm, forecast, hass.config.latitude, hass.config.longitude) - dev = [] - for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append( - OpenWeatherMapSensor(name, data, variable, SENSOR_TYPES[variable][1]) + entities = [] + for sensor_type in MONITORED_CONDITIONS: + unique_id = f"{config_entry.unique_id}-{sensor_type}" + entities.append( + OpenWeatherMapSensor( + name, + unique_id, + sensor_type, + weather_sensor_types[sensor_type], + weather_coordinator, + ) ) - if forecast: - SENSOR_TYPES["forecast"] = ["Forecast", None] - dev.append( - OpenWeatherMapSensor(name, data, "forecast", SENSOR_TYPES["temperature"][1]) + for sensor_type in FORECAST_MONITORED_CONDITIONS: + unique_id = f"{config_entry.unique_id}-forecast-{sensor_type}" + entities.append( + OpenWeatherMapForecastSensor( + f"{name} Forecast", + unique_id, + sensor_type, + forecast_sensor_types[sensor_type], + forecast_coordinator, + ) ) - add_entities(dev, True) + async_add_entities(entities) -class OpenWeatherMapSensor(Entity): +class OpenWeatherMapSensor(AbstractOpenWeatherMapSensor): """Implementation of an OpenWeatherMap sensor.""" - def __init__(self, name, weather_data, sensor_type, temp_unit): + def __init__( + self, + name, + unique_id, + sensor_type, + sensor_configuration, + weather_coordinator: WeatherUpdateCoordinator, + ): """Initialize the sensor.""" - self.client_name = name - self._name = SENSOR_TYPES[sensor_type][0] - self.owa_client = weather_data - self.temp_unit = temp_unit - self.type = sensor_type - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" + 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._state + return self._weather_coordinator.data.get(self._sensor_type, None) + + +class OpenWeatherMapForecastSensor(AbstractOpenWeatherMapSensor): + """Implementation of an OpenWeatherMap this day forecast sensor.""" + + def __init__( + self, + name, + unique_id, + sensor_type, + sensor_configuration, + forecast_coordinator: ForecastUpdateCoordinator, + ): + """Initialize the sensor.""" + super().__init__( + name, unique_id, sensor_type, sensor_configuration, forecast_coordinator + ) + self._forecast_coordinator = forecast_coordinator @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} - - def update(self): - """Get the latest data from OWM and updates the states.""" - try: - self.owa_client.update() - except APICallError: - _LOGGER.error("Error when calling API to update data") - return - - data = self.owa_client.data - fc_data = self.owa_client.fc_data - - if data is None: - return - - try: - if self.type == "weather": - self._state = data.get_detailed_status() - elif self.type == "temperature": - if self.temp_unit == TEMP_CELSIUS: - self._state = round(data.get_temperature("celsius")["temp"], 1) - elif self.temp_unit == TEMP_FAHRENHEIT: - self._state = round(data.get_temperature("fahrenheit")["temp"], 1) - else: - self._state = round(data.get_temperature()["temp"], 1) - elif self.type == "wind_speed": - self._state = round(data.get_wind()["speed"], 1) - elif self.type == "wind_bearing": - self._state = round(data.get_wind()["deg"], 1) - elif self.type == "humidity": - self._state = round(data.get_humidity(), 1) - elif self.type == "pressure": - self._state = round(data.get_pressure()["press"], 0) - elif self.type == "clouds": - self._state = data.get_clouds() - elif self.type == "rain": - rain = data.get_rain() - if "1h" in rain: - self._state = round(rain["1h"], 0) - self._unit_of_measurement = "mm" - else: - self._state = "not raining" - self._unit_of_measurement = "" - elif self.type == "snow": - snow = data.get_snow() - if "1h" in snow: - self._state = round(snow["1h"], 0) - self._unit_of_measurement = "mm" - else: - self._state = "not snowing" - self._unit_of_measurement = "" - elif self.type == "forecast": - if fc_data is None: - return - self._state = fc_data.get_weathers()[0].get_detailed_status() - elif self.type == "weather_code": - self._state = data.get_weather_code() - except KeyError: - self._state = None - _LOGGER.warning("Condition is currently not available: %s", self.type) - - -class WeatherData: - """Get the latest data from OpenWeatherMap.""" - - def __init__(self, owm, forecast, latitude, longitude): - """Initialize the data object.""" - self.owm = owm - self.forecast = forecast - self.latitude = latitude - self.longitude = longitude - self.data = None - self.fc_data = None - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from OpenWeatherMap.""" - try: - obs = self.owm.weather_at_coords(self.latitude, self.longitude) - except (APICallError, TypeError): - _LOGGER.error("Error when calling API to get weather at coordinates") - obs = None - - if obs is None: - _LOGGER.warning("Failed to fetch data") - return - - self.data = obs.get_weather() - - if self.forecast == 1: - try: - obs = self.owm.three_hours_forecast_at_coords( - self.latitude, self.longitude - ) - self.fc_data = obs.get_forecast() - except (ConnectionResetError, TypeError): - _LOGGER.warning("Failed to fetch forecast") + def state(self): + """Return the state of the device.""" + return self._forecast_coordinator.data[ATTR_API_THIS_DAY_FORECAST].get( + self._sensor_type, None + ) diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json new file mode 100644 index 00000000000..e068bb91964 --- /dev/null +++ b/homeassistant/components/openweathermap/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "OpenWeatherMap integration for these coordinates is already configured." + }, + "error": { + "auth": "API key is not correct.", + "connection": "Can't connect to OWM API" + }, + "step": { + "user": { + "data": { + "api_key": "OpenWeatherMap API key", + "language": "Language", + "latitude": "Latitude", + "longitude": "Longitude", + "mode": "Mode", + "name": "Name of the integration" + }, + "description": "Set up OpenWeatherMap integration. To generate API key go to https://openweathermap.org/appid", + "title": "OpenWeatherMap" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "language": "Language", + "mode": "Mode" + } + } + } + } +} diff --git a/homeassistant/components/openweathermap/translations/en.json b/homeassistant/components/openweathermap/translations/en.json new file mode 100644 index 00000000000..e068bb91964 --- /dev/null +++ b/homeassistant/components/openweathermap/translations/en.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "OpenWeatherMap integration for these coordinates is already configured." + }, + "error": { + "auth": "API key is not correct.", + "connection": "Can't connect to OWM API" + }, + "step": { + "user": { + "data": { + "api_key": "OpenWeatherMap API key", + "language": "Language", + "latitude": "Latitude", + "longitude": "Longitude", + "mode": "Mode", + "name": "Name of the integration" + }, + "description": "Set up OpenWeatherMap integration. To generate API key go to https://openweathermap.org/appid", + "title": "OpenWeatherMap" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "language": "Language", + "mode": "Mode" + } + } + } + } +} diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 7200e759d3b..cde7eb96732 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -1,131 +1,89 @@ """Support for the OpenWeatherMap (OWM) service.""" -from datetime import timedelta import logging -from pyowm import OWM -from pyowm.exceptions.api_call_error import APICallError -import voluptuous as vol +from homeassistant.components.weather import WeatherEntity +from homeassistant.const import TEMP_CELSIUS -from homeassistant.components.weather import ( - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, - PLATFORM_SCHEMA, - WeatherEntity, +from .const import ( + ATTR_API_CONDITION, + ATTR_API_FORECAST, + ATTR_API_HUMIDITY, + ATTR_API_PRESSURE, + ATTR_API_TEMPERATURE, + ATTR_API_WIND_BEARING, + ATTR_API_WIND_SPEED, + ATTRIBUTION, + DOMAIN, + ENTRY_FORECAST_COORDINATOR, + ENTRY_NAME, + ENTRY_WEATHER_COORDINATOR, ) -from homeassistant.const import ( - CONF_API_KEY, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_MODE, - CONF_NAME, - PRESSURE_HPA, - PRESSURE_INHG, - STATE_UNKNOWN, - TEMP_CELSIUS, -) -import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle -from homeassistant.util.pressure import convert as convert_pressure +from .forecast_update_coordinator import ForecastUpdateCoordinator +from .weather_update_coordinator import WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by OpenWeatherMap" -FORECAST_MODE = ["hourly", "daily", "freedaily"] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up OpenWeatherMap weather entity 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] + forecast_coordinator = domain_data[ENTRY_FORECAST_COORDINATOR] -DEFAULT_NAME = "OpenWeatherMap" - -MIN_TIME_BETWEEN_FORECAST_UPDATES = timedelta(minutes=30) -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) - -CONDITION_CLASSES = { - "cloudy": [803, 804], - "fog": [701, 741], - "hail": [906], - "lightning": [210, 211, 212, 221], - "lightning-rainy": [200, 201, 202, 230, 231, 232], - "partlycloudy": [801, 802], - "pouring": [504, 314, 502, 503, 522], - "rainy": [300, 301, 302, 310, 311, 312, 313, 500, 501, 520, 521], - "snowy": [600, 601, 602, 611, 612, 620, 621, 622], - "snowy-rainy": [511, 615, 616], - "sunny": [800], - "windy": [905, 951, 952, 953, 954, 955, 956, 957], - "windy-variant": [958, 959, 960, 961], - "exceptional": [711, 721, 731, 751, 761, 762, 771, 900, 901, 962, 903, 904], -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_MODE, default="hourly"): vol.In(FORECAST_MODE), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the OpenWeatherMap weather platform.""" - - longitude = config.get(CONF_LONGITUDE, round(hass.config.longitude, 5)) - latitude = config.get(CONF_LATITUDE, round(hass.config.latitude, 5)) - name = config.get(CONF_NAME) - mode = config.get(CONF_MODE) - - try: - owm = OWM(config.get(CONF_API_KEY)) - except APICallError: - _LOGGER.error("Error while connecting to OpenWeatherMap") - return False - - data = WeatherData(owm, latitude, longitude, mode) - - add_entities( - [OpenWeatherMapWeather(name, data, hass.config.units.temperature_unit, mode)], - True, + unique_id = f"{config_entry.unique_id}" + owm_weather = OpenWeatherMapWeather( + name, unique_id, weather_coordinator, forecast_coordinator ) + async_add_entities([owm_weather], False) + class OpenWeatherMapWeather(WeatherEntity): """Implementation of an OpenWeatherMap sensor.""" - def __init__(self, name, owm, temperature_unit, mode): + def __init__( + self, + name, + unique_id, + weather_coordinator: WeatherUpdateCoordinator, + forecast_coordinator: ForecastUpdateCoordinator, + ): """Initialize the sensor.""" self._name = name - self._owm = owm - self._temperature_unit = temperature_unit - self._mode = mode - self.data = None - self.forecast_data = None + self._unique_id = unique_id + self._weather_coordinator = weather_coordinator + self._forecast_coordinator = forecast_coordinator @property def name(self): """Return the name of the sensor.""" return self._name + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self._unique_id + + @property + def should_poll(self): + """Return the polling requirement of the entity.""" + return False + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + @property def condition(self): """Return the current condition.""" - try: - return [ - k - for k, v in CONDITION_CLASSES.items() - if self.data.get_weather_code() in v - ][0] - except IndexError: - return STATE_UNKNOWN + return self._weather_coordinator.data[ATTR_API_CONDITION] @property def temperature(self): """Return the temperature.""" - return self.data.get_temperature("celsius").get("temp") + return self._weather_coordinator.data[ATTR_API_TEMPERATURE] @property def temperature_unit(self): @@ -135,146 +93,49 @@ class OpenWeatherMapWeather(WeatherEntity): @property def pressure(self): """Return the pressure.""" - pressure = self.data.get_pressure().get("press") - if self.hass.config.units.name == "imperial": - return round(convert_pressure(pressure, PRESSURE_HPA, PRESSURE_INHG), 2) - return pressure + return self._weather_coordinator.data[ATTR_API_PRESSURE] @property def humidity(self): """Return the humidity.""" - return self.data.get_humidity() + return self._weather_coordinator.data[ATTR_API_HUMIDITY] @property def wind_speed(self): """Return the wind speed.""" + wind_speed = self._weather_coordinator.data[ATTR_API_WIND_SPEED] if self.hass.config.units.name == "imperial": - return round(self.data.get_wind().get("speed") * 2.24, 2) - - return round(self.data.get_wind().get("speed") * 3.6, 2) + return round(wind_speed * 2.24, 2) + return round(wind_speed * 3.6, 2) @property def wind_bearing(self): """Return the wind bearing.""" - return self.data.get_wind().get("deg") - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION + return self._weather_coordinator.data[ATTR_API_WIND_BEARING] @property def forecast(self): """Return the forecast array.""" - data = [] + return self._forecast_coordinator.data[ATTR_API_FORECAST] - def calc_precipitation(rain, snow): - """Calculate the precipitation.""" - rain_value = 0 if rain is None else rain - snow_value = 0 if snow is None else snow - if round(rain_value + snow_value, 1) == 0: - return None - return round(rain_value + snow_value, 1) + @property + def available(self): + """Return True if entity is available.""" + return ( + self._weather_coordinator.last_update_success + and self._forecast_coordinator.last_update_success + ) - if self._mode == "freedaily": - weather = self.forecast_data.get_weathers()[::8] - else: - weather = self.forecast_data.get_weathers() + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self._weather_coordinator.async_add_listener(self.async_write_ha_state) + ) + self.async_on_remove( + self._forecast_coordinator.async_add_listener(self.async_write_ha_state) + ) - for entry in weather: - if self._mode == "daily": - data.append( - { - ATTR_FORECAST_TIME: entry.get_reference_time("unix") * 1000, - ATTR_FORECAST_TEMP: entry.get_temperature("celsius").get("day"), - ATTR_FORECAST_TEMP_LOW: entry.get_temperature("celsius").get( - "night" - ), - ATTR_FORECAST_PRECIPITATION: calc_precipitation( - entry.get_rain().get("all"), entry.get_snow().get("all") - ), - ATTR_FORECAST_WIND_SPEED: entry.get_wind().get("speed"), - ATTR_FORECAST_WIND_BEARING: entry.get_wind().get("deg"), - ATTR_FORECAST_CONDITION: [ - k - for k, v in CONDITION_CLASSES.items() - if entry.get_weather_code() in v - ][0], - } - ) - else: - rain = entry.get_rain().get("1h") - if rain is not None: - rain = round(rain, 1) - data.append( - { - ATTR_FORECAST_TIME: entry.get_reference_time("unix") * 1000, - ATTR_FORECAST_TEMP: entry.get_temperature("celsius").get( - "temp" - ), - ATTR_FORECAST_PRECIPITATION: rain, - ATTR_FORECAST_CONDITION: [ - k - for k, v in CONDITION_CLASSES.items() - if entry.get_weather_code() in v - ][0], - } - ) - return data - - def update(self): + async def async_update(self): """Get the latest data from OWM and updates the states.""" - try: - self._owm.update() - self._owm.update_forecast() - except APICallError: - _LOGGER.error("Exception when calling OWM web API to update data") - return - - self.data = self._owm.data - self.forecast_data = self._owm.forecast_data - - -class WeatherData: - """Get the latest data from OpenWeatherMap.""" - - def __init__(self, owm, latitude, longitude, mode): - """Initialize the data object.""" - self._mode = mode - self.owm = owm - self.latitude = latitude - self.longitude = longitude - self.data = None - self.forecast_data = None - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from OpenWeatherMap.""" - obs = self.owm.weather_at_coords(self.latitude, self.longitude) - if obs is None: - _LOGGER.warning("Failed to fetch data from OWM") - return - - self.data = obs.get_weather() - - @Throttle(MIN_TIME_BETWEEN_FORECAST_UPDATES) - def update_forecast(self): - """Get the latest forecast from OpenWeatherMap.""" - try: - if self._mode == "daily": - fcd = self.owm.daily_forecast_at_coords( - self.latitude, self.longitude, 15 - ) - else: - fcd = self.owm.three_hours_forecast_at_coords( - self.latitude, self.longitude - ) - except APICallError: - _LOGGER.error("Exception when calling OWM web API to update forecast") - return - - if fcd is None: - _LOGGER.warning("Failed to fetch forecast data from OWM") - return - - self.forecast_data = fcd.get_forecast() + await self._weather_coordinator.async_request_refresh() + await self._forecast_coordinator.async_request_refresh() diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py new file mode 100644 index 00000000000..3c042ae1c80 --- /dev/null +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -0,0 +1,94 @@ +"""Weather data coordinator for the OpenWeatherMap (OWM) service.""" +from datetime import timedelta +import logging + +import async_timeout +from pyowm.exceptions.api_call_error import APICallError +from pyowm.exceptions.api_response_error import UnauthorizedError + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + ATTR_API_CLOUDS, + ATTR_API_CONDITION, + ATTR_API_HUMIDITY, + ATTR_API_PRESSURE, + ATTR_API_RAIN, + ATTR_API_SNOW, + ATTR_API_TEMPERATURE, + ATTR_API_WEATHER, + ATTR_API_WEATHER_CODE, + ATTR_API_WIND_BEARING, + ATTR_API_WIND_SPEED, + CONDITION_CLASSES, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) + + +class WeatherUpdateCoordinator(DataUpdateCoordinator): + """Weather data update coordinator.""" + + def __init__(self, owm, latitude, longitude, hass): + """Initialize coordinator.""" + self._owm_client = owm + self._latitude = latitude + self._longitude = longitude + + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL + ) + + async def _async_update_data(self): + data = {} + with async_timeout.timeout(20): + try: + weather_response = await self._get_owm_weather() + data = self._convert_weather_response(weather_response) + except (APICallError, UnauthorizedError) as error: + raise UpdateFailed(error) from error + return data + + async def _get_owm_weather(self): + weather = await self.hass.async_add_executor_job( + self._owm_client.weather_at_coords, self._latitude, self._longitude + ) + return weather.get_weather() + + def _convert_weather_response(self, weather_response): + return { + ATTR_API_TEMPERATURE: weather_response.get_temperature("celsius").get( + "temp" + ), + ATTR_API_PRESSURE: weather_response.get_pressure().get("press"), + ATTR_API_HUMIDITY: weather_response.get_humidity(), + ATTR_API_WIND_BEARING: weather_response.get_wind().get("deg"), + ATTR_API_WIND_SPEED: weather_response.get_wind().get("speed"), + ATTR_API_CLOUDS: weather_response.get_clouds(), + ATTR_API_RAIN: self._get_rain(weather_response.get_rain()), + ATTR_API_SNOW: self._get_snow(weather_response.get_snow()), + ATTR_API_WEATHER: weather_response.get_detailed_status(), + ATTR_API_CONDITION: self._get_condition( + weather_response.get_weather_code() + ), + ATTR_API_WEATHER_CODE: weather_response.get_weather_code(), + } + + @staticmethod + def _get_rain(rain): + if "1h" in rain: + return round(rain["1h"], 0) + return "not raining" + + @staticmethod + def _get_snow(snow): + if snow: + return round(snow, 0) + return "not snowing" + + @staticmethod + def _get_condition(weather_code): + return [k for k, v in CONDITION_CLASSES.items() if weather_code in v][0] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 05ce927c773..86d778db825 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -128,6 +128,7 @@ FLOWS = [ "onvif", "opentherm_gw", "openuv", + "openweathermap", "ovo_energy", "owntracks", "ozw", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ff64f2253e0..8d03da1e001 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -756,6 +756,9 @@ pyotgw==0.6b1 # homeassistant.components.otp pyotp==2.3.0 +# homeassistant.components.openweathermap +pyowm==2.10.0 + # homeassistant.components.point pypoint==1.1.2 diff --git a/tests/components/openweathermap/__init__.py b/tests/components/openweathermap/__init__.py new file mode 100644 index 00000000000..e718962766f --- /dev/null +++ b/tests/components/openweathermap/__init__.py @@ -0,0 +1 @@ +"""Tests for the OpenWeatherMap integration.""" diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py new file mode 100644 index 00000000000..672e7358803 --- /dev/null +++ b/tests/components/openweathermap/test_config_flow.py @@ -0,0 +1,232 @@ +"""Define tests for the OpenWeatherMap config flow.""" +from asynctest import MagicMock, patch +from pyowm.exceptions.api_call_error import APICallError +from pyowm.exceptions.api_response_error import UnauthorizedError + +from homeassistant import data_entry_flow +from homeassistant.components.openweathermap.const import ( + CONF_LANGUAGE, + DEFAULT_FORECAST_MODE, + DEFAULT_LANGUAGE, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MODE, + CONF_NAME, +) + +from tests.common import MockConfigEntry + +CONFIG = { + CONF_NAME: "openweathermap", + CONF_API_KEY: "foo", + CONF_LATITUDE: 50, + CONF_LONGITUDE: 40, + CONF_MODE: DEFAULT_FORECAST_MODE, + CONF_LANGUAGE: DEFAULT_LANGUAGE, +} + +VALID_YAML_CONFIG = {CONF_API_KEY: "foo"} + + +async def test_form(hass): + """Test that the form is served with valid input.""" + mocked_owm = _create_mocked_owm(True) + + with patch( + "pyowm.weatherapi25.owm25.OWM25", + return_value=mocked_owm, + ): + 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_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + await hass.async_block_till_done() + + conf_entries = hass.config_entries.async_entries(DOMAIN) + entry = conf_entries[0] + assert entry.state == "loaded" + + await hass.config_entries.async_unload(conf_entries[0].entry_id) + await hass.async_block_till_done() + assert entry.state == "not_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] + + +async def test_form_import(hass): + """Test we can import yaml config.""" + mocked_owm = _create_mocked_owm(True) + + with patch("pyowm.weatherapi25.owm25.OWM25", return_value=mocked_owm), patch( + "homeassistant.components.openweathermap.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.openweathermap.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=VALID_YAML_CONFIG.copy(), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_LATITUDE] == hass.config.latitude + assert result["data"][CONF_LONGITUDE] == hass.config.longitude + assert result["data"][CONF_API_KEY] == VALID_YAML_CONFIG[CONF_API_KEY] + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_options(hass): + """Test that the options form.""" + mocked_owm = _create_mocked_owm(True) + + with patch( + "pyowm.weatherapi25.owm25.OWM25", + return_value=mocked_owm, + ): + config_entry = MockConfigEntry( + domain=DOMAIN, unique_id="openweathermap_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 == "loaded" + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_MODE: "daily"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + CONF_MODE: "daily", + CONF_LANGUAGE: DEFAULT_LANGUAGE, + } + + await hass.async_block_till_done() + + assert config_entry.state == "loaded" + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_MODE: "freedaily"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + CONF_MODE: "freedaily", + CONF_LANGUAGE: DEFAULT_LANGUAGE, + } + + await hass.async_block_till_done() + + assert config_entry.state == "loaded" + + +async def test_form_invalid_api_key(hass): + """Test that the form is served with no input.""" + mocked_owm = _create_mocked_owm(True) + + with patch( + "pyowm.weatherapi25.owm25.OWM25", + return_value=mocked_owm, + side_effect=UnauthorizedError(""), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["errors"] == {"base": "auth"} + + +async def test_form_api_call_error(hass): + """Test setting up with api call error.""" + mocked_owm = _create_mocked_owm(True) + + with patch( + "pyowm.weatherapi25.owm25.OWM25", + return_value=mocked_owm, + side_effect=APICallError(""), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["errors"] == {"base": "connection"} + + +async def test_form_api_offline(hass): + """Test setting up with api call error.""" + mocked_owm = _create_mocked_owm(False) + + with patch( + "homeassistant.components.openweathermap.config_flow.OWM", + return_value=mocked_owm, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["errors"] == {"base": "auth"} + + +def _create_mocked_owm(is_api_online: bool): + mocked_owm = MagicMock() + mocked_owm.is_API_online.return_value = is_api_online + + weather = MagicMock() + weather.get_temperature.return_value.get.return_value = 10 + weather.get_pressure.return_value.get.return_value = 10 + weather.get_humidity.return_value = 10 + weather.get_wind.return_value.get.return_value = 0 + weather.get_clouds.return_value = "clouds" + weather.get_rain.return_value = [] + weather.get_snow.return_value = 3 + weather.get_detailed_status.return_value = "status" + weather.get_weather_code.return_value = 803 + + mocked_owm.weather_at_coords.return_value.get_weather.return_value = weather + + one_day_forecast = MagicMock() + one_day_forecast.get_reference_time.return_value = 10 + one_day_forecast.get_temperature.return_value.get.return_value = 10 + one_day_forecast.get_rain.return_value.get.return_value = 0 + one_day_forecast.get_snow.return_value.get.return_value = 0 + one_day_forecast.get_wind.return_value.get.return_value = 0 + one_day_forecast.get_weather_code.return_value = 803 + + mocked_owm.three_hours_forecast_at_coords.return_value.get_forecast.return_value.get_weathers.return_value = [ + one_day_forecast + ] + + return mocked_owm