Home Assistant Unofficial Reference 2024.12.1
weather.py
Go to the documentation of this file.
1 """Support for NWS weather service."""
2 
3 from __future__ import annotations
4 
5 from functools import partial
6 from types import MappingProxyType
7 from typing import TYPE_CHECKING, Any, Literal, Required, TypedDict, cast
8 
9 import voluptuous as vol
10 
12  ATTR_CONDITION_CLEAR_NIGHT,
13  ATTR_CONDITION_SUNNY,
14  ATTR_FORECAST_CONDITION,
15  ATTR_FORECAST_HUMIDITY,
16  ATTR_FORECAST_IS_DAYTIME,
17  ATTR_FORECAST_NATIVE_DEW_POINT,
18  ATTR_FORECAST_NATIVE_TEMP,
19  ATTR_FORECAST_NATIVE_WIND_SPEED,
20  ATTR_FORECAST_PRECIPITATION_PROBABILITY,
21  ATTR_FORECAST_TIME,
22  ATTR_FORECAST_WIND_BEARING,
23  DOMAIN as WEATHER_DOMAIN,
24  CoordinatorWeatherEntity,
25  Forecast,
26  WeatherEntityFeature,
27 )
28 from homeassistant.const import (
29  CONF_LATITUDE,
30  CONF_LONGITUDE,
31  UnitOfLength,
32  UnitOfPressure,
33  UnitOfSpeed,
34  UnitOfTemperature,
35 )
36 from homeassistant.core import (
37  HomeAssistant,
38  ServiceResponse,
39  SupportsResponse,
40  callback,
41 )
42 from homeassistant.helpers import entity_platform, entity_registry as er
43 from homeassistant.helpers.entity_platform import AddEntitiesCallback
44 from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
45 from homeassistant.util.json import JsonValueType
46 from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter
47 
48 from . import NWSConfigEntry, NWSData, base_unique_id, device_info
49 from .const import (
50  ATTR_FORECAST_DETAILED_DESCRIPTION,
51  ATTR_FORECAST_SHORT_DESCRIPTION,
52  ATTRIBUTION,
53  CONDITION_CLASSES,
54  DAYNIGHT,
55  DOMAIN,
56  FORECAST_VALID_TIME,
57  HOURLY,
58 )
59 
60 PARALLEL_UPDATES = 0
61 
62 
63 def convert_condition(time: str, weather: tuple[tuple[str, int | None], ...]) -> str:
64  """Convert NWS codes to HA condition.
65 
66  Choose first condition in CONDITION_CLASSES that exists in weather code.
67  If no match is found, return first condition from NWS
68  """
69  conditions: list[str] = [w[0] for w in weather]
70 
71  # Choose condition with highest priority.
72  cond = next(
73  (
74  key
75  for key, value in CONDITION_CLASSES.items()
76  if any(condition in value for condition in conditions)
77  ),
78  conditions[0],
79  )
80 
81  if cond == "clear":
82  if time == "day":
83  return ATTR_CONDITION_SUNNY
84  if time == "night":
85  return ATTR_CONDITION_CLEAR_NIGHT
86  return cond
87 
88 
90  hass: HomeAssistant, entry: NWSConfigEntry, async_add_entities: AddEntitiesCallback
91 ) -> None:
92  """Set up the NWS weather platform."""
93  entity_registry = er.async_get(hass)
94  nws_data = entry.runtime_data
95 
96  # Remove hourly entity from legacy config entries
97  if entity_id := entity_registry.async_get_entity_id(
98  WEATHER_DOMAIN,
99  DOMAIN,
100  _calculate_unique_id(entry.data, HOURLY),
101  ):
102  entity_registry.async_remove(entity_id)
103 
104  platform = entity_platform.async_get_current_platform()
105 
106  platform.async_register_entity_service(
107  "get_forecasts_extra",
108  {vol.Required("type"): vol.In(("hourly", "twice_daily"))},
109  "async_get_forecasts_extra_service",
110  supports_response=SupportsResponse.ONLY,
111  )
112 
113  async_add_entities([NWSWeather(entry.data, nws_data)], False)
114 
115 
116 class ExtraForecast(TypedDict, total=False):
117  """Forecast extra fields from NWS."""
118 
119  # common attributes
120  datetime: Required[str]
121  is_daytime: bool | None
122  # extra attributes
123  detailed_description: str | None
124  short_description: str | None
125 
126 
127 def _calculate_unique_id(entry_data: MappingProxyType[str, Any], mode: str) -> str:
128  """Calculate unique ID."""
129  latitude = entry_data[CONF_LATITUDE]
130  longitude = entry_data[CONF_LONGITUDE]
131  return f"{base_unique_id(latitude, longitude)}_{mode}"
132 
133 
134 class NWSWeather(CoordinatorWeatherEntity[TimestampDataUpdateCoordinator[None]]):
135  """Representation of a weather condition."""
136 
137  _attr_attribution = ATTRIBUTION
138  _attr_should_poll = False
139  _attr_supported_features = (
140  WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_TWICE_DAILY
141  )
142  _attr_native_temperature_unit = UnitOfTemperature.CELSIUS
143  _attr_native_pressure_unit = UnitOfPressure.PA
144  _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
145  _attr_native_visibility_unit = UnitOfLength.METERS
146 
147  def __init__(
148  self,
149  entry_data: MappingProxyType[str, Any],
150  nws_data: NWSData,
151  ) -> None:
152  """Initialise the platform with a data instance and station name."""
153  super().__init__(
154  observation_coordinator=nws_data.coordinator_observation,
155  hourly_coordinator=nws_data.coordinator_forecast_hourly,
156  twice_daily_coordinator=nws_data.coordinator_forecast,
157  hourly_forecast_valid=FORECAST_VALID_TIME,
158  twice_daily_forecast_valid=FORECAST_VALID_TIME,
159  )
160  self.nwsnws = nws_data.api
161  latitude = entry_data[CONF_LATITUDE]
162  longitude = entry_data[CONF_LONGITUDE]
163 
164  self.stationstation = self.nwsnws.station
165 
166  self._attr_unique_id_attr_unique_id = _calculate_unique_id(entry_data, DAYNIGHT)
167  self._attr_device_info_attr_device_info = device_info(latitude, longitude)
168  self._attr_name_attr_name = self.stationstation
169 
170  async def async_added_to_hass(self) -> None:
171  """When entity is added to hass."""
172  await super().async_added_to_hass()
173  self.async_on_removeasync_on_remove(partial(self._remove_forecast_listener_remove_forecast_listener, "daily"))
174  self.async_on_removeasync_on_remove(partial(self._remove_forecast_listener_remove_forecast_listener, "hourly"))
175  self.async_on_removeasync_on_remove(partial(self._remove_forecast_listener_remove_forecast_listener, "twice_daily"))
176 
177  for forecast_type in ("twice_daily", "hourly"):
178  if (coordinator := self.forecast_coordinatorsforecast_coordinators[forecast_type]) is None:
179  continue
180  if TYPE_CHECKING:
181  forecast_type = cast(Literal["twice_daily", "hourly"], forecast_type)
182  self.unsub_forecast[forecast_type] = coordinator.async_add_listener(
183  partial(self._handle_forecast_update_handle_forecast_update, forecast_type)
184  )
185 
186  @property
187  def native_temperature(self) -> float | None:
188  """Return the current temperature."""
189  if observation := self.nwsnws.observation:
190  return observation.get("temperature")
191  return None
192 
193  @property
194  def native_pressure(self) -> int | None:
195  """Return the current pressure."""
196  if observation := self.nwsnws.observation:
197  return observation.get("seaLevelPressure")
198  return None
199 
200  @property
201  def humidity(self) -> float | None:
202  """Return the name of the sensor."""
203  if observation := self.nwsnws.observation:
204  return observation.get("relativeHumidity")
205  return None
206 
207  @property
208  def native_wind_speed(self) -> float | None:
209  """Return the current windspeed."""
210  if observation := self.nwsnws.observation:
211  return observation.get("windSpeed")
212  return None
213 
214  @property
215  def wind_bearing(self) -> int | None:
216  """Return the current wind bearing (degrees)."""
217  if observation := self.nwsnws.observation:
218  return observation.get("windDirection")
219  return None
220 
221  @property
222  def condition(self) -> str | None:
223  """Return current condition."""
224  weather = None
225  if observation := self.nwsnws.observation:
226  weather = observation.get("iconWeather")
227  time = cast(str, observation.get("iconTime"))
228 
229  if weather:
230  return convert_condition(time, weather)
231  return None
232 
233  @property
234  def native_visibility(self) -> int | None:
235  """Return visibility."""
236  if observation := self.nwsnws.observation:
237  return observation.get("visibility")
238  return None
239 
241  self,
242  nws_forecast: list[dict[str, Any]],
243  mode: str,
244  ) -> list[Forecast]:
245  """Return forecast."""
246  if nws_forecast is None:
247  return []
248  forecast: list[Forecast] = []
249  for forecast_entry in nws_forecast:
250  data: Forecast = {
251  ATTR_FORECAST_TIME: cast(str, forecast_entry.get("startTime")),
252  }
253 
254  if (temp := forecast_entry.get("temperature")) is not None:
255  data[ATTR_FORECAST_NATIVE_TEMP] = TemperatureConverter.convert(
256  temp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS
257  )
258  else:
259  data[ATTR_FORECAST_NATIVE_TEMP] = None
260 
261  data[ATTR_FORECAST_PRECIPITATION_PROBABILITY] = forecast_entry.get(
262  "probabilityOfPrecipitation"
263  )
264 
265  if (dewp := forecast_entry.get("dewpoint")) is not None:
266  data[ATTR_FORECAST_NATIVE_DEW_POINT] = TemperatureConverter.convert(
267  dewp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS
268  )
269  else:
270  data[ATTR_FORECAST_NATIVE_DEW_POINT] = None
271 
272  data[ATTR_FORECAST_HUMIDITY] = forecast_entry.get("relativeHumidity")
273 
274  if mode == DAYNIGHT:
275  data[ATTR_FORECAST_IS_DAYTIME] = forecast_entry.get("isDaytime")
276 
277  time = forecast_entry.get("iconTime")
278  weather = forecast_entry.get("iconWeather")
279  data[ATTR_FORECAST_CONDITION] = (
280  convert_condition(time, weather) if time and weather else None
281  )
282 
283  data[ATTR_FORECAST_WIND_BEARING] = forecast_entry.get("windBearing")
284  wind_speed = forecast_entry.get("windSpeedAvg")
285  if wind_speed is not None:
286  data[ATTR_FORECAST_NATIVE_WIND_SPEED] = SpeedConverter.convert(
287  wind_speed,
288  UnitOfSpeed.MILES_PER_HOUR,
289  UnitOfSpeed.KILOMETERS_PER_HOUR,
290  )
291  else:
292  data[ATTR_FORECAST_NATIVE_WIND_SPEED] = None
293  forecast.append(data)
294  return forecast
295 
297  self,
298  nws_forecast: list[dict[str, Any]] | None,
299  mode: str,
300  ) -> list[ExtraForecast]:
301  """Return forecast."""
302  if nws_forecast is None:
303  return []
304  forecast: list[ExtraForecast] = []
305  for forecast_entry in nws_forecast:
306  data: ExtraForecast = {
307  ATTR_FORECAST_TIME: cast(str, forecast_entry.get("startTime")),
308  }
309  if mode == DAYNIGHT:
310  data[ATTR_FORECAST_IS_DAYTIME] = forecast_entry.get("isDaytime")
311 
312  data[ATTR_FORECAST_DETAILED_DESCRIPTION] = forecast_entry.get(
313  "detailedForecast"
314  )
315 
316  data[ATTR_FORECAST_SHORT_DESCRIPTION] = forecast_entry.get("shortForecast")
317  forecast.append(data)
318  return forecast
319 
320  @callback
321  def _async_forecast_hourly(self) -> list[Forecast] | None:
322  """Return the hourly forecast in native units."""
323  return self._forecast_forecast(self.nwsnws.forecast_hourly, HOURLY)
324 
325  @callback
326  def _async_forecast_twice_daily(self) -> list[Forecast] | None:
327  """Return the twice daily forecast in native units."""
328  return self._forecast_forecast(self.nwsnws.forecast, DAYNIGHT)
329 
330  async def async_update(self) -> None:
331  """Update the entity.
332 
333  Only used by the generic entity update service.
334  """
335  await self.coordinator.async_request_refresh()
336 
337  for forecast_type in ("twice_daily", "hourly"):
338  if (coordinator := self.forecast_coordinatorsforecast_coordinators[forecast_type]) is not None:
339  await coordinator.async_request_refresh()
340 
341  async def async_get_forecasts_extra_service(self, type) -> ServiceResponse:
342  """Get extra weather forecast."""
343  if type == "hourly":
344  nws_forecast = self._forecast_extra_forecast_extra(self.nwsnws.forecast_hourly, HOURLY)
345  else:
346  nws_forecast = self._forecast_extra_forecast_extra(self.nwsnws.forecast, DAYNIGHT)
347  return {
348  "forecast": cast(JsonValueType, nws_forecast),
349  }
list[Forecast]|None _async_forecast_twice_daily(self)
Definition: weather.py:326
list[ExtraForecast] _forecast_extra(self, list[dict[str, Any]]|None nws_forecast, str mode)
Definition: weather.py:300
ServiceResponse async_get_forecasts_extra_service(self, type)
Definition: weather.py:341
None __init__(self, MappingProxyType[str, Any] entry_data, NWSData nws_data)
Definition: weather.py:151
list[Forecast] _forecast(self, list[dict[str, Any]] nws_forecast, str mode)
Definition: weather.py:244
list[Forecast]|None _async_forecast_hourly(self)
Definition: weather.py:321
None _handle_forecast_update(self, Literal["daily", "hourly", "twice_daily"] forecast_type)
Definition: __init__.py:1109
None _remove_forecast_listener(self, Literal["daily", "hourly", "twice_daily"] forecast_type)
Definition: __init__.py:1075
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
DeviceInfo|None device_info(self)
Definition: entity.py:798
None async_setup_entry(HomeAssistant hass, NWSConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: weather.py:91
str _calculate_unique_id(MappingProxyType[str, Any] entry_data, str mode)
Definition: weather.py:127
str convert_condition(str time, tuple[tuple[str, int|None],...] weather)
Definition: weather.py:63