mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 08:47:57 +00:00
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:
parent
0a2ff3a676
commit
683c2f8d22
@ -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,
|
||||
}
|
||||
|
18
homeassistant/components/weather/services.yaml
Normal file
18
homeassistant/components/weather/services.yaml
Normal 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
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
@ -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,
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user