Migrate OpenWeaterMap to new library (support API 3.0) (#116870)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Evgeny 2024-05-24 09:51:10 +02:00 committed by GitHub
parent 5bca9d142c
commit 24d31924a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 580 additions and 523 deletions

View File

@ -972,6 +972,7 @@ omit =
homeassistant/components/openuv/sensor.py homeassistant/components/openuv/sensor.py
homeassistant/components/openweathermap/__init__.py homeassistant/components/openweathermap/__init__.py
homeassistant/components/openweathermap/coordinator.py homeassistant/components/openweathermap/coordinator.py
homeassistant/components/openweathermap/repairs.py
homeassistant/components/openweathermap/sensor.py homeassistant/components/openweathermap/sensor.py
homeassistant/components/openweathermap/weather.py homeassistant/components/openweathermap/weather.py
homeassistant/components/opnsense/__init__.py homeassistant/components/opnsense/__init__.py

View File

@ -6,8 +6,7 @@ from dataclasses import dataclass
import logging import logging
from typing import Any from typing import Any
from pyowm import OWM from pyopenweathermap import OWMClient
from pyowm.utils.config import get_default_config
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
@ -20,13 +19,9 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import ( from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS
CONFIG_FLOW_VERSION,
FORECAST_MODE_FREE_DAILY,
FORECAST_MODE_ONECALL_DAILY,
PLATFORMS,
)
from .coordinator import WeatherUpdateCoordinator from .coordinator import WeatherUpdateCoordinator
from .repairs import async_create_issue, async_delete_issue
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -49,14 +44,17 @@ async def async_setup_entry(
api_key = entry.data[CONF_API_KEY] api_key = entry.data[CONF_API_KEY]
latitude = entry.data.get(CONF_LATITUDE, hass.config.latitude) latitude = entry.data.get(CONF_LATITUDE, hass.config.latitude)
longitude = entry.data.get(CONF_LONGITUDE, hass.config.longitude) longitude = entry.data.get(CONF_LONGITUDE, hass.config.longitude)
forecast_mode = _get_config_value(entry, CONF_MODE)
language = _get_config_value(entry, CONF_LANGUAGE) language = _get_config_value(entry, CONF_LANGUAGE)
mode = _get_config_value(entry, CONF_MODE)
config_dict = _get_owm_config(language) if mode == OWM_MODE_V25:
async_create_issue(hass, entry.entry_id)
else:
async_delete_issue(hass, entry.entry_id)
owm = OWM(api_key, config_dict).weather_manager() owm_client = OWMClient(api_key, mode, lang=language)
weather_coordinator = WeatherUpdateCoordinator( weather_coordinator = WeatherUpdateCoordinator(
owm, latitude, longitude, forecast_mode, hass owm_client, latitude, longitude, hass
) )
await weather_coordinator.async_config_entry_first_refresh() await weather_coordinator.async_config_entry_first_refresh()
@ -78,11 +76,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version) _LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version)
if version == 1: if version < 3:
if (mode := data[CONF_MODE]) == FORECAST_MODE_FREE_DAILY: new_data = {**data, CONF_MODE: OWM_MODE_V25}
mode = FORECAST_MODE_ONECALL_DAILY
new_data = {**data, CONF_MODE: mode}
config_entries.async_update_entry( config_entries.async_update_entry(
entry, data=new_data, version=CONFIG_FLOW_VERSION entry, data=new_data, version=CONFIG_FLOW_VERSION
) )
@ -108,10 +103,3 @@ def _get_config_value(config_entry: ConfigEntry, key: str) -> Any:
if config_entry.options: if config_entry.options:
return config_entry.options[key] return config_entry.options[key]
return config_entry.data[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

View File

@ -2,11 +2,14 @@
from __future__ import annotations from __future__ import annotations
from pyowm import OWM
from pyowm.commons.exceptions import APIRequestError, UnauthorizedError
import voluptuous as vol 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 ( from homeassistant.const import (
CONF_API_KEY, CONF_API_KEY,
CONF_LANGUAGE, CONF_LANGUAGE,
@ -20,13 +23,14 @@ import homeassistant.helpers.config_validation as cv
from .const import ( from .const import (
CONFIG_FLOW_VERSION, CONFIG_FLOW_VERSION,
DEFAULT_FORECAST_MODE,
DEFAULT_LANGUAGE, DEFAULT_LANGUAGE,
DEFAULT_NAME, DEFAULT_NAME,
DEFAULT_OWM_MODE,
DOMAIN, DOMAIN,
FORECAST_MODES,
LANGUAGES, LANGUAGES,
OWM_MODES,
) )
from .utils import validate_api_key
class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
@ -42,27 +46,22 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return OpenWeatherMapOptionsFlow(config_entry) 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.""" """Handle a flow initialized by the user."""
errors = {} errors = {}
description_placeholders = {}
if user_input is not None: if user_input is not None:
latitude = user_input[CONF_LATITUDE] latitude = user_input[CONF_LATITUDE]
longitude = user_input[CONF_LONGITUDE] longitude = user_input[CONF_LONGITUDE]
mode = user_input[CONF_MODE]
await self.async_set_unique_id(f"{latitude}-{longitude}") await self.async_set_unique_id(f"{latitude}-{longitude}")
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
try: errors, description_placeholders = await validate_api_key(
api_online = await _is_owm_api_online( user_input[CONF_API_KEY], mode
self.hass, user_input[CONF_API_KEY], latitude, longitude
) )
if not api_online:
errors["base"] = "invalid_api_key"
except UnauthorizedError:
errors["base"] = "invalid_api_key"
except APIRequestError:
errors["base"] = "cannot_connect"
if not errors: if not errors:
return self.async_create_entry( return self.async_create_entry(
@ -79,16 +78,19 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
vol.Optional( vol.Optional(
CONF_LONGITUDE, default=self.hass.config.longitude CONF_LONGITUDE, default=self.hass.config.longitude
): cv.longitude, ): cv.longitude,
vol.Optional(CONF_MODE, default=DEFAULT_FORECAST_MODE): vol.In( vol.Optional(CONF_MODE, default=DEFAULT_OWM_MODE): vol.In(OWM_MODES),
FORECAST_MODES
),
vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In( vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(
LANGUAGES 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): class OpenWeatherMapOptionsFlow(OptionsFlow):
@ -98,7 +100,7 @@ class OpenWeatherMapOptionsFlow(OptionsFlow):
"""Initialize options flow.""" """Initialize options flow."""
self.config_entry = config_entry self.config_entry = config_entry
async def async_step_init(self, user_input=None): async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult:
"""Manage the options.""" """Manage the options."""
if user_input is not None: if user_input is not None:
return self.async_create_entry(title="", data=user_input) return self.async_create_entry(title="", data=user_input)
@ -115,9 +117,9 @@ class OpenWeatherMapOptionsFlow(OptionsFlow):
CONF_MODE, CONF_MODE,
default=self.config_entry.options.get( default=self.config_entry.options.get(
CONF_MODE, 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( vol.Optional(
CONF_LANGUAGE, CONF_LANGUAGE,
default=self.config_entry.options.get( default=self.config_entry.options.get(
@ -127,8 +129,3 @@ class OpenWeatherMapOptionsFlow(OptionsFlow):
): vol.In(LANGUAGES), ): 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)

View File

@ -25,7 +25,7 @@ DEFAULT_NAME = "OpenWeatherMap"
DEFAULT_LANGUAGE = "en" DEFAULT_LANGUAGE = "en"
ATTRIBUTION = "Data provided by OpenWeatherMap" ATTRIBUTION = "Data provided by OpenWeatherMap"
MANUFACTURER = "OpenWeather" MANUFACTURER = "OpenWeather"
CONFIG_FLOW_VERSION = 2 CONFIG_FLOW_VERSION = 3
ATTR_API_PRECIPITATION = "precipitation" ATTR_API_PRECIPITATION = "precipitation"
ATTR_API_PRECIPITATION_KIND = "precipitation_kind" ATTR_API_PRECIPITATION_KIND = "precipitation_kind"
ATTR_API_DATETIME = "datetime" ATTR_API_DATETIME = "datetime"
@ -45,7 +45,11 @@ ATTR_API_SNOW = "snow"
ATTR_API_UV_INDEX = "uv_index" ATTR_API_UV_INDEX = "uv_index"
ATTR_API_VISIBILITY_DISTANCE = "visibility_distance" ATTR_API_VISIBILITY_DISTANCE = "visibility_distance"
ATTR_API_WEATHER_CODE = "weather_code" ATTR_API_WEATHER_CODE = "weather_code"
ATTR_API_CLOUD_COVERAGE = "cloud_coverage"
ATTR_API_FORECAST = "forecast" ATTR_API_FORECAST = "forecast"
ATTR_API_CURRENT = "current"
ATTR_API_HOURLY_FORECAST = "hourly_forecast"
ATTR_API_DAILY_FORECAST = "daily_forecast"
UPDATE_LISTENER = "update_listener" UPDATE_LISTENER = "update_listener"
PLATFORMS = [Platform.SENSOR, Platform.WEATHER] PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
@ -67,13 +71,10 @@ FORECAST_MODE_DAILY = "daily"
FORECAST_MODE_FREE_DAILY = "freedaily" FORECAST_MODE_FREE_DAILY = "freedaily"
FORECAST_MODE_ONECALL_HOURLY = "onecall_hourly" FORECAST_MODE_ONECALL_HOURLY = "onecall_hourly"
FORECAST_MODE_ONECALL_DAILY = "onecall_daily" FORECAST_MODE_ONECALL_DAILY = "onecall_daily"
FORECAST_MODES = [ OWM_MODE_V25 = "v2.5"
FORECAST_MODE_HOURLY, OWM_MODE_V30 = "v3.0"
FORECAST_MODE_DAILY, OWM_MODES = [OWM_MODE_V30, OWM_MODE_V25]
FORECAST_MODE_ONECALL_HOURLY, DEFAULT_OWM_MODE = OWM_MODE_V30
FORECAST_MODE_ONECALL_DAILY,
]
DEFAULT_FORECAST_MODE = FORECAST_MODE_HOURLY
LANGUAGES = [ LANGUAGES = [
"af", "af",

View File

@ -1,39 +1,35 @@
"""Weather data coordinator for the OpenWeatherMap (OWM) service.""" """Weather data coordinator for the OpenWeatherMap (OWM) service."""
import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
from pyowm.commons.exceptions import APIRequestError, UnauthorizedError from pyopenweathermap import (
CurrentWeather,
DailyWeatherForecast,
HourlyWeatherForecast,
OWMClient,
RequestError,
WeatherReport,
)
from homeassistant.components.weather import ( from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_SUNNY, ATTR_CONDITION_SUNNY,
Forecast,
) )
from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant
from homeassistant.helpers import sun from homeassistant.helpers import sun
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import ( from .const import (
ATTR_API_CLOUDS, ATTR_API_CLOUDS,
ATTR_API_CONDITION, ATTR_API_CONDITION,
ATTR_API_CURRENT,
ATTR_API_DAILY_FORECAST,
ATTR_API_DEW_POINT, ATTR_API_DEW_POINT,
ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_API_FEELS_LIKE_TEMPERATURE,
ATTR_API_FORECAST, ATTR_API_HOURLY_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_HUMIDITY, ATTR_API_HUMIDITY,
ATTR_API_PRECIPITATION_KIND, ATTR_API_PRECIPITATION_KIND,
ATTR_API_PRESSURE, ATTR_API_PRESSURE,
@ -49,10 +45,6 @@ from .const import (
ATTR_API_WIND_SPEED, ATTR_API_WIND_SPEED,
CONDITION_MAP, CONDITION_MAP,
DOMAIN, DOMAIN,
FORECAST_MODE_DAILY,
FORECAST_MODE_HOURLY,
FORECAST_MODE_ONECALL_DAILY,
FORECAST_MODE_ONECALL_HOURLY,
WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT, WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT,
) )
@ -64,15 +56,17 @@ WEATHER_UPDATE_INTERVAL = timedelta(minutes=10)
class WeatherUpdateCoordinator(DataUpdateCoordinator): class WeatherUpdateCoordinator(DataUpdateCoordinator):
"""Weather data update coordinator.""" """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.""" """Initialize coordinator."""
self._owm_client = owm self._owm_client = owm_client
self._latitude = latitude self._latitude = latitude
self._longitude = longitude self._longitude = longitude
self.forecast_mode = forecast_mode
self._forecast_limit = None
if forecast_mode == FORECAST_MODE_DAILY:
self._forecast_limit = 15
super().__init__( super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL
@ -80,184 +74,122 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
async def _async_update_data(self): async def _async_update_data(self):
"""Update the data.""" """Update the data."""
data = {}
async with asyncio.timeout(20):
try: try:
weather_response = await self._get_owm_weather() weather_report = await self._owm_client.get_weather(
data = self._convert_weather_response(weather_response) self._latitude, self._longitude
except (APIRequestError, UnauthorizedError) as error: )
except RequestError as error:
raise UpdateFailed(error) from error raise UpdateFailed(error) from error
return data return self._convert_weather_response(weather_report)
async def _get_owm_weather(self): def _convert_weather_response(self, weather_report: WeatherReport):
"""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
)
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):
"""Format the weather response correctly.""" """Format the weather response correctly."""
current_weather = weather_response.current _LOGGER.debug("OWM weather response: %s", weather_report)
forecast_weather = self._get_forecast_from_weather_response(weather_response)
return { return {
ATTR_API_TEMPERATURE: current_weather.temperature("celsius").get("temp"), ATTR_API_CURRENT: self._get_current_weather_data(weather_report.current),
ATTR_API_FEELS_LIKE_TEMPERATURE: current_weather.temperature("celsius").get( ATTR_API_HOURLY_FORECAST: [
"feels_like" self._get_hourly_forecast_weather_data(item)
), for item in weather_report.hourly_forecast
ATTR_API_DEW_POINT: self._fmt_dewpoint(current_weather.dewpoint), ],
ATTR_API_PRESSURE: current_weather.pressure.get("press"), 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_HUMIDITY: current_weather.humidity,
ATTR_API_WIND_BEARING: current_weather.wind().get("deg"), ATTR_API_DEW_POINT: current_weather.dew_point,
ATTR_API_WIND_GUST: current_weather.wind().get("gust"), ATTR_API_CLOUDS: current_weather.cloud_coverage,
ATTR_API_WIND_SPEED: current_weather.wind().get("speed"), ATTR_API_WIND_SPEED: current_weather.wind_speed,
ATTR_API_CLOUDS: current_weather.clouds, ATTR_API_WIND_GUST: current_weather.wind_gust,
ATTR_API_RAIN: self._get_rain(current_weather.rain), ATTR_API_WIND_BEARING: current_weather.wind_bearing,
ATTR_API_SNOW: self._get_snow(current_weather.snow), 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( ATTR_API_PRECIPITATION_KIND: self._calc_precipitation_kind(
current_weather.rain, current_weather.snow 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): def _get_hourly_forecast_weather_data(self, forecast: HourlyWeatherForecast):
"""Extract the forecast data from the weather response.""" return Forecast(
forecast_arg = "forecast" datetime=forecast.date_time.isoformat(),
if self.forecast_mode == FORECAST_MODE_ONECALL_HOURLY: condition=self._get_condition(forecast.condition.id),
forecast_arg = "forecast_hourly" temperature=forecast.temperature,
elif self.forecast_mode == FORECAST_MODE_ONECALL_DAILY: native_apparent_temperature=forecast.feels_like,
forecast_arg = "forecast_daily" pressure=forecast.pressure,
return [ humidity=forecast.humidity,
self._convert_forecast(x) for x in getattr(weather_response, forecast_arg) native_dew_point=forecast.dew_point,
] cloud_coverage=forecast.cloud_coverage,
wind_speed=forecast.wind_speed,
def _convert_forecast(self, entry): native_wind_gust_speed=forecast.wind_gust,
"""Convert the forecast data.""" wind_bearing=forecast.wind_bearing,
forecast = { uv_index=float(forecast.uv_index),
ATTR_API_FORECAST_TIME: dt_util.utc_from_timestamp( precipitation_probability=round(forecast.precipitation_probability * 100),
entry.reference_time("unix") precipitation=self._calc_precipitation(forecast.rain, forecast.snow),
).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 def _get_daily_forecast_weather_data(self, forecast: DailyWeatherForecast):
return Forecast(
@staticmethod datetime=forecast.date_time.isoformat(),
def _fmt_dewpoint(dewpoint): condition=self._get_condition(forecast.condition.id),
"""Format the dewpoint data.""" temperature=forecast.temperature.max,
if dewpoint is not None: templow=forecast.temperature.min,
return round( native_apparent_temperature=forecast.feels_like,
TemperatureConverter.convert( pressure=forecast.pressure,
dewpoint, UnitOfTemperature.KELVIN, UnitOfTemperature.CELSIUS humidity=forecast.humidity,
), native_dew_point=forecast.dew_point,
1, 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),
) )
return None
@staticmethod
def _get_rain(rain):
"""Get rain data from weather data."""
if "all" in rain:
return round(rain["all"], 2)
if "3h" in rain:
return round(rain["3h"], 2)
if "1h" in rain:
return round(rain["1h"], 2)
return 0
@staticmethod
def _get_snow(snow):
"""Get snow data from weather data."""
if snow:
if "all" in snow:
return round(snow["all"], 2)
if "3h" in snow:
return round(snow["3h"], 2)
if "1h" in snow:
return round(snow["1h"], 2)
return 0
@staticmethod @staticmethod
def _calc_precipitation(rain, snow): def _calc_precipitation(rain, snow):
"""Calculate the precipitation.""" """Calculate the precipitation."""
rain_value = 0 rain_value = WeatherUpdateCoordinator._get_precipitation_value(rain)
if WeatherUpdateCoordinator._get_rain(rain) != 0: snow_value = WeatherUpdateCoordinator._get_precipitation_value(snow)
rain_value = WeatherUpdateCoordinator._get_rain(rain)
snow_value = 0
if WeatherUpdateCoordinator._get_snow(snow) != 0:
snow_value = WeatherUpdateCoordinator._get_snow(snow)
return round(rain_value + snow_value, 2) return round(rain_value + snow_value, 2)
@staticmethod @staticmethod
def _calc_precipitation_kind(rain, snow): def _calc_precipitation_kind(rain, snow):
"""Determine the precipitation kind.""" """Determine the precipitation kind."""
if WeatherUpdateCoordinator._get_rain(rain) != 0: rain_value = WeatherUpdateCoordinator._get_precipitation_value(rain)
if WeatherUpdateCoordinator._get_snow(snow) != 0: snow_value = WeatherUpdateCoordinator._get_precipitation_value(snow)
if rain_value != 0:
if snow_value != 0:
return "Snow and Rain" return "Snow and Rain"
return "Rain" return "Rain"
if WeatherUpdateCoordinator._get_snow(snow) != 0: if snow_value != 0:
return "Snow" return "Snow"
return "None" 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): def _get_condition(self, weather_code, timestamp=None):
"""Get weather condition from weather data.""" """Get weather condition from weather data."""
if weather_code == WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT: if weather_code == WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT:
@ -269,12 +201,3 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
return ATTR_CONDITION_CLEAR_NIGHT return ATTR_CONDITION_CLEAR_NIGHT
return CONDITION_MAP.get(weather_code) 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

View File

@ -5,6 +5,6 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/openweathermap", "documentation": "https://www.home-assistant.io/integrations/openweathermap",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["geojson", "pyowm", "pysocks"], "loggers": ["pyopenweathermap"],
"requirements": ["pyowm==3.2.0"] "requirements": ["pyopenweathermap==0.0.9"]
} }

View File

@ -0,0 +1,87 @@
"""Issues for OpenWeatherMap."""
from typing import cast
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_MODE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from .const import DOMAIN, OWM_MODE_V30
from .utils import validate_api_key
class DeprecatedV25RepairFlow(RepairsFlow):
"""Handler for an issue fixing flow."""
def __init__(self, entry: ConfigEntry) -> None:
"""Create flow."""
super().__init__()
self.entry = entry
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return self.async_show_form(step_id="migrate")
async def async_step_migrate(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the migrate step of a fix flow."""
errors, description_placeholders = {}, {}
new_options = {**self.entry.options, CONF_MODE: OWM_MODE_V30}
errors, description_placeholders = await validate_api_key(
self.entry.data[CONF_API_KEY], OWM_MODE_V30
)
if not errors:
self.hass.config_entries.async_update_entry(self.entry, options=new_options)
await self.hass.config_entries.async_reload(self.entry.entry_id)
return self.async_create_entry(data={})
return self.async_show_form(
step_id="migrate",
errors=errors,
description_placeholders=description_placeholders,
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None],
) -> RepairsFlow:
"""Create single repair flow."""
entry_id = cast(str, data.get("entry_id"))
entry = hass.config_entries.async_get_entry(entry_id)
assert entry
return DeprecatedV25RepairFlow(entry)
def _get_issue_id(entry_id: str) -> str:
return f"deprecated_v25_{entry_id}"
@callback
def async_create_issue(hass: HomeAssistant, entry_id: str) -> None:
"""Create issue for V2.5 deprecation."""
ir.async_create_issue(
hass=hass,
domain=DOMAIN,
issue_id=_get_issue_id(entry_id),
is_fixable=True,
is_persistent=True,
severity=ir.IssueSeverity.WARNING,
learn_more_url="https://www.home-assistant.io/integrations/openweathermap/",
translation_key="deprecated_v25",
data={"entry_id": entry_id},
)
@callback
def async_delete_issue(hass: HomeAssistant, entry_id: str) -> None:
"""Remove issue for V2.5 deprecation."""
ir.async_delete_issue(hass=hass, domain=DOMAIN, issue_id=_get_issue_id(entry_id))

View File

@ -30,12 +30,13 @@ from homeassistant.util import dt as dt_util
from . import OpenweathermapConfigEntry from . import OpenweathermapConfigEntry
from .const import ( from .const import (
ATTR_API_CLOUD_COVERAGE,
ATTR_API_CLOUDS, ATTR_API_CLOUDS,
ATTR_API_CONDITION, ATTR_API_CONDITION,
ATTR_API_CURRENT,
ATTR_API_DAILY_FORECAST,
ATTR_API_DEW_POINT, ATTR_API_DEW_POINT,
ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_API_FEELS_LIKE_TEMPERATURE,
ATTR_API_FORECAST,
ATTR_API_FORECAST_CONDITION,
ATTR_API_FORECAST_PRECIPITATION, ATTR_API_FORECAST_PRECIPITATION,
ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, ATTR_API_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_API_FORECAST_PRESSURE, ATTR_API_FORECAST_PRESSURE,
@ -162,7 +163,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
) )
FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription( SensorEntityDescription(
key=ATTR_API_FORECAST_CONDITION, key=ATTR_API_CONDITION,
name="Condition", name="Condition",
), ),
SensorEntityDescription( SensorEntityDescription(
@ -211,7 +212,7 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.WIND_SPEED, device_class=SensorDeviceClass.WIND_SPEED,
), ),
SensorEntityDescription( SensorEntityDescription(
key=ATTR_API_CLOUDS, key=ATTR_API_CLOUD_COVERAGE,
name="Cloud coverage", name="Cloud coverage",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
), ),
@ -313,7 +314,9 @@ class OpenWeatherMapSensor(AbstractOpenWeatherMapSensor):
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType:
"""Return the state of the device.""" """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): class OpenWeatherMapForecastSensor(AbstractOpenWeatherMapSensor):
@ -333,11 +336,8 @@ class OpenWeatherMapForecastSensor(AbstractOpenWeatherMapSensor):
@property @property
def native_value(self) -> StateType | datetime: def native_value(self) -> StateType | datetime:
"""Return the state of the device.""" """Return the state of the device."""
forecasts = self._weather_coordinator.data.get(ATTR_API_FORECAST) forecasts = self._weather_coordinator.data[ATTR_API_DAILY_FORECAST]
if not forecasts: value = forecasts[0].get(self.entity_description.key)
return None
value = forecasts[0].get(self.entity_description.key, None)
if ( if (
value value
and self.entity_description.device_class is SensorDeviceClass.TIMESTAMP and self.entity_description.device_class is SensorDeviceClass.TIMESTAMP

View File

@ -5,7 +5,7 @@
}, },
"error": { "error": {
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", "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": { "step": {
"user": { "user": {
@ -30,5 +30,22 @@
} }
} }
} }
},
"issues": {
"deprecated_v25": {
"title": "OpenWeatherMap API V2.5 deprecated",
"fix_flow": {
"step": {
"migrate": {
"title": "OpenWeatherMap API V2.5 deprecated",
"description": "OWM API v2.5 will be closed in June 2024.\nYou need to migrate all your OpenWeatherMap integration to mode v3.0.\n\nBefore the migration, you must have active subscription (be aware subscripiton activation take up to 2h). After your subscription is activated click **Submit** to migrate the integration to API V3.0. Read documentation for more information."
}
},
"error": {
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"cannot_connect": "Failed to connect: {error}"
}
}
}
} }
} }

View File

@ -0,0 +1,20 @@
"""Util functions for OpenWeatherMap."""
from pyopenweathermap import OWMClient, RequestError
async def validate_api_key(api_key, mode):
"""Validate API key."""
api_key_valid = None
errors, description_placeholders = {}, {}
try:
owm_client = OWMClient(api_key, mode)
api_key_valid = await owm_client.validate_key()
except RequestError as error:
errors["base"] = "cannot_connect"
description_placeholders["error"] = str(error)
if api_key_valid is False:
errors["base"] = "invalid_api_key"
return errors, description_placeholders

View File

@ -2,21 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import cast
from homeassistant.components.weather import ( 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, Forecast,
SingleCoordinatorWeatherEntity, SingleCoordinatorWeatherEntity,
WeatherEntityFeature, WeatherEntityFeature,
@ -35,21 +21,11 @@ from . import OpenweathermapConfigEntry
from .const import ( from .const import (
ATTR_API_CLOUDS, ATTR_API_CLOUDS,
ATTR_API_CONDITION, ATTR_API_CONDITION,
ATTR_API_CURRENT,
ATTR_API_DAILY_FORECAST,
ATTR_API_DEW_POINT, ATTR_API_DEW_POINT,
ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_API_FEELS_LIKE_TEMPERATURE,
ATTR_API_FORECAST, ATTR_API_HOURLY_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_HUMIDITY, ATTR_API_HUMIDITY,
ATTR_API_PRESSURE, ATTR_API_PRESSURE,
ATTR_API_TEMPERATURE, ATTR_API_TEMPERATURE,
@ -59,27 +35,10 @@ from .const import (
ATTRIBUTION, ATTRIBUTION,
DEFAULT_NAME, DEFAULT_NAME,
DOMAIN, DOMAIN,
FORECAST_MODE_DAILY,
FORECAST_MODE_ONECALL_DAILY,
MANUFACTURER, MANUFACTURER,
) )
from .coordinator import WeatherUpdateCoordinator from .coordinator import WeatherUpdateCoordinator
FORECAST_MAP = {
ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION,
ATTR_API_FORECAST_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION,
ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_API_FORECAST_PRESSURE: ATTR_FORECAST_NATIVE_PRESSURE,
ATTR_API_FORECAST_TEMP_LOW: ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP,
ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME,
ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING,
ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_API_FORECAST_CLOUDS: ATTR_FORECAST_CLOUD_COVERAGE,
ATTR_API_FORECAST_HUMIDITY: ATTR_FORECAST_HUMIDITY,
ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE: ATTR_FORECAST_NATIVE_APPARENT_TEMP,
}
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -124,84 +83,66 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
name=DEFAULT_NAME, name=DEFAULT_NAME,
) )
if weather_coordinator.forecast_mode in ( self._attr_supported_features = (
FORECAST_MODE_DAILY, WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
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
@property @property
def condition(self) -> str | None: def condition(self) -> str | None:
"""Return the current condition.""" """Return the current condition."""
return self.coordinator.data[ATTR_API_CONDITION] return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_CONDITION]
@property @property
def cloud_coverage(self) -> float | None: def cloud_coverage(self) -> float | None:
"""Return the Cloud coverage in %.""" """Return the Cloud coverage in %."""
return self.coordinator.data[ATTR_API_CLOUDS] return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_CLOUDS]
@property @property
def native_apparent_temperature(self) -> float | None: def native_apparent_temperature(self) -> float | None:
"""Return the apparent temperature.""" """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 @property
def native_temperature(self) -> float | None: def native_temperature(self) -> float | None:
"""Return the temperature.""" """Return the temperature."""
return self.coordinator.data[ATTR_API_TEMPERATURE] return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_TEMPERATURE]
@property @property
def native_pressure(self) -> float | None: def native_pressure(self) -> float | None:
"""Return the pressure.""" """Return the pressure."""
return self.coordinator.data[ATTR_API_PRESSURE] return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_PRESSURE]
@property @property
def humidity(self) -> float | None: def humidity(self) -> float | None:
"""Return the humidity.""" """Return the humidity."""
return self.coordinator.data[ATTR_API_HUMIDITY] return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_HUMIDITY]
@property @property
def native_dew_point(self) -> float | None: def native_dew_point(self) -> float | None:
"""Return the dew point.""" """Return the dew point."""
return self.coordinator.data[ATTR_API_DEW_POINT] return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_DEW_POINT]
@property @property
def native_wind_gust_speed(self) -> float | None: def native_wind_gust_speed(self) -> float | None:
"""Return the wind gust speed.""" """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 @property
def native_wind_speed(self) -> float | None: def native_wind_speed(self) -> float | None:
"""Return the wind speed.""" """Return the wind speed."""
return self.coordinator.data[ATTR_API_WIND_SPEED] return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_SPEED]
@property @property
def wind_bearing(self) -> float | str | None: def wind_bearing(self) -> float | str | None:
"""Return the wind bearing.""" """Return the wind bearing."""
return self.coordinator.data[ATTR_API_WIND_BEARING] return self.coordinator.data[ATTR_API_CURRENT][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)
@callback @callback
def _async_forecast_daily(self) -> list[Forecast] | None: def _async_forecast_daily(self) -> list[Forecast] | None:
"""Return the daily forecast in native units.""" """Return the daily forecast in native units."""
return self._forecast return self.coordinator.data[ATTR_API_DAILY_FORECAST]
@callback @callback
def _async_forecast_hourly(self) -> list[Forecast] | None: def _async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast in native units.""" """Return the hourly forecast in native units."""
return self._forecast return self.coordinator.data[ATTR_API_HOURLY_FORECAST]

View File

@ -2039,6 +2039,9 @@ pyombi==0.1.10
# homeassistant.components.openuv # homeassistant.components.openuv
pyopenuv==2023.02.0 pyopenuv==2023.02.0
# homeassistant.components.openweathermap
pyopenweathermap==0.0.9
# homeassistant.components.opnsense # homeassistant.components.opnsense
pyopnsense==0.4.0 pyopnsense==0.4.0
@ -2059,9 +2062,6 @@ pyotp==2.8.0
# homeassistant.components.overkiz # homeassistant.components.overkiz
pyoverkiz==1.13.10 pyoverkiz==1.13.10
# homeassistant.components.openweathermap
pyowm==3.2.0
# homeassistant.components.onewire # homeassistant.components.onewire
pyownet==0.10.0.post1 pyownet==0.10.0.post1

View File

@ -1599,6 +1599,9 @@ pyoctoprintapi==0.1.12
# homeassistant.components.openuv # homeassistant.components.openuv
pyopenuv==2023.02.0 pyopenuv==2023.02.0
# homeassistant.components.openweathermap
pyopenweathermap==0.0.9
# homeassistant.components.opnsense # homeassistant.components.opnsense
pyopnsense==0.4.0 pyopnsense==0.4.0
@ -1616,9 +1619,6 @@ pyotp==2.8.0
# homeassistant.components.overkiz # homeassistant.components.overkiz
pyoverkiz==1.13.10 pyoverkiz==1.13.10
# homeassistant.components.openweathermap
pyowm==3.2.0
# homeassistant.components.onewire # homeassistant.components.onewire
pyownet==0.10.0.post1 pyownet==0.10.0.post1

View File

@ -1,13 +1,23 @@
"""Define tests for the OpenWeatherMap config flow.""" """Define tests for the OpenWeatherMap config flow."""
from unittest.mock import MagicMock, patch from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
from pyowm.commons.exceptions import APIRequestError, UnauthorizedError from pyopenweathermap import (
CurrentWeather,
DailyTemperature,
DailyWeatherForecast,
RequestError,
WeatherCondition,
WeatherReport,
)
import pytest
from homeassistant.components.openweathermap.const import ( from homeassistant.components.openweathermap.const import (
DEFAULT_FORECAST_MODE,
DEFAULT_LANGUAGE, DEFAULT_LANGUAGE,
DEFAULT_OWM_MODE,
DOMAIN, DOMAIN,
OWM_MODE_V25,
) )
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
from homeassistant.const import ( from homeassistant.const import (
@ -28,21 +38,110 @@ CONFIG = {
CONF_API_KEY: "foo", CONF_API_KEY: "foo",
CONF_LATITUDE: 50, CONF_LATITUDE: 50,
CONF_LONGITUDE: 40, CONF_LONGITUDE: 40,
CONF_MODE: DEFAULT_FORECAST_MODE,
CONF_LANGUAGE: DEFAULT_LANGUAGE, CONF_LANGUAGE: DEFAULT_LANGUAGE,
CONF_MODE: OWM_MODE_V25,
} }
VALID_YAML_CONFIG = {CONF_API_KEY: "foo"} VALID_YAML_CONFIG = {CONF_API_KEY: "foo"}
async def test_form(hass: HomeAssistant) -> None: def _create_mocked_owm_client(is_valid: bool):
"""Test that the form is served with valid input.""" current_weather = CurrentWeather(
mocked_owm = _create_mocked_owm(True) 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( with patch(
"pyowm.weatherapi25.weather_manager.WeatherManager", "homeassistant.components.openweathermap.OWMClient",
return_value=mocked_owm, ) as owm_client_mock:
): yield owm_client_mock
@pytest.fixture(name="config_flow_owm_client_mock")
def mock_config_flow_owm_client():
"""Mock config_flow OWMClient."""
with patch(
"homeassistant.components.openweathermap.utils.OWMClient",
) as config_flow_owm_client_mock:
yield config_flow_owm_client_mock
async def test_successful_config_flow(
hass: HomeAssistant,
owm_client_mock,
config_flow_owm_client_mock,
) -> None:
"""Test that the form is served with valid input."""
mock = _create_mocked_owm_client(True)
owm_client_mock.return_value = mock
config_flow_owm_client_mock.return_value = mock
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
) )
@ -72,14 +171,39 @@ async def test_form(hass: HomeAssistant) -> None:
assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY] assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY]
async def test_form_options(hass: HomeAssistant) -> None: async def test_abort_config_flow(
"""Test that the options form.""" hass: HomeAssistant,
mocked_owm = _create_mocked_owm(True) owm_client_mock,
config_flow_owm_client_mock,
) -> None:
"""Test that the form is served with same data."""
mock = _create_mocked_owm_client(True)
owm_client_mock.return_value = mock
config_flow_owm_client_mock.return_value = mock
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
async def test_config_flow_options_change(
hass: HomeAssistant,
owm_client_mock,
config_flow_owm_client_mock,
) -> None:
"""Test that the options form."""
mock = _create_mocked_owm_client(True)
owm_client_mock.return_value = mock
config_flow_owm_client_mock.return_value = mock
with patch(
"pyowm.weatherapi25.weather_manager.WeatherManager",
return_value=mocked_owm,
):
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, unique_id="openweathermap_unique_id", data=CONFIG domain=DOMAIN, unique_id="openweathermap_unique_id", data=CONFIG
) )
@ -95,14 +219,16 @@ async def test_form_options(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init" assert result["step_id"] == "init"
new_language = "es"
result = await hass.config_entries.options.async_configure( result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={CONF_MODE: "daily"} result["flow_id"],
user_input={CONF_MODE: DEFAULT_OWM_MODE, CONF_LANGUAGE: new_language},
) )
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert config_entry.options == { assert config_entry.options == {
CONF_MODE: "daily", CONF_LANGUAGE: new_language,
CONF_LANGUAGE: DEFAULT_LANGUAGE, CONF_MODE: DEFAULT_OWM_MODE,
} }
await hass.async_block_till_done() await hass.async_block_till_done()
@ -114,14 +240,15 @@ async def test_form_options(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init" assert result["step_id"] == "init"
updated_language = "es"
result = await hass.config_entries.options.async_configure( result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={CONF_MODE: "onecall_daily"} result["flow_id"], user_input={CONF_LANGUAGE: updated_language}
) )
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert config_entry.options == { assert config_entry.options == {
CONF_MODE: "onecall_daily", CONF_LANGUAGE: updated_language,
CONF_LANGUAGE: DEFAULT_LANGUAGE, CONF_MODE: DEFAULT_OWM_MODE,
} }
await hass.async_block_till_done() await hass.async_block_till_done()
@ -129,89 +256,44 @@ async def test_form_options(hass: HomeAssistant) -> None:
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
async def test_form_invalid_api_key(hass: HomeAssistant) -> None: async def test_form_invalid_api_key(
hass: HomeAssistant,
config_flow_owm_client_mock,
) -> None:
"""Test that the form is served with no input.""" """Test that the form is served with no input."""
mocked_owm = _create_mocked_owm(True) config_flow_owm_client_mock.return_value = _create_mocked_owm_client(False)
with patch(
"pyowm.weatherapi25.weather_manager.WeatherManager",
return_value=mocked_owm,
side_effect=UnauthorizedError(""),
):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
) )
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_api_key"} 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
)
async def test_form_api_call_error(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_form_api_call_error(
hass: HomeAssistant,
config_flow_owm_client_mock,
) -> None:
"""Test setting up with api call error.""" """Test setting up with api call error."""
mocked_owm = _create_mocked_owm(True) config_flow_owm_client_mock.return_value = _create_mocked_owm_client(True)
config_flow_owm_client_mock.side_effect = RequestError("oops")
with patch(
"pyowm.weatherapi25.weather_manager.WeatherManager",
return_value=mocked_owm,
side_effect=APIRequestError(""),
):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
) )
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"} assert result["errors"] == {"base": "cannot_connect"}
config_flow_owm_client_mock.side_effect = None
async def test_form_api_offline(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure(
"""Test setting up with api call error.""" result["flow_id"], user_input=CONFIG
mocked_owm = _create_mocked_owm(False)
with patch(
"homeassistant.components.openweathermap.config_flow.OWM",
return_value=mocked_owm,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
) )
assert result["errors"] == {"base": "invalid_api_key"} assert result["type"] is FlowResultType.CREATE_ENTRY
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
)
return mocked_owm