mirror of
https://github.com/home-assistant/core.git
synced 2025-04-25 17:57:55 +00:00
Modernize nws weather (#98748)
This commit is contained in:
parent
79811984f0
commit
68e2809c36
@ -90,7 +90,6 @@ class NwsDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
|||||||
# the base class allows None, but this one doesn't
|
# the base class allows None, but this one doesn't
|
||||||
assert self.update_interval is not None
|
assert self.update_interval is not None
|
||||||
update_interval = self.update_interval
|
update_interval = self.update_interval
|
||||||
self.last_update_success_time = utcnow()
|
|
||||||
else:
|
else:
|
||||||
update_interval = self.failed_update_interval
|
update_interval = self.failed_update_interval
|
||||||
self._unsub_refresh = async_track_point_in_utc_time(
|
self._unsub_refresh = async_track_point_in_utc_time(
|
||||||
@ -99,6 +98,23 @@ class NwsDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
|||||||
utcnow().replace(microsecond=0) + update_interval,
|
utcnow().replace(microsecond=0) + update_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _async_refresh(
|
||||||
|
self,
|
||||||
|
log_failures: bool = True,
|
||||||
|
raise_on_auth_failed: bool = False,
|
||||||
|
scheduled: bool = False,
|
||||||
|
raise_on_entry_error: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Refresh data."""
|
||||||
|
await super()._async_refresh(
|
||||||
|
log_failures,
|
||||||
|
raise_on_auth_failed,
|
||||||
|
scheduled,
|
||||||
|
raise_on_entry_error,
|
||||||
|
)
|
||||||
|
if self.last_update_success:
|
||||||
|
self.last_update_success_time = utcnow()
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up a National Weather Service entry."""
|
"""Set up a National Weather Service entry."""
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
from homeassistant.components.weather import (
|
from homeassistant.components.weather import (
|
||||||
ATTR_CONDITION_CLOUDY,
|
ATTR_CONDITION_CLOUDY,
|
||||||
@ -25,7 +26,7 @@ CONF_STATION = "station"
|
|||||||
|
|
||||||
ATTRIBUTION = "Data from National Weather Service/NOAA"
|
ATTRIBUTION = "Data from National Weather Service/NOAA"
|
||||||
|
|
||||||
ATTR_FORECAST_DETAILED_DESCRIPTION = "detailed_description"
|
ATTR_FORECAST_DETAILED_DESCRIPTION: Final = "detailed_description"
|
||||||
|
|
||||||
CONDITION_CLASSES: dict[str, list[str]] = {
|
CONDITION_CLASSES: dict[str, list[str]] = {
|
||||||
ATTR_CONDITION_EXCEPTIONAL: [
|
ATTR_CONDITION_EXCEPTIONAL: [
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
"""Support for NWS weather service."""
|
"""Support for NWS weather service."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||||
|
|
||||||
from homeassistant.components.weather import (
|
from homeassistant.components.weather import (
|
||||||
ATTR_CONDITION_CLEAR_NIGHT,
|
ATTR_CONDITION_CLEAR_NIGHT,
|
||||||
@ -16,8 +17,10 @@ from homeassistant.components.weather import (
|
|||||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
|
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
|
||||||
ATTR_FORECAST_TIME,
|
ATTR_FORECAST_TIME,
|
||||||
ATTR_FORECAST_WIND_BEARING,
|
ATTR_FORECAST_WIND_BEARING,
|
||||||
|
DOMAIN as WEATHER_DOMAIN,
|
||||||
Forecast,
|
Forecast,
|
||||||
WeatherEntity,
|
WeatherEntity,
|
||||||
|
WeatherEntityFeature,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
@ -29,13 +32,19 @@ from homeassistant.const import (
|
|||||||
UnitOfTemperature,
|
UnitOfTemperature,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.util.dt import utcnow
|
from homeassistant.util.dt import utcnow
|
||||||
from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter
|
from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter
|
||||||
from homeassistant.util.unit_system import UnitSystem
|
|
||||||
|
|
||||||
from . import NWSData, base_unique_id, device_info
|
from . import (
|
||||||
|
DEFAULT_SCAN_INTERVAL,
|
||||||
|
NWSData,
|
||||||
|
NwsDataUpdateCoordinator,
|
||||||
|
base_unique_id,
|
||||||
|
device_info,
|
||||||
|
)
|
||||||
from .const import (
|
from .const import (
|
||||||
ATTR_FORECAST_DETAILED_DESCRIPTION,
|
ATTR_FORECAST_DETAILED_DESCRIPTION,
|
||||||
ATTRIBUTION,
|
ATTRIBUTION,
|
||||||
@ -80,15 +89,20 @@ async def async_setup_entry(
|
|||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the NWS weather platform."""
|
"""Set up the NWS weather platform."""
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
nws_data: NWSData = hass.data[DOMAIN][entry.entry_id]
|
nws_data: NWSData = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
|
||||||
async_add_entities(
|
entities = [NWSWeather(entry.data, nws_data, DAYNIGHT)]
|
||||||
[
|
|
||||||
NWSWeather(entry.data, nws_data, DAYNIGHT, hass.config.units),
|
# Add hourly entity to legacy config entries
|
||||||
NWSWeather(entry.data, nws_data, HOURLY, hass.config.units),
|
if entity_registry.async_get_entity_id(
|
||||||
],
|
WEATHER_DOMAIN,
|
||||||
False,
|
DOMAIN,
|
||||||
)
|
_calculate_unique_id(entry.data, HOURLY),
|
||||||
|
):
|
||||||
|
entities.append(NWSWeather(entry.data, nws_data, HOURLY))
|
||||||
|
|
||||||
|
async_add_entities(entities, False)
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -99,34 +113,51 @@ if TYPE_CHECKING:
|
|||||||
detailed_description: str | None
|
detailed_description: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_unique_id(entry_data: MappingProxyType[str, Any], mode: str) -> str:
|
||||||
|
"""Calculate unique ID."""
|
||||||
|
latitude = entry_data[CONF_LATITUDE]
|
||||||
|
longitude = entry_data[CONF_LONGITUDE]
|
||||||
|
return f"{base_unique_id(latitude, longitude)}_{mode}"
|
||||||
|
|
||||||
|
|
||||||
class NWSWeather(WeatherEntity):
|
class NWSWeather(WeatherEntity):
|
||||||
"""Representation of a weather condition."""
|
"""Representation of a weather condition."""
|
||||||
|
|
||||||
_attr_attribution = ATTRIBUTION
|
_attr_attribution = ATTRIBUTION
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
|
_attr_supported_features = (
|
||||||
|
WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_TWICE_DAILY
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
entry_data: MappingProxyType[str, Any],
|
entry_data: MappingProxyType[str, Any],
|
||||||
nws_data: NWSData,
|
nws_data: NWSData,
|
||||||
mode: str,
|
mode: str,
|
||||||
units: UnitSystem,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialise the platform with a data instance and station name."""
|
"""Initialise the platform with a data instance and station name."""
|
||||||
self.nws = nws_data.api
|
self.nws = nws_data.api
|
||||||
self.latitude = entry_data[CONF_LATITUDE]
|
self.latitude = entry_data[CONF_LATITUDE]
|
||||||
self.longitude = entry_data[CONF_LONGITUDE]
|
self.longitude = entry_data[CONF_LONGITUDE]
|
||||||
|
self.coordinator_forecast_hourly = nws_data.coordinator_forecast_hourly
|
||||||
|
self.coordinator_forecast_twice_daily = nws_data.coordinator_forecast
|
||||||
self.coordinator_observation = nws_data.coordinator_observation
|
self.coordinator_observation = nws_data.coordinator_observation
|
||||||
if mode == DAYNIGHT:
|
if mode == DAYNIGHT:
|
||||||
self.coordinator_forecast = nws_data.coordinator_forecast
|
self.coordinator_forecast_legacy = nws_data.coordinator_forecast
|
||||||
else:
|
else:
|
||||||
self.coordinator_forecast = nws_data.coordinator_forecast_hourly
|
self.coordinator_forecast_legacy = nws_data.coordinator_forecast_hourly
|
||||||
self.station = self.nws.station
|
self.station = self.nws.station
|
||||||
|
self._unsub_hourly_forecast: Callable[[], None] | None = None
|
||||||
|
self._unsub_twice_daily_forecast: Callable[[], None] | None = None
|
||||||
|
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
|
|
||||||
self.observation = None
|
self.observation: dict[str, Any] | None = None
|
||||||
self._forecast = None
|
self._forecast_hourly: list[dict[str, Any]] | None = None
|
||||||
|
self._forecast_legacy: list[dict[str, Any]] | None = None
|
||||||
|
self._forecast_twice_daily: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
self._attr_unique_id = _calculate_unique_id(entry_data, mode)
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Set up a listener and load data."""
|
"""Set up a listener and load data."""
|
||||||
@ -134,20 +165,72 @@ class NWSWeather(WeatherEntity):
|
|||||||
self.coordinator_observation.async_add_listener(self._update_callback)
|
self.coordinator_observation.async_add_listener(self._update_callback)
|
||||||
)
|
)
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
self.coordinator_forecast.async_add_listener(self._update_callback)
|
self.coordinator_forecast_legacy.async_add_listener(self._update_callback)
|
||||||
)
|
)
|
||||||
|
self.async_on_remove(self._remove_hourly_forecast_listener)
|
||||||
|
self.async_on_remove(self._remove_twice_daily_forecast_listener)
|
||||||
self._update_callback()
|
self._update_callback()
|
||||||
|
|
||||||
|
def _remove_hourly_forecast_listener(self) -> None:
|
||||||
|
"""Remove hourly forecast listener."""
|
||||||
|
if self._unsub_hourly_forecast:
|
||||||
|
self._unsub_hourly_forecast()
|
||||||
|
self._unsub_hourly_forecast = None
|
||||||
|
|
||||||
|
def _remove_twice_daily_forecast_listener(self) -> None:
|
||||||
|
"""Remove hourly forecast listener."""
|
||||||
|
if self._unsub_twice_daily_forecast:
|
||||||
|
self._unsub_twice_daily_forecast()
|
||||||
|
self._unsub_twice_daily_forecast = None
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_subscription_started(
|
||||||
|
self,
|
||||||
|
forecast_type: Literal["daily", "hourly", "twice_daily"],
|
||||||
|
) -> None:
|
||||||
|
"""Start subscription to forecast_type."""
|
||||||
|
if forecast_type == "hourly" and self.mode == DAYNIGHT:
|
||||||
|
self._unsub_hourly_forecast = (
|
||||||
|
self.coordinator_forecast_hourly.async_add_listener(
|
||||||
|
self._update_callback
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if forecast_type == "twice_daily" and self.mode == HOURLY:
|
||||||
|
self._unsub_twice_daily_forecast = (
|
||||||
|
self.coordinator_forecast_twice_daily.async_add_listener(
|
||||||
|
self._update_callback
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_subscription_ended(
|
||||||
|
self,
|
||||||
|
forecast_type: Literal["daily", "hourly", "twice_daily"],
|
||||||
|
) -> None:
|
||||||
|
"""End subscription to forecast_type."""
|
||||||
|
if forecast_type == "hourly" and self.mode == DAYNIGHT:
|
||||||
|
self._remove_hourly_forecast_listener()
|
||||||
|
if forecast_type == "twice_daily" and self.mode == HOURLY:
|
||||||
|
self._remove_twice_daily_forecast_listener()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _update_callback(self) -> None:
|
def _update_callback(self) -> None:
|
||||||
"""Load data from integration."""
|
"""Load data from integration."""
|
||||||
self.observation = self.nws.observation
|
self.observation = self.nws.observation
|
||||||
|
self._forecast_hourly = self.nws.forecast_hourly
|
||||||
|
self._forecast_twice_daily = self.nws.forecast
|
||||||
if self.mode == DAYNIGHT:
|
if self.mode == DAYNIGHT:
|
||||||
self._forecast = self.nws.forecast
|
self._forecast_legacy = self.nws.forecast
|
||||||
else:
|
else:
|
||||||
self._forecast = self.nws.forecast_hourly
|
self._forecast_legacy = self.nws.forecast_hourly
|
||||||
|
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
assert self.platform.config_entry
|
||||||
|
self.platform.config_entry.async_create_task(
|
||||||
|
self.hass, self.async_update_listeners(("hourly", "twice_daily"))
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@ -210,7 +293,7 @@ class NWSWeather(WeatherEntity):
|
|||||||
weather = None
|
weather = None
|
||||||
if self.observation:
|
if self.observation:
|
||||||
weather = self.observation.get("iconWeather")
|
weather = self.observation.get("iconWeather")
|
||||||
time = self.observation.get("iconTime")
|
time = cast(str, self.observation.get("iconTime"))
|
||||||
|
|
||||||
if weather:
|
if weather:
|
||||||
return convert_condition(time, weather)
|
return convert_condition(time, weather)
|
||||||
@ -228,18 +311,19 @@ class NWSWeather(WeatherEntity):
|
|||||||
"""Return visibility unit."""
|
"""Return visibility unit."""
|
||||||
return UnitOfLength.METERS
|
return UnitOfLength.METERS
|
||||||
|
|
||||||
@property
|
def _forecast(
|
||||||
def forecast(self) -> list[Forecast] | None:
|
self, nws_forecast: list[dict[str, Any]] | None, mode: str
|
||||||
|
) -> list[Forecast] | None:
|
||||||
"""Return forecast."""
|
"""Return forecast."""
|
||||||
if self._forecast is None:
|
if nws_forecast is None:
|
||||||
return None
|
return None
|
||||||
forecast: list[NWSForecast] = []
|
forecast: list[Forecast] = []
|
||||||
for forecast_entry in self._forecast:
|
for forecast_entry in nws_forecast:
|
||||||
data = {
|
data: NWSForecast = {
|
||||||
ATTR_FORECAST_DETAILED_DESCRIPTION: forecast_entry.get(
|
ATTR_FORECAST_DETAILED_DESCRIPTION: forecast_entry.get(
|
||||||
"detailedForecast"
|
"detailedForecast"
|
||||||
),
|
),
|
||||||
ATTR_FORECAST_TIME: forecast_entry.get("startTime"),
|
ATTR_FORECAST_TIME: cast(str, forecast_entry.get("startTime")),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (temp := forecast_entry.get("temperature")) is not None:
|
if (temp := forecast_entry.get("temperature")) is not None:
|
||||||
@ -262,7 +346,7 @@ class NWSWeather(WeatherEntity):
|
|||||||
|
|
||||||
data[ATTR_FORECAST_HUMIDITY] = forecast_entry.get("relativeHumidity")
|
data[ATTR_FORECAST_HUMIDITY] = forecast_entry.get("relativeHumidity")
|
||||||
|
|
||||||
if self.mode == DAYNIGHT:
|
if mode == DAYNIGHT:
|
||||||
data[ATTR_FORECAST_IS_DAYTIME] = forecast_entry.get("isDaytime")
|
data[ATTR_FORECAST_IS_DAYTIME] = forecast_entry.get("isDaytime")
|
||||||
|
|
||||||
time = forecast_entry.get("iconTime")
|
time = forecast_entry.get("iconTime")
|
||||||
@ -285,25 +369,56 @@ class NWSWeather(WeatherEntity):
|
|||||||
return forecast
|
return forecast
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unique_id(self) -> str:
|
def forecast(self) -> list[Forecast] | None:
|
||||||
"""Return a unique_id for this entity."""
|
"""Return forecast."""
|
||||||
return f"{base_unique_id(self.latitude, self.longitude)}_{self.mode}"
|
return self._forecast(self._forecast_legacy, self.mode)
|
||||||
|
|
||||||
|
async def _async_forecast(
|
||||||
|
self,
|
||||||
|
coordinator: NwsDataUpdateCoordinator,
|
||||||
|
nws_forecast: list[dict[str, Any]] | None,
|
||||||
|
mode: str,
|
||||||
|
) -> list[Forecast] | None:
|
||||||
|
"""Refresh stale forecast and return it in native units."""
|
||||||
|
if (
|
||||||
|
not (last_success_time := coordinator.last_update_success_time)
|
||||||
|
or utcnow() - last_success_time >= DEFAULT_SCAN_INTERVAL
|
||||||
|
):
|
||||||
|
await coordinator.async_refresh()
|
||||||
|
if (
|
||||||
|
not (last_success_time := coordinator.last_update_success_time)
|
||||||
|
or utcnow() - last_success_time >= FORECAST_VALID_TIME
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return self._forecast(nws_forecast, mode)
|
||||||
|
|
||||||
|
async def async_forecast_hourly(self) -> list[Forecast] | None:
|
||||||
|
"""Return the hourly forecast in native units."""
|
||||||
|
coordinator = self.coordinator_forecast_hourly
|
||||||
|
return await self._async_forecast(coordinator, self._forecast_hourly, HOURLY)
|
||||||
|
|
||||||
|
async def async_forecast_twice_daily(self) -> list[Forecast] | None:
|
||||||
|
"""Return the twice daily forecast in native units."""
|
||||||
|
coordinator = self.coordinator_forecast_twice_daily
|
||||||
|
return await self._async_forecast(
|
||||||
|
coordinator, self._forecast_twice_daily, DAYNIGHT
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return if state is available."""
|
"""Return if state is available."""
|
||||||
last_success = (
|
last_success = (
|
||||||
self.coordinator_observation.last_update_success
|
self.coordinator_observation.last_update_success
|
||||||
and self.coordinator_forecast.last_update_success
|
and self.coordinator_forecast_legacy.last_update_success
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
self.coordinator_observation.last_update_success_time
|
self.coordinator_observation.last_update_success_time
|
||||||
and self.coordinator_forecast.last_update_success_time
|
and self.coordinator_forecast_legacy.last_update_success_time
|
||||||
):
|
):
|
||||||
last_success_time = (
|
last_success_time = (
|
||||||
utcnow() - self.coordinator_observation.last_update_success_time
|
utcnow() - self.coordinator_observation.last_update_success_time
|
||||||
< OBSERVATION_VALID_TIME
|
< OBSERVATION_VALID_TIME
|
||||||
and utcnow() - self.coordinator_forecast.last_update_success_time
|
and utcnow() - self.coordinator_forecast_legacy.last_update_success_time
|
||||||
< FORECAST_VALID_TIME
|
< FORECAST_VALID_TIME
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -316,7 +431,7 @@ class NWSWeather(WeatherEntity):
|
|||||||
Only used by the generic entity update service.
|
Only used by the generic entity update service.
|
||||||
"""
|
"""
|
||||||
await self.coordinator_observation.async_request_refresh()
|
await self.coordinator_observation.async_request_refresh()
|
||||||
await self.coordinator_forecast.async_request_refresh()
|
await self.coordinator_forecast_legacy.async_request_refresh()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def entity_registry_enabled_default(self) -> bool:
|
def entity_registry_enabled_default(self) -> bool:
|
||||||
|
@ -1079,6 +1079,22 @@ class WeatherEntity(Entity, PostInit):
|
|||||||
) and custom_unit_visibility in VALID_UNITS[ATTR_WEATHER_VISIBILITY_UNIT]:
|
) and custom_unit_visibility in VALID_UNITS[ATTR_WEATHER_VISIBILITY_UNIT]:
|
||||||
self._weather_option_visibility_unit = custom_unit_visibility
|
self._weather_option_visibility_unit = custom_unit_visibility
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_subscription_started(
|
||||||
|
self,
|
||||||
|
forecast_type: Literal["daily", "hourly", "twice_daily"],
|
||||||
|
) -> None:
|
||||||
|
"""Start subscription to forecast_type."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_subscription_ended(
|
||||||
|
self,
|
||||||
|
forecast_type: Literal["daily", "hourly", "twice_daily"],
|
||||||
|
) -> None:
|
||||||
|
"""End subscription to forecast_type."""
|
||||||
|
return None
|
||||||
|
|
||||||
@final
|
@final
|
||||||
@callback
|
@callback
|
||||||
def async_subscribe_forecast(
|
def async_subscribe_forecast(
|
||||||
@ -1090,11 +1106,16 @@ class WeatherEntity(Entity, PostInit):
|
|||||||
|
|
||||||
Called by websocket API.
|
Called by websocket API.
|
||||||
"""
|
"""
|
||||||
|
subscription_started = not self._forecast_listeners[forecast_type]
|
||||||
self._forecast_listeners[forecast_type].append(forecast_listener)
|
self._forecast_listeners[forecast_type].append(forecast_listener)
|
||||||
|
if subscription_started:
|
||||||
|
self._async_subscription_started(forecast_type)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def unsubscribe() -> None:
|
def unsubscribe() -> None:
|
||||||
self._forecast_listeners[forecast_type].remove(forecast_listener)
|
self._forecast_listeners[forecast_type].remove(forecast_listener)
|
||||||
|
if not self._forecast_listeners[forecast_type]:
|
||||||
|
self._async_subscription_ended(forecast_type)
|
||||||
|
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
|
|
||||||
|
229
tests/components/nws/snapshots/test_weather.ambr
Normal file
229
tests/components/nws/snapshots/test_weather.ambr
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
# serializer version: 1
|
||||||
|
# name: test_forecast_service
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_forecast_service.1
|
||||||
|
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.2
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_forecast_service.3
|
||||||
|
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.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.5
|
||||||
|
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_subscription[hourly-weather.abc_daynight]
|
||||||
|
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_subscription[hourly-weather.abc_daynight].1
|
||||||
|
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_subscription[hourly]
|
||||||
|
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_subscription[hourly].1
|
||||||
|
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_subscription[twice_daily-weather.abc_hourly]
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
# ---
|
||||||
|
# name: test_forecast_subscription[twice_daily-weather.abc_hourly].1
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
# ---
|
||||||
|
# name: test_forecast_subscription[twice_daily]
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
# ---
|
||||||
|
# name: test_forecast_subscription[twice_daily].1
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
# ---
|
@ -3,7 +3,9 @@ from datetime import timedelta
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
import pytest
|
import pytest
|
||||||
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
from homeassistant.components import nws
|
from homeassistant.components import nws
|
||||||
from homeassistant.components.weather import (
|
from homeassistant.components.weather import (
|
||||||
@ -11,6 +13,7 @@ from homeassistant.components.weather import (
|
|||||||
ATTR_CONDITION_SUNNY,
|
ATTR_CONDITION_SUNNY,
|
||||||
ATTR_FORECAST,
|
ATTR_FORECAST,
|
||||||
DOMAIN as WEATHER_DOMAIN,
|
DOMAIN as WEATHER_DOMAIN,
|
||||||
|
SERVICE_GET_FORECAST,
|
||||||
)
|
)
|
||||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@ -31,6 +34,7 @@ from .const import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||||
|
from tests.typing import WebSocketGenerator
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@ -354,10 +358,10 @@ async def test_error_forecast_hourly(
|
|||||||
assert state.state == ATTR_CONDITION_SUNNY
|
assert state.state == ATTR_CONDITION_SUNNY
|
||||||
|
|
||||||
|
|
||||||
async def test_forecast_hourly_disable_enable(
|
async def test_new_config_entry(hass: HomeAssistant, no_sensor) -> None:
|
||||||
hass: HomeAssistant, mock_simple_nws, no_sensor
|
"""Test the expected entities are created."""
|
||||||
) -> None:
|
registry = er.async_get(hass)
|
||||||
"""Test error during update forecast hourly."""
|
|
||||||
entry = MockConfigEntry(
|
entry = MockConfigEntry(
|
||||||
domain=nws.DOMAIN,
|
domain=nws.DOMAIN,
|
||||||
data=NWS_CONFIG,
|
data=NWS_CONFIG,
|
||||||
@ -367,17 +371,203 @@ async def test_forecast_hourly_disable_enable(
|
|||||||
await hass.config_entries.async_setup(entry.entry_id)
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(hass.states.async_entity_ids("weather")) == 1
|
||||||
|
entry = hass.config_entries.async_entries()[0]
|
||||||
|
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_legacy_config_entry(hass: HomeAssistant, no_sensor) -> None:
|
||||||
|
"""Test the expected entities are created."""
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
entry = registry.async_get_or_create(
|
# Pre-create the hourly entity
|
||||||
|
registry.async_get_or_create(
|
||||||
WEATHER_DOMAIN,
|
WEATHER_DOMAIN,
|
||||||
nws.DOMAIN,
|
nws.DOMAIN,
|
||||||
"35_-75_hourly",
|
"35_-75_hourly",
|
||||||
)
|
)
|
||||||
assert entry.disabled is True
|
|
||||||
|
|
||||||
# Test enabling entity
|
entry = MockConfigEntry(
|
||||||
updated_entry = registry.async_update_entity(
|
domain=nws.DOMAIN,
|
||||||
entry.entity_id, **{"disabled_by": None}
|
data=NWS_CONFIG,
|
||||||
)
|
)
|
||||||
assert updated_entry != entry
|
entry.add_to_hass(hass)
|
||||||
assert updated_entry.disabled is False
|
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(hass.states.async_entity_ids("weather")) == 2
|
||||||
|
entry = hass.config_entries.async_entries()[0]
|
||||||
|
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_forecast_service(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
mock_simple_nws,
|
||||||
|
no_sensor,
|
||||||
|
) -> None:
|
||||||
|
"""Test multiple forecast."""
|
||||||
|
instance = mock_simple_nws.return_value
|
||||||
|
|
||||||
|
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()
|
||||||
|
instance.update_observation.assert_called_once()
|
||||||
|
instance.update_forecast.assert_called_once()
|
||||||
|
instance.update_forecast_hourly.assert_called_once()
|
||||||
|
|
||||||
|
for forecast_type in ("twice_daily", "hourly"):
|
||||||
|
response = await hass.services.async_call(
|
||||||
|
WEATHER_DOMAIN,
|
||||||
|
SERVICE_GET_FORECAST,
|
||||||
|
{
|
||||||
|
"entity_id": "weather.abc_daynight",
|
||||||
|
"type": forecast_type,
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
||||||
|
assert response["forecast"] != []
|
||||||
|
assert response == snapshot
|
||||||
|
|
||||||
|
# Calling the services should use cached data
|
||||||
|
instance.update_observation.assert_called_once()
|
||||||
|
instance.update_forecast.assert_called_once()
|
||||||
|
instance.update_forecast_hourly.assert_called_once()
|
||||||
|
|
||||||
|
# Trigger data refetch
|
||||||
|
freezer.tick(nws.DEFAULT_SCAN_INTERVAL + timedelta(seconds=1))
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert instance.update_observation.call_count == 2
|
||||||
|
assert instance.update_forecast.call_count == 2
|
||||||
|
assert instance.update_forecast_hourly.call_count == 1
|
||||||
|
|
||||||
|
for forecast_type in ("twice_daily", "hourly"):
|
||||||
|
response = await hass.services.async_call(
|
||||||
|
WEATHER_DOMAIN,
|
||||||
|
SERVICE_GET_FORECAST,
|
||||||
|
{
|
||||||
|
"entity_id": "weather.abc_daynight",
|
||||||
|
"type": forecast_type,
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
||||||
|
assert response["forecast"] != []
|
||||||
|
assert response == snapshot
|
||||||
|
|
||||||
|
# Calling the services should update the hourly forecast
|
||||||
|
assert instance.update_observation.call_count == 2
|
||||||
|
assert instance.update_forecast.call_count == 2
|
||||||
|
assert instance.update_forecast_hourly.call_count == 2
|
||||||
|
|
||||||
|
# third update fails, but data is cached
|
||||||
|
instance.update_forecast_hourly.side_effect = aiohttp.ClientError
|
||||||
|
freezer.tick(nws.DEFAULT_SCAN_INTERVAL + timedelta(seconds=1))
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
response = await hass.services.async_call(
|
||||||
|
WEATHER_DOMAIN,
|
||||||
|
SERVICE_GET_FORECAST,
|
||||||
|
{
|
||||||
|
"entity_id": "weather.abc_daynight",
|
||||||
|
"type": "hourly",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
||||||
|
assert response["forecast"] != []
|
||||||
|
assert response == snapshot
|
||||||
|
|
||||||
|
# after additional 35 minutes data caching expires, data is no longer shown
|
||||||
|
freezer.tick(timedelta(minutes=35))
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
response = await hass.services.async_call(
|
||||||
|
WEATHER_DOMAIN,
|
||||||
|
SERVICE_GET_FORECAST,
|
||||||
|
{
|
||||||
|
"entity_id": "weather.abc_daynight",
|
||||||
|
"type": "hourly",
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
||||||
|
assert response["forecast"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("forecast_type", "entity_id"),
|
||||||
|
[("hourly", "weather.abc_daynight"), ("twice_daily", "weather.abc_hourly")],
|
||||||
|
)
|
||||||
|
async def test_forecast_subscription(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hass_ws_client: WebSocketGenerator,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
mock_simple_nws,
|
||||||
|
no_sensor,
|
||||||
|
forecast_type: str,
|
||||||
|
entity_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Test multiple forecast."""
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
# Pre-create the hourly entity
|
||||||
|
registry.async_get_or_create(
|
||||||
|
WEATHER_DOMAIN,
|
||||||
|
nws.DOMAIN,
|
||||||
|
"35_-75_hourly",
|
||||||
|
suggested_object_id="abc_hourly",
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{
|
||||||
|
"type": "weather/subscribe_forecast",
|
||||||
|
"forecast_type": forecast_type,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
msg = await client.receive_json()
|
||||||
|
assert msg["success"]
|
||||||
|
assert msg["result"] is None
|
||||||
|
subscription_id = msg["id"]
|
||||||
|
|
||||||
|
msg = await client.receive_json()
|
||||||
|
assert msg["id"] == subscription_id
|
||||||
|
assert msg["type"] == "event"
|
||||||
|
forecast1 = msg["event"]["forecast"]
|
||||||
|
|
||||||
|
assert forecast1 != []
|
||||||
|
assert forecast1 == snapshot
|
||||||
|
|
||||||
|
freezer.tick(nws.DEFAULT_SCAN_INTERVAL + timedelta(seconds=1))
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
msg = await client.receive_json()
|
||||||
|
|
||||||
|
assert msg["id"] == subscription_id
|
||||||
|
assert msg["type"] == "event"
|
||||||
|
forecast2 = msg["event"]["forecast"]
|
||||||
|
|
||||||
|
assert forecast2 != []
|
||||||
|
assert forecast2 == snapshot
|
||||||
|
Loading…
x
Reference in New Issue
Block a user