Add OpenWeatherMap Minute forecast action (#128799)
parent
f96e31fad8
commit
1fb51ef189
|
@ -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"
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"services": {
|
||||
"get_minute_forecast": {
|
||||
"service": "mdi:weather-snowy-rainy"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
get_minute_forecast:
|
||||
target:
|
||||
entity:
|
||||
domain: weather
|
||||
integration: openweathermap
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
# ---
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
)
|
Loading…
Reference in New Issue