mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 05:07:41 +00:00
Add OpenWeatherMap Minute forecast action (#128799)
This commit is contained in:
parent
f96e31fad8
commit
1fb51ef189
@ -48,6 +48,7 @@ ATTR_API_WEATHER_CODE = "weather_code"
|
|||||||
ATTR_API_CLOUD_COVERAGE = "cloud_coverage"
|
ATTR_API_CLOUD_COVERAGE = "cloud_coverage"
|
||||||
ATTR_API_FORECAST = "forecast"
|
ATTR_API_FORECAST = "forecast"
|
||||||
ATTR_API_CURRENT = "current"
|
ATTR_API_CURRENT = "current"
|
||||||
|
ATTR_API_MINUTE_FORECAST = "minute_forecast"
|
||||||
ATTR_API_HOURLY_FORECAST = "hourly_forecast"
|
ATTR_API_HOURLY_FORECAST = "hourly_forecast"
|
||||||
ATTR_API_DAILY_FORECAST = "daily_forecast"
|
ATTR_API_DAILY_FORECAST = "daily_forecast"
|
||||||
UPDATE_LISTENER = "update_listener"
|
UPDATE_LISTENER = "update_listener"
|
||||||
|
@ -10,6 +10,7 @@ from pyopenweathermap import (
|
|||||||
CurrentWeather,
|
CurrentWeather,
|
||||||
DailyWeatherForecast,
|
DailyWeatherForecast,
|
||||||
HourlyWeatherForecast,
|
HourlyWeatherForecast,
|
||||||
|
MinutelyWeatherForecast,
|
||||||
OWMClient,
|
OWMClient,
|
||||||
RequestError,
|
RequestError,
|
||||||
WeatherReport,
|
WeatherReport,
|
||||||
@ -34,10 +35,14 @@ from .const import (
|
|||||||
ATTR_API_CONDITION,
|
ATTR_API_CONDITION,
|
||||||
ATTR_API_CURRENT,
|
ATTR_API_CURRENT,
|
||||||
ATTR_API_DAILY_FORECAST,
|
ATTR_API_DAILY_FORECAST,
|
||||||
|
ATTR_API_DATETIME,
|
||||||
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_HOURLY_FORECAST,
|
||||||
ATTR_API_HUMIDITY,
|
ATTR_API_HUMIDITY,
|
||||||
|
ATTR_API_MINUTE_FORECAST,
|
||||||
|
ATTR_API_PRECIPITATION,
|
||||||
ATTR_API_PRECIPITATION_KIND,
|
ATTR_API_PRECIPITATION_KIND,
|
||||||
ATTR_API_PRESSURE,
|
ATTR_API_PRESSURE,
|
||||||
ATTR_API_RAIN,
|
ATTR_API_RAIN,
|
||||||
@ -106,6 +111,11 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ATTR_API_CURRENT: current_weather,
|
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: [
|
ATTR_API_HOURLY_FORECAST: [
|
||||||
self._get_hourly_forecast_weather_data(item)
|
self._get_hourly_forecast_weather_data(item)
|
||||||
for item in weather_report.hourly_forecast
|
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):
|
def _get_current_weather_data(self, current_weather: CurrentWeather):
|
||||||
return {
|
return {
|
||||||
ATTR_API_CONDITION: self._get_condition(current_weather.condition.id),
|
ATTR_API_CONDITION: self._get_condition(current_weather.condition.id),
|
||||||
|
7
homeassistant/components/openweathermap/icons.json
Normal file
7
homeassistant/components/openweathermap/icons.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"services": {
|
||||||
|
"get_minute_forecast": {
|
||||||
|
"service": "mdi:weather-snowy-rainy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
homeassistant/components/openweathermap/services.yaml
Normal file
5
homeassistant/components/openweathermap/services.yaml
Normal file
@ -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,
|
UnitOfSpeed,
|
||||||
UnitOfTemperature,
|
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.device_registry import DeviceEntryType, DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
@ -28,6 +30,7 @@ from .const import (
|
|||||||
ATTR_API_FEELS_LIKE_TEMPERATURE,
|
ATTR_API_FEELS_LIKE_TEMPERATURE,
|
||||||
ATTR_API_HOURLY_FORECAST,
|
ATTR_API_HOURLY_FORECAST,
|
||||||
ATTR_API_HUMIDITY,
|
ATTR_API_HUMIDITY,
|
||||||
|
ATTR_API_MINUTE_FORECAST,
|
||||||
ATTR_API_PRESSURE,
|
ATTR_API_PRESSURE,
|
||||||
ATTR_API_TEMPERATURE,
|
ATTR_API_TEMPERATURE,
|
||||||
ATTR_API_VISIBILITY_DISTANCE,
|
ATTR_API_VISIBILITY_DISTANCE,
|
||||||
@ -44,6 +47,8 @@ from .const import (
|
|||||||
)
|
)
|
||||||
from .coordinator import WeatherUpdateCoordinator
|
from .coordinator import WeatherUpdateCoordinator
|
||||||
|
|
||||||
|
SERVICE_GET_MINUTE_FORECAST = "get_minute_forecast"
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@ -61,6 +66,14 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
async_add_entities([owm_weather], False)
|
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]):
|
class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
|
||||||
"""Implementation of an OpenWeatherMap sensor."""
|
"""Implementation of an OpenWeatherMap sensor."""
|
||||||
@ -91,6 +104,7 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina
|
|||||||
manufacturer=MANUFACTURER,
|
manufacturer=MANUFACTURER,
|
||||||
name=DEFAULT_NAME,
|
name=DEFAULT_NAME,
|
||||||
)
|
)
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
if mode in (OWM_MODE_V30, OWM_MODE_V25):
|
if mode in (OWM_MODE_V30, OWM_MODE_V25):
|
||||||
self._attr_supported_features = (
|
self._attr_supported_features = (
|
||||||
@ -100,6 +114,17 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina
|
|||||||
elif mode == OWM_MODE_FREE_FORECAST:
|
elif mode == OWM_MODE_FREE_FORECAST:
|
||||||
self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY
|
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
|
@property
|
||||||
def condition(self) -> str | None:
|
def condition(self) -> str | None:
|
||||||
"""Return the current condition."""
|
"""Return the current condition."""
|
||||||
|
25
tests/components/openweathermap/snapshots/test_weather.ambr
Normal file
25
tests/components/openweathermap/snapshots/test_weather.ambr
Normal 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,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
# ---
|
@ -18,7 +18,7 @@ from homeassistant.components.openweathermap.const import (
|
|||||||
DEFAULT_LANGUAGE,
|
DEFAULT_LANGUAGE,
|
||||||
DEFAULT_OWM_MODE,
|
DEFAULT_OWM_MODE,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
OWM_MODE_V25,
|
OWM_MODE_V30,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
|
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
@ -40,13 +40,15 @@ CONFIG = {
|
|||||||
CONF_LATITUDE: 50,
|
CONF_LATITUDE: 50,
|
||||||
CONF_LONGITUDE: 40,
|
CONF_LONGITUDE: 40,
|
||||||
CONF_LANGUAGE: DEFAULT_LANGUAGE,
|
CONF_LANGUAGE: DEFAULT_LANGUAGE,
|
||||||
CONF_MODE: OWM_MODE_V25,
|
CONF_MODE: OWM_MODE_V30,
|
||||||
}
|
}
|
||||||
|
|
||||||
VALID_YAML_CONFIG = {CONF_API_KEY: "foo"}
|
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(
|
current_weather = CurrentWeather(
|
||||||
date_time=datetime.fromtimestamp(1714063536, tz=UTC),
|
date_time=datetime.fromtimestamp(1714063536, tz=UTC),
|
||||||
temperature=6.84,
|
temperature=6.84,
|
||||||
@ -60,8 +62,8 @@ def _create_mocked_owm_factory(is_valid: bool):
|
|||||||
wind_speed=9.83,
|
wind_speed=9.83,
|
||||||
wind_bearing=199,
|
wind_bearing=199,
|
||||||
wind_gust=None,
|
wind_gust=None,
|
||||||
rain={},
|
rain={"1h": 1.21},
|
||||||
snow={},
|
snow=None,
|
||||||
condition=WeatherCondition(
|
condition=WeatherCondition(
|
||||||
id=803,
|
id=803,
|
||||||
main="Clouds",
|
main="Clouds",
|
||||||
@ -106,13 +108,21 @@ def _create_mocked_owm_factory(is_valid: bool):
|
|||||||
rain=0,
|
rain=0,
|
||||||
snow=0,
|
snow=0,
|
||||||
)
|
)
|
||||||
minutely_weather_forecast = MinutelyWeatherForecast(
|
minutely_weather_forecast = [
|
||||||
date_time=1728672360, precipitation=2.54
|
MinutelyWeatherForecast(date_time=1728672360, precipitation=0),
|
||||||
)
|
MinutelyWeatherForecast(date_time=1728672420, precipitation=1.23),
|
||||||
weather_report = WeatherReport(
|
MinutelyWeatherForecast(date_time=1728672480, precipitation=4.5),
|
||||||
current_weather, [minutely_weather_forecast], [], [daily_weather_forecast]
|
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 = MagicMock()
|
||||||
mocked_owm_client.validate_key = AsyncMock(return_value=is_valid)
|
mocked_owm_client.validate_key = AsyncMock(return_value=is_valid)
|
||||||
mocked_owm_client.get_weather = AsyncMock(return_value=weather_report)
|
mocked_owm_client.get_weather = AsyncMock(return_value=weather_report)
|
||||||
|
121
tests/components/openweathermap/test_weather.py
Normal file
121
tests/components/openweathermap/test_weather.py
Normal 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,
|
||||||
|
)
|
Loading…
x
Reference in New Issue
Block a user