diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index f0f2a12cfec..a6af045776f 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -90,7 +90,6 @@ class NwsDataUpdateCoordinator(DataUpdateCoordinator[None]): # the base class allows None, but this one doesn't assert self.update_interval is not None update_interval = self.update_interval - self.last_update_success_time = utcnow() else: update_interval = self.failed_update_interval self._unsub_refresh = async_track_point_in_utc_time( @@ -99,6 +98,23 @@ class NwsDataUpdateCoordinator(DataUpdateCoordinator[None]): 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: """Set up a National Weather Service entry.""" diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index 5db541106b9..1e028649d89 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from typing import Final from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, @@ -25,7 +26,7 @@ CONF_STATION = "station" 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]] = { ATTR_CONDITION_EXCEPTIONAL: [ diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 0e5fd412e0c..dec7e9bf3b3 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -1,8 +1,9 @@ """Support for NWS weather service.""" from __future__ import annotations +from collections.abc import Callable from types import MappingProxyType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal, cast from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -16,8 +17,10 @@ from homeassistant.components.weather import ( ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + DOMAIN as WEATHER_DOMAIN, Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -29,13 +32,19 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow 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 ( ATTR_FORECAST_DETAILED_DESCRIPTION, ATTRIBUTION, @@ -80,15 +89,20 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the NWS weather platform.""" + entity_registry = er.async_get(hass) nws_data: NWSData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - [ - NWSWeather(entry.data, nws_data, DAYNIGHT, hass.config.units), - NWSWeather(entry.data, nws_data, HOURLY, hass.config.units), - ], - False, - ) + entities = [NWSWeather(entry.data, nws_data, DAYNIGHT)] + + # Add hourly entity to legacy config entries + if entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + _calculate_unique_id(entry.data, HOURLY), + ): + entities.append(NWSWeather(entry.data, nws_data, HOURLY)) + + async_add_entities(entities, False) if TYPE_CHECKING: @@ -99,34 +113,51 @@ if TYPE_CHECKING: 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): """Representation of a weather condition.""" _attr_attribution = ATTRIBUTION _attr_should_poll = False + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_TWICE_DAILY + ) def __init__( self, entry_data: MappingProxyType[str, Any], nws_data: NWSData, mode: str, - units: UnitSystem, ) -> None: """Initialise the platform with a data instance and station name.""" self.nws = nws_data.api self.latitude = entry_data[CONF_LATITUDE] 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 if mode == DAYNIGHT: - self.coordinator_forecast = nws_data.coordinator_forecast + self.coordinator_forecast_legacy = nws_data.coordinator_forecast else: - self.coordinator_forecast = nws_data.coordinator_forecast_hourly + self.coordinator_forecast_legacy = nws_data.coordinator_forecast_hourly 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.observation = None - self._forecast = None + self.observation: dict[str, Any] | None = 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: """Set up a listener and load data.""" @@ -134,20 +165,72 @@ class NWSWeather(WeatherEntity): self.coordinator_observation.async_add_listener(self._update_callback) ) 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() + 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 def _update_callback(self) -> None: """Load data from integration.""" self.observation = self.nws.observation + self._forecast_hourly = self.nws.forecast_hourly + self._forecast_twice_daily = self.nws.forecast if self.mode == DAYNIGHT: - self._forecast = self.nws.forecast + self._forecast_legacy = self.nws.forecast else: - self._forecast = self.nws.forecast_hourly + self._forecast_legacy = self.nws.forecast_hourly 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 def name(self) -> str: @@ -210,7 +293,7 @@ class NWSWeather(WeatherEntity): weather = None if self.observation: weather = self.observation.get("iconWeather") - time = self.observation.get("iconTime") + time = cast(str, self.observation.get("iconTime")) if weather: return convert_condition(time, weather) @@ -228,18 +311,19 @@ class NWSWeather(WeatherEntity): """Return visibility unit.""" return UnitOfLength.METERS - @property - def forecast(self) -> list[Forecast] | None: + def _forecast( + self, nws_forecast: list[dict[str, Any]] | None, mode: str + ) -> list[Forecast] | None: """Return forecast.""" - if self._forecast is None: + if nws_forecast is None: return None - forecast: list[NWSForecast] = [] - for forecast_entry in self._forecast: - data = { + forecast: list[Forecast] = [] + for forecast_entry in nws_forecast: + data: NWSForecast = { ATTR_FORECAST_DETAILED_DESCRIPTION: forecast_entry.get( "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: @@ -262,7 +346,7 @@ class NWSWeather(WeatherEntity): data[ATTR_FORECAST_HUMIDITY] = forecast_entry.get("relativeHumidity") - if self.mode == DAYNIGHT: + if mode == DAYNIGHT: data[ATTR_FORECAST_IS_DAYTIME] = forecast_entry.get("isDaytime") time = forecast_entry.get("iconTime") @@ -285,25 +369,56 @@ class NWSWeather(WeatherEntity): return forecast @property - def unique_id(self) -> str: - """Return a unique_id for this entity.""" - return f"{base_unique_id(self.latitude, self.longitude)}_{self.mode}" + def forecast(self) -> list[Forecast] | None: + """Return forecast.""" + 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 def available(self) -> bool: """Return if state is available.""" last_success = ( self.coordinator_observation.last_update_success - and self.coordinator_forecast.last_update_success + and self.coordinator_forecast_legacy.last_update_success ) if ( 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 = ( utcnow() - self.coordinator_observation.last_update_success_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 ) else: @@ -316,7 +431,7 @@ class NWSWeather(WeatherEntity): Only used by the generic entity update service. """ await self.coordinator_observation.async_request_refresh() - await self.coordinator_forecast.async_request_refresh() + await self.coordinator_forecast_legacy.async_request_refresh() @property def entity_registry_enabled_default(self) -> bool: diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 8652f947f7c..eb137f06d7b 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -1079,6 +1079,22 @@ class WeatherEntity(Entity, PostInit): ) and custom_unit_visibility in VALID_UNITS[ATTR_WEATHER_VISIBILITY_UNIT]: 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 @callback def async_subscribe_forecast( @@ -1090,11 +1106,16 @@ class WeatherEntity(Entity, PostInit): Called by websocket API. """ + subscription_started = not self._forecast_listeners[forecast_type] self._forecast_listeners[forecast_type].append(forecast_listener) + if subscription_started: + self._async_subscription_started(forecast_type) @callback def unsubscribe() -> None: self._forecast_listeners[forecast_type].remove(forecast_listener) + if not self._forecast_listeners[forecast_type]: + self._async_subscription_ended(forecast_type) return unsubscribe diff --git a/tests/components/nws/snapshots/test_weather.ambr b/tests/components/nws/snapshots/test_weather.ambr new file mode 100644 index 00000000000..0dddca954be --- /dev/null +++ b/tests/components/nws/snapshots/test_weather.ambr @@ -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, + }), + ]) +# --- diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index 06d2c2006d8..54069eec02c 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -3,7 +3,9 @@ from datetime import timedelta from unittest.mock import patch import aiohttp +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import nws from homeassistant.components.weather import ( @@ -11,6 +13,7 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, ) from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -31,6 +34,7 @@ from .const import ( ) from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator @pytest.mark.parametrize( @@ -354,10 +358,10 @@ async def test_error_forecast_hourly( assert state.state == ATTR_CONDITION_SUNNY -async def test_forecast_hourly_disable_enable( - hass: HomeAssistant, mock_simple_nws, no_sensor -) -> None: - """Test error during update forecast hourly.""" +async def test_new_config_entry(hass: HomeAssistant, no_sensor) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + entry = MockConfigEntry( domain=nws.DOMAIN, 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.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) - entry = registry.async_get_or_create( + # Pre-create the hourly entity + registry.async_get_or_create( WEATHER_DOMAIN, nws.DOMAIN, "35_-75_hourly", ) - assert entry.disabled is True - # Test enabling entity - updated_entry = registry.async_update_entity( - entry.entity_id, **{"disabled_by": None} + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, ) - assert updated_entry != entry - assert updated_entry.disabled is False + entry.add_to_hass(hass) + + 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