Skip to content

Commit 8f85632

Browse files
committed
Add support for minutely forecasts
1 parent c1c62e6 commit 8f85632

14 files changed

Lines changed: 240 additions & 16 deletions

File tree

homeassistant/components/weather/__init__.py

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@
152152
bound=TimestampDataUpdateCoordinator[Any],
153153
default=_DailyForecastUpdateCoordinatorT,
154154
)
155+
_MinutelyForecastUpdateCoordinatorT = TypeVar(
156+
"_MinutelyForecastUpdateCoordinatorT",
157+
bound=TimestampDataUpdateCoordinator[Any],
158+
default=_DailyForecastUpdateCoordinatorT,
159+
)
155160

156161
# mypy: disallow-any-generics
157162

@@ -210,12 +215,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
210215
)
211216
component.async_register_entity_service(
212217
SERVICE_GET_FORECASTS,
213-
{vol.Required("type"): vol.In(("daily", "hourly", "twice_daily"))},
218+
{vol.Required("type"): vol.In(("daily", "hourly", "twice_daily", "minutely"))},
214219
async_get_forecasts_service,
215220
required_features=[
216221
WeatherEntityFeature.FORECAST_DAILY,
217222
WeatherEntityFeature.FORECAST_HOURLY,
218223
WeatherEntityFeature.FORECAST_TWICE_DAILY,
224+
WeatherEntityFeature.FORECAST_MINUTELY,
219225
],
220226
supports_response=SupportsResponse.ONLY,
221227
)
@@ -305,7 +311,7 @@ class WeatherEntity(Entity, PostInit, cached_properties=CACHED_PROPERTIES_WITH_A
305311
_attr_native_dew_point: float | None = None
306312

307313
_forecast_listeners: dict[
308-
Literal["daily", "hourly", "twice_daily"],
314+
Literal["daily", "hourly", "twice_daily", "minutely"],
309315
list[Callable[[list[JsonValueType] | None], None]],
310316
]
311317

@@ -317,7 +323,12 @@ class WeatherEntity(Entity, PostInit, cached_properties=CACHED_PROPERTIES_WITH_A
317323

318324
def __post_init__(self, *args: Any, **kwargs: Any) -> None:
319325
"""Finish initializing."""
320-
self._forecast_listeners = {"daily": [], "hourly": [], "twice_daily": []}
326+
self._forecast_listeners = {
327+
"daily": [],
328+
"hourly": [],
329+
"twice_daily": [],
330+
"minutely": [],
331+
}
321332

322333
async def async_internal_added_to_hass(self) -> None:
323334
"""Call when the weather entity is added to hass."""
@@ -514,6 +525,10 @@ async def async_forecast_hourly(self) -> list[Forecast] | None:
514525
"""Return the hourly forecast in native units."""
515526
raise NotImplementedError
516527

528+
async def async_forecast_minutely(self) -> list[Forecast] | None:
529+
"""Return the minutely forecast in native units."""
530+
raise NotImplementedError
531+
517532
@cached_property
518533
def native_precipitation_unit(self) -> str | None:
519534
"""Return the native unit of measurement for accumulated precipitation."""
@@ -927,22 +942,22 @@ def async_registry_entry_updated(self) -> None:
927942
@callback
928943
def _async_subscription_started(
929944
self,
930-
forecast_type: Literal["daily", "hourly", "twice_daily"],
945+
forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"],
931946
) -> None:
932947
"""Start subscription to forecast_type."""
933948

934949
@callback
935950
def _async_subscription_ended(
936951
self,
937-
forecast_type: Literal["daily", "hourly", "twice_daily"],
952+
forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"],
938953
) -> None:
939954
"""End subscription to forecast_type."""
940955

941956
@final
942957
@callback
943958
def async_subscribe_forecast(
944959
self,
945-
forecast_type: Literal["daily", "hourly", "twice_daily"],
960+
forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"],
946961
forecast_listener: Callable[[list[JsonValueType] | None], None],
947962
) -> CALLBACK_TYPE:
948963
"""Subscribe to forecast updates.
@@ -964,11 +979,13 @@ def unsubscribe() -> None:
964979

965980
@final
966981
async def async_update_listeners(
967-
self, forecast_types: Iterable[Literal["daily", "hourly", "twice_daily"]] | None
982+
self,
983+
forecast_types: Iterable[Literal["daily", "hourly", "twice_daily", "minutely"]]
984+
| None,
968985
) -> None:
969986
"""Push updated forecast to all listeners."""
970987
if forecast_types is None:
971-
forecast_types = {"daily", "hourly", "twice_daily"}
988+
forecast_types = {"daily", "hourly", "twice_daily", "minutely"}
972989
for forecast_type in forecast_types:
973990
if not self._forecast_listeners[forecast_type]:
974991
continue
@@ -1015,6 +1032,10 @@ async def async_get_forecasts_service(
10151032
if (supported_features & WeatherEntityFeature.FORECAST_HOURLY) == 0:
10161033
raise_unsupported_forecast(weather.entity_id, forecast_type)
10171034
native_forecast_list = await weather.async_forecast_hourly()
1035+
elif forecast_type == "minutely":
1036+
if (supported_features & WeatherEntityFeature.FORECAST_MINUTELY) == 0:
1037+
raise_unsupported_forecast(weather.entity_id, forecast_type)
1038+
native_forecast_list = await weather.async_forecast_minutely()
10181039
else:
10191040
if (supported_features & WeatherEntityFeature.FORECAST_TWICE_DAILY) == 0:
10201041
raise_unsupported_forecast(weather.entity_id, forecast_type)
@@ -1036,6 +1057,7 @@ class CoordinatorWeatherEntity(
10361057
_DailyForecastUpdateCoordinatorT,
10371058
_HourlyForecastUpdateCoordinatorT,
10381059
_TwiceDailyForecastUpdateCoordinatorT,
1060+
_MinutelyForecastUpdateCoordinatorT,
10391061
],
10401062
):
10411063
"""A class for weather entities using DataUpdateCoordinators."""
@@ -1048,26 +1070,31 @@ def __init__(
10481070
daily_coordinator: _DailyForecastUpdateCoordinatorT | None = None,
10491071
hourly_coordinator: _HourlyForecastUpdateCoordinatorT | None = None,
10501072
twice_daily_coordinator: _TwiceDailyForecastUpdateCoordinatorT | None = None,
1073+
minutely_coordinator: _MinutelyForecastUpdateCoordinatorT | None = None,
10511074
daily_forecast_valid: timedelta | None = None,
10521075
hourly_forecast_valid: timedelta | None = None,
10531076
twice_daily_forecast_valid: timedelta | None = None,
1077+
minutely_forecast_valid: timedelta | None = None,
10541078
) -> None:
10551079
"""Initialize."""
10561080
super().__init__(observation_coordinator, context)
10571081
self.forecast_coordinators = {
10581082
"daily": daily_coordinator,
10591083
"hourly": hourly_coordinator,
10601084
"twice_daily": twice_daily_coordinator,
1085+
"minutely": minutely_coordinator,
10611086
}
10621087
self.forecast_valid = {
10631088
"daily": daily_forecast_valid,
10641089
"hourly": hourly_forecast_valid,
10651090
"twice_daily": twice_daily_forecast_valid,
1091+
"minutely": minutely_forecast_valid,
10661092
}
10671093
self.unsub_forecast: dict[str, Callable[[], None] | None] = {
10681094
"daily": None,
10691095
"hourly": None,
10701096
"twice_daily": None,
1097+
"minutely": None,
10711098
}
10721099

10731100
async def async_added_to_hass(self) -> None:
@@ -1076,9 +1103,10 @@ async def async_added_to_hass(self) -> None:
10761103
self.async_on_remove(partial(self._remove_forecast_listener, "daily"))
10771104
self.async_on_remove(partial(self._remove_forecast_listener, "hourly"))
10781105
self.async_on_remove(partial(self._remove_forecast_listener, "twice_daily"))
1106+
self.async_on_remove(partial(self._remove_forecast_listener, "minutely"))
10791107

10801108
def _remove_forecast_listener(
1081-
self, forecast_type: Literal["daily", "hourly", "twice_daily"]
1109+
self, forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"]
10821110
) -> None:
10831111
"""Remove weather forecast listener."""
10841112
if unsub_fn := self.unsub_forecast[forecast_type]:
@@ -1088,7 +1116,7 @@ def _remove_forecast_listener(
10881116
@callback
10891117
def _async_subscription_started(
10901118
self,
1091-
forecast_type: Literal["daily", "hourly", "twice_daily"],
1119+
forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"],
10921120
) -> None:
10931121
"""Start subscription to forecast_type."""
10941122
if not (coordinator := self.forecast_coordinators[forecast_type]):
@@ -1109,10 +1137,14 @@ def _handle_hourly_forecast_coordinator_update(self) -> None:
11091137
def _handle_twice_daily_forecast_coordinator_update(self) -> None:
11101138
"""Handle updated data from the twice daily forecast coordinator."""
11111139

1140+
@callback
1141+
def _handle_minutely_forecast_coordinator_update(self) -> None:
1142+
"""Handle updated data from the minutely forecast coordinator."""
1143+
11121144
@final
11131145
@callback
11141146
def _handle_forecast_update(
1115-
self, forecast_type: Literal["daily", "hourly", "twice_daily"]
1147+
self, forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"]
11161148
) -> None:
11171149
"""Update forecast data."""
11181150
coordinator = self.forecast_coordinators[forecast_type]
@@ -1126,7 +1158,7 @@ def _handle_forecast_update(
11261158
@callback
11271159
def _async_subscription_ended(
11281160
self,
1129-
forecast_type: Literal["daily", "hourly", "twice_daily"],
1161+
forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"],
11301162
) -> None:
11311163
"""End subscription to forecast_type."""
11321164
self._remove_forecast_listener(forecast_type)
@@ -1169,9 +1201,14 @@ def _async_forecast_twice_daily(self) -> list[Forecast] | None:
11691201
"""Return the twice daily forecast in native units."""
11701202
raise NotImplementedError
11711203

1204+
@callback
1205+
def _async_forecast_minutely(self) -> list[Forecast] | None:
1206+
"""Return the minutely forecast in native units."""
1207+
raise NotImplementedError
1208+
11721209
@final
11731210
async def _async_forecast(
1174-
self, forecast_type: Literal["daily", "hourly", "twice_daily"]
1211+
self, forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"]
11751212
) -> list[Forecast] | None:
11761213
"""Return the forecast in native units."""
11771214
coordinator = self.forecast_coordinators[forecast_type]
@@ -1198,6 +1235,11 @@ async def async_forecast_twice_daily(self) -> list[Forecast] | None:
11981235
"""Return the twice daily forecast in native units."""
11991236
return await self._async_forecast("twice_daily")
12001237

1238+
@final
1239+
async def async_forecast_minutely(self) -> list[Forecast] | None:
1240+
"""Return the minutely forecast in native units."""
1241+
return await self._async_forecast("minutely")
1242+
12011243

12021244
class SingleCoordinatorWeatherEntity(
12031245
CoordinatorWeatherEntity[

homeassistant/components/weather/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class WeatherEntityFeature(IntFlag):
3131
FORECAST_DAILY = 1
3232
FORECAST_HOURLY = 2
3333
FORECAST_TWICE_DAILY = 4
34+
FORECAST_MINUTELY = 8
3435

3536

3637
ATTR_WEATHER_HUMIDITY = "humidity"

homeassistant/components/weather/services.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ get_forecast:
66
- weather.WeatherEntityFeature.FORECAST_DAILY
77
- weather.WeatherEntityFeature.FORECAST_HOURLY
88
- weather.WeatherEntityFeature.FORECAST_TWICE_DAILY
9+
- weather.WeatherEntityFeature.FORECAST_MINUTELY
910
fields:
1011
type:
1112
required: true
@@ -15,6 +16,7 @@ get_forecast:
1516
- "daily"
1617
- "hourly"
1718
- "twice_daily"
19+
- "minutely"
1820
translation_key: forecast_type
1921
get_forecasts:
2022
target:
@@ -24,6 +26,7 @@ get_forecasts:
2426
- weather.WeatherEntityFeature.FORECAST_DAILY
2527
- weather.WeatherEntityFeature.FORECAST_HOURLY
2628
- weather.WeatherEntityFeature.FORECAST_TWICE_DAILY
29+
- weather.WeatherEntityFeature.FORECAST_MINUTELY
2730
fields:
2831
type:
2932
required: true
@@ -33,4 +36,5 @@ get_forecasts:
3336
- "daily"
3437
- "hourly"
3538
- "twice_daily"
39+
- "minutely"
3640
translation_key: forecast_type

homeassistant/components/weather/strings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"options": {
9696
"daily": "Daily",
9797
"hourly": "Hourly",
98+
"minutely": "Minutely",
9899
"twice_daily": "Twice daily"
99100
}
100101
}

homeassistant/components/weather/websocket_api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"daily": WeatherEntityFeature.FORECAST_DAILY,
1616
"hourly": WeatherEntityFeature.FORECAST_HOURLY,
1717
"twice_daily": WeatherEntityFeature.FORECAST_TWICE_DAILY,
18+
"minutely": WeatherEntityFeature.FORECAST_MINUTELY,
1819
}
1920

2021

homeassistant/components/weatherkit/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,6 @@
1919
CONF_KEY_PEM = "key_pem"
2020

2121
ATTR_CURRENT_WEATHER = "currentWeather"
22+
ATTR_FORECAST_NEXT_HOUR = "forecastNextHour"
2223
ATTR_FORECAST_HOURLY = "forecastHourly"
2324
ATTR_FORECAST_DAILY = "forecastDaily"

homeassistant/components/weatherkit/coordinator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
DataSetType.CURRENT_WEATHER,
1818
DataSetType.DAILY_FORECAST,
1919
DataSetType.HOURLY_FORECAST,
20+
DataSetType.NEXT_HOUR_FORECAST,
2021
]
2122

2223
STALE_DATA_THRESHOLD = timedelta(hours=1)

homeassistant/components/weatherkit/weather.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
ATTR_CURRENT_WEATHER,
3535
ATTR_FORECAST_DAILY,
3636
ATTR_FORECAST_HOURLY,
37+
ATTR_FORECAST_NEXT_HOUR,
3738
ATTRIBUTION,
3839
)
3940
from .coordinator import WeatherKitConfigEntry, WeatherKitDataUpdateCoordinator
@@ -118,6 +119,16 @@ def _map_hourly_forecast(forecast: dict[str, Any]) -> Forecast:
118119
}
119120

120121

122+
def _map_next_hour_forecast(forecast: dict[str, Any]) -> Forecast:
123+
precipitation_intensity = forecast.get("precipitationIntensity")
124+
125+
return {
126+
"datetime": forecast["startTime"],
127+
"native_precipitation": precipitation_intensity,
128+
"precipitation_probability": forecast["precipitationChance"] * 100,
129+
}
130+
131+
121132
class WeatherKitWeather(
122133
SingleCoordinatorWeatherEntity[WeatherKitDataUpdateCoordinator], WeatherKitEntity
123134
):
@@ -153,6 +164,8 @@ def supported_features(self) -> WeatherEntityFeature:
153164
features |= WeatherEntityFeature.FORECAST_DAILY
154165
if DataSetType.HOURLY_FORECAST in self.coordinator.supported_data_sets:
155166
features |= WeatherEntityFeature.FORECAST_HOURLY
167+
if DataSetType.NEXT_HOUR_FORECAST in self.coordinator.supported_data_sets:
168+
features |= WeatherEntityFeature.FORECAST_MINUTELY
156169
return features
157170

158171
@property
@@ -250,3 +263,15 @@ def _async_forecast_hourly(self) -> list[Forecast] | None:
250263

251264
forecast = hourly_forecast.get("hours")
252265
return [_map_hourly_forecast(f) for f in forecast]
266+
267+
@callback
268+
def _async_forecast_minutely(self) -> list[Forecast] | None:
269+
"""Return the minutely forecast."""
270+
minutely_forecast = self.data.get(ATTR_FORECAST_NEXT_HOUR)
271+
if not minutely_forecast:
272+
return None
273+
274+
forecast = minutely_forecast.get("minutes")
275+
if forecast is None:
276+
return None
277+
return [_map_next_hour_forecast(f) for f in forecast]

tests/components/weather/snapshots/test_init.ambr

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,21 @@
2929
}),
3030
})
3131
# ---
32+
# name: test_get_forecast[minutely-8]
33+
dict({
34+
'weather.testing': dict({
35+
'forecast': list([
36+
dict({
37+
'cloud_coverage': None,
38+
'temperature': 38.0,
39+
'templow': 38.0,
40+
'uv_index': None,
41+
'wind_bearing': None,
42+
}),
43+
]),
44+
}),
45+
})
46+
# ---
3247
# name: test_get_forecast[twice_daily-4]
3348
dict({
3449
'weather.testing': dict({

0 commit comments

Comments
 (0)