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
6 changed files with 229 additions and 6 deletions
|
@ -9,6 +9,8 @@ import inspect
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Final, Literal, Required, TypedDict, final
|
from typing import Any, Final, Literal, Required, TypedDict, final
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
PRECISION_HALVES,
|
PRECISION_HALVES,
|
||||||
|
@ -18,7 +20,15 @@ from homeassistant.const import (
|
||||||
UnitOfSpeed,
|
UnitOfSpeed,
|
||||||
UnitOfTemperature,
|
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
|
from homeassistant.helpers.config_validation import ( # noqa: F401
|
||||||
PLATFORM_SCHEMA,
|
PLATFORM_SCHEMA,
|
||||||
PLATFORM_SCHEMA_BASE,
|
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 import Entity, EntityDescription
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
from homeassistant.util.json import JsonValueType
|
||||||
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
|
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
|
||||||
|
|
||||||
from .const import ( # noqa: F401
|
from .const import ( # noqa: F401
|
||||||
|
@ -103,6 +114,8 @@ SCAN_INTERVAL = timedelta(seconds=30)
|
||||||
|
|
||||||
ROUNDING_PRECISION = 2
|
ROUNDING_PRECISION = 2
|
||||||
|
|
||||||
|
SERVICE_GET_FORECAST: Final = "get_forecast"
|
||||||
|
|
||||||
# mypy: disallow-any-generics
|
# mypy: disallow-any-generics
|
||||||
|
|
||||||
|
|
||||||
|
@ -158,6 +171,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
component = hass.data[DOMAIN] = EntityComponent[WeatherEntity](
|
component = hass.data[DOMAIN] = EntityComponent[WeatherEntity](
|
||||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
|
_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)
|
async_setup_ws_api(hass)
|
||||||
await component.async_setup(config)
|
await component.async_setup(config)
|
||||||
return True
|
return True
|
||||||
|
@ -238,7 +262,7 @@ class WeatherEntity(Entity):
|
||||||
|
|
||||||
_forecast_listeners: dict[
|
_forecast_listeners: dict[
|
||||||
Literal["daily", "hourly", "twice_daily"],
|
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
|
_weather_option_temperature_unit: str | None = None
|
||||||
|
@ -789,9 +813,9 @@ class WeatherEntity(Entity):
|
||||||
@final
|
@final
|
||||||
def _convert_forecast(
|
def _convert_forecast(
|
||||||
self, native_forecast_list: list[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."""
|
"""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
|
precision = self.precision
|
||||||
|
|
||||||
from_temp_unit = self.native_temperature_unit or self._default_temperature_unit
|
from_temp_unit = self.native_temperature_unit or self._default_temperature_unit
|
||||||
|
@ -1029,7 +1053,7 @@ class WeatherEntity(Entity):
|
||||||
def async_subscribe_forecast(
|
def async_subscribe_forecast(
|
||||||
self,
|
self,
|
||||||
forecast_type: Literal["daily", "hourly", "twice_daily"],
|
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:
|
) -> CALLBACK_TYPE:
|
||||||
"""Subscribe to forecast updates.
|
"""Subscribe to forecast updates.
|
||||||
|
|
||||||
|
@ -1079,3 +1103,38 @@ class WeatherEntity(Entity):
|
||||||
converted_forecast_list = self._convert_forecast(native_forecast_list)
|
converted_forecast_list = self._convert_forecast(native_forecast_list)
|
||||||
for listener in self._forecast_listeners[forecast_type]:
|
for listener in self._forecast_listeners[forecast_type]:
|
||||||
listener(converted_forecast_list)
|
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.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
|
from homeassistant.util.json import JsonValueType
|
||||||
|
|
||||||
from .const import DOMAIN, VALID_UNITS, WeatherEntityFeature
|
from .const import DOMAIN, VALID_UNITS, WeatherEntityFeature
|
||||||
|
|
||||||
|
@ -80,7 +81,7 @@ async def ws_subscribe_forecast(
|
||||||
return
|
return
|
||||||
|
|
||||||
@callback
|
@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."""
|
"""Push a new forecast to websocket."""
|
||||||
connection.send_message(
|
connection.send_message(
|
||||||
websocket_api.event_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.update import UpdateEntityFeature
|
||||||
from homeassistant.components.vacuum import VacuumEntityFeature
|
from homeassistant.components.vacuum import VacuumEntityFeature
|
||||||
from homeassistant.components.water_heater import WaterHeaterEntityFeature
|
from homeassistant.components.water_heater import WaterHeaterEntityFeature
|
||||||
|
from homeassistant.components.weather import WeatherEntityFeature
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"AlarmControlPanelEntityFeature": AlarmControlPanelEntityFeature,
|
"AlarmControlPanelEntityFeature": AlarmControlPanelEntityFeature,
|
||||||
|
@ -117,6 +118,7 @@ def _entity_features() -> dict[str, type[IntFlag]]:
|
||||||
"UpdateEntityFeature": UpdateEntityFeature,
|
"UpdateEntityFeature": UpdateEntityFeature,
|
||||||
"VacuumEntityFeature": VacuumEntityFeature,
|
"VacuumEntityFeature": VacuumEntityFeature,
|
||||||
"WaterHeaterEntityFeature": WaterHeaterEntityFeature,
|
"WaterHeaterEntityFeature": WaterHeaterEntityFeature,
|
||||||
|
"WeatherEntityFeature": WeatherEntityFeature,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
"""The test for weather entity."""
|
"""The test for weather entity."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
@ -31,7 +32,9 @@ from homeassistant.components.weather import (
|
||||||
ATTR_WEATHER_WIND_GUST_SPEED,
|
ATTR_WEATHER_WIND_GUST_SPEED,
|
||||||
ATTR_WEATHER_WIND_SPEED,
|
ATTR_WEATHER_WIND_SPEED,
|
||||||
ATTR_WEATHER_WIND_SPEED_UNIT,
|
ATTR_WEATHER_WIND_SPEED_UNIT,
|
||||||
|
DOMAIN,
|
||||||
ROUNDING_PRECISION,
|
ROUNDING_PRECISION,
|
||||||
|
SERVICE_GET_FORECAST,
|
||||||
Forecast,
|
Forecast,
|
||||||
WeatherEntity,
|
WeatherEntity,
|
||||||
WeatherEntityFeature,
|
WeatherEntityFeature,
|
||||||
|
@ -53,6 +56,7 @@ from homeassistant.const import (
|
||||||
UnitOfTemperature,
|
UnitOfTemperature,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
from homeassistant.util import dt as dt_util
|
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 msg["error"] == {"code": "unknown_error", "message": "Unknown error"}
|
||||||
assert not msg["success"]
|
assert not msg["success"]
|
||||||
assert msg["type"] == "result"
|
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…
Add table
Add a link
Reference in a new issue