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"
ATTR_FORECAST_SHORT_DESCRIPTION: Final = "short_description"
ATTR_FORECAST_DETAILED_DESCRIPTION: Final = "detailed_description"
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": {
"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 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),
}

View file

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

View file

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

View file

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

View file

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