mirror of
https://github.com/home-assistant/core.git
synced 2025-04-25 01:38:02 +00:00
Migrate OpenWeaterMap to new library (support API 3.0) (#116870)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
5bca9d142c
commit
24d31924a0
@ -972,6 +972,7 @@ omit =
|
||||
homeassistant/components/openuv/sensor.py
|
||||
homeassistant/components/openweathermap/__init__.py
|
||||
homeassistant/components/openweathermap/coordinator.py
|
||||
homeassistant/components/openweathermap/repairs.py
|
||||
homeassistant/components/openweathermap/sensor.py
|
||||
homeassistant/components/openweathermap/weather.py
|
||||
homeassistant/components/opnsense/__init__.py
|
||||
|
@ -6,8 +6,7 @@ from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyowm import OWM
|
||||
from pyowm.utils.config import get_default_config
|
||||
from pyopenweathermap import OWMClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@ -20,13 +19,9 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
CONFIG_FLOW_VERSION,
|
||||
FORECAST_MODE_FREE_DAILY,
|
||||
FORECAST_MODE_ONECALL_DAILY,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS
|
||||
from .coordinator import WeatherUpdateCoordinator
|
||||
from .repairs import async_create_issue, async_delete_issue
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -49,14 +44,17 @@ async def async_setup_entry(
|
||||
api_key = entry.data[CONF_API_KEY]
|
||||
latitude = entry.data.get(CONF_LATITUDE, hass.config.latitude)
|
||||
longitude = entry.data.get(CONF_LONGITUDE, hass.config.longitude)
|
||||
forecast_mode = _get_config_value(entry, CONF_MODE)
|
||||
language = _get_config_value(entry, CONF_LANGUAGE)
|
||||
mode = _get_config_value(entry, CONF_MODE)
|
||||
|
||||
config_dict = _get_owm_config(language)
|
||||
if mode == OWM_MODE_V25:
|
||||
async_create_issue(hass, entry.entry_id)
|
||||
else:
|
||||
async_delete_issue(hass, entry.entry_id)
|
||||
|
||||
owm = OWM(api_key, config_dict).weather_manager()
|
||||
owm_client = OWMClient(api_key, mode, lang=language)
|
||||
weather_coordinator = WeatherUpdateCoordinator(
|
||||
owm, latitude, longitude, forecast_mode, hass
|
||||
owm_client, latitude, longitude, hass
|
||||
)
|
||||
|
||||
await weather_coordinator.async_config_entry_first_refresh()
|
||||
@ -78,11 +76,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
_LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version)
|
||||
|
||||
if version == 1:
|
||||
if (mode := data[CONF_MODE]) == FORECAST_MODE_FREE_DAILY:
|
||||
mode = FORECAST_MODE_ONECALL_DAILY
|
||||
|
||||
new_data = {**data, CONF_MODE: mode}
|
||||
if version < 3:
|
||||
new_data = {**data, CONF_MODE: OWM_MODE_V25}
|
||||
config_entries.async_update_entry(
|
||||
entry, data=new_data, version=CONFIG_FLOW_VERSION
|
||||
)
|
||||
@ -108,10 +103,3 @@ def _get_config_value(config_entry: ConfigEntry, key: str) -> Any:
|
||||
if config_entry.options:
|
||||
return config_entry.options[key]
|
||||
return config_entry.data[key]
|
||||
|
||||
|
||||
def _get_owm_config(language: str) -> dict[str, Any]:
|
||||
"""Get OpenWeatherMap configuration and add language to it."""
|
||||
config_dict = get_default_config()
|
||||
config_dict["language"] = language
|
||||
return config_dict
|
||||
|
@ -2,11 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyowm import OWM
|
||||
from pyowm.commons.exceptions import APIRequestError, UnauthorizedError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LANGUAGE,
|
||||
@ -20,13 +23,14 @@ import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONFIG_FLOW_VERSION,
|
||||
DEFAULT_FORECAST_MODE,
|
||||
DEFAULT_LANGUAGE,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_OWM_MODE,
|
||||
DOMAIN,
|
||||
FORECAST_MODES,
|
||||
LANGUAGES,
|
||||
OWM_MODES,
|
||||
)
|
||||
from .utils import validate_api_key
|
||||
|
||||
|
||||
class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@ -42,27 +46,22 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Get the options flow for this handler."""
|
||||
return OpenWeatherMapOptionsFlow(config_entry)
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
async def async_step_user(self, user_input=None) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the user."""
|
||||
errors = {}
|
||||
description_placeholders = {}
|
||||
|
||||
if user_input is not None:
|
||||
latitude = user_input[CONF_LATITUDE]
|
||||
longitude = user_input[CONF_LONGITUDE]
|
||||
mode = user_input[CONF_MODE]
|
||||
|
||||
await self.async_set_unique_id(f"{latitude}-{longitude}")
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
try:
|
||||
api_online = await _is_owm_api_online(
|
||||
self.hass, user_input[CONF_API_KEY], latitude, longitude
|
||||
errors, description_placeholders = await validate_api_key(
|
||||
user_input[CONF_API_KEY], mode
|
||||
)
|
||||
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:
|
||||
return self.async_create_entry(
|
||||
@ -79,16 +78,19 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
vol.Optional(
|
||||
CONF_LONGITUDE, default=self.hass.config.longitude
|
||||
): cv.longitude,
|
||||
vol.Optional(CONF_MODE, default=DEFAULT_FORECAST_MODE): vol.In(
|
||||
FORECAST_MODES
|
||||
),
|
||||
vol.Optional(CONF_MODE, default=DEFAULT_OWM_MODE): vol.In(OWM_MODES),
|
||||
vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(
|
||||
LANGUAGES
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=schema,
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
|
||||
class OpenWeatherMapOptionsFlow(OptionsFlow):
|
||||
@ -98,7 +100,7 @@ class OpenWeatherMapOptionsFlow(OptionsFlow):
|
||||
"""Initialize options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
@ -115,9 +117,9 @@ class OpenWeatherMapOptionsFlow(OptionsFlow):
|
||||
CONF_MODE,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_MODE,
|
||||
self.config_entry.data.get(CONF_MODE, DEFAULT_FORECAST_MODE),
|
||||
self.config_entry.data.get(CONF_MODE, DEFAULT_OWM_MODE),
|
||||
),
|
||||
): vol.In(FORECAST_MODES),
|
||||
): vol.In(OWM_MODES),
|
||||
vol.Optional(
|
||||
CONF_LANGUAGE,
|
||||
default=self.config_entry.options.get(
|
||||
@ -127,8 +129,3 @@ class OpenWeatherMapOptionsFlow(OptionsFlow):
|
||||
): vol.In(LANGUAGES),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _is_owm_api_online(hass, api_key, lat, lon):
|
||||
owm = OWM(api_key).weather_manager()
|
||||
return await hass.async_add_executor_job(owm.weather_at_coords, lat, lon)
|
||||
|
@ -25,7 +25,7 @@ DEFAULT_NAME = "OpenWeatherMap"
|
||||
DEFAULT_LANGUAGE = "en"
|
||||
ATTRIBUTION = "Data provided by OpenWeatherMap"
|
||||
MANUFACTURER = "OpenWeather"
|
||||
CONFIG_FLOW_VERSION = 2
|
||||
CONFIG_FLOW_VERSION = 3
|
||||
ATTR_API_PRECIPITATION = "precipitation"
|
||||
ATTR_API_PRECIPITATION_KIND = "precipitation_kind"
|
||||
ATTR_API_DATETIME = "datetime"
|
||||
@ -45,7 +45,11 @@ ATTR_API_SNOW = "snow"
|
||||
ATTR_API_UV_INDEX = "uv_index"
|
||||
ATTR_API_VISIBILITY_DISTANCE = "visibility_distance"
|
||||
ATTR_API_WEATHER_CODE = "weather_code"
|
||||
ATTR_API_CLOUD_COVERAGE = "cloud_coverage"
|
||||
ATTR_API_FORECAST = "forecast"
|
||||
ATTR_API_CURRENT = "current"
|
||||
ATTR_API_HOURLY_FORECAST = "hourly_forecast"
|
||||
ATTR_API_DAILY_FORECAST = "daily_forecast"
|
||||
UPDATE_LISTENER = "update_listener"
|
||||
PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
|
||||
|
||||
@ -67,13 +71,10 @@ FORECAST_MODE_DAILY = "daily"
|
||||
FORECAST_MODE_FREE_DAILY = "freedaily"
|
||||
FORECAST_MODE_ONECALL_HOURLY = "onecall_hourly"
|
||||
FORECAST_MODE_ONECALL_DAILY = "onecall_daily"
|
||||
FORECAST_MODES = [
|
||||
FORECAST_MODE_HOURLY,
|
||||
FORECAST_MODE_DAILY,
|
||||
FORECAST_MODE_ONECALL_HOURLY,
|
||||
FORECAST_MODE_ONECALL_DAILY,
|
||||
]
|
||||
DEFAULT_FORECAST_MODE = FORECAST_MODE_HOURLY
|
||||
OWM_MODE_V25 = "v2.5"
|
||||
OWM_MODE_V30 = "v3.0"
|
||||
OWM_MODES = [OWM_MODE_V30, OWM_MODE_V25]
|
||||
DEFAULT_OWM_MODE = OWM_MODE_V30
|
||||
|
||||
LANGUAGES = [
|
||||
"af",
|
||||
|
@ -1,39 +1,35 @@
|
||||
"""Weather data coordinator for the OpenWeatherMap (OWM) service."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyowm.commons.exceptions import APIRequestError, UnauthorizedError
|
||||
from pyopenweathermap import (
|
||||
CurrentWeather,
|
||||
DailyWeatherForecast,
|
||||
HourlyWeatherForecast,
|
||||
OWMClient,
|
||||
RequestError,
|
||||
WeatherReport,
|
||||
)
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_CONDITION_CLEAR_NIGHT,
|
||||
ATTR_CONDITION_SUNNY,
|
||||
Forecast,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import sun
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.unit_conversion import TemperatureConverter
|
||||
|
||||
from .const import (
|
||||
ATTR_API_CLOUDS,
|
||||
ATTR_API_CONDITION,
|
||||
ATTR_API_CURRENT,
|
||||
ATTR_API_DAILY_FORECAST,
|
||||
ATTR_API_DEW_POINT,
|
||||
ATTR_API_FEELS_LIKE_TEMPERATURE,
|
||||
ATTR_API_FORECAST,
|
||||
ATTR_API_FORECAST_CLOUDS,
|
||||
ATTR_API_FORECAST_CONDITION,
|
||||
ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE,
|
||||
ATTR_API_FORECAST_HUMIDITY,
|
||||
ATTR_API_FORECAST_PRECIPITATION,
|
||||
ATTR_API_FORECAST_PRECIPITATION_PROBABILITY,
|
||||
ATTR_API_FORECAST_PRESSURE,
|
||||
ATTR_API_FORECAST_TEMP,
|
||||
ATTR_API_FORECAST_TEMP_LOW,
|
||||
ATTR_API_FORECAST_TIME,
|
||||
ATTR_API_FORECAST_WIND_BEARING,
|
||||
ATTR_API_FORECAST_WIND_SPEED,
|
||||
ATTR_API_HOURLY_FORECAST,
|
||||
ATTR_API_HUMIDITY,
|
||||
ATTR_API_PRECIPITATION_KIND,
|
||||
ATTR_API_PRESSURE,
|
||||
@ -49,10 +45,6 @@ from .const import (
|
||||
ATTR_API_WIND_SPEED,
|
||||
CONDITION_MAP,
|
||||
DOMAIN,
|
||||
FORECAST_MODE_DAILY,
|
||||
FORECAST_MODE_HOURLY,
|
||||
FORECAST_MODE_ONECALL_DAILY,
|
||||
FORECAST_MODE_ONECALL_HOURLY,
|
||||
WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT,
|
||||
)
|
||||
|
||||
@ -64,15 +56,17 @@ WEATHER_UPDATE_INTERVAL = timedelta(minutes=10)
|
||||
class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Weather data update coordinator."""
|
||||
|
||||
def __init__(self, owm, latitude, longitude, forecast_mode, hass):
|
||||
def __init__(
|
||||
self,
|
||||
owm_client: OWMClient,
|
||||
latitude,
|
||||
longitude,
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Initialize coordinator."""
|
||||
self._owm_client = owm
|
||||
self._owm_client = owm_client
|
||||
self._latitude = latitude
|
||||
self._longitude = longitude
|
||||
self.forecast_mode = forecast_mode
|
||||
self._forecast_limit = None
|
||||
if forecast_mode == FORECAST_MODE_DAILY:
|
||||
self._forecast_limit = 15
|
||||
|
||||
super().__init__(
|
||||
hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL
|
||||
@ -80,184 +74,122 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Update the data."""
|
||||
data = {}
|
||||
async with asyncio.timeout(20):
|
||||
try:
|
||||
weather_response = await self._get_owm_weather()
|
||||
data = self._convert_weather_response(weather_response)
|
||||
except (APIRequestError, UnauthorizedError) as error:
|
||||
weather_report = await self._owm_client.get_weather(
|
||||
self._latitude, self._longitude
|
||||
)
|
||||
except RequestError as error:
|
||||
raise UpdateFailed(error) from error
|
||||
return data
|
||||
return self._convert_weather_response(weather_report)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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"
|
||||
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),
|
||||
)
|
||||
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,
|
||||
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),
|
||||
)
|
||||
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
|
||||
def _calc_precipitation(rain, snow):
|
||||
"""Calculate the precipitation."""
|
||||
rain_value = 0
|
||||
if WeatherUpdateCoordinator._get_rain(rain) != 0:
|
||||
rain_value = WeatherUpdateCoordinator._get_rain(rain)
|
||||
|
||||
snow_value = 0
|
||||
if WeatherUpdateCoordinator._get_snow(snow) != 0:
|
||||
snow_value = WeatherUpdateCoordinator._get_snow(snow)
|
||||
|
||||
rain_value = WeatherUpdateCoordinator._get_precipitation_value(rain)
|
||||
snow_value = WeatherUpdateCoordinator._get_precipitation_value(snow)
|
||||
return round(rain_value + snow_value, 2)
|
||||
|
||||
@staticmethod
|
||||
def _calc_precipitation_kind(rain, snow):
|
||||
"""Determine the precipitation kind."""
|
||||
if WeatherUpdateCoordinator._get_rain(rain) != 0:
|
||||
if WeatherUpdateCoordinator._get_snow(snow) != 0:
|
||||
rain_value = WeatherUpdateCoordinator._get_precipitation_value(rain)
|
||||
snow_value = WeatherUpdateCoordinator._get_precipitation_value(snow)
|
||||
if rain_value != 0:
|
||||
if snow_value != 0:
|
||||
return "Snow and Rain"
|
||||
return "Rain"
|
||||
|
||||
if WeatherUpdateCoordinator._get_snow(snow) != 0:
|
||||
if snow_value != 0:
|
||||
return "Snow"
|
||||
return "None"
|
||||
|
||||
@staticmethod
|
||||
def _get_precipitation_value(precipitation):
|
||||
"""Get precipitation value from weather data."""
|
||||
if "all" in precipitation:
|
||||
return round(precipitation["all"], 2)
|
||||
if "3h" in precipitation:
|
||||
return round(precipitation["3h"], 2)
|
||||
if "1h" in precipitation:
|
||||
return round(precipitation["1h"], 2)
|
||||
return 0
|
||||
|
||||
def _get_condition(self, weather_code, timestamp=None):
|
||||
"""Get weather condition from weather data."""
|
||||
if weather_code == WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT:
|
||||
@ -269,12 +201,3 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
return ATTR_CONDITION_CLEAR_NIGHT
|
||||
|
||||
return CONDITION_MAP.get(weather_code)
|
||||
|
||||
|
||||
class LegacyWeather:
|
||||
"""Class to harmonize weather data model for hourly, daily and One Call APIs."""
|
||||
|
||||
def __init__(self, current_weather, forecast):
|
||||
"""Initialize weather object."""
|
||||
self.current = current_weather
|
||||
self.forecast = forecast
|
||||
|
@ -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"]
|
||||
}
|
||||
|
87
homeassistant/components/openweathermap/repairs.py
Normal file
87
homeassistant/components/openweathermap/repairs.py
Normal 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))
|
@ -30,12 +30,13 @@ from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import OpenweathermapConfigEntry
|
||||
from .const import (
|
||||
ATTR_API_CLOUD_COVERAGE,
|
||||
ATTR_API_CLOUDS,
|
||||
ATTR_API_CONDITION,
|
||||
ATTR_API_CURRENT,
|
||||
ATTR_API_DAILY_FORECAST,
|
||||
ATTR_API_DEW_POINT,
|
||||
ATTR_API_FEELS_LIKE_TEMPERATURE,
|
||||
ATTR_API_FORECAST,
|
||||
ATTR_API_FORECAST_CONDITION,
|
||||
ATTR_API_FORECAST_PRECIPITATION,
|
||||
ATTR_API_FORECAST_PRECIPITATION_PROBABILITY,
|
||||
ATTR_API_FORECAST_PRESSURE,
|
||||
@ -162,7 +163,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
)
|
||||
FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key=ATTR_API_FORECAST_CONDITION,
|
||||
key=ATTR_API_CONDITION,
|
||||
name="Condition",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
@ -211,7 +212,7 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=ATTR_API_CLOUDS,
|
||||
key=ATTR_API_CLOUD_COVERAGE,
|
||||
name="Cloud coverage",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
@ -313,7 +314,9 @@ class OpenWeatherMapSensor(AbstractOpenWeatherMapSensor):
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the device."""
|
||||
return self._weather_coordinator.data.get(self.entity_description.key, None)
|
||||
return self._weather_coordinator.data[ATTR_API_CURRENT].get(
|
||||
self.entity_description.key
|
||||
)
|
||||
|
||||
|
||||
class OpenWeatherMapForecastSensor(AbstractOpenWeatherMapSensor):
|
||||
@ -333,11 +336,8 @@ class OpenWeatherMapForecastSensor(AbstractOpenWeatherMapSensor):
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state of the device."""
|
||||
forecasts = self._weather_coordinator.data.get(ATTR_API_FORECAST)
|
||||
if not forecasts:
|
||||
return None
|
||||
|
||||
value = forecasts[0].get(self.entity_description.key, None)
|
||||
forecasts = self._weather_coordinator.data[ATTR_API_DAILY_FORECAST]
|
||||
value = forecasts[0].get(self.entity_description.key)
|
||||
if (
|
||||
value
|
||||
and self.entity_description.device_class is SensorDeviceClass.TIMESTAMP
|
||||
|
@ -5,7 +5,7 @@
|
||||
},
|
||||
"error": {
|
||||
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
"cannot_connect": "Failed to connect: {error}"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
@ -30,5 +30,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_v25": {
|
||||
"title": "OpenWeatherMap API V2.5 deprecated",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"migrate": {
|
||||
"title": "OpenWeatherMap API V2.5 deprecated",
|
||||
"description": "OWM API v2.5 will be closed in June 2024.\nYou need to migrate all your OpenWeatherMap integration to mode v3.0.\n\nBefore the migration, you must have active subscription (be aware subscripiton activation take up to 2h). After your subscription is activated click **Submit** to migrate the integration to API V3.0. Read documentation for more information."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
|
||||
"cannot_connect": "Failed to connect: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
20
homeassistant/components/openweathermap/utils.py
Normal file
20
homeassistant/components/openweathermap/utils.py
Normal 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
|
@ -2,21 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_FORECAST_CLOUD_COVERAGE,
|
||||
ATTR_FORECAST_CONDITION,
|
||||
ATTR_FORECAST_HUMIDITY,
|
||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP,
|
||||
ATTR_FORECAST_NATIVE_PRECIPITATION,
|
||||
ATTR_FORECAST_NATIVE_PRESSURE,
|
||||
ATTR_FORECAST_NATIVE_TEMP,
|
||||
ATTR_FORECAST_NATIVE_TEMP_LOW,
|
||||
ATTR_FORECAST_NATIVE_WIND_SPEED,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
|
||||
ATTR_FORECAST_TIME,
|
||||
ATTR_FORECAST_WIND_BEARING,
|
||||
Forecast,
|
||||
SingleCoordinatorWeatherEntity,
|
||||
WeatherEntityFeature,
|
||||
@ -35,21 +21,11 @@ from . import OpenweathermapConfigEntry
|
||||
from .const import (
|
||||
ATTR_API_CLOUDS,
|
||||
ATTR_API_CONDITION,
|
||||
ATTR_API_CURRENT,
|
||||
ATTR_API_DAILY_FORECAST,
|
||||
ATTR_API_DEW_POINT,
|
||||
ATTR_API_FEELS_LIKE_TEMPERATURE,
|
||||
ATTR_API_FORECAST,
|
||||
ATTR_API_FORECAST_CLOUDS,
|
||||
ATTR_API_FORECAST_CONDITION,
|
||||
ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE,
|
||||
ATTR_API_FORECAST_HUMIDITY,
|
||||
ATTR_API_FORECAST_PRECIPITATION,
|
||||
ATTR_API_FORECAST_PRECIPITATION_PROBABILITY,
|
||||
ATTR_API_FORECAST_PRESSURE,
|
||||
ATTR_API_FORECAST_TEMP,
|
||||
ATTR_API_FORECAST_TEMP_LOW,
|
||||
ATTR_API_FORECAST_TIME,
|
||||
ATTR_API_FORECAST_WIND_BEARING,
|
||||
ATTR_API_FORECAST_WIND_SPEED,
|
||||
ATTR_API_HOURLY_FORECAST,
|
||||
ATTR_API_HUMIDITY,
|
||||
ATTR_API_PRESSURE,
|
||||
ATTR_API_TEMPERATURE,
|
||||
@ -59,27 +35,10 @@ from .const import (
|
||||
ATTRIBUTION,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
FORECAST_MODE_DAILY,
|
||||
FORECAST_MODE_ONECALL_DAILY,
|
||||
MANUFACTURER,
|
||||
)
|
||||
from .coordinator import WeatherUpdateCoordinator
|
||||
|
||||
FORECAST_MAP = {
|
||||
ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION,
|
||||
ATTR_API_FORECAST_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION,
|
||||
ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY,
|
||||
ATTR_API_FORECAST_PRESSURE: ATTR_FORECAST_NATIVE_PRESSURE,
|
||||
ATTR_API_FORECAST_TEMP_LOW: ATTR_FORECAST_NATIVE_TEMP_LOW,
|
||||
ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP,
|
||||
ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME,
|
||||
ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING,
|
||||
ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED,
|
||||
ATTR_API_FORECAST_CLOUDS: ATTR_FORECAST_CLOUD_COVERAGE,
|
||||
ATTR_API_FORECAST_HUMIDITY: ATTR_FORECAST_HUMIDITY,
|
||||
ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE: ATTR_FORECAST_NATIVE_APPARENT_TEMP,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@ -124,84 +83,66 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina
|
||||
manufacturer=MANUFACTURER,
|
||||
name=DEFAULT_NAME,
|
||||
)
|
||||
if weather_coordinator.forecast_mode in (
|
||||
FORECAST_MODE_DAILY,
|
||||
FORECAST_MODE_ONECALL_DAILY,
|
||||
):
|
||||
self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY
|
||||
else: # FORECAST_MODE_DAILY or FORECAST_MODE_ONECALL_HOURLY
|
||||
self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY
|
||||
self._attr_supported_features = (
|
||||
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
|
||||
)
|
||||
|
||||
@property
|
||||
def condition(self) -> str | None:
|
||||
"""Return the current condition."""
|
||||
return self.coordinator.data[ATTR_API_CONDITION]
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_CONDITION]
|
||||
|
||||
@property
|
||||
def cloud_coverage(self) -> float | None:
|
||||
"""Return the Cloud coverage in %."""
|
||||
return self.coordinator.data[ATTR_API_CLOUDS]
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_CLOUDS]
|
||||
|
||||
@property
|
||||
def native_apparent_temperature(self) -> float | None:
|
||||
"""Return the apparent temperature."""
|
||||
return self.coordinator.data[ATTR_API_FEELS_LIKE_TEMPERATURE]
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_FEELS_LIKE_TEMPERATURE]
|
||||
|
||||
@property
|
||||
def native_temperature(self) -> float | None:
|
||||
"""Return the temperature."""
|
||||
return self.coordinator.data[ATTR_API_TEMPERATURE]
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_TEMPERATURE]
|
||||
|
||||
@property
|
||||
def native_pressure(self) -> float | None:
|
||||
"""Return the pressure."""
|
||||
return self.coordinator.data[ATTR_API_PRESSURE]
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_PRESSURE]
|
||||
|
||||
@property
|
||||
def humidity(self) -> float | None:
|
||||
"""Return the humidity."""
|
||||
return self.coordinator.data[ATTR_API_HUMIDITY]
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_HUMIDITY]
|
||||
|
||||
@property
|
||||
def native_dew_point(self) -> float | None:
|
||||
"""Return the dew point."""
|
||||
return self.coordinator.data[ATTR_API_DEW_POINT]
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_DEW_POINT]
|
||||
|
||||
@property
|
||||
def native_wind_gust_speed(self) -> float | None:
|
||||
"""Return the wind gust speed."""
|
||||
return self.coordinator.data[ATTR_API_WIND_GUST]
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_GUST]
|
||||
|
||||
@property
|
||||
def native_wind_speed(self) -> float | None:
|
||||
"""Return the wind speed."""
|
||||
return self.coordinator.data[ATTR_API_WIND_SPEED]
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_SPEED]
|
||||
|
||||
@property
|
||||
def wind_bearing(self) -> float | str | None:
|
||||
"""Return the wind bearing."""
|
||||
return self.coordinator.data[ATTR_API_WIND_BEARING]
|
||||
|
||||
@property
|
||||
def _forecast(self) -> list[Forecast] | None:
|
||||
"""Return the forecast array."""
|
||||
api_forecasts = self.coordinator.data[ATTR_API_FORECAST]
|
||||
forecasts = [
|
||||
{
|
||||
ha_key: forecast[api_key]
|
||||
for api_key, ha_key in FORECAST_MAP.items()
|
||||
if api_key in forecast
|
||||
}
|
||||
for forecast in api_forecasts
|
||||
]
|
||||
return cast(list[Forecast], forecasts)
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_BEARING]
|
||||
|
||||
@callback
|
||||
def _async_forecast_daily(self) -> list[Forecast] | None:
|
||||
"""Return the daily forecast in native units."""
|
||||
return self._forecast
|
||||
return self.coordinator.data[ATTR_API_DAILY_FORECAST]
|
||||
|
||||
@callback
|
||||
def _async_forecast_hourly(self) -> list[Forecast] | None:
|
||||
"""Return the hourly forecast in native units."""
|
||||
return self._forecast
|
||||
return self.coordinator.data[ATTR_API_HOURLY_FORECAST]
|
||||
|
@ -2039,6 +2039,9 @@ pyombi==0.1.10
|
||||
# homeassistant.components.openuv
|
||||
pyopenuv==2023.02.0
|
||||
|
||||
# homeassistant.components.openweathermap
|
||||
pyopenweathermap==0.0.9
|
||||
|
||||
# homeassistant.components.opnsense
|
||||
pyopnsense==0.4.0
|
||||
|
||||
@ -2059,9 +2062,6 @@ pyotp==2.8.0
|
||||
# homeassistant.components.overkiz
|
||||
pyoverkiz==1.13.10
|
||||
|
||||
# homeassistant.components.openweathermap
|
||||
pyowm==3.2.0
|
||||
|
||||
# homeassistant.components.onewire
|
||||
pyownet==0.10.0.post1
|
||||
|
||||
|
@ -1599,6 +1599,9 @@ pyoctoprintapi==0.1.12
|
||||
# homeassistant.components.openuv
|
||||
pyopenuv==2023.02.0
|
||||
|
||||
# homeassistant.components.openweathermap
|
||||
pyopenweathermap==0.0.9
|
||||
|
||||
# homeassistant.components.opnsense
|
||||
pyopnsense==0.4.0
|
||||
|
||||
@ -1616,9 +1619,6 @@ pyotp==2.8.0
|
||||
# homeassistant.components.overkiz
|
||||
pyoverkiz==1.13.10
|
||||
|
||||
# homeassistant.components.openweathermap
|
||||
pyowm==3.2.0
|
||||
|
||||
# homeassistant.components.onewire
|
||||
pyownet==0.10.0.post1
|
||||
|
||||
|
@ -1,13 +1,23 @@
|
||||
"""Define tests for the OpenWeatherMap config flow."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from pyowm.commons.exceptions import APIRequestError, UnauthorizedError
|
||||
from pyopenweathermap import (
|
||||
CurrentWeather,
|
||||
DailyTemperature,
|
||||
DailyWeatherForecast,
|
||||
RequestError,
|
||||
WeatherCondition,
|
||||
WeatherReport,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.openweathermap.const import (
|
||||
DEFAULT_FORECAST_MODE,
|
||||
DEFAULT_LANGUAGE,
|
||||
DEFAULT_OWM_MODE,
|
||||
DOMAIN,
|
||||
OWM_MODE_V25,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
@ -28,21 +38,110 @@ CONFIG = {
|
||||
CONF_API_KEY: "foo",
|
||||
CONF_LATITUDE: 50,
|
||||
CONF_LONGITUDE: 40,
|
||||
CONF_MODE: DEFAULT_FORECAST_MODE,
|
||||
CONF_LANGUAGE: DEFAULT_LANGUAGE,
|
||||
CONF_MODE: OWM_MODE_V25,
|
||||
}
|
||||
|
||||
VALID_YAML_CONFIG = {CONF_API_KEY: "foo"}
|
||||
|
||||
|
||||
async def test_form(hass: HomeAssistant) -> None:
|
||||
"""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,
|
||||
):
|
||||
"homeassistant.components.openweathermap.OWMClient",
|
||||
) as owm_client_mock:
|
||||
yield owm_client_mock
|
||||
|
||||
|
||||
@pytest.fixture(name="config_flow_owm_client_mock")
|
||||
def mock_config_flow_owm_client():
|
||||
"""Mock config_flow OWMClient."""
|
||||
with patch(
|
||||
"homeassistant.components.openweathermap.utils.OWMClient",
|
||||
) as config_flow_owm_client_mock:
|
||||
yield config_flow_owm_client_mock
|
||||
|
||||
|
||||
async def test_successful_config_flow(
|
||||
hass: HomeAssistant,
|
||||
owm_client_mock,
|
||||
config_flow_owm_client_mock,
|
||||
) -> None:
|
||||
"""Test that the form is served with valid input."""
|
||||
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}
|
||||
)
|
||||
@ -72,14 +171,39 @@ async def test_form(hass: HomeAssistant) -> None:
|
||||
assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY]
|
||||
|
||||
|
||||
async def test_form_options(hass: HomeAssistant) -> None:
|
||||
"""Test that the options form."""
|
||||
mocked_owm = _create_mocked_owm(True)
|
||||
async def test_abort_config_flow(
|
||||
hass: HomeAssistant,
|
||||
owm_client_mock,
|
||||
config_flow_owm_client_mock,
|
||||
) -> None:
|
||||
"""Test that the form is served with same data."""
|
||||
mock = _create_mocked_owm_client(True)
|
||||
owm_client_mock.return_value = mock
|
||||
config_flow_owm_client_mock.return_value = mock
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
|
||||
|
||||
async def test_config_flow_options_change(
|
||||
hass: HomeAssistant,
|
||||
owm_client_mock,
|
||||
config_flow_owm_client_mock,
|
||||
) -> None:
|
||||
"""Test that the options form."""
|
||||
mock = _create_mocked_owm_client(True)
|
||||
owm_client_mock.return_value = mock
|
||||
config_flow_owm_client_mock.return_value = mock
|
||||
|
||||
with patch(
|
||||
"pyowm.weatherapi25.weather_manager.WeatherManager",
|
||||
return_value=mocked_owm,
|
||||
):
|
||||
config_entry = MockConfigEntry(
|
||||
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["step_id"] == "init"
|
||||
|
||||
new_language = "es"
|
||||
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 config_entry.options == {
|
||||
CONF_MODE: "daily",
|
||||
CONF_LANGUAGE: DEFAULT_LANGUAGE,
|
||||
CONF_LANGUAGE: new_language,
|
||||
CONF_MODE: DEFAULT_OWM_MODE,
|
||||
}
|
||||
|
||||
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["step_id"] == "init"
|
||||
|
||||
updated_language = "es"
|
||||
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 config_entry.options == {
|
||||
CONF_MODE: "onecall_daily",
|
||||
CONF_LANGUAGE: DEFAULT_LANGUAGE,
|
||||
CONF_LANGUAGE: updated_language,
|
||||
CONF_MODE: DEFAULT_OWM_MODE,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
mocked_owm = _create_mocked_owm(True)
|
||||
|
||||
with patch(
|
||||
"pyowm.weatherapi25.weather_manager.WeatherManager",
|
||||
return_value=mocked_owm,
|
||||
side_effect=UnauthorizedError(""),
|
||||
):
|
||||
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
|
||||
)
|
||||
|
||||
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."""
|
||||
mocked_owm = _create_mocked_owm(True)
|
||||
|
||||
with patch(
|
||||
"pyowm.weatherapi25.weather_manager.WeatherManager",
|
||||
return_value=mocked_owm,
|
||||
side_effect=APIRequestError(""),
|
||||
):
|
||||
config_flow_owm_client_mock.return_value = _create_mocked_owm_client(True)
|
||||
config_flow_owm_client_mock.side_effect = RequestError("oops")
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_form_api_offline(hass: HomeAssistant) -> None:
|
||||
"""Test setting up with api call error."""
|
||||
mocked_owm = _create_mocked_owm(False)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.openweathermap.config_flow.OWM",
|
||||
return_value=mocked_owm,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
|
||||
config_flow_owm_client_mock.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=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
|
||||
)
|
||||
|
||||
return mocked_owm
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
Loading…
x
Reference in New Issue
Block a user