diff --git a/.coveragerc b/.coveragerc index b67fb376a9f..538adf8531e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -632,7 +632,6 @@ 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/* diff --git a/CODEOWNERS b/CODEOWNERS index 1c94eb3f542..828bedb01b9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -318,7 +318,7 @@ homeassistant/components/openerz/* @misialq homeassistant/components/opengarage/* @danielhiversen homeassistant/components/opentherm_gw/* @mvn23 homeassistant/components/openuv/* @bachya -homeassistant/components/openweathermap/* @fabaff @freekode +homeassistant/components/openweathermap/* @fabaff @freekode @nzapponi 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 bdda75bae29..4754c4b2eff 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -3,8 +3,9 @@ import asyncio import logging from pyowm import OWM +from pyowm.utils.config import get_default_config -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -18,13 +19,14 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import ( COMPONENTS, CONF_LANGUAGE, + CONFIG_FLOW_VERSION, DOMAIN, - ENTRY_FORECAST_COORDINATOR, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, + FORECAST_MODE_FREE_DAILY, + FORECAST_MODE_ONECALL_DAILY, UPDATE_LISTENER, ) -from .forecast_update_coordinator import ForecastUpdateCoordinator from .weather_update_coordinator import WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -33,11 +35,6 @@ _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 @@ -50,26 +47,22 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): 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( + config_dict = _get_owm_config(language) + + owm = OWM(api_key, config_dict).weather_manager() + weather_coordinator = WeatherUpdateCoordinator( 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 - ): + if not weather_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: @@ -83,6 +76,28 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): return True +async def async_migrate_entry(hass, entry): + """Migrate old entry.""" + config_entries = hass.config_entries + data = entry.data + version = entry.version + + _LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version) + + if version == 1: + mode = data[CONF_MODE] + if mode == FORECAST_MODE_FREE_DAILY: + mode = FORECAST_MODE_ONECALL_DAILY + + new_data = {**data, CONF_MODE: mode} + version = entry.version = CONFIG_FLOW_VERSION + config_entries.async_update_entry(entry, data=new_data) + + _LOGGER.info("Migration to version %s successful", version) + + return True + + async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry): """Update options.""" await hass.config_entries.async_reload(config_entry.entry_id) @@ -106,18 +121,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): 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)) @@ -126,3 +129,10 @@ def _get_config_value(config_entry, key): if config_entry.options: return config_entry.options[key] return config_entry.data[key] + + +def _get_owm_config(language): + """Get OpenWeatherMap configuration and add language to it.""" + config_dict = get_default_config() + config_dict["language"] = language + return config_dict diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index f383a5cb123..2c2070141d5 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -1,7 +1,6 @@ """Config flow for OpenWeatherMap.""" from pyowm import OWM -from pyowm.exceptions.api_call_error import APICallError -from pyowm.exceptions.api_response_error import UnauthorizedError +from pyowm.commons.exceptions import APIRequestError, UnauthorizedError import voluptuous as vol from homeassistant import config_entries @@ -17,6 +16,7 @@ import homeassistant.helpers.config_validation as cv from .const import ( CONF_LANGUAGE, + CONFIG_FLOW_VERSION, DEFAULT_FORECAST_MODE, DEFAULT_LANGUAGE, DEFAULT_NAME, @@ -25,22 +25,11 @@ from .const import ( ) 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), - } -) - class OpenWeatherMapConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for OpenWeatherMap.""" - VERSION = 1 + VERSION = CONFIG_FLOW_VERSION CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL @staticmethod @@ -62,35 +51,40 @@ class OpenWeatherMapConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: api_online = await _is_owm_api_online( - self.hass, user_input[CONF_API_KEY] + self.hass, user_input[CONF_API_KEY], latitude, longitude ) if not api_online: errors["base"] = "invalid_api_key" except UnauthorizedError: errors["base"] = "invalid_api_key" - except APICallError: + except APIRequestError: errors["base"] = "cannot_connect" 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_FORECAST_MODE - if CONF_LANGUAGE not in config: - config[CONF_LANGUAGE] = DEFAULT_LANGUAGE - return await self.async_step_user(config) + 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, + vol.Optional(CONF_MODE, default=DEFAULT_FORECAST_MODE): vol.In( + FORECAST_MODES + ), + vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In( + LANGUAGES + ), + } + ) + + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) class OpenWeatherMapOptionsFlow(config_entries.OptionsFlow): @@ -129,6 +123,6 @@ class OpenWeatherMapOptionsFlow(config_entries.OptionsFlow): ) -async def _is_owm_api_online(hass, api_key): - owm = OWM(api_key) - return await hass.async_add_executor_job(owm.is_API_online) +async def _is_owm_api_online(hass, api_key, lat, lon): + owm = OWM(api_key).weather_manager() + return await hass.async_add_executor_job(owm.one_call, lat, lon) diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 03ed97d4075..e5451322ea6 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -24,11 +24,10 @@ from homeassistant.const import ( DOMAIN = "openweathermap" DEFAULT_NAME = "OpenWeatherMap" DEFAULT_LANGUAGE = "en" -DEFAULT_FORECAST_MODE = "freedaily" ATTRIBUTION = "Data provided by OpenWeatherMap" CONF_LANGUAGE = "language" +CONFIG_FLOW_VERSION = 2 ENTRY_NAME = "name" -ENTRY_FORECAST_COORDINATOR = "forecast_coordinator" ENTRY_WEATHER_COORDINATOR = "weather_coordinator" ATTR_API_PRECIPITATION = "precipitation" ATTR_API_DATETIME = "datetime" @@ -44,13 +43,25 @@ 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"] + +FORECAST_MODE_HOURLY = "hourly" +FORECAST_MODE_DAILY = "daily" +FORECAST_MODE_FREE_DAILY = "freedaily" +FORECAST_MODE_ONECALL_HOURLY = "onecall_hourly" +FORECAST_MODE_ONECALL_DAILY = "onecall_daily" +FORECAST_MODES = [ + FORECAST_MODE_HOURLY, + FORECAST_MODE_DAILY, + FORECAST_MODE_ONECALL_HOURLY, + FORECAST_MODE_ONECALL_DAILY, +] +DEFAULT_FORECAST_MODE = FORECAST_MODE_ONECALL_DAILY + MONITORED_CONDITIONS = [ ATTR_API_WEATHER, ATTR_API_TEMPERATURE, diff --git a/homeassistant/components/openweathermap/forecast_update_coordinator.py b/homeassistant/components/openweathermap/forecast_update_coordinator.py deleted file mode 100644 index 66fa7d39ab4..00000000000 --- a/homeassistant/components/openweathermap/forecast_update_coordinator.py +++ /dev/null @@ -1,137 +0,0 @@ -"""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 dcd5d15f18d..4ebdaf44c9e 100644 --- a/homeassistant/components/openweathermap/manifest.json +++ b/homeassistant/components/openweathermap/manifest.json @@ -3,6 +3,6 @@ "name": "OpenWeatherMap", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openweathermap", - "requirements": ["pyowm==2.10.0"], - "codeowners": ["@fabaff", "@freekode"] + "requirements": ["pyowm==3.1.0"], + "codeowners": ["@fabaff", "@freekode", "@nzapponi"] } diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 3879d270b52..39c50c3b941 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -1,9 +1,8 @@ """Support for the OpenWeatherMap (OWM) service.""" from .abstract_owm_sensor import AbstractOpenWeatherMapSensor from .const import ( - ATTR_API_THIS_DAY_FORECAST, + ATTR_API_FORECAST, DOMAIN, - ENTRY_FORECAST_COORDINATOR, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, FORECAST_MONITORED_CONDITIONS, @@ -11,7 +10,6 @@ from .const import ( MONITORED_CONDITIONS, WEATHER_SENSOR_TYPES, ) -from .forecast_update_coordinator import ForecastUpdateCoordinator from .weather_update_coordinator import WeatherUpdateCoordinator @@ -20,7 +18,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): 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] weather_sensor_types = WEATHER_SENSOR_TYPES forecast_sensor_types = FORECAST_SENSOR_TYPES @@ -46,7 +43,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): unique_id, sensor_type, forecast_sensor_types[sensor_type], - forecast_coordinator, + weather_coordinator, ) ) @@ -85,17 +82,18 @@ class OpenWeatherMapForecastSensor(AbstractOpenWeatherMapSensor): unique_id, sensor_type, sensor_configuration, - forecast_coordinator: ForecastUpdateCoordinator, + weather_coordinator: WeatherUpdateCoordinator, ): """Initialize the sensor.""" super().__init__( - name, unique_id, sensor_type, sensor_configuration, forecast_coordinator + name, unique_id, sensor_type, sensor_configuration, weather_coordinator ) - self._forecast_coordinator = forecast_coordinator + self._weather_coordinator = weather_coordinator @property def state(self): """Return the state of the device.""" - return self._forecast_coordinator.data[ATTR_API_THIS_DAY_FORECAST].get( - self._sensor_type, None - ) + forecasts = self._weather_coordinator.data.get(ATTR_API_FORECAST) + if forecasts is not None and len(forecasts) > 0: + return forecasts[0].get(self._sensor_type, None) + return None diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index e1ec96e5d07..7908beb61d6 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -12,11 +12,9 @@ from .const import ( ATTR_API_WIND_SPEED, ATTRIBUTION, DOMAIN, - ENTRY_FORECAST_COORDINATOR, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, ) -from .forecast_update_coordinator import ForecastUpdateCoordinator from .weather_update_coordinator import WeatherUpdateCoordinator @@ -25,12 +23,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): 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] unique_id = f"{config_entry.unique_id}" - owm_weather = OpenWeatherMapWeather( - name, unique_id, weather_coordinator, forecast_coordinator - ) + owm_weather = OpenWeatherMapWeather(name, unique_id, weather_coordinator) async_add_entities([owm_weather], False) @@ -43,13 +38,11 @@ class OpenWeatherMapWeather(WeatherEntity): name, unique_id, weather_coordinator: WeatherUpdateCoordinator, - forecast_coordinator: ForecastUpdateCoordinator, ): """Initialize the sensor.""" self._name = name self._unique_id = unique_id self._weather_coordinator = weather_coordinator - self._forecast_coordinator = forecast_coordinator @property def name(self): @@ -112,26 +105,19 @@ class OpenWeatherMapWeather(WeatherEntity): @property def forecast(self): """Return the forecast array.""" - return self._forecast_coordinator.data[ATTR_API_FORECAST] + return self._weather_coordinator.data[ATTR_API_FORECAST] @property def available(self): """Return True if entity is available.""" - return ( - self._weather_coordinator.last_update_success - and self._forecast_coordinator.last_update_success - ) + return self._weather_coordinator.last_update_success 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) - ) async def async_update(self): """Get the latest data from OWM and updates the states.""" 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 index 3c042ae1c80..40dddc2e90d 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -3,14 +3,23 @@ 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 pyowm.commons.exceptions import APIRequestError, 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_CLOUDS, ATTR_API_CONDITION, + ATTR_API_FORECAST, ATTR_API_HUMIDITY, ATTR_API_PRESSURE, ATTR_API_RAIN, @@ -22,6 +31,10 @@ from .const import ( ATTR_API_WIND_SPEED, CONDITION_CLASSES, DOMAIN, + FORECAST_MODE_DAILY, + FORECAST_MODE_HOURLY, + FORECAST_MODE_ONECALL_DAILY, + FORECAST_MODE_ONECALL_HOURLY, ) _LOGGER = logging.getLogger(__name__) @@ -32,11 +45,15 @@ WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) class WeatherUpdateCoordinator(DataUpdateCoordinator): """Weather data update coordinator.""" - def __init__(self, owm, latitude, longitude, hass): + def __init__(self, owm, latitude, longitude, forecast_mode, hass): """Initialize coordinator.""" self._owm_client = owm self._latitude = latitude self._longitude = longitude + self._forecast_mode = forecast_mode + self._forecast_limit = None + if forecast_mode == FORECAST_MODE_DAILY: + self._forecast_limit = 15 super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL @@ -48,47 +65,137 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): try: weather_response = await self._get_owm_weather() data = self._convert_weather_response(weather_response) - except (APICallError, UnauthorizedError) as error: + except (APIRequestError, 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 + """Poll weather data from OWM.""" + if ( + self._forecast_mode == FORECAST_MODE_ONECALL_HOURLY + or self._forecast_mode == FORECAST_MODE_ONECALL_DAILY + ): + weather = await self.hass.async_add_executor_job( + self._owm_client.one_call, self._latitude, self._longitude + ) + else: + weather = await self.hass.async_add_executor_job( + self._get_legacy_weather_and_forecast + ) + + return weather + + def _get_legacy_weather_and_forecast(self): + """Get weather and forecast data from OWM.""" + interval = self._get_forecast_interval() + weather = self._owm_client.weather_at_coords(self._latitude, self._longitude) + forecast = self._owm_client.forecast_at_coords( + self._latitude, self._longitude, interval, self._forecast_limit ) - return weather.get_weather() + return LegacyWeather(weather.weather, forecast.forecast.weathers) + + def _get_forecast_interval(self): + """Get the correct forecast interval depending on the forecast mode.""" + interval = "daily" + if self._forecast_mode == FORECAST_MODE_HOURLY: + interval = "3h" + return interval def _convert_weather_response(self, weather_response): + """Format the weather response correctly.""" + current_weather = weather_response.current + forecast_weather = self._get_forecast_from_weather_response(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(), + ATTR_API_TEMPERATURE: current_weather.temperature("celsius").get("temp"), + ATTR_API_PRESSURE: current_weather.pressure.get("press"), + ATTR_API_HUMIDITY: current_weather.humidity, + ATTR_API_WIND_BEARING: current_weather.wind().get("deg"), + ATTR_API_WIND_SPEED: current_weather.wind().get("speed"), + ATTR_API_CLOUDS: current_weather.clouds, + ATTR_API_RAIN: self._get_rain(current_weather.rain), + ATTR_API_SNOW: self._get_snow(current_weather.snow), + ATTR_API_WEATHER: current_weather.detailed_status, + ATTR_API_CONDITION: self._get_condition(current_weather.weather_code), + ATTR_API_WEATHER_CODE: current_weather.weather_code, + ATTR_API_FORECAST: forecast_weather, } + def _get_forecast_from_weather_response(self, weather_response): + forecast_arg = "forecast" + if self._forecast_mode == FORECAST_MODE_ONECALL_HOURLY: + forecast_arg = "forecast_hourly" + elif self._forecast_mode == FORECAST_MODE_ONECALL_DAILY: + forecast_arg = "forecast_daily" + return [ + self._convert_forecast(x) for x in getattr(weather_response, forecast_arg) + ] + + def _convert_forecast(self, entry): + forecast = { + ATTR_FORECAST_TIME: entry.reference_time("unix") * 1000, + ATTR_FORECAST_PRECIPITATION: self._calc_precipitation( + entry.rain, entry.snow + ), + ATTR_FORECAST_WIND_SPEED: entry.wind().get("speed"), + ATTR_FORECAST_WIND_BEARING: entry.wind().get("deg"), + ATTR_FORECAST_CONDITION: self._get_condition(entry.weather_code), + } + + temperature_dict = entry.temperature("celsius") + if "max" in temperature_dict and "min" in temperature_dict: + forecast[ATTR_FORECAST_TEMP] = entry.temperature("celsius").get("max") + forecast[ATTR_FORECAST_TEMP_LOW] = entry.temperature("celsius").get("min") + else: + forecast[ATTR_FORECAST_TEMP] = entry.temperature("celsius").get("temp") + + return forecast + @staticmethod def _get_rain(rain): + """Get rain data from weather data.""" + if "all" in rain: + return round(rain["all"], 0) if "1h" in rain: return round(rain["1h"], 0) return "not raining" @staticmethod def _get_snow(snow): + """Get snow data from weather data.""" if snow: - return round(snow, 0) + if "all" in snow: + return round(snow["all"], 0) + if "1h" in snow: + return round(snow["1h"], 0) + return "not snowing" return "not snowing" + @staticmethod + def _calc_precipitation(rain, snow): + """Calculate the precipitation.""" + rain_value = 0 + if WeatherUpdateCoordinator._get_rain(rain) != "not raining": + rain_value = WeatherUpdateCoordinator._get_rain(rain) + + snow_value = 0 + if WeatherUpdateCoordinator._get_snow(snow) != "not snowing": + snow_value = WeatherUpdateCoordinator._get_snow(snow) + + if round(rain_value + snow_value, 1) == 0: + return None + return round(rain_value + snow_value, 1) + @staticmethod def _get_condition(weather_code): + """Get weather condition from weather data.""" return [k for k, v in CONDITION_CLASSES.items() if weather_code in v][0] + + +class LegacyWeather: + """Class to harmonize weather data model for hourly, daily and One Call APIs.""" + + def __init__(self, current_weather, forecast): + """Initialize weather object.""" + self.current = current_weather + self.forecast = forecast diff --git a/requirements_all.txt b/requirements_all.txt index 1ce41a39643..0bc8613e397 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1589,7 +1589,7 @@ pyotgw==0.6b1 pyotp==2.3.0 # homeassistant.components.openweathermap -pyowm==2.10.0 +pyowm==3.1.0 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33db5d38275..6e95a296aea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -790,7 +790,7 @@ pyotgw==0.6b1 pyotp==2.3.0 # homeassistant.components.openweathermap -pyowm==2.10.0 +pyowm==3.1.0 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index 4b3297563ed..c4d6be156bf 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -1,6 +1,5 @@ """Define tests for the OpenWeatherMap config flow.""" -from pyowm.exceptions.api_call_error import APICallError -from pyowm.exceptions.api_response_error import UnauthorizedError +from pyowm.commons.exceptions import APIRequestError, UnauthorizedError from homeassistant import data_entry_flow from homeassistant.components.openweathermap.const import ( @@ -9,7 +8,7 @@ from homeassistant.components.openweathermap.const import ( DEFAULT_LANGUAGE, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -38,7 +37,7 @@ async def test_form(hass): mocked_owm = _create_mocked_owm(True) with patch( - "pyowm.weatherapi25.owm25.OWM25", + "pyowm.weatherapi25.weather_manager.WeatherManager", return_value=mocked_owm, ): result = await hass.config_entries.flow.async_init( @@ -70,38 +69,12 @@ async def test_form(hass): 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", + "pyowm.weatherapi25.weather_manager.WeatherManager", return_value=mocked_owm, ): config_entry = MockConfigEntry( @@ -139,12 +112,12 @@ async def test_form_options(hass): assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_MODE: "freedaily"} + result["flow_id"], user_input={CONF_MODE: "onecall_daily"} ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == { - CONF_MODE: "freedaily", + CONF_MODE: "onecall_daily", CONF_LANGUAGE: DEFAULT_LANGUAGE, } @@ -158,7 +131,7 @@ async def test_form_invalid_api_key(hass): mocked_owm = _create_mocked_owm(True) with patch( - "pyowm.weatherapi25.owm25.OWM25", + "pyowm.weatherapi25.weather_manager.WeatherManager", return_value=mocked_owm, side_effect=UnauthorizedError(""), ): @@ -174,9 +147,9 @@ async def test_form_api_call_error(hass): mocked_owm = _create_mocked_owm(True) with patch( - "pyowm.weatherapi25.owm25.OWM25", + "pyowm.weatherapi25.weather_manager.WeatherManager", return_value=mocked_owm, - side_effect=APICallError(""), + side_effect=APIRequestError(""), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONFIG @@ -202,31 +175,37 @@ async def test_form_api_offline(hass): 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 + weather.temperature.return_value.get.return_value = 10 + weather.pressure.get.return_value = 10 + weather.humidity.return_value = 10 + weather.wind.return_value.get.return_value = 0 + weather.clouds.return_value = "clouds" + weather.rain.return_value = [] + weather.snow.return_value = [] + weather.detailed_status.return_value = "status" + weather.weather_code = 803 - mocked_owm.weather_at_coords.return_value.get_weather.return_value = weather + mocked_owm.weather_at_coords.return_value.weather = 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 + one_day_forecast.reference_time.return_value = 10 + one_day_forecast.temperature.return_value.get.return_value = 10 + one_day_forecast.rain.return_value.get.return_value = 0 + one_day_forecast.snow.return_value.get.return_value = 0 + one_day_forecast.wind.return_value.get.return_value = 0 + one_day_forecast.weather_code = 803 - mocked_owm.three_hours_forecast_at_coords.return_value.get_forecast.return_value.get_weathers.return_value = [ - one_day_forecast - ] + mocked_owm.forecast_at_coords.return_value.forecast.weathers = [one_day_forecast] + + one_call = MagicMock() + one_call.current = weather + one_call.forecast_hourly = [one_day_forecast] + one_call.forecast_daily = [one_day_forecast] + + mocked_owm.one_call.return_value = one_call + + mocked_owm.weather_manager.return_value.one_call.return_value = is_api_online return mocked_owm