From 683c2f8d22c7ea89f8d9cbc1fe2fb38d97c87871 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 7 Aug 2023 14:05:37 +0200 Subject: [PATCH] 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 --- homeassistant/components/weather/__init__.py | 69 +++++++++- .../components/weather/services.yaml | 18 +++ homeassistant/components/weather/strings.json | 21 +++ .../components/weather/websocket_api.py | 3 +- homeassistant/helpers/selector.py | 2 + tests/components/weather/test_init.py | 122 ++++++++++++++++++ 6 files changed, 229 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/weather/services.yaml diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index bfad18cb84a..7bd897bb638 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -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, + } diff --git a/homeassistant/components/weather/services.yaml b/homeassistant/components/weather/services.yaml new file mode 100644 index 00000000000..b2b71396fab --- /dev/null +++ b/homeassistant/components/weather/services.yaml @@ -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 diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 21029c77284..5f08013684c 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -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." + } + } + } } } diff --git a/homeassistant/components/weather/websocket_api.py b/homeassistant/components/weather/websocket_api.py index f2be4dfec6d..39a487dcb2f 100644 --- a/homeassistant/components/weather/websocket_api.py +++ b/homeassistant/components/weather/websocket_api.py @@ -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( diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 192777ae3be..ba473758121 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -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, } diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 92643b616c9..d8636330b5e 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -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, + )