Skip to content

Commit 18f4c05

Browse files
committed
Add support for minutely forecasts
1 parent c1c62e6 commit 18f4c05

14 files changed

Lines changed: 247 additions & 16 deletions

File tree

homeassistant/components/weather/__init__.py

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Weather component that handles meteorological data for your location."""
22

3+
from __future__ import annotations
4+
35
import abc
46
from collections.abc import Callable, Iterable
57
from contextlib import suppress
@@ -152,6 +154,11 @@
152154
bound=TimestampDataUpdateCoordinator[Any],
153155
default=_DailyForecastUpdateCoordinatorT,
154156
)
157+
_MinutelyForecastUpdateCoordinatorT = TypeVar(
158+
"_MinutelyForecastUpdateCoordinatorT",
159+
bound=TimestampDataUpdateCoordinator[Any],
160+
default=_DailyForecastUpdateCoordinatorT,
161+
)
155162

156163
# mypy: disallow-any-generics
157164

@@ -210,12 +217,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
210217
)
211218
component.async_register_entity_service(
212219
SERVICE_GET_FORECASTS,
213-
{vol.Required("type"): vol.In(("daily", "hourly", "twice_daily"))},
220+
{vol.Required("type"): vol.In(("daily", "hourly", "twice_daily", "minutely"))},
214221
async_get_forecasts_service,
215222
required_features=[
216223
WeatherEntityFeature.FORECAST_DAILY,
217224
WeatherEntityFeature.FORECAST_HOURLY,
218225
WeatherEntityFeature.FORECAST_TWICE_DAILY,
226+
WeatherEntityFeature.FORECAST_MINUTELY,
219227
],
220228
supports_response=SupportsResponse.ONLY,
221229
)
@@ -305,7 +313,7 @@ class WeatherEntity(Entity, PostInit, cached_properties=CACHED_PROPERTIES_WITH_A
305313
_attr_native_dew_point: float | None = None
306314

307315
_forecast_listeners: dict[
308-
Literal["daily", "hourly", "twice_daily"],
316+
Literal["daily", "hourly", "twice_daily", "minutely"],
309317
list[Callable[[list[JsonValueType] | None], None]],
310318
]
311319

@@ -317,7 +325,12 @@ class WeatherEntity(Entity, PostInit, cached_properties=CACHED_PROPERTIES_WITH_A
317325

318326
def __post_init__(self, *args: Any, **kwargs: Any) -> None:
319327
"""Finish initializing."""
320-
self._forecast_listeners = {"daily": [], "hourly": [], "twice_daily": []}
328+
self._forecast_listeners = {
329+
"daily": [],
330+
"hourly": [],
331+
"twice_daily": [],
332+
"minutely": [],
333+
}
321334

322335
async def async_internal_added_to_hass(self) -> None:
323336
"""Call when the weather entity is added to hass."""
@@ -514,6 +527,10 @@ async def async_forecast_hourly(self) -> list[Forecast] | None:
514527
"""Return the hourly forecast in native units."""
515528
raise NotImplementedError
516529

530+
async def async_forecast_minutely(self) -> list[Forecast] | None:
531+
"""Return the minutely forecast in native units."""
532+
raise NotImplementedError
533+
517534
@cached_property
518535
def native_precipitation_unit(self) -> str | None:
519536
"""Return the native unit of measurement for accumulated precipitation."""
@@ -927,22 +944,22 @@ def async_registry_entry_updated(self) -> None:
927944
@callback
928945
def _async_subscription_started(
929946
self,
930-
forecast_type: Literal["daily", "hourly", "twice_daily"],
947+
forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"],
931948
) -> None:
932949
"""Start subscription to forecast_type."""
933950

934951
@callback
935952
def _async_subscription_ended(
936953
self,
937-
forecast_type: Literal["daily", "hourly", "twice_daily"],
954+
forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"],
938955
) -> None:
939956
"""End subscription to forecast_type."""
940957

941958
@final
942959
@callback
943960
def async_subscribe_forecast(
944961
self,
945-
forecast_type: Literal["daily", "hourly", "twice_daily"],
962+
forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"],
946963
forecast_listener: Callable[[list[JsonValueType] | None], None],
947964
) -> CALLBACK_TYPE:
948965
"""Subscribe to forecast updates.
@@ -964,11 +981,13 @@ def unsubscribe() -> None:
964981

965982
@final
966983
async def async_update_listeners(
967-
self, forecast_types: Iterable[Literal["daily", "hourly", "twice_daily"]] | None
984+
self,
985+
forecast_types: Iterable[Literal["daily", "hourly", "twice_daily", "minutely"]]
986+
| None,
968987
) -> None:
969988
"""Push updated forecast to all listeners."""
970989
if forecast_types is None:
971-
forecast_types = {"daily", "hourly", "twice_daily"}
990+
forecast_types = {"daily", "hourly", "twice_daily", "minutely"}
972991
for forecast_type in forecast_types:
973992
if not self._forecast_listeners[forecast_type]:
974993
continue
@@ -1015,6 +1034,10 @@ async def async_get_forecasts_service(
10151034
if (supported_features & WeatherEntityFeature.FORECAST_HOURLY) == 0:
10161035
raise_unsupported_forecast(weather.entity_id, forecast_type)
10171036
native_forecast_list = await weather.async_forecast_hourly()
1037+
elif forecast_type == "minutely":
1038+
if (supported_features & WeatherEntityFeature.FORECAST_MINUTELY) == 0:
1039+
raise_unsupported_forecast(weather.entity_id, forecast_type)
1040+
native_forecast_list = await weather.async_forecast_minutely()
10181041
else:
10191042
if (supported_features & WeatherEntityFeature.FORECAST_TWICE_DAILY) == 0:
10201043
raise_unsupported_forecast(weather.entity_id, forecast_type)
@@ -1036,6 +1059,7 @@ class CoordinatorWeatherEntity(
10361059
_DailyForecastUpdateCoordinatorT,
10371060
_HourlyForecastUpdateCoordinatorT,
10381061
_TwiceDailyForecastUpdateCoordinatorT,
1062+
_MinutelyForecastUpdateCoordinatorT,
10391063
],
10401064
):
10411065
"""A class for weather entities using DataUpdateCoordinators."""
@@ -1048,26 +1072,31 @@ def __init__(
10481072
daily_coordinator: _DailyForecastUpdateCoordinatorT | None = None,
10491073
hourly_coordinator: _HourlyForecastUpdateCoordinatorT | None = None,
10501074
twice_daily_coordinator: _TwiceDailyForecastUpdateCoordinatorT | None = None,
1075+
minutely_coordinator: _MinutelyForecastUpdateCoordinatorT | None = None,
10511076
daily_forecast_valid: timedelta | None = None,
10521077
hourly_forecast_valid: timedelta | None = None,
10531078
twice_daily_forecast_valid: timedelta | None = None,
1079+
minutely_forecast_valid: timedelta | None = None,
10541080
) -> None:
10551081
"""Initialize."""
10561082
super().__init__(observation_coordinator, context)
10571083
self.forecast_coordinators = {
10581084
"daily": daily_coordinator,
10591085
"hourly": hourly_coordinator,
10601086
"twice_daily": twice_daily_coordinator,
1087+
"minutely": minutely_coordinator,
10611088
}
10621089
self.forecast_valid = {
10631090
"daily": daily_forecast_valid,
10641091
"hourly": hourly_forecast_valid,
10651092
"twice_daily": twice_daily_forecast_valid,
1093+
"minutely": minutely_forecast_valid,
10661094
}
10671095
self.unsub_forecast: dict[str, Callable[[], None] | None] = {
10681096
"daily": None,
10691097
"hourly": None,
10701098
"twice_daily": None,
1099+
"minutely": None,
10711100
}
10721101

10731102
async def async_added_to_hass(self) -> None:
@@ -1076,9 +1105,10 @@ async def async_added_to_hass(self) -> None:
10761105
self.async_on_remove(partial(self._remove_forecast_listener, "daily"))
10771106
self.async_on_remove(partial(self._remove_forecast_listener, "hourly"))
10781107
self.async_on_remove(partial(self._remove_forecast_listener, "twice_daily"))
1108+
self.async_on_remove(partial(self._remove_forecast_listener, "minutely"))
10791109

10801110
def _remove_forecast_listener(
1081-
self, forecast_type: Literal["daily", "hourly", "twice_daily"]
1111+
self, forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"]
10821112
) -> None:
10831113
"""Remove weather forecast listener."""
10841114
if unsub_fn := self.unsub_forecast[forecast_type]:
@@ -1088,7 +1118,7 @@ def _remove_forecast_listener(
10881118
@callback
10891119
def _async_subscription_started(
10901120
self,
1091-
forecast_type: Literal["daily", "hourly", "twice_daily"],
1121+
forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"],
10921122
) -> None:
10931123
"""Start subscription to forecast_type."""
10941124
if not (coordinator := self.forecast_coordinators[forecast_type]):
@@ -1109,10 +1139,14 @@ def _handle_hourly_forecast_coordinator_update(self) -> None:
11091139
def _handle_twice_daily_forecast_coordinator_update(self) -> None:
11101140
"""Handle updated data from the twice daily forecast coordinator."""
11111141

1142+
@callback
1143+
def _handle_minutely_forecast_coordinator_update(self) -> None:
1144+
"""Handle updated data from the minutely forecast coordinator."""
1145+
11121146
@final
11131147
@callback
11141148
def _handle_forecast_update(
1115-
self, forecast_type: Literal["daily", "hourly", "twice_daily"]
1149+
self, forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"]
11161150
) -> None:
11171151
"""Update forecast data."""
11181152
coordinator = self.forecast_coordinators[forecast_type]
@@ -1126,7 +1160,7 @@ def _handle_forecast_update(
11261160
@callback
11271161
def _async_subscription_ended(
11281162
self,
1129-
forecast_type: Literal["daily", "hourly", "twice_daily"],
1163+
forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"],
11301164
) -> None:
11311165
"""End subscription to forecast_type."""
11321166
self._remove_forecast_listener(forecast_type)
@@ -1169,9 +1203,14 @@ def _async_forecast_twice_daily(self) -> list[Forecast] | None:
11691203
"""Return the twice daily forecast in native units."""
11701204
raise NotImplementedError
11711205

1206+
@callback
1207+
def _async_forecast_minutely(self) -> list[Forecast] | None:
1208+
"""Return the minutely forecast in native units."""
1209+
raise NotImplementedError
1210+
11721211
@final
11731212
async def _async_forecast(
1174-
self, forecast_type: Literal["daily", "hourly", "twice_daily"]
1213+
self, forecast_type: Literal["daily", "hourly", "twice_daily", "minutely"]
11751214
) -> list[Forecast] | None:
11761215
"""Return the forecast in native units."""
11771216
coordinator = self.forecast_coordinators[forecast_type]
@@ -1198,6 +1237,11 @@ async def async_forecast_twice_daily(self) -> list[Forecast] | None:
11981237
"""Return the twice daily forecast in native units."""
11991238
return await self._async_forecast("twice_daily")
12001239

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

12021246
class SingleCoordinatorWeatherEntity(
12031247
CoordinatorWeatherEntity[

homeassistant/components/weather/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Constants for weather."""
22

3+
from __future__ import annotations
4+
35
from collections.abc import Callable
46
from enum import IntFlag
57
from typing import TYPE_CHECKING, Final
@@ -31,6 +33,7 @@ class WeatherEntityFeature(IntFlag):
3133
FORECAST_DAILY = 1
3234
FORECAST_HOURLY = 2
3335
FORECAST_TWICE_DAILY = 4
36+
FORECAST_MINUTELY = 8
3437

3538

3639
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""The weather websocket API."""
22

3+
from __future__ import annotations
4+
35
from typing import Any, Literal
46

57
import voluptuous as vol

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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""DataUpdateCoordinator for WeatherKit integration."""
22

3+
from __future__ import annotations
4+
35
from datetime import datetime, timedelta
46

57
from apple_weatherkit import DataSetType
@@ -17,6 +19,7 @@
1719
DataSetType.CURRENT_WEATHER,
1820
DataSetType.DAILY_FORECAST,
1921
DataSetType.HOURLY_FORECAST,
22+
DataSetType.NEXT_HOUR_FORECAST,
2023
]
2124

2225
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)