From f3f7887f3f688d01e1ae9b10a700cdf25a246f43 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 5 May 2024 15:56:31 +0000 Subject: [PATCH 01/15] migrate to owm v3 --- .../components/openweathermap/__init__.py | 27 +- .../components/openweathermap/config_flow.py | 56 +-- .../components/openweathermap/const.py | 17 +- .../components/openweathermap/manifest.json | 4 +- .../components/openweathermap/repairs.py | 20 + .../components/openweathermap/sensor.py | 20 +- .../components/openweathermap/strings.json | 8 +- .../components/openweathermap/weather.py | 95 +---- .../weather_update_coordinator.py | 297 ++++++-------- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- .../openweathermap/test_config_flow.py | 372 +++++++++++------- 12 files changed, 452 insertions(+), 476 deletions(-) create mode 100644 homeassistant/components/openweathermap/repairs.py 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 From 49a9443c1dcc271e4b1c13fabb7e630cf15e35ff Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 5 May 2024 17:00:48 +0000 Subject: [PATCH 02/15] exclude repairs from coverage --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index 7986785d86e7a..995db4a64a213 100644 --- a/.coveragerc +++ b/.coveragerc @@ -975,6 +975,7 @@ omit = homeassistant/components/openuv/coordinator.py homeassistant/components/openuv/sensor.py homeassistant/components/openweathermap/__init__.py + homeassistant/components/openweathermap/repairs.py homeassistant/components/openweathermap/sensor.py homeassistant/components/openweathermap/weather.py homeassistant/components/openweathermap/weather_update_coordinator.py From 208982e6f3f155d855c57acb48c3f2d9c530b319 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 5 May 2024 17:06:51 +0000 Subject: [PATCH 03/15] add test for v25 --- .coveragerc | 1 - tests/components/openweathermap/test_config_flow.py | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.coveragerc b/.coveragerc index 995db4a64a213..7986785d86e7a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -975,7 +975,6 @@ omit = homeassistant/components/openuv/coordinator.py homeassistant/components/openuv/sensor.py homeassistant/components/openweathermap/__init__.py - homeassistant/components/openweathermap/repairs.py homeassistant/components/openweathermap/sensor.py homeassistant/components/openweathermap/weather.py homeassistant/components/openweathermap/weather_update_coordinator.py diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index 50b38aa233b2d..f60c1fb69b0e1 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -17,6 +17,7 @@ DEFAULT_LANGUAGE, DEFAULT_OWM_MODE, DOMAIN, + OWM_MODE_V25, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( @@ -38,7 +39,7 @@ CONF_LATITUDE: 50, CONF_LONGITUDE: 40, CONF_LANGUAGE: DEFAULT_LANGUAGE, - CONF_MODE: DEFAULT_OWM_MODE, + CONF_MODE: OWM_MODE_V25, } VALID_YAML_CONFIG = {CONF_API_KEY: "foo"} @@ -220,7 +221,8 @@ async def test_config_flow_options_change( new_language = "es" result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_LANGUAGE: new_language} + result["flow_id"], + user_input={CONF_MODE: DEFAULT_OWM_MODE, CONF_LANGUAGE: new_language}, ) assert result["type"] is FlowResultType.CREATE_ENTRY From f5e30f8fc27d2acbca94d9e8a8277ac87185ade0 Mon Sep 17 00:00:00 2001 From: Evgeny <940893+freekode@users.noreply.github.com> Date: Mon, 6 May 2024 09:01:48 +0200 Subject: [PATCH 04/15] Update homeassistant/components/openweathermap/repairs.py Co-authored-by: J. Nick Koston --- homeassistant/components/openweathermap/repairs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/repairs.py b/homeassistant/components/openweathermap/repairs.py index ba213f5aa026a..1cf2d0c6c333c 100644 --- a/homeassistant/components/openweathermap/repairs.py +++ b/homeassistant/components/openweathermap/repairs.py @@ -6,7 +6,8 @@ from .const import DOMAIN -def create_issue(hass: HomeAssistant): +@callback +def async_create_issue(hass: HomeAssistant) -> None: """Create issue for V2.5 deprecation.""" ir.async_create_issue( hass=hass, From 1db37052d9631f004e6cbe0503ddc7d4cf571380 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 6 May 2024 07:37:22 +0000 Subject: [PATCH 05/15] upd repairs --- .../components/openweathermap/__init__.py | 4 ++-- .../components/openweathermap/config_flow.py | 6 ++++++ .../components/openweathermap/repairs.py | 16 +++++++++++++--- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index 8265d1c34f234..07bc2da5b7554 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -26,7 +26,7 @@ OWM_MODE_V25, PLATFORMS, ) -from .repairs import create_issue +from .repairs import async_create_issue from .weather_update_coordinator import WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -54,7 +54,7 @@ async def async_setup_entry( mode = _get_config_value(entry, CONF_MODE) if mode == OWM_MODE_V25: - create_issue(hass) + async_create_issue(hass, entry.entry_id) owm_client = OWMClient(api_key, mode, lang=language) weather_coordinator = WeatherUpdateCoordinator( diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index c3fb426be916e..e9fce257a1935 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -29,8 +29,10 @@ DEFAULT_OWM_MODE, DOMAIN, LANGUAGES, + OWM_MODE_V30, OWM_MODES, ) +from .repairs import async_delete_issue class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): @@ -110,6 +112,10 @@ def __init__(self, config_entry: ConfigEntry) -> None: async def async_step_init(self, user_input=None) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: + mode = user_input[CONF_MODE] + if mode == OWM_MODE_V30: + async_delete_issue(self.hass, self.config_entry.entry_id) + return self.async_create_entry(title="", data=user_input) return self.async_show_form( diff --git a/homeassistant/components/openweathermap/repairs.py b/homeassistant/components/openweathermap/repairs.py index 1cf2d0c6c333c..757eedb37ff4f 100644 --- a/homeassistant/components/openweathermap/repairs.py +++ b/homeassistant/components/openweathermap/repairs.py @@ -1,21 +1,31 @@ """Issues for OpenWeatherMap.""" -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir from .const import DOMAIN +def _get_issue_id(entry_id: str) -> str: + return "deprecated_v25_" + entry_id + + @callback -def async_create_issue(hass: HomeAssistant) -> None: +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="deprecated_v25", + issue_id=_get_issue_id(entry_id), is_fixable=False, is_persistent=True, severity=ir.IssueSeverity.WARNING, learn_more_url="https://openweathermap.org/one-call-transfer", translation_key="deprecated_v25", ) + + +@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)) From c3402edb53fb1aadf1b775fc3350ab144f48e3bf Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 6 May 2024 13:24:47 +0000 Subject: [PATCH 06/15] fix migration --- .../components/openweathermap/__init__.py | 23 ++++--------------- .../components/openweathermap/config_flow.py | 6 ----- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index 07bc2da5b7554..d429bd049a3ba 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -19,14 +19,8 @@ ) from homeassistant.core import HomeAssistant -from .const import ( - CONFIG_FLOW_VERSION, - FORECAST_MODE_FREE_DAILY, - FORECAST_MODE_ONECALL_DAILY, - OWM_MODE_V25, - PLATFORMS, -) -from .repairs import async_create_issue +from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS +from .repairs import async_create_issue, async_delete_issue from .weather_update_coordinator import WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -55,6 +49,8 @@ async def async_setup_entry( if mode == OWM_MODE_V25: async_create_issue(hass, entry.entry_id) + else: + async_delete_issue(hass, entry.entry_id) owm_client = OWMClient(api_key, mode, lang=language) weather_coordinator = WeatherUpdateCoordinator( @@ -80,16 +76,7 @@ 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} - config_entries.async_update_entry( - entry, data=new_data, version=CONFIG_FLOW_VERSION - ) - - if version == 2: + if version < 3: new_data = {**data, CONF_MODE: OWM_MODE_V25} config_entries.async_update_entry( entry, data=new_data, version=CONFIG_FLOW_VERSION diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index e9fce257a1935..c3fb426be916e 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -29,10 +29,8 @@ DEFAULT_OWM_MODE, DOMAIN, LANGUAGES, - OWM_MODE_V30, OWM_MODES, ) -from .repairs import async_delete_issue class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): @@ -112,10 +110,6 @@ def __init__(self, config_entry: ConfigEntry) -> None: async def async_step_init(self, user_input=None) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: - mode = user_input[CONF_MODE] - if mode == OWM_MODE_V30: - async_delete_issue(self.hass, self.config_entry.entry_id) - return self.async_create_entry(title="", data=user_input) return self.async_show_form( From efb84928a375a5bf58df6c07b86da65bc0fbfba8 Mon Sep 17 00:00:00 2001 From: Evgeny <940893+freekode@users.noreply.github.com> Date: Mon, 6 May 2024 17:07:02 +0200 Subject: [PATCH 07/15] Update homeassistant/components/openweathermap/strings.json Co-authored-by: J. Nick Koston --- homeassistant/components/openweathermap/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 00101dc1aaefd..86092925a0a90 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -34,7 +34,7 @@ "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." + "description": "OWM API v2.5 will be closed in June 2024; migrate all your OpenWeatherMap integration to mode v3.0." } } } From c833e1cf934af03bcb8b16d82c76540251808bff Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 6 May 2024 17:12:44 +0000 Subject: [PATCH 08/15] add docs --- homeassistant/components/openweathermap/repairs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/repairs.py b/homeassistant/components/openweathermap/repairs.py index 757eedb37ff4f..4e20d6d4d7e70 100644 --- a/homeassistant/components/openweathermap/repairs.py +++ b/homeassistant/components/openweathermap/repairs.py @@ -20,7 +20,7 @@ def async_create_issue(hass: HomeAssistant, entry_id: str) -> None: is_fixable=False, is_persistent=True, severity=ir.IssueSeverity.WARNING, - learn_more_url="https://openweathermap.org/one-call-transfer", + learn_more_url="https://www.home-assistant.io/integrations/openweathermap/", translation_key="deprecated_v25", ) From f9389af68ebdfcf9d06f6e07eafe9579e119d3c3 Mon Sep 17 00:00:00 2001 From: Evgeny <940893+freekode@users.noreply.github.com> Date: Sun, 12 May 2024 09:56:40 +0200 Subject: [PATCH 09/15] Update homeassistant/components/openweathermap/repairs.py Co-authored-by: J. Nick Koston --- homeassistant/components/openweathermap/repairs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/repairs.py b/homeassistant/components/openweathermap/repairs.py index 4e20d6d4d7e70..257ecb5bf03cf 100644 --- a/homeassistant/components/openweathermap/repairs.py +++ b/homeassistant/components/openweathermap/repairs.py @@ -7,7 +7,7 @@ def _get_issue_id(entry_id: str) -> str: - return "deprecated_v25_" + entry_id + return f"deprecated_v25_{entry_id}" @callback From 92d1d1493d6cab3cb4690f8577acfaad56998adb Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 12 May 2024 08:01:31 +0000 Subject: [PATCH 10/15] fixes --- homeassistant/components/openweathermap/config_flow.py | 2 +- homeassistant/components/openweathermap/repairs.py | 2 +- homeassistant/components/openweathermap/strings.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index c3fb426be916e..cefee161fe9a6 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -107,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) -> ConfigFlowResult: + 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) diff --git a/homeassistant/components/openweathermap/repairs.py b/homeassistant/components/openweathermap/repairs.py index 257ecb5bf03cf..6d277bb1be5ea 100644 --- a/homeassistant/components/openweathermap/repairs.py +++ b/homeassistant/components/openweathermap/repairs.py @@ -17,7 +17,7 @@ def async_create_issue(hass: HomeAssistant, entry_id: str) -> None: hass=hass, domain=DOMAIN, issue_id=_get_issue_id(entry_id), - is_fixable=False, + is_fixable=True, is_persistent=True, severity=ir.IssueSeverity.WARNING, learn_more_url="https://www.home-assistant.io/integrations/openweathermap/", diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 86092925a0a90..88a4324d54432 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -34,7 +34,7 @@ "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." + "description": "OWM API v2.5 will be closed in June 2024; migrate all your OpenWeatherMap integration to mode v3.0 (active subscription required). Read documentation for more information." } } } From 07ddf92802091ebfc208d7def5210801d54bdf90 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 20 May 2024 17:02:33 +0000 Subject: [PATCH 11/15] add repair flow --- .../components/openweathermap/repairs.py | 48 ++++++++++++++++++- .../components/openweathermap/strings.json | 9 +++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openweathermap/repairs.py b/homeassistant/components/openweathermap/repairs.py index 6d277bb1be5ea..26c8a90072313 100644 --- a/homeassistant/components/openweathermap/repairs.py +++ b/homeassistant/components/openweathermap/repairs.py @@ -1,9 +1,54 @@ """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_MODE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir -from .const import DOMAIN +from .const import DOMAIN, OWM_MODE_V30 + + +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 await self.async_step_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.""" + if user_input is None: + return self.async_show_form(step_id="migrate") + + new_options = {**self.entry.options, CONF_MODE: OWM_MODE_V30} + 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={}) + + +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: @@ -22,6 +67,7 @@ def async_create_issue(hass: HomeAssistant, entry_id: str) -> None: severity=ir.IssueSeverity.WARNING, learn_more_url="https://www.home-assistant.io/integrations/openweathermap/", translation_key="deprecated_v25", + data={"entry_id": entry_id}, ) diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 88a4324d54432..81bb014e95852 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -34,7 +34,14 @@ "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 (active subscription required). Read documentation for more information." + "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." + } + } + } } } } From 4d3e05ed183768d2a42f8ca32e28968d30f3a4ac Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 21 May 2024 08:25:40 +0000 Subject: [PATCH 12/15] add repair flow errors --- .../components/openweathermap/config_flow.py | 15 +++-------- .../components/openweathermap/repairs.py | 26 +++++++++++++------ .../components/openweathermap/strings.json | 4 +++ .../components/openweathermap/utils.py | 20 ++++++++++++++ 4 files changed, 46 insertions(+), 19 deletions(-) create mode 100644 homeassistant/components/openweathermap/utils.py diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index cefee161fe9a6..3090af94979f0 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations -from pyopenweathermap import OWMClient, RequestError import voluptuous as vol from homeassistant.config_entries import ( @@ -31,6 +30,7 @@ LANGUAGES, OWM_MODES, ) +from .utils import validate_api_key class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): @@ -59,16 +59,9 @@ async def async_step_user(self, user_input=None) -> ConfigFlowResult: await self.async_set_unique_id(f"{latitude}-{longitude}") self._abort_if_unique_id_configured() - api_key_valid = None - try: - 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" + errors, description_placeholders = await validate_api_key( + user_input[CONF_API_KEY], mode + ) if not errors: return self.async_create_entry( diff --git a/homeassistant/components/openweathermap/repairs.py b/homeassistant/components/openweathermap/repairs.py index 26c8a90072313..0f411a4540524 100644 --- a/homeassistant/components/openweathermap/repairs.py +++ b/homeassistant/components/openweathermap/repairs.py @@ -5,11 +5,12 @@ from homeassistant import data_entry_flow from homeassistant.components.repairs import RepairsFlow from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODE +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): @@ -24,19 +25,28 @@ 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 await self.async_step_migrate() + 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.""" - if user_input is None: - return self.async_show_form(step_id="migrate") - + errors, description_placeholders = {}, {} new_options = {**self.entry.options, CONF_MODE: OWM_MODE_V30} - 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={}) + + 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( diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 81bb014e95852..916e1e0a71371 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -40,6 +40,10 @@ "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 0000000000000..65e48320a45aa --- /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(mode, 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 From 6759b5f45cd4d7450e3872c433ce76e4c4ec6a26 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 21 May 2024 14:18:41 +0000 Subject: [PATCH 13/15] fix test --- tests/components/openweathermap/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index f60c1fb69b0e1..be02a6b01a901 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -127,7 +127,7 @@ def mock_owm_client(): def mock_config_flow_owm_client(): """Mock config_flow OWMClient.""" with patch( - "homeassistant.components.openweathermap.config_flow.OWMClient", + "homeassistant.components.openweathermap.utils.OWMClient", ) as config_flow_owm_client_mock: yield config_flow_owm_client_mock From 6babe7131b7871f4b2c5f52c3629295126e461e7 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 21 May 2024 14:54:53 +0000 Subject: [PATCH 14/15] repair no coverage --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index fbae5ff5228cf..878f07b532270 100644 --- a/.coveragerc +++ b/.coveragerc @@ -975,6 +975,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 From 90c663344860ffbf39e30515dbde2ce629817ad6 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 23 May 2024 08:51:52 +0000 Subject: [PATCH 15/15] fix repair --- homeassistant/components/openweathermap/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/utils.py b/homeassistant/components/openweathermap/utils.py index 65e48320a45aa..cbdd1eab81524 100644 --- a/homeassistant/components/openweathermap/utils.py +++ b/homeassistant/components/openweathermap/utils.py @@ -8,7 +8,7 @@ async def validate_api_key(api_key, mode): api_key_valid = None errors, description_placeholders = {}, {} try: - owm_client = OWMClient(mode, mode) + owm_client = OWMClient(api_key, mode) api_key_valid = await owm_client.validate_key() except RequestError as error: errors["base"] = "cannot_connect"