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

View file

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

View file

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

View file

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

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