Add OpenWeatherMap Minute forecast action (#128799)

pull/139266/head
Andrew 2025-02-25 14:54:10 +00:00 committed by GitHub
parent f96e31fad8
commit 1fb51ef189
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 240 additions and 11 deletions

View File

@ -48,6 +48,7 @@ ATTR_API_WEATHER_CODE = "weather_code"
ATTR_API_CLOUD_COVERAGE = "cloud_coverage"
ATTR_API_FORECAST = "forecast"
ATTR_API_CURRENT = "current"
ATTR_API_MINUTE_FORECAST = "minute_forecast"
ATTR_API_HOURLY_FORECAST = "hourly_forecast"
ATTR_API_DAILY_FORECAST = "daily_forecast"
UPDATE_LISTENER = "update_listener"

View File

@ -10,6 +10,7 @@ from pyopenweathermap import (
CurrentWeather,
DailyWeatherForecast,
HourlyWeatherForecast,
MinutelyWeatherForecast,
OWMClient,
RequestError,
WeatherReport,
@ -34,10 +35,14 @@ from .const import (
ATTR_API_CONDITION,
ATTR_API_CURRENT,
ATTR_API_DAILY_FORECAST,
ATTR_API_DATETIME,
ATTR_API_DEW_POINT,
ATTR_API_FEELS_LIKE_TEMPERATURE,
ATTR_API_FORECAST,
ATTR_API_HOURLY_FORECAST,
ATTR_API_HUMIDITY,
ATTR_API_MINUTE_FORECAST,
ATTR_API_PRECIPITATION,
ATTR_API_PRECIPITATION_KIND,
ATTR_API_PRESSURE,
ATTR_API_RAIN,
@ -106,6 +111,11 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
return {
ATTR_API_CURRENT: current_weather,
ATTR_API_MINUTE_FORECAST: (
self._get_minute_weather_data(weather_report.minutely_forecast)
if weather_report.minutely_forecast is not None
else {}
),
ATTR_API_HOURLY_FORECAST: [
self._get_hourly_forecast_weather_data(item)
for item in weather_report.hourly_forecast
@ -116,6 +126,20 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
],
}
def _get_minute_weather_data(
self, minute_forecast: list[MinutelyWeatherForecast]
) -> dict:
"""Get minute weather data from the forecast."""
return {
ATTR_API_FORECAST: [
{
ATTR_API_DATETIME: item.date_time,
ATTR_API_PRECIPITATION: round(item.precipitation, 2),
}
for item in minute_forecast
]
}
def _get_current_weather_data(self, current_weather: CurrentWeather):
return {
ATTR_API_CONDITION: self._get_condition(current_weather.condition.id),

View File

@ -0,0 +1,7 @@
{
"services": {
"get_minute_forecast": {
"service": "mdi:weather-snowy-rainy"
}
}
}

View File

@ -0,0 +1,5 @@
get_minute_forecast:
target:
entity:
domain: weather
integration: openweathermap

View File

@ -47,5 +47,16 @@
}
}
}
},
"services": {
"get_minute_forecast": {
"name": "Get minute forecast",
"description": "Get minute weather forecast."
}
},
"exceptions": {
"service_minute_forecast_mode": {
"message": "Minute forecast is available only when {name} mode is set to v3.0"
}
}
}

View File

@ -14,7 +14,9 @@ from homeassistant.const import (
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant, SupportsResponse, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_platform
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@ -28,6 +30,7 @@ from .const import (
ATTR_API_FEELS_LIKE_TEMPERATURE,
ATTR_API_HOURLY_FORECAST,
ATTR_API_HUMIDITY,
ATTR_API_MINUTE_FORECAST,
ATTR_API_PRESSURE,
ATTR_API_TEMPERATURE,
ATTR_API_VISIBILITY_DISTANCE,
@ -44,6 +47,8 @@ from .const import (
)
from .coordinator import WeatherUpdateCoordinator
SERVICE_GET_MINUTE_FORECAST = "get_minute_forecast"
async def async_setup_entry(
hass: HomeAssistant,
@ -61,6 +66,14 @@ async def async_setup_entry(
async_add_entities([owm_weather], False)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
name=SERVICE_GET_MINUTE_FORECAST,
schema=None,
func="async_get_minute_forecast",
supports_response=SupportsResponse.ONLY,
)
class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
"""Implementation of an OpenWeatherMap sensor."""
@ -91,6 +104,7 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina
manufacturer=MANUFACTURER,
name=DEFAULT_NAME,
)
self.mode = mode
if mode in (OWM_MODE_V30, OWM_MODE_V25):
self._attr_supported_features = (
@ -100,6 +114,17 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina
elif mode == OWM_MODE_FREE_FORECAST:
self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY
async def async_get_minute_forecast(self) -> dict[str, list[dict]] | dict:
"""Return Minute forecast."""
if self.mode == OWM_MODE_V30:
return self.coordinator.data[ATTR_API_MINUTE_FORECAST]
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="service_minute_forecast_mode",
translation_placeholders={"name": DEFAULT_NAME},
)
@property
def condition(self) -> str | None:
"""Return the current condition."""

View File

@ -0,0 +1,25 @@
# serializer version: 1
# name: test_get_minute_forecast[mock_service_response]
dict({
'weather.openweathermap': dict({
'forecast': list([
dict({
'datetime': 1728672360,
'precipitation': 0,
}),
dict({
'datetime': 1728672420,
'precipitation': 1.23,
}),
dict({
'datetime': 1728672480,
'precipitation': 4.5,
}),
dict({
'datetime': 1728672540,
'precipitation': 0,
}),
]),
}),
})
# ---

View File

@ -18,7 +18,7 @@ from homeassistant.components.openweathermap.const import (
DEFAULT_LANGUAGE,
DEFAULT_OWM_MODE,
DOMAIN,
OWM_MODE_V25,
OWM_MODE_V30,
)
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
from homeassistant.const import (
@ -40,13 +40,15 @@ CONFIG = {
CONF_LATITUDE: 50,
CONF_LONGITUDE: 40,
CONF_LANGUAGE: DEFAULT_LANGUAGE,
CONF_MODE: OWM_MODE_V25,
CONF_MODE: OWM_MODE_V30,
}
VALID_YAML_CONFIG = {CONF_API_KEY: "foo"}
def _create_mocked_owm_factory(is_valid: bool):
def _create_static_weather_report() -> WeatherReport:
"""Create a static WeatherReport."""
current_weather = CurrentWeather(
date_time=datetime.fromtimestamp(1714063536, tz=UTC),
temperature=6.84,
@ -60,8 +62,8 @@ def _create_mocked_owm_factory(is_valid: bool):
wind_speed=9.83,
wind_bearing=199,
wind_gust=None,
rain={},
snow={},
rain={"1h": 1.21},
snow=None,
condition=WeatherCondition(
id=803,
main="Clouds",
@ -106,13 +108,21 @@ def _create_mocked_owm_factory(is_valid: bool):
rain=0,
snow=0,
)
minutely_weather_forecast = MinutelyWeatherForecast(
date_time=1728672360, precipitation=2.54
)
weather_report = WeatherReport(
current_weather, [minutely_weather_forecast], [], [daily_weather_forecast]
minutely_weather_forecast = [
MinutelyWeatherForecast(date_time=1728672360, precipitation=0),
MinutelyWeatherForecast(date_time=1728672420, precipitation=1.23),
MinutelyWeatherForecast(date_time=1728672480, precipitation=4.5),
MinutelyWeatherForecast(date_time=1728672540, precipitation=0),
]
return WeatherReport(
current_weather, minutely_weather_forecast, [], [daily_weather_forecast]
)
def _create_mocked_owm_factory(is_valid: bool):
"""Create a mocked OWM client."""
weather_report = _create_static_weather_report()
mocked_owm_client = MagicMock()
mocked_owm_client.validate_key = AsyncMock(return_value=is_valid)
mocked_owm_client.get_weather = AsyncMock(return_value=weather_report)

View File

@ -0,0 +1,121 @@
"""Test the OpenWeatherMap weather entity."""
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.openweathermap.const import (
DEFAULT_LANGUAGE,
DOMAIN,
OWM_MODE_V25,
OWM_MODE_V30,
)
from homeassistant.components.openweathermap.weather import SERVICE_GET_MINUTE_FORECAST
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
CONF_API_KEY,
CONF_LANGUAGE,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_MODE,
CONF_NAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from .test_config_flow import _create_static_weather_report
from tests.common import AsyncMock, MockConfigEntry, patch
ENTITY_ID = "weather.openweathermap"
API_KEY = "test_api_key"
LATITUDE = 12.34
LONGITUDE = 56.78
NAME = "openweathermap"
# Define test data for mocked weather report
static_weather_report = _create_static_weather_report()
def mock_config_entry(mode: str) -> MockConfigEntry:
"""Create a mock OpenWeatherMap config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_API_KEY: API_KEY,
CONF_LATITUDE: LATITUDE,
CONF_LONGITUDE: LONGITUDE,
CONF_NAME: NAME,
},
options={CONF_MODE: mode, CONF_LANGUAGE: DEFAULT_LANGUAGE},
version=5,
)
@pytest.fixture
def mock_config_entry_v25() -> MockConfigEntry:
"""Create a mock OpenWeatherMap v2.5 config entry."""
return mock_config_entry(OWM_MODE_V25)
@pytest.fixture
def mock_config_entry_v30() -> MockConfigEntry:
"""Create a mock OpenWeatherMap v3.0 config entry."""
return mock_config_entry(OWM_MODE_V30)
async def setup_mock_config_entry(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
):
"""Set up the MockConfigEntry and assert it is loaded correctly."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get(ENTITY_ID)
assert mock_config_entry.state is ConfigEntryState.LOADED
@patch(
"pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather",
AsyncMock(return_value=static_weather_report),
)
async def test_get_minute_forecast(
hass: HomeAssistant,
mock_config_entry_v30: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the get_minute_forecast Service call."""
await setup_mock_config_entry(hass, mock_config_entry_v30)
result = await hass.services.async_call(
DOMAIN,
SERVICE_GET_MINUTE_FORECAST,
{"entity_id": ENTITY_ID},
blocking=True,
return_response=True,
)
assert result == snapshot(name="mock_service_response")
@patch(
"pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather",
AsyncMock(return_value=static_weather_report),
)
async def test_mode_fail(
hass: HomeAssistant,
mock_config_entry_v25: MockConfigEntry,
) -> None:
"""Test that Minute forecasting fails when mode is not v3.0."""
await setup_mock_config_entry(hass, mock_config_entry_v25)
# Expect a ServiceValidationError when mode is not OWM_MODE_V30
with pytest.raises(
ServiceValidationError,
match="Minute forecast is available only when OpenWeatherMap mode is set to v3.0",
):
await hass.services.async_call(
DOMAIN,
SERVICE_GET_MINUTE_FORECAST,
{"entity_id": ENTITY_ID},
blocking=True,
return_response=True,
)