diff --git a/.coveragerc b/.coveragerc index 0faedef6cb3..10530e1252f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -972,6 +972,7 @@ omit = homeassistant/components/openuv/sensor.py homeassistant/components/openweathermap/__init__.py homeassistant/components/openweathermap/coordinator.py + homeassistant/components/openweathermap/repairs.py homeassistant/components/openweathermap/sensor.py homeassistant/components/openweathermap/weather.py homeassistant/components/opnsense/__init__.py diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index 259939454b1..4d6cae86f39 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -6,8 +6,7 @@ from dataclasses import dataclass import logging from typing import Any -from pyowm import OWM -from pyowm.utils.config import get_default_config +from pyopenweathermap import OWMClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -20,13 +19,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import ( - CONFIG_FLOW_VERSION, - FORECAST_MODE_FREE_DAILY, - FORECAST_MODE_ONECALL_DAILY, - PLATFORMS, -) +from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS from .coordinator import WeatherUpdateCoordinator +from .repairs import async_create_issue, async_delete_issue _LOGGER = logging.getLogger(__name__) @@ -49,14 +44,17 @@ async def async_setup_entry( api_key = entry.data[CONF_API_KEY] latitude = entry.data.get(CONF_LATITUDE, hass.config.latitude) longitude = entry.data.get(CONF_LONGITUDE, hass.config.longitude) - forecast_mode = _get_config_value(entry, CONF_MODE) language = _get_config_value(entry, CONF_LANGUAGE) + mode = _get_config_value(entry, CONF_MODE) - config_dict = _get_owm_config(language) + if mode == OWM_MODE_V25: + async_create_issue(hass, entry.entry_id) + else: + async_delete_issue(hass, entry.entry_id) - owm = OWM(api_key, config_dict).weather_manager() + owm_client = OWMClient(api_key, mode, lang=language) weather_coordinator = WeatherUpdateCoordinator( - owm, latitude, longitude, forecast_mode, hass + owm_client, latitude, longitude, hass ) await weather_coordinator.async_config_entry_first_refresh() @@ -78,11 +76,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version) - if version == 1: - if (mode := data[CONF_MODE]) == FORECAST_MODE_FREE_DAILY: - mode = FORECAST_MODE_ONECALL_DAILY - - new_data = {**data, CONF_MODE: mode} + if version < 3: + new_data = {**data, CONF_MODE: OWM_MODE_V25} config_entries.async_update_entry( entry, data=new_data, version=CONFIG_FLOW_VERSION ) @@ -108,10 +103,3 @@ def _get_config_value(config_entry: ConfigEntry, key: str) -> Any: if config_entry.options: return config_entry.options[key] return config_entry.data[key] - - -def _get_owm_config(language: str) -> dict[str, Any]: - """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 cc4c71c2bd5..3090af94979 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -2,11 +2,14 @@ from __future__ import annotations -from pyowm import OWM -from pyowm.commons.exceptions import APIRequestError, UnauthorizedError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import ( CONF_API_KEY, CONF_LANGUAGE, @@ -20,13 +23,14 @@ import homeassistant.helpers.config_validation as cv from .const import ( CONFIG_FLOW_VERSION, - DEFAULT_FORECAST_MODE, DEFAULT_LANGUAGE, DEFAULT_NAME, + DEFAULT_OWM_MODE, DOMAIN, - FORECAST_MODES, LANGUAGES, + OWM_MODES, ) +from .utils import validate_api_key class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): @@ -42,27 +46,22 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OpenWeatherMapOptionsFlow(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> ConfigFlowResult: """Handle a flow initialized by the user.""" errors = {} + description_placeholders = {} if user_input is not None: latitude = user_input[CONF_LATITUDE] longitude = user_input[CONF_LONGITUDE] + mode = user_input[CONF_MODE] 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], latitude, longitude - ) - if not api_online: - errors["base"] = "invalid_api_key" - except UnauthorizedError: - errors["base"] = "invalid_api_key" - except APIRequestError: - errors["base"] = "cannot_connect" + errors, description_placeholders = await validate_api_key( + user_input[CONF_API_KEY], mode + ) if not errors: return self.async_create_entry( @@ -79,16 +78,19 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): 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_MODE, default=DEFAULT_OWM_MODE): vol.In(OWM_MODES), vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In( LANGUAGES ), } ) - return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors, + description_placeholders=description_placeholders, + ) class OpenWeatherMapOptionsFlow(OptionsFlow): @@ -98,7 +100,7 @@ class OpenWeatherMapOptionsFlow(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) @@ -115,9 +117,9 @@ class OpenWeatherMapOptionsFlow(OptionsFlow): CONF_MODE, default=self.config_entry.options.get( CONF_MODE, - self.config_entry.data.get(CONF_MODE, DEFAULT_FORECAST_MODE), + self.config_entry.data.get(CONF_MODE, DEFAULT_OWM_MODE), ), - ): vol.In(FORECAST_MODES), + ): vol.In(OWM_MODES), vol.Optional( CONF_LANGUAGE, default=self.config_entry.options.get( @@ -127,8 +129,3 @@ class OpenWeatherMapOptionsFlow(OptionsFlow): ): vol.In(LANGUAGES), } ) - - -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.weather_at_coords, lat, lon) diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index cae21e8f054..1e5bfff4697 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -25,7 +25,7 @@ DEFAULT_NAME = "OpenWeatherMap" DEFAULT_LANGUAGE = "en" ATTRIBUTION = "Data provided by OpenWeatherMap" MANUFACTURER = "OpenWeather" -CONFIG_FLOW_VERSION = 2 +CONFIG_FLOW_VERSION = 3 ATTR_API_PRECIPITATION = "precipitation" ATTR_API_PRECIPITATION_KIND = "precipitation_kind" ATTR_API_DATETIME = "datetime" @@ -45,7 +45,11 @@ ATTR_API_SNOW = "snow" ATTR_API_UV_INDEX = "uv_index" ATTR_API_VISIBILITY_DISTANCE = "visibility_distance" ATTR_API_WEATHER_CODE = "weather_code" +ATTR_API_CLOUD_COVERAGE = "cloud_coverage" ATTR_API_FORECAST = "forecast" +ATTR_API_CURRENT = "current" +ATTR_API_HOURLY_FORECAST = "hourly_forecast" +ATTR_API_DAILY_FORECAST = "daily_forecast" UPDATE_LISTENER = "update_listener" PLATFORMS = [Platform.SENSOR, Platform.WEATHER] @@ -67,13 +71,10 @@ 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_HOURLY +OWM_MODE_V25 = "v2.5" +OWM_MODE_V30 = "v3.0" +OWM_MODES = [OWM_MODE_V30, OWM_MODE_V25] +DEFAULT_OWM_MODE = OWM_MODE_V30 LANGUAGES = [ "af", diff --git a/homeassistant/components/openweathermap/coordinator.py b/homeassistant/components/openweathermap/coordinator.py index 32b5509a826..0f99af5ad64 100644 --- a/homeassistant/components/openweathermap/coordinator.py +++ b/homeassistant/components/openweathermap/coordinator.py @@ -1,39 +1,35 @@ """Weather data coordinator for the OpenWeatherMap (OWM) service.""" -import asyncio from datetime import timedelta import logging -from pyowm.commons.exceptions import APIRequestError, UnauthorizedError +from pyopenweathermap import ( + CurrentWeather, + DailyWeatherForecast, + HourlyWeatherForecast, + OWMClient, + RequestError, + WeatherReport, +) from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, + Forecast, ) -from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant from homeassistant.helpers import sun from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( ATTR_API_CLOUDS, ATTR_API_CONDITION, + ATTR_API_CURRENT, + ATTR_API_DAILY_FORECAST, ATTR_API_DEW_POINT, ATTR_API_FEELS_LIKE_TEMPERATURE, - ATTR_API_FORECAST, - ATTR_API_FORECAST_CLOUDS, - ATTR_API_FORECAST_CONDITION, - ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE, - ATTR_API_FORECAST_HUMIDITY, - ATTR_API_FORECAST_PRECIPITATION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_PRESSURE, - ATTR_API_FORECAST_TEMP, - ATTR_API_FORECAST_TEMP_LOW, - ATTR_API_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_SPEED, + ATTR_API_HOURLY_FORECAST, ATTR_API_HUMIDITY, ATTR_API_PRECIPITATION_KIND, ATTR_API_PRESSURE, @@ -49,10 +45,6 @@ from .const import ( ATTR_API_WIND_SPEED, CONDITION_MAP, DOMAIN, - FORECAST_MODE_DAILY, - FORECAST_MODE_HOURLY, - FORECAST_MODE_ONECALL_DAILY, - FORECAST_MODE_ONECALL_HOURLY, WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT, ) @@ -64,15 +56,17 @@ WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) class WeatherUpdateCoordinator(DataUpdateCoordinator): """Weather data update coordinator.""" - def __init__(self, owm, latitude, longitude, forecast_mode, hass): + def __init__( + self, + owm_client: OWMClient, + latitude, + longitude, + hass: HomeAssistant, + ) -> None: """Initialize coordinator.""" - self._owm_client = owm + self._owm_client = owm_client 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 @@ -80,184 +74,122 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Update the data.""" - data = {} - async with asyncio.timeout(20): - try: - weather_response = await self._get_owm_weather() - data = self._convert_weather_response(weather_response) - except (APIRequestError, UnauthorizedError) as error: - raise UpdateFailed(error) from error - return data - - async def _get_owm_weather(self): - """Poll weather data from OWM.""" - if self.forecast_mode in ( - FORECAST_MODE_ONECALL_HOURLY, - 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 + try: + weather_report = await self._owm_client.get_weather( + self._latitude, self._longitude ) + except RequestError as error: + raise UpdateFailed(error) from error + return self._convert_weather_response(weather_report) - return weather - - def _get_legacy_weather_and_forecast(self): - """Get weather and forecast data from OWM.""" - interval = self._get_legacy_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 LegacyWeather(weather.weather, forecast.forecast.weathers) - - def _get_legacy_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): + def _convert_weather_response(self, weather_report: WeatherReport): """Format the weather response correctly.""" - current_weather = weather_response.current - forecast_weather = self._get_forecast_from_weather_response(weather_response) + _LOGGER.debug("OWM weather response: %s", weather_report) return { - ATTR_API_TEMPERATURE: current_weather.temperature("celsius").get("temp"), - ATTR_API_FEELS_LIKE_TEMPERATURE: current_weather.temperature("celsius").get( - "feels_like" - ), - ATTR_API_DEW_POINT: self._fmt_dewpoint(current_weather.dewpoint), - ATTR_API_PRESSURE: current_weather.pressure.get("press"), + ATTR_API_CURRENT: self._get_current_weather_data(weather_report.current), + ATTR_API_HOURLY_FORECAST: [ + self._get_hourly_forecast_weather_data(item) + for item in weather_report.hourly_forecast + ], + ATTR_API_DAILY_FORECAST: [ + self._get_daily_forecast_weather_data(item) + for item in weather_report.daily_forecast + ], + } + + def _get_current_weather_data(self, current_weather: CurrentWeather): + return { + ATTR_API_CONDITION: self._get_condition(current_weather.condition.id), + ATTR_API_TEMPERATURE: current_weather.temperature, + ATTR_API_FEELS_LIKE_TEMPERATURE: current_weather.feels_like, + ATTR_API_PRESSURE: current_weather.pressure, ATTR_API_HUMIDITY: current_weather.humidity, - ATTR_API_WIND_BEARING: current_weather.wind().get("deg"), - ATTR_API_WIND_GUST: current_weather.wind().get("gust"), - 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_DEW_POINT: current_weather.dew_point, + ATTR_API_CLOUDS: current_weather.cloud_coverage, + ATTR_API_WIND_SPEED: current_weather.wind_speed, + ATTR_API_WIND_GUST: current_weather.wind_gust, + ATTR_API_WIND_BEARING: current_weather.wind_bearing, + ATTR_API_WEATHER: current_weather.condition.description, + ATTR_API_WEATHER_CODE: current_weather.condition.id, + ATTR_API_UV_INDEX: current_weather.uv_index, + ATTR_API_VISIBILITY_DISTANCE: current_weather.visibility, + ATTR_API_RAIN: self._get_precipitation_value(current_weather.rain), + ATTR_API_SNOW: self._get_precipitation_value(current_weather.snow), ATTR_API_PRECIPITATION_KIND: self._calc_precipitation_kind( current_weather.rain, current_weather.snow ), - ATTR_API_WEATHER: current_weather.detailed_status, - ATTR_API_CONDITION: self._get_condition(current_weather.weather_code), - ATTR_API_UV_INDEX: current_weather.uvi, - ATTR_API_VISIBILITY_DISTANCE: current_weather.visibility_distance, - ATTR_API_WEATHER_CODE: current_weather.weather_code, - ATTR_API_FORECAST: forecast_weather, } - def _get_forecast_from_weather_response(self, weather_response): - """Extract the forecast data from the 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 _get_hourly_forecast_weather_data(self, forecast: HourlyWeatherForecast): + return Forecast( + datetime=forecast.date_time.isoformat(), + condition=self._get_condition(forecast.condition.id), + temperature=forecast.temperature, + native_apparent_temperature=forecast.feels_like, + pressure=forecast.pressure, + humidity=forecast.humidity, + native_dew_point=forecast.dew_point, + cloud_coverage=forecast.cloud_coverage, + wind_speed=forecast.wind_speed, + native_wind_gust_speed=forecast.wind_gust, + wind_bearing=forecast.wind_bearing, + uv_index=float(forecast.uv_index), + precipitation_probability=round(forecast.precipitation_probability * 100), + precipitation=self._calc_precipitation(forecast.rain, forecast.snow), + ) - def _convert_forecast(self, entry): - """Convert the forecast data.""" - forecast = { - ATTR_API_FORECAST_TIME: dt_util.utc_from_timestamp( - entry.reference_time("unix") - ).isoformat(), - ATTR_API_FORECAST_PRECIPITATION: self._calc_precipitation( - entry.rain, entry.snow - ), - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ( - round(entry.precipitation_probability * 100) - ), - ATTR_API_FORECAST_PRESSURE: entry.pressure.get("press"), - ATTR_API_FORECAST_WIND_SPEED: entry.wind().get("speed"), - ATTR_API_FORECAST_WIND_BEARING: entry.wind().get("deg"), - ATTR_API_FORECAST_CONDITION: self._get_condition( - entry.weather_code, entry.reference_time("unix") - ), - ATTR_API_FORECAST_CLOUDS: entry.clouds, - ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE: entry.temperature("celsius").get( - "feels_like_day" - ), - ATTR_API_FORECAST_HUMIDITY: entry.humidity, - } - - temperature_dict = entry.temperature("celsius") - if "max" in temperature_dict and "min" in temperature_dict: - forecast[ATTR_API_FORECAST_TEMP] = entry.temperature("celsius").get("max") - forecast[ATTR_API_FORECAST_TEMP_LOW] = entry.temperature("celsius").get( - "min" - ) - else: - forecast[ATTR_API_FORECAST_TEMP] = entry.temperature("celsius").get("temp") - - return forecast - - @staticmethod - def _fmt_dewpoint(dewpoint): - """Format the dewpoint data.""" - if dewpoint is not None: - return round( - TemperatureConverter.convert( - dewpoint, UnitOfTemperature.KELVIN, UnitOfTemperature.CELSIUS - ), - 1, - ) - return None - - @staticmethod - def _get_rain(rain): - """Get rain data from weather data.""" - if "all" in rain: - return round(rain["all"], 2) - if "3h" in rain: - return round(rain["3h"], 2) - if "1h" in rain: - return round(rain["1h"], 2) - return 0 - - @staticmethod - def _get_snow(snow): - """Get snow data from weather data.""" - if snow: - if "all" in snow: - return round(snow["all"], 2) - if "3h" in snow: - return round(snow["3h"], 2) - if "1h" in snow: - return round(snow["1h"], 2) - return 0 + def _get_daily_forecast_weather_data(self, forecast: DailyWeatherForecast): + return Forecast( + datetime=forecast.date_time.isoformat(), + condition=self._get_condition(forecast.condition.id), + temperature=forecast.temperature.max, + templow=forecast.temperature.min, + native_apparent_temperature=forecast.feels_like, + pressure=forecast.pressure, + humidity=forecast.humidity, + native_dew_point=forecast.dew_point, + cloud_coverage=forecast.cloud_coverage, + wind_speed=forecast.wind_speed, + native_wind_gust_speed=forecast.wind_gust, + wind_bearing=forecast.wind_bearing, + uv_index=float(forecast.uv_index), + precipitation_probability=round(forecast.precipitation_probability * 100), + precipitation=round(forecast.rain + forecast.snow, 2), + ) @staticmethod def _calc_precipitation(rain, snow): """Calculate the precipitation.""" - rain_value = 0 - if WeatherUpdateCoordinator._get_rain(rain) != 0: - rain_value = WeatherUpdateCoordinator._get_rain(rain) - - snow_value = 0 - if WeatherUpdateCoordinator._get_snow(snow) != 0: - snow_value = WeatherUpdateCoordinator._get_snow(snow) - + rain_value = WeatherUpdateCoordinator._get_precipitation_value(rain) + snow_value = WeatherUpdateCoordinator._get_precipitation_value(snow) return round(rain_value + snow_value, 2) @staticmethod def _calc_precipitation_kind(rain, snow): """Determine the precipitation kind.""" - if WeatherUpdateCoordinator._get_rain(rain) != 0: - if WeatherUpdateCoordinator._get_snow(snow) != 0: + rain_value = WeatherUpdateCoordinator._get_precipitation_value(rain) + snow_value = WeatherUpdateCoordinator._get_precipitation_value(snow) + if rain_value != 0: + if snow_value != 0: return "Snow and Rain" return "Rain" - if WeatherUpdateCoordinator._get_snow(snow) != 0: + if snow_value != 0: return "Snow" return "None" + @staticmethod + def _get_precipitation_value(precipitation): + """Get precipitation value from weather data.""" + if "all" in precipitation: + return round(precipitation["all"], 2) + if "3h" in precipitation: + return round(precipitation["3h"], 2) + if "1h" in precipitation: + return round(precipitation["1h"], 2) + return 0 + def _get_condition(self, weather_code, timestamp=None): """Get weather condition from weather data.""" if weather_code == WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT: @@ -269,12 +201,3 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return ATTR_CONDITION_CLEAR_NIGHT return CONDITION_MAP.get(weather_code) - - -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/homeassistant/components/openweathermap/manifest.json b/homeassistant/components/openweathermap/manifest.json index de2261a8024..e2c809cf385 100644 --- a/homeassistant/components/openweathermap/manifest.json +++ b/homeassistant/components/openweathermap/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openweathermap", "iot_class": "cloud_polling", - "loggers": ["geojson", "pyowm", "pysocks"], - "requirements": ["pyowm==3.2.0"] + "loggers": ["pyopenweathermap"], + "requirements": ["pyopenweathermap==0.0.9"] } diff --git a/homeassistant/components/openweathermap/repairs.py b/homeassistant/components/openweathermap/repairs.py new file mode 100644 index 00000000000..0f411a45405 --- /dev/null +++ b/homeassistant/components/openweathermap/repairs.py @@ -0,0 +1,87 @@ +"""Issues for OpenWeatherMap.""" + +from typing import cast + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_MODE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN, OWM_MODE_V30 +from .utils import validate_api_key + + +class DeprecatedV25RepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, entry: ConfigEntry) -> None: + """Create flow.""" + super().__init__() + self.entry = entry + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return self.async_show_form(step_id="migrate") + + async def async_step_migrate( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the migrate step of a fix flow.""" + errors, description_placeholders = {}, {} + new_options = {**self.entry.options, CONF_MODE: OWM_MODE_V30} + + errors, description_placeholders = await validate_api_key( + self.entry.data[CONF_API_KEY], OWM_MODE_V30 + ) + if not errors: + self.hass.config_entries.async_update_entry(self.entry, options=new_options) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + + return self.async_show_form( + step_id="migrate", + errors=errors, + description_placeholders=description_placeholders, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None], +) -> RepairsFlow: + """Create single repair flow.""" + entry_id = cast(str, data.get("entry_id")) + entry = hass.config_entries.async_get_entry(entry_id) + assert entry + return DeprecatedV25RepairFlow(entry) + + +def _get_issue_id(entry_id: str) -> str: + return f"deprecated_v25_{entry_id}" + + +@callback +def async_create_issue(hass: HomeAssistant, entry_id: str) -> None: + """Create issue for V2.5 deprecation.""" + ir.async_create_issue( + hass=hass, + domain=DOMAIN, + issue_id=_get_issue_id(entry_id), + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + learn_more_url="https://www.home-assistant.io/integrations/openweathermap/", + translation_key="deprecated_v25", + data={"entry_id": entry_id}, + ) + + +@callback +def async_delete_issue(hass: HomeAssistant, entry_id: str) -> None: + """Remove issue for V2.5 deprecation.""" + ir.async_delete_issue(hass=hass, domain=DOMAIN, issue_id=_get_issue_id(entry_id)) diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index d8d993bb28c..5fe0df60387 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -30,12 +30,13 @@ from homeassistant.util import dt as dt_util from . import OpenweathermapConfigEntry from .const import ( + ATTR_API_CLOUD_COVERAGE, ATTR_API_CLOUDS, ATTR_API_CONDITION, + ATTR_API_CURRENT, + ATTR_API_DAILY_FORECAST, ATTR_API_DEW_POINT, ATTR_API_FEELS_LIKE_TEMPERATURE, - ATTR_API_FORECAST, - ATTR_API_FORECAST_CONDITION, ATTR_API_FORECAST_PRECIPITATION, ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, ATTR_API_FORECAST_PRESSURE, @@ -162,7 +163,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ) FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( - key=ATTR_API_FORECAST_CONDITION, + key=ATTR_API_CONDITION, name="Condition", ), SensorEntityDescription( @@ -211,7 +212,7 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( - key=ATTR_API_CLOUDS, + key=ATTR_API_CLOUD_COVERAGE, name="Cloud coverage", native_unit_of_measurement=PERCENTAGE, ), @@ -313,7 +314,9 @@ class OpenWeatherMapSensor(AbstractOpenWeatherMapSensor): @property def native_value(self) -> StateType: """Return the state of the device.""" - return self._weather_coordinator.data.get(self.entity_description.key, None) + return self._weather_coordinator.data[ATTR_API_CURRENT].get( + self.entity_description.key + ) class OpenWeatherMapForecastSensor(AbstractOpenWeatherMapSensor): @@ -333,11 +336,8 @@ class OpenWeatherMapForecastSensor(AbstractOpenWeatherMapSensor): @property def native_value(self) -> StateType | datetime: """Return the state of the device.""" - forecasts = self._weather_coordinator.data.get(ATTR_API_FORECAST) - if not forecasts: - return None - - value = forecasts[0].get(self.entity_description.key, None) + forecasts = self._weather_coordinator.data[ATTR_API_DAILY_FORECAST] + value = forecasts[0].get(self.entity_description.key) if ( value and self.entity_description.device_class is SensorDeviceClass.TIMESTAMP diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index c53b685af91..916e1e0a713 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -5,7 +5,7 @@ }, "error": { "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "Failed to connect: {error}" }, "step": { "user": { @@ -30,5 +30,22 @@ } } } + }, + "issues": { + "deprecated_v25": { + "title": "OpenWeatherMap API V2.5 deprecated", + "fix_flow": { + "step": { + "migrate": { + "title": "OpenWeatherMap API V2.5 deprecated", + "description": "OWM API v2.5 will be closed in June 2024.\nYou need to migrate all your OpenWeatherMap integration to mode v3.0.\n\nBefore the migration, you must have active subscription (be aware subscripiton activation take up to 2h). After your subscription is activated click **Submit** to migrate the integration to API V3.0. Read documentation for more information." + } + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "cannot_connect": "Failed to connect: {error}" + } + } + } } } diff --git a/homeassistant/components/openweathermap/utils.py b/homeassistant/components/openweathermap/utils.py new file mode 100644 index 00000000000..cbdd1eab815 --- /dev/null +++ b/homeassistant/components/openweathermap/utils.py @@ -0,0 +1,20 @@ +"""Util functions for OpenWeatherMap.""" + +from pyopenweathermap import OWMClient, RequestError + + +async def validate_api_key(api_key, mode): + """Validate API key.""" + api_key_valid = None + errors, description_placeholders = {}, {} + try: + owm_client = OWMClient(api_key, mode) + api_key_valid = await owm_client.validate_key() + except RequestError as error: + errors["base"] = "cannot_connect" + description_placeholders["error"] = str(error) + + if api_key_valid is False: + errors["base"] = "invalid_api_key" + + return errors, description_placeholders diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 7ef5a97f729..62b15218233 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -2,21 +2,7 @@ from __future__ import annotations -from typing import cast - from homeassistant.components.weather import ( - ATTR_FORECAST_CLOUD_COVERAGE, - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_HUMIDITY, - ATTR_FORECAST_NATIVE_APPARENT_TEMP, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_PRESSURE, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_SPEED, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, Forecast, SingleCoordinatorWeatherEntity, WeatherEntityFeature, @@ -35,21 +21,11 @@ from . import OpenweathermapConfigEntry from .const import ( ATTR_API_CLOUDS, ATTR_API_CONDITION, + ATTR_API_CURRENT, + ATTR_API_DAILY_FORECAST, ATTR_API_DEW_POINT, ATTR_API_FEELS_LIKE_TEMPERATURE, - ATTR_API_FORECAST, - ATTR_API_FORECAST_CLOUDS, - ATTR_API_FORECAST_CONDITION, - ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE, - ATTR_API_FORECAST_HUMIDITY, - ATTR_API_FORECAST_PRECIPITATION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_PRESSURE, - ATTR_API_FORECAST_TEMP, - ATTR_API_FORECAST_TEMP_LOW, - ATTR_API_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_SPEED, + ATTR_API_HOURLY_FORECAST, ATTR_API_HUMIDITY, ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, @@ -59,27 +35,10 @@ from .const import ( ATTRIBUTION, DEFAULT_NAME, DOMAIN, - FORECAST_MODE_DAILY, - FORECAST_MODE_ONECALL_DAILY, MANUFACTURER, ) from .coordinator import WeatherUpdateCoordinator -FORECAST_MAP = { - ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION, - ATTR_API_FORECAST_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_PRESSURE: ATTR_FORECAST_NATIVE_PRESSURE, - ATTR_API_FORECAST_TEMP_LOW: ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, - ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, - ATTR_API_FORECAST_CLOUDS: ATTR_FORECAST_CLOUD_COVERAGE, - ATTR_API_FORECAST_HUMIDITY: ATTR_FORECAST_HUMIDITY, - ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE: ATTR_FORECAST_NATIVE_APPARENT_TEMP, -} - async def async_setup_entry( hass: HomeAssistant, @@ -124,84 +83,66 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina manufacturer=MANUFACTURER, name=DEFAULT_NAME, ) - if weather_coordinator.forecast_mode in ( - FORECAST_MODE_DAILY, - FORECAST_MODE_ONECALL_DAILY, - ): - self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY - else: # FORECAST_MODE_DAILY or FORECAST_MODE_ONECALL_HOURLY - self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY + self._attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) @property def condition(self) -> str | None: """Return the current condition.""" - return self.coordinator.data[ATTR_API_CONDITION] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_CONDITION] @property def cloud_coverage(self) -> float | None: """Return the Cloud coverage in %.""" - return self.coordinator.data[ATTR_API_CLOUDS] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_CLOUDS] @property def native_apparent_temperature(self) -> float | None: """Return the apparent temperature.""" - return self.coordinator.data[ATTR_API_FEELS_LIKE_TEMPERATURE] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_FEELS_LIKE_TEMPERATURE] @property def native_temperature(self) -> float | None: """Return the temperature.""" - return self.coordinator.data[ATTR_API_TEMPERATURE] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_TEMPERATURE] @property def native_pressure(self) -> float | None: """Return the pressure.""" - return self.coordinator.data[ATTR_API_PRESSURE] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_PRESSURE] @property def humidity(self) -> float | None: """Return the humidity.""" - return self.coordinator.data[ATTR_API_HUMIDITY] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_HUMIDITY] @property def native_dew_point(self) -> float | None: """Return the dew point.""" - return self.coordinator.data[ATTR_API_DEW_POINT] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_DEW_POINT] @property def native_wind_gust_speed(self) -> float | None: """Return the wind gust speed.""" - return self.coordinator.data[ATTR_API_WIND_GUST] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_GUST] @property def native_wind_speed(self) -> float | None: """Return the wind speed.""" - return self.coordinator.data[ATTR_API_WIND_SPEED] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_SPEED] @property def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" - return self.coordinator.data[ATTR_API_WIND_BEARING] - - @property - def _forecast(self) -> list[Forecast] | None: - """Return the forecast array.""" - api_forecasts = self.coordinator.data[ATTR_API_FORECAST] - forecasts = [ - { - ha_key: forecast[api_key] - for api_key, ha_key in FORECAST_MAP.items() - if api_key in forecast - } - for forecast in api_forecasts - ] - return cast(list[Forecast], forecasts) + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_BEARING] @callback def _async_forecast_daily(self) -> list[Forecast] | None: """Return the daily forecast in native units.""" - return self._forecast + return self.coordinator.data[ATTR_API_DAILY_FORECAST] @callback def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" - return self._forecast + return self.coordinator.data[ATTR_API_HOURLY_FORECAST] diff --git a/requirements_all.txt b/requirements_all.txt index d2931470798..aab9587f1cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2039,6 +2039,9 @@ pyombi==0.1.10 # homeassistant.components.openuv pyopenuv==2023.02.0 +# homeassistant.components.openweathermap +pyopenweathermap==0.0.9 + # homeassistant.components.opnsense pyopnsense==0.4.0 @@ -2059,9 +2062,6 @@ pyotp==2.8.0 # homeassistant.components.overkiz pyoverkiz==1.13.10 -# homeassistant.components.openweathermap -pyowm==3.2.0 - # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b26c115c981..01c4fc59e3e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1599,6 +1599,9 @@ pyoctoprintapi==0.1.12 # homeassistant.components.openuv pyopenuv==2023.02.0 +# homeassistant.components.openweathermap +pyopenweathermap==0.0.9 + # homeassistant.components.opnsense pyopnsense==0.4.0 @@ -1616,9 +1619,6 @@ pyotp==2.8.0 # homeassistant.components.overkiz pyoverkiz==1.13.10 -# homeassistant.components.openweathermap -pyowm==3.2.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 2715d83f4f0..be02a6b01a9 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -1,13 +1,23 @@ """Define tests for the OpenWeatherMap config flow.""" -from unittest.mock import MagicMock, patch +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch -from pyowm.commons.exceptions import APIRequestError, UnauthorizedError +from pyopenweathermap import ( + CurrentWeather, + DailyTemperature, + DailyWeatherForecast, + RequestError, + WeatherCondition, + WeatherReport, +) +import pytest from homeassistant.components.openweathermap.const import ( - DEFAULT_FORECAST_MODE, DEFAULT_LANGUAGE, + DEFAULT_OWM_MODE, DOMAIN, + OWM_MODE_V25, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( @@ -28,190 +38,262 @@ CONFIG = { CONF_API_KEY: "foo", CONF_LATITUDE: 50, CONF_LONGITUDE: 40, - CONF_MODE: DEFAULT_FORECAST_MODE, CONF_LANGUAGE: DEFAULT_LANGUAGE, + CONF_MODE: OWM_MODE_V25, } VALID_YAML_CONFIG = {CONF_API_KEY: "foo"} -async def test_form(hass: HomeAssistant) -> None: +def _create_mocked_owm_client(is_valid: bool): + current_weather = CurrentWeather( + date_time=datetime.fromtimestamp(1714063536, tz=UTC), + temperature=6.84, + feels_like=2.07, + pressure=1000, + humidity=82, + dew_point=3.99, + uv_index=0.13, + cloud_coverage=75, + visibility=10000, + wind_speed=9.83, + wind_bearing=199, + wind_gust=None, + rain={}, + snow={}, + condition=WeatherCondition( + id=803, + main="Clouds", + description="broken clouds", + icon="04d", + ), + ) + daily_weather_forecast = DailyWeatherForecast( + date_time=datetime.fromtimestamp(1714063536, tz=UTC), + summary="There will be clear sky until morning, then partly cloudy", + temperature=DailyTemperature( + day=18.76, + min=8.11, + max=21.26, + night=13.06, + evening=20.51, + morning=8.47, + ), + feels_like=DailyTemperature( + day=18.76, + min=8.11, + max=21.26, + night=13.06, + evening=20.51, + morning=8.47, + ), + pressure=1015, + humidity=62, + dew_point=11.34, + wind_speed=8.14, + wind_bearing=168, + wind_gust=11.81, + condition=WeatherCondition( + id=803, + main="Clouds", + description="broken clouds", + icon="04d", + ), + cloud_coverage=84, + precipitation_probability=0, + uv_index=4.06, + rain=0, + snow=0, + ) + weather_report = WeatherReport(current_weather, [], [daily_weather_forecast]) + + mocked_owm_client = MagicMock() + mocked_owm_client.validate_key = AsyncMock(return_value=is_valid) + mocked_owm_client.get_weather = AsyncMock(return_value=weather_report) + + return mocked_owm_client + + +@pytest.fixture(name="owm_client_mock") +def mock_owm_client(): + """Mock config_flow OWMClient.""" + with patch( + "homeassistant.components.openweathermap.OWMClient", + ) as owm_client_mock: + yield owm_client_mock + + +@pytest.fixture(name="config_flow_owm_client_mock") +def mock_config_flow_owm_client(): + """Mock config_flow OWMClient.""" + with patch( + "homeassistant.components.openweathermap.utils.OWMClient", + ) as config_flow_owm_client_mock: + yield config_flow_owm_client_mock + + +async def test_successful_config_flow( + hass: HomeAssistant, + owm_client_mock, + config_flow_owm_client_mock, +) -> None: """Test that the form is served with valid input.""" - mocked_owm = _create_mocked_owm(True) + mock = _create_mocked_owm_client(True) + owm_client_mock.return_value = mock + config_flow_owm_client_mock.return_value = mock - with patch( - "pyowm.weatherapi25.weather_manager.WeatherManager", - return_value=mocked_owm, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "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 is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(conf_entries[0].entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED - - assert result["type"] is FlowResultType.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_options(hass: HomeAssistant) -> None: - """Test that the options form.""" - mocked_owm = _create_mocked_owm(True) - - with patch( - "pyowm.weatherapi25.weather_manager.WeatherManager", - 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 is ConfigEntryState.LOADED - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - - assert result["type"] is FlowResultType.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"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == { - CONF_MODE: "daily", - CONF_LANGUAGE: DEFAULT_LANGUAGE, - } - - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_MODE: "onecall_daily"} - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == { - CONF_MODE: "onecall_daily", - CONF_LANGUAGE: DEFAULT_LANGUAGE, - } - - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - - -async def test_form_invalid_api_key(hass: HomeAssistant) -> None: - """Test that the form is served with no input.""" - mocked_owm = _create_mocked_owm(True) - - with patch( - "pyowm.weatherapi25.weather_manager.WeatherManager", - 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": "invalid_api_key"} - - -async def test_form_api_call_error(hass: HomeAssistant) -> None: - """Test setting up with api call error.""" - mocked_owm = _create_mocked_owm(True) - - with patch( - "pyowm.weatherapi25.weather_manager.WeatherManager", - return_value=mocked_owm, - side_effect=APIRequestError(""), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG - ) - - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_form_api_offline(hass: HomeAssistant) -> None: - """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": "invalid_api_key"} - - -def _create_mocked_owm(is_api_online: bool): - mocked_owm = MagicMock() - - weather = MagicMock() - 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 - weather.dewpoint = 10 - - mocked_owm.weather_at_coords.return_value.weather = weather - - one_day_forecast = MagicMock() - 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.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.weather_at_coords.return_value = ( - is_api_online + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - return mocked_owm + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "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 is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(conf_entries[0].entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED + + assert result["type"] is FlowResultType.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_abort_config_flow( + hass: HomeAssistant, + owm_client_mock, + config_flow_owm_client_mock, +) -> None: + """Test that the form is served with same data.""" + mock = _create_mocked_owm_client(True) + owm_client_mock.return_value = mock + config_flow_owm_client_mock.return_value = mock + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + + +async def test_config_flow_options_change( + hass: HomeAssistant, + owm_client_mock, + config_flow_owm_client_mock, +) -> None: + """Test that the options form.""" + mock = _create_mocked_owm_client(True) + owm_client_mock.return_value = mock + config_flow_owm_client_mock.return_value = mock + + 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 is ConfigEntryState.LOADED + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + new_language = "es" + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_MODE: DEFAULT_OWM_MODE, CONF_LANGUAGE: new_language}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_LANGUAGE: new_language, + CONF_MODE: DEFAULT_OWM_MODE, + } + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + updated_language = "es" + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_LANGUAGE: updated_language} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_LANGUAGE: updated_language, + CONF_MODE: DEFAULT_OWM_MODE, + } + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_form_invalid_api_key( + hass: HomeAssistant, + config_flow_owm_client_mock, +) -> None: + """Test that the form is served with no input.""" + config_flow_owm_client_mock.return_value = _create_mocked_owm_client(False) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_api_key"} + + config_flow_owm_client_mock.return_value = _create_mocked_owm_client(True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_form_api_call_error( + hass: HomeAssistant, + config_flow_owm_client_mock, +) -> None: + """Test setting up with api call error.""" + config_flow_owm_client_mock.return_value = _create_mocked_owm_client(True) + config_flow_owm_client_mock.side_effect = RequestError("oops") + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + config_flow_owm_client_mock.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY