Add service for getting a weather forecast (#97078)

* Add service for getting a weather forecast

* Fix translations

* Improve service description

* Improve error handling

* Adjust typing

* Adjust typing

* Adjust service response format
This commit is contained in:
Erik Montnemery 2023-08-07 14:05:37 +02:00 committed by GitHub
parent 0a2ff3a676
commit 683c2f8d22
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 229 additions and 6 deletions

View File

@ -9,6 +9,8 @@ import inspect
import logging
from typing import Any, Final, Literal, Required, TypedDict, final
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PRECISION_HALVES,
@ -18,7 +20,15 @@ from homeassistant.const import (
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.core import (
CALLBACK_TYPE,
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.config_validation import ( # noqa: F401
PLATFORM_SCHEMA,
PLATFORM_SCHEMA_BASE,
@ -26,6 +36,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.json import JsonValueType
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from .const import ( # noqa: F401
@ -103,6 +114,8 @@ SCAN_INTERVAL = timedelta(seconds=30)
ROUNDING_PRECISION = 2
SERVICE_GET_FORECAST: Final = "get_forecast"
# mypy: disallow-any-generics
@ -158,6 +171,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component = hass.data[DOMAIN] = EntityComponent[WeatherEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
component.async_register_entity_service(
SERVICE_GET_FORECAST,
{vol.Required("type"): vol.In(("daily", "hourly", "twice_daily"))},
async_get_forecast_service,
required_features=[
WeatherEntityFeature.FORECAST_DAILY,
WeatherEntityFeature.FORECAST_HOURLY,
WeatherEntityFeature.FORECAST_TWICE_DAILY,
],
supports_response=SupportsResponse.ONLY,
)
async_setup_ws_api(hass)
await component.async_setup(config)
return True
@ -238,7 +262,7 @@ class WeatherEntity(Entity):
_forecast_listeners: dict[
Literal["daily", "hourly", "twice_daily"],
list[Callable[[list[dict[str, Any]] | None], None]],
list[Callable[[list[JsonValueType] | None], None]],
]
_weather_option_temperature_unit: str | None = None
@ -789,9 +813,9 @@ class WeatherEntity(Entity):
@final
def _convert_forecast(
self, native_forecast_list: list[Forecast]
) -> list[dict[str, Any]]:
) -> list[JsonValueType]:
"""Convert a forecast in native units to the unit configured by the user."""
converted_forecast_list: list[dict[str, Any]] = []
converted_forecast_list: list[JsonValueType] = []
precision = self.precision
from_temp_unit = self.native_temperature_unit or self._default_temperature_unit
@ -1029,7 +1053,7 @@ class WeatherEntity(Entity):
def async_subscribe_forecast(
self,
forecast_type: Literal["daily", "hourly", "twice_daily"],
forecast_listener: Callable[[list[dict[str, Any]] | None], None],
forecast_listener: Callable[[list[JsonValueType] | None], None],
) -> CALLBACK_TYPE:
"""Subscribe to forecast updates.
@ -1079,3 +1103,38 @@ class WeatherEntity(Entity):
converted_forecast_list = self._convert_forecast(native_forecast_list)
for listener in self._forecast_listeners[forecast_type]:
listener(converted_forecast_list)
def raise_unsupported_forecast(entity_id: str, forecast_type: str) -> None:
"""Raise error on attempt to get an unsupported forecast."""
raise HomeAssistantError(
f"Weather entity '{entity_id}' does not support '{forecast_type}' forecast"
)
async def async_get_forecast_service(
weather: WeatherEntity, service_call: ServiceCall
) -> ServiceResponse:
"""Get weather forecast."""
forecast_type = service_call.data["type"]
supported_features = weather.supported_features or 0
if forecast_type == "daily":
if (supported_features & WeatherEntityFeature.FORECAST_DAILY) == 0:
raise_unsupported_forecast(weather.entity_id, forecast_type)
native_forecast_list = await weather.async_forecast_daily()
elif forecast_type == "hourly":
if (supported_features & WeatherEntityFeature.FORECAST_HOURLY) == 0:
raise_unsupported_forecast(weather.entity_id, forecast_type)
native_forecast_list = await weather.async_forecast_hourly()
else:
if (supported_features & WeatherEntityFeature.FORECAST_TWICE_DAILY) == 0:
raise_unsupported_forecast(weather.entity_id, forecast_type)
native_forecast_list = await weather.async_forecast_twice_daily()
if native_forecast_list is None:
converted_forecast_list = []
else:
# pylint: disable-next=protected-access
converted_forecast_list = weather._convert_forecast(native_forecast_list)
return {
"forecast": converted_forecast_list,
}

View File

@ -0,0 +1,18 @@
get_forecast:
target:
entity:
domain: weather
supported_features:
- weather.WeatherEntityFeature.FORECAST_DAILY
- weather.WeatherEntityFeature.FORECAST_HOURLY
- weather.WeatherEntityFeature.FORECAST_TWICE_DAILY
fields:
type:
required: true
selector:
select:
options:
- "daily"
- "hourly"
- "twice_daily"
translation_key: forecast_type

View File

@ -77,5 +77,26 @@
}
}
}
},
"selector": {
"forecast_type": {
"options": {
"daily": "Daily",
"hourly": "Hourly",
"twice_daily": "Twice daily"
}
}
},
"services": {
"get_forecast": {
"name": "Get forecast",
"description": "Get weather forecast.",
"fields": {
"type": {
"name": "Forecast type",
"description": "Forecast type: daily, hourly or twice daily."
}
}
}
}
}

View File

@ -9,6 +9,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.util.json import JsonValueType
from .const import DOMAIN, VALID_UNITS, WeatherEntityFeature
@ -80,7 +81,7 @@ async def ws_subscribe_forecast(
return
@callback
def forecast_listener(forecast: list[dict[str, Any]] | None) -> None:
def forecast_listener(forecast: list[JsonValueType] | None) -> None:
"""Push a new forecast to websocket."""
connection.send_message(
websocket_api.event_message(

View File

@ -100,6 +100,7 @@ def _entity_features() -> dict[str, type[IntFlag]]:
from homeassistant.components.update import UpdateEntityFeature
from homeassistant.components.vacuum import VacuumEntityFeature
from homeassistant.components.water_heater import WaterHeaterEntityFeature
from homeassistant.components.weather import WeatherEntityFeature
return {
"AlarmControlPanelEntityFeature": AlarmControlPanelEntityFeature,
@ -117,6 +118,7 @@ def _entity_features() -> dict[str, type[IntFlag]]:
"UpdateEntityFeature": UpdateEntityFeature,
"VacuumEntityFeature": VacuumEntityFeature,
"WaterHeaterEntityFeature": WaterHeaterEntityFeature,
"WeatherEntityFeature": WeatherEntityFeature,
}

View File

@ -1,5 +1,6 @@
"""The test for weather entity."""
from datetime import datetime
from typing import Any
import pytest
@ -31,7 +32,9 @@ from homeassistant.components.weather import (
ATTR_WEATHER_WIND_GUST_SPEED,
ATTR_WEATHER_WIND_SPEED,
ATTR_WEATHER_WIND_SPEED_UNIT,
DOMAIN,
ROUNDING_PRECISION,
SERVICE_GET_FORECAST,
Forecast,
WeatherEntity,
WeatherEntityFeature,
@ -53,6 +56,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@ -1103,3 +1107,121 @@ async def test_forecast_twice_daily_missing_is_daytime(
assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"}
assert not msg["success"]
assert msg["type"] == "result"
@pytest.mark.parametrize(
("forecast_type", "supported_features", "extra"),
[
("daily", WeatherEntityFeature.FORECAST_DAILY, {}),
("hourly", WeatherEntityFeature.FORECAST_HOURLY, {}),
(
"twice_daily",
WeatherEntityFeature.FORECAST_TWICE_DAILY,
{"is_daytime": True},
),
],
)
async def test_get_forecast(
hass: HomeAssistant,
enable_custom_integrations: None,
forecast_type: str,
supported_features: int,
extra: dict[str, Any],
) -> None:
"""Test get forecast service."""
entity0 = await create_entity(
hass,
native_temperature=38,
native_temperature_unit=UnitOfTemperature.CELSIUS,
supported_features=supported_features,
)
response = await hass.services.async_call(
DOMAIN,
SERVICE_GET_FORECAST,
{
"entity_id": entity0.entity_id,
"type": forecast_type,
},
blocking=True,
return_response=True,
)
assert response == {
"forecast": [
{
"cloud_coverage": None,
"temperature": 38.0,
"templow": 38.0,
"uv_index": None,
"wind_bearing": None,
}
| extra
],
}
async def test_get_forecast_no_forecast(
hass: HomeAssistant,
enable_custom_integrations: None,
) -> None:
"""Test get forecast service."""
entity0 = await create_entity(
hass,
native_temperature=38,
native_temperature_unit=UnitOfTemperature.CELSIUS,
supported_features=WeatherEntityFeature.FORECAST_DAILY,
)
entity0.forecast_list = None
response = await hass.services.async_call(
DOMAIN,
SERVICE_GET_FORECAST,
{
"entity_id": entity0.entity_id,
"type": "daily",
},
blocking=True,
return_response=True,
)
assert response == {
"forecast": [],
}
@pytest.mark.parametrize(
("supported_features", "forecast_types"),
[
(WeatherEntityFeature.FORECAST_DAILY, ["hourly", "twice_daily"]),
(WeatherEntityFeature.FORECAST_HOURLY, ["daily", "twice_daily"]),
(WeatherEntityFeature.FORECAST_TWICE_DAILY, ["daily", "hourly"]),
],
)
async def test_get_forecast_unsupported(
hass: HomeAssistant,
enable_custom_integrations: None,
forecast_types: list[str],
supported_features: int,
) -> None:
"""Test get forecast service."""
entity0 = await create_entity(
hass,
native_temperature=38,
native_temperature_unit=UnitOfTemperature.CELSIUS,
supported_features=supported_features,
)
for forecast_type in forecast_types:
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
DOMAIN,
SERVICE_GET_FORECAST,
{
"entity_id": entity0.entity_id,
"type": forecast_type,
},
blocking=True,
return_response=True,
)