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 <goran.johansson@shiftit.se>

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
This commit is contained in:
MatthewFlamm 2024-07-18 10:26:07 -04:00 committed by GitHub
parent ec937781ca
commit f479b64ff9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 221 additions and 103 deletions

View File

@ -27,6 +27,7 @@ CONF_STATION = "station"
ATTRIBUTION = "Data from National Weather Service/NOAA" ATTRIBUTION = "Data from National Weather Service/NOAA"
ATTR_FORECAST_SHORT_DESCRIPTION: Final = "short_description"
ATTR_FORECAST_DETAILED_DESCRIPTION: Final = "detailed_description" ATTR_FORECAST_DETAILED_DESCRIPTION: Final = "detailed_description"
CONDITION_CLASSES: dict[str, list[str]] = { CONDITION_CLASSES: dict[str, list[str]] = {

View File

@ -0,0 +1,5 @@
{
"services": {
"get_forecasts_extra": "mdi:weather-cloudy-clock"
}
}

View File

@ -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

View File

@ -19,5 +19,25 @@
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]" "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."
}
}
}
} }
} }

View File

@ -4,7 +4,9 @@ from __future__ import annotations
from functools import partial from functools import partial
from types import MappingProxyType 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 ( from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLEAR_NIGHT,
@ -31,15 +33,22 @@ from homeassistant.const import (
UnitOfSpeed, UnitOfSpeed,
UnitOfTemperature, UnitOfTemperature,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import (
from homeassistant.helpers import entity_registry as er HomeAssistant,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.helpers import entity_platform, entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
from homeassistant.util.json import JsonValueType
from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter
from . import NWSConfigEntry, NWSData, base_unique_id, device_info from . import NWSConfigEntry, NWSData, base_unique_id, device_info
from .const import ( from .const import (
ATTR_FORECAST_DETAILED_DESCRIPTION, ATTR_FORECAST_DETAILED_DESCRIPTION,
ATTR_FORECAST_SHORT_DESCRIPTION,
ATTRIBUTION, ATTRIBUTION,
CONDITION_CLASSES, CONDITION_CLASSES,
DAYNIGHT, DAYNIGHT,
@ -92,15 +101,27 @@ async def async_setup_entry(
): ):
entity_registry.async_remove(entity_id) 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) 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): # common attributes
"""Forecast with extra fields needed for NWS.""" datetime: Required[str]
is_daytime: bool | None
detailed_description: str | None # extra attributes
detailed_description: str | None
short_description: str | None
def _calculate_unique_id(entry_data: MappingProxyType[str, Any], mode: str) -> str: def _calculate_unique_id(entry_data: MappingProxyType[str, Any], mode: str) -> str:
@ -217,17 +238,16 @@ class NWSWeather(CoordinatorWeatherEntity[TimestampDataUpdateCoordinator[None]])
return None return None
def _forecast( def _forecast(
self, nws_forecast: list[dict[str, Any]] | None, mode: str self,
) -> list[Forecast] | None: nws_forecast: list[dict[str, Any]],
mode: str,
) -> list[Forecast]:
"""Return forecast.""" """Return forecast."""
if nws_forecast is None: if nws_forecast is None:
return None return []
forecast: list[Forecast] = [] forecast: list[Forecast] = []
for forecast_entry in nws_forecast: for forecast_entry in nws_forecast:
data: NWSForecast = { data: Forecast = {
ATTR_FORECAST_DETAILED_DESCRIPTION: forecast_entry.get(
"detailedForecast"
),
ATTR_FORECAST_TIME: cast(str, forecast_entry.get("startTime")), ATTR_FORECAST_TIME: cast(str, forecast_entry.get("startTime")),
} }
@ -273,6 +293,30 @@ class NWSWeather(CoordinatorWeatherEntity[TimestampDataUpdateCoordinator[None]])
forecast.append(data) forecast.append(data)
return forecast 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 @callback
def _async_forecast_hourly(self) -> list[Forecast] | None: def _async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast in native units.""" """Return the hourly forecast in native units."""
@ -293,3 +337,13 @@ class NWSWeather(CoordinatorWeatherEntity[TimestampDataUpdateCoordinator[None]])
for forecast_type in ("twice_daily", "hourly"): for forecast_type in ("twice_daily", "hourly"):
if (coordinator := self.forecast_coordinators[forecast_type]) is not None: if (coordinator := self.forecast_coordinators[forecast_type]) is not None:
await coordinator.async_request_refresh() 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),
}

View File

@ -185,6 +185,7 @@ DEFAULT_FORECAST = [
"temperature": 10, "temperature": 10,
"windSpeedAvg": 10, "windSpeedAvg": 10,
"windBearing": 180, "windBearing": 180,
"shortForecast": "A short forecast.",
"detailedForecast": "A detailed forecast.", "detailedForecast": "A detailed forecast.",
"timestamp": "2019-08-12T23:53:00+00:00", "timestamp": "2019-08-12T23:53:00+00:00",
"iconTime": "night", "iconTime": "night",

View File

@ -21,6 +21,7 @@
'number': 1, 'number': 1,
'probabilityOfPrecipitation': 89, 'probabilityOfPrecipitation': 89,
'relativeHumidity': 75, 'relativeHumidity': 75,
'shortForecast': 'A short forecast.',
'startTime': '2019-08-12T20:00:00-04:00', 'startTime': '2019-08-12T20:00:00-04:00',
'temperature': 10, 'temperature': 10,
'timestamp': '2019-08-12T23:53:00+00:00', 'timestamp': '2019-08-12T23:53:00+00:00',
@ -48,6 +49,7 @@
'number': 1, 'number': 1,
'probabilityOfPrecipitation': 89, 'probabilityOfPrecipitation': 89,
'relativeHumidity': 75, 'relativeHumidity': 75,
'shortForecast': 'A short forecast.',
'startTime': '2019-08-12T20:00:00-04:00', 'startTime': '2019-08-12T20:00:00-04:00',
'temperature': 10, 'temperature': 10,
'timestamp': '2019-08-12T23:53:00+00:00', 'timestamp': '2019-08-12T23:53:00+00:00',

View File

@ -1,95 +1,44 @@
# serializer version: 1 # serializer version: 1
# name: test_forecast_service[get_forecast] # name: test_detailed_forecast_service[hourly]
dict({ dict({
'forecast': list([ 'weather.abc': dict({
dict({ 'forecast': list([
'condition': 'lightning-rainy', dict({
'datetime': '2019-08-12T20:00:00-04:00', 'datetime': '2019-08-12T20:00:00-04:00',
'detailed_description': 'A detailed forecast.', 'short_description': 'A short forecast.',
'dew_point': -15.6, }),
'humidity': 75, ]),
'is_daytime': False, }),
'precipitation_probability': 89,
'temperature': -12.2,
'wind_bearing': 180,
'wind_speed': 16.09,
}),
]),
}) })
# --- # ---
# name: test_forecast_service[get_forecast].1 # name: test_detailed_forecast_service[twice_daily]
dict({ dict({
'forecast': list([ 'weather.abc': dict({
dict({ 'forecast': list([
'condition': 'lightning-rainy', dict({
'datetime': '2019-08-12T20:00:00-04:00', 'datetime': '2019-08-12T20:00:00-04:00',
'detailed_description': 'A detailed forecast.', 'detailed_description': 'A detailed forecast.',
'dew_point': -15.6, 'is_daytime': False,
'humidity': 75, 'short_description': 'A short forecast.',
'precipitation_probability': 89, }),
'temperature': -12.2, ]),
'wind_bearing': 180, }),
'wind_speed': 16.09,
}),
]),
}) })
# --- # ---
# name: test_forecast_service[get_forecast].2 # name: test_detailed_forecast_service_no_data[hourly]
dict({ dict({
'forecast': list([ 'weather.abc': dict({
dict({ 'forecast': list([
'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,
}),
]),
}) })
# --- # ---
# name: test_forecast_service[get_forecast].3 # name: test_detailed_forecast_service_no_data[twice_daily]
dict({ dict({
'forecast': list([ 'weather.abc': dict({
dict({ 'forecast': list([
'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([
]),
}) })
# --- # ---
# name: test_forecast_service[get_forecasts] # name: test_forecast_service[get_forecasts]
@ -99,7 +48,6 @@
dict({ dict({
'condition': 'lightning-rainy', 'condition': 'lightning-rainy',
'datetime': '2019-08-12T20:00:00-04:00', 'datetime': '2019-08-12T20:00:00-04:00',
'detailed_description': 'A detailed forecast.',
'dew_point': -15.6, 'dew_point': -15.6,
'humidity': 75, 'humidity': 75,
'is_daytime': False, 'is_daytime': False,
@ -119,7 +67,6 @@
dict({ dict({
'condition': 'lightning-rainy', 'condition': 'lightning-rainy',
'datetime': '2019-08-12T20:00:00-04:00', 'datetime': '2019-08-12T20:00:00-04:00',
'detailed_description': 'A detailed forecast.',
'dew_point': -15.6, 'dew_point': -15.6,
'humidity': 75, 'humidity': 75,
'precipitation_probability': 89, 'precipitation_probability': 89,
@ -138,7 +85,6 @@
dict({ dict({
'condition': 'lightning-rainy', 'condition': 'lightning-rainy',
'datetime': '2019-08-12T20:00:00-04:00', 'datetime': '2019-08-12T20:00:00-04:00',
'detailed_description': 'A detailed forecast.',
'dew_point': -15.6, 'dew_point': -15.6,
'humidity': 75, 'humidity': 75,
'is_daytime': False, 'is_daytime': False,
@ -158,7 +104,6 @@
dict({ dict({
'condition': 'lightning-rainy', 'condition': 'lightning-rainy',
'datetime': '2019-08-12T20:00:00-04:00', 'datetime': '2019-08-12T20:00:00-04:00',
'detailed_description': 'A detailed forecast.',
'dew_point': -15.6, 'dew_point': -15.6,
'humidity': 75, 'humidity': 75,
'precipitation_probability': 89, 'precipitation_probability': 89,
@ -177,7 +122,6 @@
dict({ dict({
'condition': 'lightning-rainy', 'condition': 'lightning-rainy',
'datetime': '2019-08-12T20:00:00-04:00', 'datetime': '2019-08-12T20:00:00-04:00',
'detailed_description': 'A detailed forecast.',
'dew_point': -15.6, 'dew_point': -15.6,
'humidity': 75, 'humidity': 75,
'precipitation_probability': 89, 'precipitation_probability': 89,
@ -202,7 +146,6 @@
dict({ dict({
'condition': 'lightning-rainy', 'condition': 'lightning-rainy',
'datetime': '2019-08-12T20:00:00-04:00', 'datetime': '2019-08-12T20:00:00-04:00',
'detailed_description': 'A detailed forecast.',
'dew_point': -15.6, 'dew_point': -15.6,
'humidity': 75, 'humidity': 75,
'precipitation_probability': 89, 'precipitation_probability': 89,
@ -217,7 +160,6 @@
dict({ dict({
'condition': 'lightning-rainy', 'condition': 'lightning-rainy',
'datetime': '2019-08-12T20:00:00-04:00', 'datetime': '2019-08-12T20:00:00-04:00',
'detailed_description': 'A detailed forecast.',
'dew_point': -15.6, 'dew_point': -15.6,
'humidity': 75, 'humidity': 75,
'precipitation_probability': 89, 'precipitation_probability': 89,

View File

@ -554,3 +554,83 @@ async def test_forecast_subscription_with_failing_coordinator(
) )
msg = await client.receive_json() msg = await client.receive_json()
assert not msg["success"] 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