Modernize nws weather (#98748)

This commit is contained in:
Erik Montnemery 2023-08-22 10:01:17 +02:00 committed by GitHub
parent 79811984f0
commit 68e2809c36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 619 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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

View 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,
}),
])
# ---

View File

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