From f479b64ff9d91ce02c811e1e0ed1fb3f83e8985b Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Thu, 18 Jul 2024 10:26:07 -0400 Subject: [PATCH] Add forecast service call for extra attributes for nws (#117254) * add service call * fix snapshots in test * add tests * fix no data service;add test * remove unreachable code * use only extra attributes+context attributes * detailed descr. only in twice daily; add dewpoint * fix import from merge * Remove dewpoint from twice daily. nws recently removed it * cleanup unused snapshots * remove dewpoint; use short_forecast * return [] for forecasts instead of None * Use str for short_description Co-authored-by: G Johansson --------- Co-authored-by: G Johansson --- homeassistant/components/nws/const.py | 1 + homeassistant/components/nws/icons.json | 5 + homeassistant/components/nws/services.yaml | 13 ++ homeassistant/components/nws/strings.json | 20 +++ homeassistant/components/nws/weather.py | 84 ++++++++++--- tests/components/nws/const.py | 1 + .../nws/snapshots/test_diagnostics.ambr | 2 + .../nws/snapshots/test_weather.ambr | 118 +++++------------- tests/components/nws/test_weather.py | 80 ++++++++++++ 9 files changed, 221 insertions(+), 103 deletions(-) create mode 100644 homeassistant/components/nws/icons.json create mode 100644 homeassistant/components/nws/services.yaml diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index 381537775da..80e2d0b237a 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -27,6 +27,7 @@ CONF_STATION = "station" ATTRIBUTION = "Data from National Weather Service/NOAA" +ATTR_FORECAST_SHORT_DESCRIPTION: Final = "short_description" ATTR_FORECAST_DETAILED_DESCRIPTION: Final = "detailed_description" CONDITION_CLASSES: dict[str, list[str]] = { diff --git a/homeassistant/components/nws/icons.json b/homeassistant/components/nws/icons.json new file mode 100644 index 00000000000..8f91388a3ef --- /dev/null +++ b/homeassistant/components/nws/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "get_forecasts_extra": "mdi:weather-cloudy-clock" + } +} diff --git a/homeassistant/components/nws/services.yaml b/homeassistant/components/nws/services.yaml new file mode 100644 index 00000000000..0d439a9d278 --- /dev/null +++ b/homeassistant/components/nws/services.yaml @@ -0,0 +1,13 @@ +get_forecasts_extra: + target: + entity: + domain: weather + fields: + type: + required: true + selector: + select: + options: + - "hourly" + - "twice_daily" + translation_key: nws_forecast_type diff --git a/homeassistant/components/nws/strings.json b/homeassistant/components/nws/strings.json index 0f119e7c2ee..c9ee8349631 100644 --- a/homeassistant/components/nws/strings.json +++ b/homeassistant/components/nws/strings.json @@ -19,5 +19,25 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "selector": { + "nws_forecast_type": { + "options": { + "hourly": "Hourly", + "twice_daily": "Twice daily" + } + } + }, + "services": { + "get_forecasts_extra": { + "name": "Get extra forecasts data.", + "description": "Get extra data for weather forecasts.", + "fields": { + "type": { + "name": "Forecast type", + "description": "Forecast type: hourly or twice_daily." + } + } + } } } diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 9ae1f9f7ff9..3c7393aa184 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -4,7 +4,9 @@ from __future__ import annotations from functools import partial from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, Required, TypedDict, cast + +import voluptuous as vol from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -31,15 +33,22 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.core import ( + HomeAssistant, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator +from homeassistant.util.json import JsonValueType from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter from . import NWSConfigEntry, NWSData, base_unique_id, device_info from .const import ( ATTR_FORECAST_DETAILED_DESCRIPTION, + ATTR_FORECAST_SHORT_DESCRIPTION, ATTRIBUTION, CONDITION_CLASSES, DAYNIGHT, @@ -92,15 +101,27 @@ async def async_setup_entry( ): entity_registry.async_remove(entity_id) + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + "get_forecasts_extra", + {vol.Required("type"): vol.In(("hourly", "twice_daily"))}, + "async_get_forecasts_extra_service", + supports_response=SupportsResponse.ONLY, + ) + async_add_entities([NWSWeather(entry.data, nws_data)], False) -if TYPE_CHECKING: +class ExtraForecast(TypedDict, total=False): + """Forecast extra fields from NWS.""" - class NWSForecast(Forecast): - """Forecast with extra fields needed for NWS.""" - - detailed_description: str | None + # common attributes + datetime: Required[str] + is_daytime: bool | None + # extra attributes + detailed_description: str | None + short_description: str | None def _calculate_unique_id(entry_data: MappingProxyType[str, Any], mode: str) -> str: @@ -217,17 +238,16 @@ class NWSWeather(CoordinatorWeatherEntity[TimestampDataUpdateCoordinator[None]]) return None def _forecast( - self, nws_forecast: list[dict[str, Any]] | None, mode: str - ) -> list[Forecast] | None: + self, + nws_forecast: list[dict[str, Any]], + mode: str, + ) -> list[Forecast]: """Return forecast.""" if nws_forecast is None: - return None + return [] forecast: list[Forecast] = [] for forecast_entry in nws_forecast: - data: NWSForecast = { - ATTR_FORECAST_DETAILED_DESCRIPTION: forecast_entry.get( - "detailedForecast" - ), + data: Forecast = { ATTR_FORECAST_TIME: cast(str, forecast_entry.get("startTime")), } @@ -273,6 +293,30 @@ class NWSWeather(CoordinatorWeatherEntity[TimestampDataUpdateCoordinator[None]]) forecast.append(data) return forecast + def _forecast_extra( + self, + nws_forecast: list[dict[str, Any]] | None, + mode: str, + ) -> list[ExtraForecast]: + """Return forecast.""" + if nws_forecast is None: + return [] + forecast: list[ExtraForecast] = [] + for forecast_entry in nws_forecast: + data: ExtraForecast = { + ATTR_FORECAST_TIME: cast(str, forecast_entry.get("startTime")), + } + if mode == DAYNIGHT: + data[ATTR_FORECAST_IS_DAYTIME] = forecast_entry.get("isDaytime") + + data[ATTR_FORECAST_DETAILED_DESCRIPTION] = forecast_entry.get( + "detailedForecast" + ) + + data[ATTR_FORECAST_SHORT_DESCRIPTION] = forecast_entry.get("shortForecast") + forecast.append(data) + return forecast + @callback def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" @@ -293,3 +337,13 @@ class NWSWeather(CoordinatorWeatherEntity[TimestampDataUpdateCoordinator[None]]) for forecast_type in ("twice_daily", "hourly"): if (coordinator := self.forecast_coordinators[forecast_type]) is not None: await coordinator.async_request_refresh() + + async def async_get_forecasts_extra_service(self, type) -> ServiceResponse: + """Get extra weather forecast.""" + if type == "hourly": + nws_forecast = self._forecast_extra(self.nws.forecast_hourly, HOURLY) + else: + nws_forecast = self._forecast_extra(self.nws.forecast, DAYNIGHT) + return { + "forecast": cast(JsonValueType, nws_forecast), + } diff --git a/tests/components/nws/const.py b/tests/components/nws/const.py index e5fc9df909f..06aef2c8da7 100644 --- a/tests/components/nws/const.py +++ b/tests/components/nws/const.py @@ -185,6 +185,7 @@ DEFAULT_FORECAST = [ "temperature": 10, "windSpeedAvg": 10, "windBearing": 180, + "shortForecast": "A short forecast.", "detailedForecast": "A detailed forecast.", "timestamp": "2019-08-12T23:53:00+00:00", "iconTime": "night", diff --git a/tests/components/nws/snapshots/test_diagnostics.ambr b/tests/components/nws/snapshots/test_diagnostics.ambr index 2db73f90054..f8bd82a35c4 100644 --- a/tests/components/nws/snapshots/test_diagnostics.ambr +++ b/tests/components/nws/snapshots/test_diagnostics.ambr @@ -21,6 +21,7 @@ 'number': 1, 'probabilityOfPrecipitation': 89, 'relativeHumidity': 75, + 'shortForecast': 'A short forecast.', 'startTime': '2019-08-12T20:00:00-04:00', 'temperature': 10, 'timestamp': '2019-08-12T23:53:00+00:00', @@ -48,6 +49,7 @@ 'number': 1, 'probabilityOfPrecipitation': 89, 'relativeHumidity': 75, + 'shortForecast': 'A short forecast.', 'startTime': '2019-08-12T20:00:00-04:00', 'temperature': 10, 'timestamp': '2019-08-12T23:53:00+00:00', diff --git a/tests/components/nws/snapshots/test_weather.ambr b/tests/components/nws/snapshots/test_weather.ambr index f4669f47615..1df1c2fa644 100644 --- a/tests/components/nws/snapshots/test_weather.ambr +++ b/tests/components/nws/snapshots/test_weather.ambr @@ -1,95 +1,44 @@ # serializer version: 1 -# name: test_forecast_service[get_forecast] +# name: test_detailed_forecast_service[hourly] dict({ - 'forecast': list([ - dict({ - 'condition': 'lightning-rainy', - 'datetime': '2019-08-12T20:00:00-04:00', - 'detailed_description': 'A detailed forecast.', - 'dew_point': -15.6, - 'humidity': 75, - 'is_daytime': False, - 'precipitation_probability': 89, - 'temperature': -12.2, - 'wind_bearing': 180, - 'wind_speed': 16.09, - }), - ]), + 'weather.abc': dict({ + 'forecast': list([ + dict({ + 'datetime': '2019-08-12T20:00:00-04:00', + 'short_description': 'A short forecast.', + }), + ]), + }), }) # --- -# name: test_forecast_service[get_forecast].1 +# name: test_detailed_forecast_service[twice_daily] dict({ - 'forecast': list([ - dict({ - 'condition': 'lightning-rainy', - 'datetime': '2019-08-12T20:00:00-04:00', - 'detailed_description': 'A detailed forecast.', - 'dew_point': -15.6, - 'humidity': 75, - 'precipitation_probability': 89, - 'temperature': -12.2, - 'wind_bearing': 180, - 'wind_speed': 16.09, - }), - ]), + 'weather.abc': dict({ + 'forecast': list([ + dict({ + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'is_daytime': False, + 'short_description': 'A short forecast.', + }), + ]), + }), }) # --- -# name: test_forecast_service[get_forecast].2 +# name: test_detailed_forecast_service_no_data[hourly] dict({ - 'forecast': list([ - dict({ - 'condition': 'lightning-rainy', - 'datetime': '2019-08-12T20:00:00-04:00', - 'detailed_description': 'A detailed forecast.', - 'dew_point': -15.6, - 'humidity': 75, - 'is_daytime': False, - 'precipitation_probability': 89, - 'temperature': -12.2, - 'wind_bearing': 180, - 'wind_speed': 16.09, - }), - ]), + 'weather.abc': dict({ + 'forecast': list([ + ]), + }), }) # --- -# name: test_forecast_service[get_forecast].3 +# name: test_detailed_forecast_service_no_data[twice_daily] dict({ - 'forecast': list([ - dict({ - 'condition': 'lightning-rainy', - 'datetime': '2019-08-12T20:00:00-04:00', - 'detailed_description': 'A detailed forecast.', - 'dew_point': -15.6, - 'humidity': 75, - 'precipitation_probability': 89, - 'temperature': -12.2, - 'wind_bearing': 180, - 'wind_speed': 16.09, - }), - ]), - }) -# --- -# name: test_forecast_service[get_forecast].4 - dict({ - 'forecast': list([ - dict({ - 'condition': 'lightning-rainy', - 'datetime': '2019-08-12T20:00:00-04:00', - 'detailed_description': 'A detailed forecast.', - 'dew_point': -15.6, - 'humidity': 75, - 'precipitation_probability': 89, - 'temperature': -12.2, - 'wind_bearing': 180, - 'wind_speed': 16.09, - }), - ]), - }) -# --- -# name: test_forecast_service[get_forecast].5 - dict({ - 'forecast': list([ - ]), + 'weather.abc': dict({ + 'forecast': list([ + ]), + }), }) # --- # name: test_forecast_service[get_forecasts] @@ -99,7 +48,6 @@ dict({ 'condition': 'lightning-rainy', 'datetime': '2019-08-12T20:00:00-04:00', - 'detailed_description': 'A detailed forecast.', 'dew_point': -15.6, 'humidity': 75, 'is_daytime': False, @@ -119,7 +67,6 @@ dict({ 'condition': 'lightning-rainy', 'datetime': '2019-08-12T20:00:00-04:00', - 'detailed_description': 'A detailed forecast.', 'dew_point': -15.6, 'humidity': 75, 'precipitation_probability': 89, @@ -138,7 +85,6 @@ dict({ 'condition': 'lightning-rainy', 'datetime': '2019-08-12T20:00:00-04:00', - 'detailed_description': 'A detailed forecast.', 'dew_point': -15.6, 'humidity': 75, 'is_daytime': False, @@ -158,7 +104,6 @@ dict({ 'condition': 'lightning-rainy', 'datetime': '2019-08-12T20:00:00-04:00', - 'detailed_description': 'A detailed forecast.', 'dew_point': -15.6, 'humidity': 75, 'precipitation_probability': 89, @@ -177,7 +122,6 @@ dict({ 'condition': 'lightning-rainy', 'datetime': '2019-08-12T20:00:00-04:00', - 'detailed_description': 'A detailed forecast.', 'dew_point': -15.6, 'humidity': 75, 'precipitation_probability': 89, @@ -202,7 +146,6 @@ dict({ 'condition': 'lightning-rainy', 'datetime': '2019-08-12T20:00:00-04:00', - 'detailed_description': 'A detailed forecast.', 'dew_point': -15.6, 'humidity': 75, 'precipitation_probability': 89, @@ -217,7 +160,6 @@ dict({ 'condition': 'lightning-rainy', 'datetime': '2019-08-12T20:00:00-04:00', - 'detailed_description': 'A detailed forecast.', 'dew_point': -15.6, 'humidity': 75, 'precipitation_probability': 89, diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index b4f4b5155a1..bbf808dbd1f 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -554,3 +554,83 @@ async def test_forecast_subscription_with_failing_coordinator( ) msg = await client.receive_json() assert not msg["success"] + + +@pytest.mark.parametrize( + ("forecast_type"), + [ + "hourly", + "twice_daily", + ], +) +async def test_detailed_forecast_service( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + mock_simple_nws, + no_sensor, + forecast_type: str, +) -> None: + """Test detailed forecast.""" + + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + nws.DOMAIN, + "get_forecasts_extra", + { + "entity_id": "weather.abc", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +@pytest.mark.parametrize( + ("forecast_type"), + [ + "hourly", + "twice_daily", + ], +) +async def test_detailed_forecast_service_no_data( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + mock_simple_nws, + no_sensor, + forecast_type: str, +) -> None: + """Test detailed forecast.""" + instance = mock_simple_nws.return_value + instance.forecast = None + instance.forecast_hourly = None + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + nws.DOMAIN, + "get_forecasts_extra", + { + "entity_id": "weather.abc", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response == snapshot