diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index f740bf6c55101..8265d1c34f234 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -6,8 +6,7 @@ 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 ( @@ -24,8 +23,10 @@ CONFIG_FLOW_VERSION, FORECAST_MODE_FREE_DAILY, FORECAST_MODE_ONECALL_DAILY, + OWM_MODE_V25, PLATFORMS, ) +from .repairs import create_issue from .weather_update_coordinator import WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -49,14 +50,15 @@ 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: + create_issue(hass) - 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() @@ -87,6 +89,12 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, data=new_data, version=CONFIG_FLOW_VERSION ) + if version == 2: + new_data = {**data, CONF_MODE: OWM_MODE_V25} + config_entries.async_update_entry( + entry, data=new_data, version=CONFIG_FLOW_VERSION + ) + _LOGGER.info("Migration to version %s successful", CONFIG_FLOW_VERSION) return True @@ -108,10 +116,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 cc4c71c2bd5c5..c3fb426be916e 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -2,11 +2,15 @@ from __future__ import annotations -from pyowm import OWM -from pyowm.commons.exceptions import APIRequestError, UnauthorizedError +from pyopenweathermap import OWMClient, RequestError 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,12 +24,12 @@ from .const import ( CONFIG_FLOW_VERSION, - DEFAULT_FORECAST_MODE, DEFAULT_LANGUAGE, DEFAULT_NAME, + DEFAULT_OWM_MODE, DOMAIN, - FORECAST_MODES, LANGUAGES, + OWM_MODES, ) @@ -42,27 +46,29 @@ def async_get_options_flow( """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() + api_key_valid = None 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: + owm_client = OWMClient(user_input[CONF_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" if not errors: return self.async_create_entry( @@ -79,16 +85,19 @@ async def async_step_user(self, user_input=None): 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 +107,7 @@ def __init__(self, config_entry: ConfigEntry) -> None: """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=None) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) @@ -115,9 +124,9 @@ def _get_options_schema(self): 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 +136,3 @@ def _get_options_schema(self): ): 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 cae21e8f05484..1e5bfff4697c1 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -25,7 +25,7 @@ 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_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_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/manifest.json b/homeassistant/components/openweathermap/manifest.json index de2261a802469..e2c809cf38571 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 0000000000000..ba213f5aa026a --- /dev/null +++ b/homeassistant/components/openweathermap/repairs.py @@ -0,0 +1,20 @@ +"""Issues for OpenWeatherMap.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN + + +def create_issue(hass: HomeAssistant): + """Create issue for V2.5 deprecation.""" + ir.async_create_issue( + hass=hass, + domain=DOMAIN, + issue_id="deprecated_v25", + is_fixable=False, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + learn_more_url="https://openweathermap.org/one-call-transfer", + translation_key="deprecated_v25", + ) diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 70b21324b4627..5ed5532a9151a 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -30,12 +30,13 @@ 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 @@ ) FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( - key=ATTR_API_FORECAST_CONDITION, + key=ATTR_API_CONDITION, name="Condition", ), SensorEntityDescription( @@ -211,7 +212,7 @@ 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 @@ def __init__( @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 @@ def __init__( @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 c53b685af9148..00101dc1aaefd 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,11 @@ } } } + }, + "issues": { + "deprecated_v25": { + "title": "OpenWeatherMap API V2.5 deprecated", + "description": "OWM API v2.5 will be closed in June 2024, migrate all your OpenWeatherMap integration to mode `v3.0." + } } } diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 406b1c8ad4b00..95ac67cee5a5f 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 .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 @@ ATTRIBUTION, DEFAULT_NAME, DOMAIN, - FORECAST_MODE_DAILY, - FORECAST_MODE_ONECALL_DAILY, MANUFACTURER, ) from .weather_update_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 @@ def __init__( 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/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index d54a7fa899fde..e30ece80c6291 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_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 @@ 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 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """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 @@ def __init__(self, owm, latitude, longitude, forecast_mode, hass): 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 _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 + 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), + ) - @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 @@ def _get_condition(self, weather_code, timestamp=None): 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/requirements_all.txt b/requirements_all.txt index ea80e4248968a..62cf8d7371728 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2024,6 +2024,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 @@ -2044,9 +2047,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 245387d372394..7d328991b257d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1584,6 +1584,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 @@ -1601,9 +1604,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 2715d83f4f042..50b38aa233b2d 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -1,12 +1,21 @@ """Define tests for the OpenWeatherMap config flow.""" -from unittest.mock import MagicMock, patch - -from pyowm.commons.exceptions import APIRequestError, UnauthorizedError +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +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, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState @@ -28,190 +37,261 @@ CONF_API_KEY: "foo", CONF_LATITUDE: 50, CONF_LONGITUDE: 40, - CONF_MODE: DEFAULT_FORECAST_MODE, CONF_LANGUAGE: DEFAULT_LANGUAGE, + CONF_MODE: DEFAULT_OWM_MODE, } VALID_YAML_CONFIG = {CONF_API_KEY: "foo"} -async def test_form(hass: HomeAssistant) -> None: - """Test that the form is served with valid input.""" - mocked_owm = _create_mocked_owm(True) +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( - "pyowm.weatherapi25.weather_manager.WeatherManager", - return_value=mocked_owm, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) + "homeassistant.components.openweathermap.OWMClient", + ) as owm_client_mock: + yield owm_client_mock - 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 - ) +@pytest.fixture(name="config_flow_owm_client_mock") +def mock_config_flow_owm_client(): + """Mock config_flow OWMClient.""" + with patch( + "homeassistant.components.openweathermap.config_flow.OWMClient", + ) as config_flow_owm_client_mock: + yield config_flow_owm_client_mock - await hass.async_block_till_done() - conf_entries = hass.config_entries.async_entries(DOMAIN) - entry = conf_entries[0] - assert entry.state is ConfigEntryState.LOADED +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.""" + mock = _create_mocked_owm_client(True) + owm_client_mock.return_value = mock + config_flow_owm_client_mock.return_value = mock - await hass.config_entries.async_unload(conf_entries[0].entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) - 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] + 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 + ) -async def test_form_options(hass: HomeAssistant) -> None: - """Test that the options form.""" - mocked_owm = _create_mocked_owm(True) + await hass.async_block_till_done() - 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) + conf_entries = hass.config_entries.async_entries(DOMAIN) + entry = conf_entries[0] + assert entry.state is ConfigEntryState.LOADED - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + 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 config_entry.state is ConfigEntryState.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] - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" +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.options.async_configure( - result["flow_id"], user_input={CONF_MODE: "daily"} - ) + 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.CREATE_ENTRY - assert config_entry.options == { - CONF_MODE: "daily", - CONF_LANGUAGE: DEFAULT_LANGUAGE, - } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + await hass.async_block_till_done() - await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT - assert config_entry.state is ConfigEntryState.LOADED - result = await hass.config_entries.options.async_init(config_entry.entry_id) +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 - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + config_entry = MockConfigEntry( + domain=DOMAIN, unique_id="openweathermap_unique_id", data=CONFIG + ) + config_entry.add_to_hass(hass) - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_MODE: "onecall_daily"} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == { - CONF_MODE: "onecall_daily", - CONF_LANGUAGE: DEFAULT_LANGUAGE, - } + assert config_entry.state is ConfigEntryState.LOADED - await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert config_entry.state is ConfigEntryState.LOADED + 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_LANGUAGE: new_language} + ) -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) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_LANGUAGE: new_language, + CONF_MODE: DEFAULT_OWM_MODE, + } - 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 - ) + await hass.async_block_till_done() - assert result["errors"] == {"base": "invalid_api_key"} + assert config_entry.state is ConfigEntryState.LOADED + result = await hass.config_entries.options.async_init(config_entry.entry_id) -async def test_form_api_call_error(hass: HomeAssistant) -> None: - """Test setting up with api call error.""" - mocked_owm = _create_mocked_owm(True) + 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 - 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_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_offline(hass: HomeAssistant) -> None: + +async def test_form_api_call_error( + hass: HomeAssistant, + config_flow_owm_client_mock, +) -> None: """Test setting up with api call error.""" - mocked_owm = _create_mocked_owm(False) + 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 + ) - 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 + 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 ) - return mocked_owm + assert result["type"] is FlowResultType.CREATE_ENTRY