diff --git a/.coveragerc b/.coveragerc index 6b3270f85e9..9e5541a07bc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -596,6 +596,7 @@ omit = homeassistant/components/keymitt_ble/entity.py homeassistant/components/keymitt_ble/switch.py homeassistant/components/keymitt_ble/coordinator.py + homeassistant/components/kitchen_sink/weather.py homeassistant/components/kiwi/lock.py homeassistant/components/kodi/__init__.py homeassistant/components/kodi/browse_media.py diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 6d54255f8ed..04eba5f0586 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -56,6 +56,7 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.IMAGE_PROCESSING, Platform.CALENDAR, Platform.DEVICE_TRACKER, + Platform.WEATHER, ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index e64d0bcc28d..887a9212335 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -1,7 +1,7 @@ """Demo platform that offers fake meteorological data.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, @@ -20,11 +20,13 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY_VARIANT, Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util @@ -45,6 +47,8 @@ CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_EXCEPTIONAL: [], } +WEATHER_UPDATE_INTERVAL = timedelta(minutes=30) + async def async_setup_entry( hass: HomeAssistant, @@ -83,6 +87,8 @@ def setup_platform( [ATTR_CONDITION_RAINY, 15, 18, 7, 0], [ATTR_CONDITION_FOG, 0.2, 21, 12, 100], ], + None, + None, ), DemoWeather( "North", @@ -103,6 +109,24 @@ def setup_platform( [ATTR_CONDITION_SUNNY, 0.3, -14, -19, 0], [ATTR_CONDITION_SUNNY, 0, -9, -12, 0], ], + [ + [ATTR_CONDITION_SUNNY, 2, -10, -15, 60], + [ATTR_CONDITION_SUNNY, 1, -13, -14, 25], + [ATTR_CONDITION_SUNNY, 0, -18, -22, 70], + [ATTR_CONDITION_SUNNY, 0.1, -23, -23, 90], + [ATTR_CONDITION_SUNNY, 4, -19, -20, 40], + [ATTR_CONDITION_SUNNY, 0.3, -14, -19, 0], + [ATTR_CONDITION_SUNNY, 0, -9, -12, 0], + ], + [ + [ATTR_CONDITION_SNOWY, 2, -10, -15, 60, True], + [ATTR_CONDITION_PARTLYCLOUDY, 1, -13, -14, 25, False], + [ATTR_CONDITION_SUNNY, 0, -18, -22, 70, True], + [ATTR_CONDITION_SUNNY, 0.1, -23, -23, 90, False], + [ATTR_CONDITION_SNOWY, 4, -19, -20, 40, True], + [ATTR_CONDITION_SUNNY, 0.3, -14, -19, 0, False], + [ATTR_CONDITION_SUNNY, 0, -9, -12, 0, True], + ], ), ] ) @@ -125,10 +149,13 @@ class DemoWeather(WeatherEntity): temperature_unit: str, pressure_unit: str, wind_speed_unit: str, - forecast: list[list], + forecast_daily: list[list] | None, + forecast_hourly: list[list] | None, + forecast_twice_daily: list[list] | None, ) -> None: """Initialize the Demo weather.""" self._attr_name = f"Demo Weather {name}" + self._attr_unique_id = f"demo-weather-{name.lower()}" self._condition = condition self._native_temperature = temperature self._native_temperature_unit = temperature_unit @@ -137,7 +164,40 @@ class DemoWeather(WeatherEntity): self._native_pressure_unit = pressure_unit self._native_wind_speed = wind_speed self._native_wind_speed_unit = wind_speed_unit - self._forecast = forecast + self._forecast_daily = forecast_daily + self._forecast_hourly = forecast_hourly + self._forecast_twice_daily = forecast_twice_daily + self._attr_supported_features = 0 + if self._forecast_daily: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_DAILY + if self._forecast_hourly: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_HOURLY + if self._forecast_twice_daily: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_TWICE_DAILY + + async def async_added_to_hass(self) -> None: + """Set up a timer updating the forecasts.""" + + async def update_forecasts(_: datetime) -> None: + if self._forecast_daily: + self._forecast_daily = ( + self._forecast_daily[1:] + self._forecast_daily[:1] + ) + if self._forecast_hourly: + self._forecast_hourly = ( + self._forecast_hourly[1:] + self._forecast_hourly[:1] + ) + if self._forecast_twice_daily: + self._forecast_twice_daily = ( + self._forecast_twice_daily[1:] + self._forecast_twice_daily[:1] + ) + await self.async_update_listeners(None) + + self.async_on_remove( + async_track_time_interval( + self.hass, update_forecasts, WEATHER_UPDATE_INTERVAL + ) + ) @property def native_temperature(self) -> float: @@ -181,13 +241,13 @@ class DemoWeather(WeatherEntity): k for k, v in CONDITION_CLASSES.items() if self._condition.lower() in v ][0] - @property - def forecast(self) -> list[Forecast]: - """Return the forecast.""" + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast.""" reftime = dt_util.now().replace(hour=16, minute=00) forecast_data = [] - for entry in self._forecast: + assert self._forecast_daily is not None + for entry in self._forecast_daily: data_dict = Forecast( datetime=reftime.isoformat(), condition=entry[0], @@ -196,7 +256,48 @@ class DemoWeather(WeatherEntity): templow=entry[3], precipitation_probability=entry[4], ) - reftime = reftime + timedelta(hours=4) + reftime = reftime + timedelta(hours=24) + forecast_data.append(data_dict) + + return forecast_data + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the hourly forecast.""" + reftime = dt_util.now().replace(hour=16, minute=00) + + forecast_data = [] + assert self._forecast_hourly is not None + for entry in self._forecast_hourly: + data_dict = Forecast( + datetime=reftime.isoformat(), + condition=entry[0], + precipitation=entry[1], + temperature=entry[2], + templow=entry[3], + precipitation_probability=entry[4], + ) + reftime = reftime + timedelta(hours=1) + forecast_data.append(data_dict) + + return forecast_data + + async def async_forecast_twice_daily(self) -> list[Forecast]: + """Return the twice daily forecast.""" + reftime = dt_util.now().replace(hour=11, minute=00) + + forecast_data = [] + assert self._forecast_twice_daily is not None + for entry in self._forecast_twice_daily: + data_dict = Forecast( + datetime=reftime.isoformat(), + condition=entry[0], + precipitation=entry[1], + temperature=entry[2], + templow=entry[3], + precipitation_probability=entry[4], + is_daytime=entry[5], + ) + reftime = reftime + timedelta(hours=12) forecast_data.append(data_dict) return forecast_data diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 7857e6b3149..a85221108f8 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -26,7 +26,12 @@ import homeassistant.util.dt as dt_util DOMAIN = "kitchen_sink" -COMPONENTS_WITH_DEMO_PLATFORM = [Platform.SENSOR, Platform.LOCK, Platform.IMAGE] +COMPONENTS_WITH_DEMO_PLATFORM = [ + Platform.SENSOR, + Platform.LOCK, + Platform.IMAGE, + Platform.WEATHER, +] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/kitchen_sink/weather.py b/homeassistant/components/kitchen_sink/weather.py new file mode 100644 index 00000000000..aba30013746 --- /dev/null +++ b/homeassistant/components/kitchen_sink/weather.py @@ -0,0 +1,446 @@ +"""Demo platform that offers fake meteorological data.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_EXCEPTIONAL, + ATTR_CONDITION_FOG, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, + ATTR_CONDITION_WINDY_VARIANT, + Forecast, + WeatherEntity, + WeatherEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval +import homeassistant.util.dt as dt_util + +CONDITION_CLASSES: dict[str, list[str]] = { + ATTR_CONDITION_CLOUDY: [], + ATTR_CONDITION_FOG: [], + ATTR_CONDITION_HAIL: [], + ATTR_CONDITION_LIGHTNING: [], + ATTR_CONDITION_LIGHTNING_RAINY: [], + ATTR_CONDITION_PARTLYCLOUDY: [], + ATTR_CONDITION_POURING: [], + ATTR_CONDITION_RAINY: ["shower rain"], + ATTR_CONDITION_SNOWY: [], + ATTR_CONDITION_SNOWY_RAINY: [], + ATTR_CONDITION_SUNNY: ["sunshine"], + ATTR_CONDITION_WINDY: [], + ATTR_CONDITION_WINDY_VARIANT: [], + ATTR_CONDITION_EXCEPTIONAL: [], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + async_add_entities( + [ + DemoWeather( + "Legacy weather", + "Sunshine", + 21.6414, + 92, + 1099, + 0.5, + UnitOfTemperature.CELSIUS, + UnitOfPressure.HPA, + UnitOfSpeed.METERS_PER_SECOND, + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10], + [ATTR_CONDITION_SUNNY, 0, 12, 6, 0], + [ATTR_CONDITION_PARTLYCLOUDY, 2, 14, 7, 20], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0], + [ATTR_CONDITION_FOG, 0.2, 21, 12, 100], + ], + None, + None, + None, + ), + DemoWeather( + "Legacy + daily weather", + "Sunshine", + 21.6414, + 92, + 1099, + 0.5, + UnitOfTemperature.CELSIUS, + UnitOfPressure.HPA, + UnitOfSpeed.METERS_PER_SECOND, + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10], + [ATTR_CONDITION_SUNNY, 0, 12, 6, 0], + [ATTR_CONDITION_PARTLYCLOUDY, 2, 14, 7, 20], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0], + [ATTR_CONDITION_FOG, 0.2, 21, 12, 100], + ], + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10], + [ATTR_CONDITION_SUNNY, 0, 12, 6, 0], + [ATTR_CONDITION_PARTLYCLOUDY, 2, 14, 7, 20], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0], + [ATTR_CONDITION_FOG, 0.2, 21, 12, 100], + ], + None, + None, + ), + DemoWeather( + "Daily + hourly weather", + "Shower rain", + -12, + 54, + 987, + 4.8, + UnitOfTemperature.FAHRENHEIT, + UnitOfPressure.INHG, + UnitOfSpeed.MILES_PER_HOUR, + None, + [ + [ATTR_CONDITION_SNOWY, 2, -10, -15, 60], + [ATTR_CONDITION_PARTLYCLOUDY, 1, -13, -14, 25], + [ATTR_CONDITION_SUNNY, 0, -18, -22, 70], + [ATTR_CONDITION_SUNNY, 0.1, -23, -23, 90], + [ATTR_CONDITION_SNOWY, 4, -19, -20, 40], + [ATTR_CONDITION_SUNNY, 0.3, -14, -19, 0], + [ATTR_CONDITION_SUNNY, 0, -9, -12, 0], + ], + [ + [ATTR_CONDITION_SUNNY, 2, -10, -15, 60], + [ATTR_CONDITION_SUNNY, 1, -13, -14, 25], + [ATTR_CONDITION_SUNNY, 0, -18, -22, 70], + [ATTR_CONDITION_SUNNY, 0.1, -23, -23, 90], + [ATTR_CONDITION_SUNNY, 4, -19, -20, 40], + [ATTR_CONDITION_SUNNY, 0.3, -14, -19, 0], + [ATTR_CONDITION_SUNNY, 0, -9, -12, 0], + ], + None, + ), + DemoWeather( + "Daily + bi-daily + hourly weather", + "Sunshine", + 21.6414, + 92, + 1099, + 0.5, + UnitOfTemperature.CELSIUS, + UnitOfPressure.HPA, + UnitOfSpeed.METERS_PER_SECOND, + None, + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30], + [ATTR_CONDITION_RAINY, 0, 15, 9, 10], + [ATTR_CONDITION_RAINY, 0, 12, 6, 0], + [ATTR_CONDITION_RAINY, 2, 14, 7, 20], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0], + [ATTR_CONDITION_RAINY, 0.2, 21, 12, 100], + ], + [ + [ATTR_CONDITION_CLOUDY, 1, 22, 15, 60], + [ATTR_CONDITION_CLOUDY, 5, 19, 8, 30], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10], + [ATTR_CONDITION_CLOUDY, 0, 12, 6, 0], + [ATTR_CONDITION_CLOUDY, 2, 14, 7, 20], + [ATTR_CONDITION_CLOUDY, 15, 18, 7, 0], + [ATTR_CONDITION_CLOUDY, 0.2, 21, 12, 100], + ], + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60, True], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30, False], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10, True], + [ATTR_CONDITION_SUNNY, 0, 12, 6, 0, False], + [ATTR_CONDITION_PARTLYCLOUDY, 2, 14, 7, 20, True], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0, False], + [ATTR_CONDITION_FOG, 0.2, 21, 12, 100, True], + ], + ), + DemoWeather( + "Hourly + bi-daily weather", + "Sunshine", + 21.6414, + 92, + 1099, + 0.5, + UnitOfTemperature.CELSIUS, + UnitOfPressure.HPA, + UnitOfSpeed.METERS_PER_SECOND, + None, + None, + [ + [ATTR_CONDITION_CLOUDY, 1, 22, 15, 60], + [ATTR_CONDITION_CLOUDY, 5, 19, 8, 30], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10], + [ATTR_CONDITION_CLOUDY, 0, 12, 6, 0], + [ATTR_CONDITION_CLOUDY, 2, 14, 7, 20], + [ATTR_CONDITION_CLOUDY, 15, 18, 7, 0], + [ATTR_CONDITION_CLOUDY, 0.2, 21, 12, 100], + ], + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60, True], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30, False], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10, True], + [ATTR_CONDITION_SUNNY, 0, 12, 6, 0, False], + [ATTR_CONDITION_PARTLYCLOUDY, 2, 14, 7, 20, True], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0, False], + [ATTR_CONDITION_FOG, 0.2, 21, 12, 100, True], + ], + ), + DemoWeather( + "Daily + broken bi-daily weather", + "Sunshine", + 21.6414, + 92, + 1099, + 0.5, + UnitOfTemperature.CELSIUS, + UnitOfPressure.HPA, + UnitOfSpeed.METERS_PER_SECOND, + None, + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30], + [ATTR_CONDITION_RAINY, 0, 15, 9, 10], + [ATTR_CONDITION_RAINY, 0, 12, 6, 0], + [ATTR_CONDITION_RAINY, 2, 14, 7, 20], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0], + [ATTR_CONDITION_RAINY, 0.2, 21, 12, 100], + ], + None, + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10], + [ATTR_CONDITION_SUNNY, 0, 12, 6, 0], + [ATTR_CONDITION_PARTLYCLOUDY, 2, 14, 7, 20], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0], + [ATTR_CONDITION_FOG, 0.2, 21, 12, 100], + ], + ), + ] + ) + + +class DemoWeather(WeatherEntity): + """Representation of a weather condition.""" + + _attr_attribution = "Powered by Home Assistant" + _attr_should_poll = False + + def __init__( + self, + name: str, + condition: str, + temperature: float, + humidity: float, + pressure: float, + wind_speed: float, + temperature_unit: str, + pressure_unit: str, + wind_speed_unit: str, + forecast: list[list] | None, + forecast_daily: list[list] | None, + forecast_hourly: list[list] | None, + forecast_twice_daily: list[list] | None, + ) -> None: + """Initialize the Demo weather.""" + self._attr_name = f"Test Weather {name}" + self._attr_unique_id = f"test-weather-{name.lower()}" + self._condition = condition + self._native_temperature = temperature + self._native_temperature_unit = temperature_unit + self._humidity = humidity + self._native_pressure = pressure + self._native_pressure_unit = pressure_unit + self._native_wind_speed = wind_speed + self._native_wind_speed_unit = wind_speed_unit + self._forecast = forecast + self._forecast_daily = forecast_daily + self._forecast_hourly = forecast_hourly + self._forecast_twice_daily = forecast_twice_daily + self._attr_supported_features = 0 + if self._forecast_daily: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_DAILY + if self._forecast_hourly: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_HOURLY + if self._forecast_twice_daily: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_TWICE_DAILY + + async def async_added_to_hass(self) -> None: + """Set up a timer updating the forecasts.""" + + async def update_forecasts(_) -> None: + if self._forecast_daily: + self._forecast_daily = ( + self._forecast_daily[1:] + self._forecast_daily[:1] + ) + if self._forecast_hourly: + self._forecast_hourly = ( + self._forecast_hourly[1:] + self._forecast_hourly[:1] + ) + if self._forecast_twice_daily: + self._forecast_twice_daily = ( + self._forecast_twice_daily[1:] + self._forecast_twice_daily[:1] + ) + await self.async_update_listeners(None) + + self.async_on_remove( + async_track_time_interval( + self.hass, update_forecasts, timedelta(seconds=30) + ) + ) + + @property + def native_temperature(self) -> float: + """Return the temperature.""" + return self._native_temperature + + @property + def native_temperature_unit(self) -> str: + """Return the unit of measurement.""" + return self._native_temperature_unit + + @property + def humidity(self) -> float: + """Return the humidity.""" + return self._humidity + + @property + def native_wind_speed(self) -> float: + """Return the wind speed.""" + return self._native_wind_speed + + @property + def native_wind_speed_unit(self) -> str: + """Return the wind speed.""" + return self._native_wind_speed_unit + + @property + def native_pressure(self) -> float: + """Return the pressure.""" + return self._native_pressure + + @property + def native_pressure_unit(self) -> str: + """Return the pressure.""" + return self._native_pressure_unit + + @property + def condition(self) -> str: + """Return the weather condition.""" + return [ + k for k, v in CONDITION_CLASSES.items() if self._condition.lower() in v + ][0] + + @property + def forecast(self) -> list[Forecast]: + """Return legacy forecast.""" + if self._forecast is None: + return [] + reftime = dt_util.now().replace(hour=16, minute=00) + + forecast_data = [] + for entry in self._forecast: + data_dict = Forecast( + datetime=reftime.isoformat(), + condition=entry[0], + precipitation=entry[1], + temperature=entry[2], + templow=entry[3], + precipitation_probability=entry[4], + ) + reftime = reftime + timedelta(hours=24) + forecast_data.append(data_dict) + + return forecast_data + + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast.""" + if self._forecast_daily is None: + return [] + reftime = dt_util.now().replace(hour=16, minute=00) + + forecast_data = [] + for entry in self._forecast_daily: + data_dict = Forecast( + datetime=reftime.isoformat(), + condition=entry[0], + precipitation=entry[1], + temperature=entry[2], + templow=entry[3], + precipitation_probability=entry[4], + ) + reftime = reftime + timedelta(hours=24) + forecast_data.append(data_dict) + + return forecast_data + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the hourly forecast.""" + if self._forecast_hourly is None: + return [] + reftime = dt_util.now().replace(hour=16, minute=00) + + forecast_data = [] + for entry in self._forecast_hourly: + data_dict = Forecast( + datetime=reftime.isoformat(), + condition=entry[0], + precipitation=entry[1], + temperature=entry[2], + templow=entry[3], + precipitation_probability=entry[4], + ) + reftime = reftime + timedelta(hours=1) + forecast_data.append(data_dict) + + return forecast_data + + async def async_forecast_twice_daily(self) -> list[Forecast]: + """Return the twice daily forecast.""" + if self._forecast_twice_daily is None: + return [] + reftime = dt_util.now().replace(hour=11, minute=00) + + forecast_data = [] + for entry in self._forecast_twice_daily: + try: + data_dict = Forecast( + datetime=reftime.isoformat(), + condition=entry[0], + precipitation=entry[1], + temperature=entry[2], + templow=entry[3], + precipitation_probability=entry[4], + is_daytime=entry[5], + ) + reftime = reftime + timedelta(hours=12) + forecast_data.append(data_dict) + except IndexError: + continue + + return forecast_data diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 45b5cbe9fba..c63db816711 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -1,12 +1,13 @@ """Weather component that handles meteorological data for your location.""" from __future__ import annotations +from collections.abc import Callable, Iterable from contextlib import suppress from dataclasses import dataclass from datetime import timedelta import inspect import logging -from typing import Any, Final, TypedDict, final +from typing import Any, Final, Literal, TypedDict, final from typing_extensions import Required @@ -19,7 +20,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -29,7 +30,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .const import ( +from .const import ( # noqa: F401 ATTR_WEATHER_APPARENT_TEMPERATURE, ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_DEW_POINT, @@ -50,6 +51,7 @@ from .const import ( DOMAIN, UNIT_CONVERSIONS, VALID_UNITS, + WeatherEntityFeature, ) from .websocket_api import async_setup as async_setup_ws_api @@ -72,6 +74,7 @@ ATTR_CONDITION_SUNNY = "sunny" ATTR_CONDITION_WINDY = "windy" ATTR_CONDITION_WINDY_VARIANT = "windy-variant" ATTR_FORECAST = "forecast" +ATTR_FORECAST_IS_DAYTIME: Final = "is_daytime" ATTR_FORECAST_CONDITION: Final = "condition" ATTR_FORECAST_HUMIDITY: Final = "humidity" ATTR_FORECAST_NATIVE_PRECIPITATION: Final = "native_precipitation" @@ -149,6 +152,7 @@ class Forecast(TypedDict, total=False): wind_speed: None native_dew_point: float | None uv_index: float | None + is_daytime: bool | None # Mandatory to use with forecast_twice_daily async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -183,6 +187,8 @@ class WeatherEntity(Entity): entity_description: WeatherEntityDescription _attr_condition: str | None + # _attr_forecast is deprecated, implement async_forecast_daily, + # async_forecast_hourly or async_forecast_twice daily instead _attr_forecast: list[Forecast] | None = None _attr_humidity: float | None = None _attr_ozone: float | None = None @@ -232,6 +238,11 @@ class WeatherEntity(Entity): _attr_native_wind_speed_unit: str | None = None _attr_native_dew_point: float | None = None + _forecast_listeners: dict[ + Literal["daily", "hourly", "twice_daily"], + list[Callable[[list[dict[str, Any]] | None], None]], + ] + _weather_option_temperature_unit: str | None = None _weather_option_pressure_unit: str | None = None _weather_option_visibility_unit: str | None = None @@ -263,6 +274,8 @@ class WeatherEntity(Entity): "visibility_unit", "_attr_precipitation_unit", "precipitation_unit", + "_attr_forecast", + "forecast", ) ): if _reported is False: @@ -291,8 +304,9 @@ class WeatherEntity(Entity): ) async def async_internal_added_to_hass(self) -> None: - """Call when the sensor entity is added to hass.""" + """Call when the weather entity is added to hass.""" await super().async_internal_added_to_hass() + self._forecast_listeners = {"daily": [], "hourly": [], "twice_daily": []} if not self.registry_entry: return self.async_registry_entry_updated() @@ -571,9 +585,24 @@ class WeatherEntity(Entity): @property def forecast(self) -> list[Forecast] | None: - """Return the forecast in native units.""" + """Return the forecast in native units. + + Should not be overridden by integrations. Kept for backwards compatibility. + """ return self._attr_forecast + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + raise NotImplementedError + + async def async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + raise NotImplementedError + + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + raise NotImplementedError + @property def native_precipitation_unit(self) -> str | None: """Return the native unit of measurement for accumulated precipitation.""" @@ -756,197 +785,197 @@ class WeatherEntity(Entity): data[ATTR_WEATHER_VISIBILITY_UNIT] = self._visibility_unit data[ATTR_WEATHER_PRECIPITATION_UNIT] = self._precipitation_unit - if self.forecast is not None: - forecast: list[dict[str, Any]] = [] - for existing_forecast_entry in self.forecast: - forecast_entry: dict[str, Any] = dict(existing_forecast_entry) - - temperature = forecast_entry.pop( - ATTR_FORECAST_NATIVE_TEMP, forecast_entry.get(ATTR_FORECAST_TEMP) - ) - - from_temp_unit = ( - self.native_temperature_unit or self._default_temperature_unit - ) - to_temp_unit = self._temperature_unit - - if temperature is None: - forecast_entry[ATTR_FORECAST_TEMP] = None - else: - with suppress(TypeError, ValueError): - temperature_f = float(temperature) - value_temp = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( - temperature_f, - from_temp_unit, - to_temp_unit, - ) - forecast_entry[ATTR_FORECAST_TEMP] = round_temperature( - value_temp, precision - ) - - if ( - forecast_apparent_temp := forecast_entry.pop( - ATTR_FORECAST_NATIVE_APPARENT_TEMP, - forecast_entry.get(ATTR_FORECAST_NATIVE_APPARENT_TEMP), - ) - ) is not None: - with suppress(TypeError, ValueError): - forecast_apparent_temp = float(forecast_apparent_temp) - value_apparent_temp = UNIT_CONVERSIONS[ - ATTR_WEATHER_TEMPERATURE_UNIT - ]( - forecast_apparent_temp, - from_temp_unit, - to_temp_unit, - ) - - forecast_entry[ATTR_FORECAST_APPARENT_TEMP] = round_temperature( - value_apparent_temp, precision - ) - - if ( - forecast_temp_low := forecast_entry.pop( - ATTR_FORECAST_NATIVE_TEMP_LOW, - forecast_entry.get(ATTR_FORECAST_TEMP_LOW), - ) - ) is not None: - with suppress(TypeError, ValueError): - forecast_temp_low_f = float(forecast_temp_low) - value_temp_low = UNIT_CONVERSIONS[ - ATTR_WEATHER_TEMPERATURE_UNIT - ]( - forecast_temp_low_f, - from_temp_unit, - to_temp_unit, - ) - - forecast_entry[ATTR_FORECAST_TEMP_LOW] = round_temperature( - value_temp_low, precision - ) - - if ( - forecast_dew_point := forecast_entry.pop( - ATTR_FORECAST_NATIVE_DEW_POINT, - None, - ) - ) is not None: - with suppress(TypeError, ValueError): - forecast_dew_point_f = float(forecast_dew_point) - value_dew_point = UNIT_CONVERSIONS[ - ATTR_WEATHER_TEMPERATURE_UNIT - ]( - forecast_dew_point_f, - from_temp_unit, - to_temp_unit, - ) - - forecast_entry[ATTR_FORECAST_DEW_POINT] = round_temperature( - value_dew_point, precision - ) - - if ( - forecast_pressure := forecast_entry.pop( - ATTR_FORECAST_NATIVE_PRESSURE, - forecast_entry.get(ATTR_FORECAST_PRESSURE), - ) - ) is not None: - from_pressure_unit = ( - self.native_pressure_unit or self._default_pressure_unit - ) - to_pressure_unit = self._pressure_unit - with suppress(TypeError, ValueError): - forecast_pressure_f = float(forecast_pressure) - forecast_entry[ATTR_FORECAST_PRESSURE] = round( - UNIT_CONVERSIONS[ATTR_WEATHER_PRESSURE_UNIT]( - forecast_pressure_f, - from_pressure_unit, - to_pressure_unit, - ), - ROUNDING_PRECISION, - ) - - if ( - forecast_wind_gust_speed := forecast_entry.pop( - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, - None, - ) - ) is not None: - from_wind_speed_unit = ( - self.native_wind_speed_unit or self._default_wind_speed_unit - ) - to_wind_speed_unit = self._wind_speed_unit - with suppress(TypeError, ValueError): - forecast_wind_gust_speed_f = float(forecast_wind_gust_speed) - forecast_entry[ATTR_FORECAST_WIND_GUST_SPEED] = round( - UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( - forecast_wind_gust_speed_f, - from_wind_speed_unit, - to_wind_speed_unit, - ), - ROUNDING_PRECISION, - ) - - if ( - forecast_wind_speed := forecast_entry.pop( - ATTR_FORECAST_NATIVE_WIND_SPEED, - forecast_entry.get(ATTR_FORECAST_WIND_SPEED), - ) - ) is not None: - from_wind_speed_unit = ( - self.native_wind_speed_unit or self._default_wind_speed_unit - ) - to_wind_speed_unit = self._wind_speed_unit - with suppress(TypeError, ValueError): - forecast_wind_speed_f = float(forecast_wind_speed) - forecast_entry[ATTR_FORECAST_WIND_SPEED] = round( - UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( - forecast_wind_speed_f, - from_wind_speed_unit, - to_wind_speed_unit, - ), - ROUNDING_PRECISION, - ) - - if ( - forecast_precipitation := forecast_entry.pop( - ATTR_FORECAST_NATIVE_PRECIPITATION, - forecast_entry.get(ATTR_FORECAST_PRECIPITATION), - ) - ) is not None: - from_precipitation_unit = ( - self.native_precipitation_unit - or self._default_precipitation_unit - ) - to_precipitation_unit = self._precipitation_unit - with suppress(TypeError, ValueError): - forecast_precipitation_f = float(forecast_precipitation) - forecast_entry[ATTR_FORECAST_PRECIPITATION] = round( - UNIT_CONVERSIONS[ATTR_WEATHER_PRECIPITATION_UNIT]( - forecast_precipitation_f, - from_precipitation_unit, - to_precipitation_unit, - ), - ROUNDING_PRECISION, - ) - - if ( - forecast_humidity := forecast_entry.pop( - ATTR_FORECAST_HUMIDITY, - None, - ) - ) is not None: - with suppress(TypeError, ValueError): - forecast_humidity_f = float(forecast_humidity) - forecast_entry[ATTR_FORECAST_HUMIDITY] = round( - forecast_humidity_f - ) - - forecast.append(forecast_entry) - - data[ATTR_FORECAST] = forecast + if self.forecast: + data[ATTR_FORECAST] = self._convert_forecast(self.forecast) return data + @final + def _convert_forecast( + self, native_forecast_list: list[Forecast] + ) -> list[dict[str, Any]]: + """Convert a forecast in native units to the unit configured by the user.""" + converted_forecast_list: list[dict[str, Any]] = [] + precision = self.precision + + from_temp_unit = self.native_temperature_unit or self._default_temperature_unit + to_temp_unit = self._temperature_unit + + for _forecast_entry in native_forecast_list: + forecast_entry: dict[str, Any] = dict(_forecast_entry) + + temperature = forecast_entry.pop( + ATTR_FORECAST_NATIVE_TEMP, forecast_entry.get(ATTR_FORECAST_TEMP) + ) + + if temperature is None: + forecast_entry[ATTR_FORECAST_TEMP] = None + else: + with suppress(TypeError, ValueError): + temperature_f = float(temperature) + value_temp = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( + temperature_f, + from_temp_unit, + to_temp_unit, + ) + forecast_entry[ATTR_FORECAST_TEMP] = round_temperature( + value_temp, precision + ) + + if ( + forecast_apparent_temp := forecast_entry.pop( + ATTR_FORECAST_NATIVE_APPARENT_TEMP, + forecast_entry.get(ATTR_FORECAST_NATIVE_APPARENT_TEMP), + ) + ) is not None: + with suppress(TypeError, ValueError): + forecast_apparent_temp = float(forecast_apparent_temp) + value_apparent_temp = UNIT_CONVERSIONS[ + ATTR_WEATHER_TEMPERATURE_UNIT + ]( + forecast_apparent_temp, + from_temp_unit, + to_temp_unit, + ) + + forecast_entry[ATTR_FORECAST_APPARENT_TEMP] = round_temperature( + value_apparent_temp, precision + ) + + if ( + forecast_temp_low := forecast_entry.pop( + ATTR_FORECAST_NATIVE_TEMP_LOW, + forecast_entry.get(ATTR_FORECAST_TEMP_LOW), + ) + ) is not None: + with suppress(TypeError, ValueError): + forecast_temp_low_f = float(forecast_temp_low) + value_temp_low = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( + forecast_temp_low_f, + from_temp_unit, + to_temp_unit, + ) + + forecast_entry[ATTR_FORECAST_TEMP_LOW] = round_temperature( + value_temp_low, precision + ) + + if ( + forecast_dew_point := forecast_entry.pop( + ATTR_FORECAST_NATIVE_DEW_POINT, + None, + ) + ) is not None: + with suppress(TypeError, ValueError): + forecast_dew_point_f = float(forecast_dew_point) + value_dew_point = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( + forecast_dew_point_f, + from_temp_unit, + to_temp_unit, + ) + + forecast_entry[ATTR_FORECAST_DEW_POINT] = round_temperature( + value_dew_point, precision + ) + + if ( + forecast_pressure := forecast_entry.pop( + ATTR_FORECAST_NATIVE_PRESSURE, + forecast_entry.get(ATTR_FORECAST_PRESSURE), + ) + ) is not None: + from_pressure_unit = ( + self.native_pressure_unit or self._default_pressure_unit + ) + to_pressure_unit = self._pressure_unit + with suppress(TypeError, ValueError): + forecast_pressure_f = float(forecast_pressure) + forecast_entry[ATTR_FORECAST_PRESSURE] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_PRESSURE_UNIT]( + forecast_pressure_f, + from_pressure_unit, + to_pressure_unit, + ), + ROUNDING_PRECISION, + ) + + if ( + forecast_wind_gust_speed := forecast_entry.pop( + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + None, + ) + ) is not None: + from_wind_speed_unit = ( + self.native_wind_speed_unit or self._default_wind_speed_unit + ) + to_wind_speed_unit = self._wind_speed_unit + with suppress(TypeError, ValueError): + forecast_wind_gust_speed_f = float(forecast_wind_gust_speed) + forecast_entry[ATTR_FORECAST_WIND_GUST_SPEED] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( + forecast_wind_gust_speed_f, + from_wind_speed_unit, + to_wind_speed_unit, + ), + ROUNDING_PRECISION, + ) + + if ( + forecast_wind_speed := forecast_entry.pop( + ATTR_FORECAST_NATIVE_WIND_SPEED, + forecast_entry.get(ATTR_FORECAST_WIND_SPEED), + ) + ) is not None: + from_wind_speed_unit = ( + self.native_wind_speed_unit or self._default_wind_speed_unit + ) + to_wind_speed_unit = self._wind_speed_unit + with suppress(TypeError, ValueError): + forecast_wind_speed_f = float(forecast_wind_speed) + forecast_entry[ATTR_FORECAST_WIND_SPEED] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( + forecast_wind_speed_f, + from_wind_speed_unit, + to_wind_speed_unit, + ), + ROUNDING_PRECISION, + ) + + if ( + forecast_precipitation := forecast_entry.pop( + ATTR_FORECAST_NATIVE_PRECIPITATION, + forecast_entry.get(ATTR_FORECAST_PRECIPITATION), + ) + ) is not None: + from_precipitation_unit = ( + self.native_precipitation_unit or self._default_precipitation_unit + ) + to_precipitation_unit = self._precipitation_unit + with suppress(TypeError, ValueError): + forecast_precipitation_f = float(forecast_precipitation) + forecast_entry[ATTR_FORECAST_PRECIPITATION] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_PRECIPITATION_UNIT]( + forecast_precipitation_f, + from_precipitation_unit, + to_precipitation_unit, + ), + ROUNDING_PRECISION, + ) + + if ( + forecast_humidity := forecast_entry.pop( + ATTR_FORECAST_HUMIDITY, + None, + ) + ) is not None: + with suppress(TypeError, ValueError): + forecast_humidity_f = float(forecast_humidity) + forecast_entry[ATTR_FORECAST_HUMIDITY] = round(forecast_humidity_f) + + converted_forecast_list.append(forecast_entry) + + return converted_forecast_list + @property @final def state(self) -> str | None: @@ -998,3 +1027,53 @@ class WeatherEntity(Entity): ) ) and custom_unit_visibility in VALID_UNITS[ATTR_WEATHER_VISIBILITY_UNIT]: self._weather_option_visibility_unit = custom_unit_visibility + + @final + @callback + def async_subscribe_forecast( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + forecast_listener: Callable[[list[dict[str, Any]] | None], None], + ) -> CALLBACK_TYPE: + """Subscribe to forecast updates. + + Called by websocket API. + """ + self._forecast_listeners[forecast_type].append(forecast_listener) + + @callback + def unsubscribe() -> None: + self._forecast_listeners[forecast_type].remove(forecast_listener) + + return unsubscribe + + @final + async def async_update_listeners( + self, forecast_types: Iterable[Literal["daily", "hourly", "twice_daily"]] | None + ) -> None: + """Push updated forecast to all listeners.""" + if forecast_types is None: + forecast_types = {"daily", "hourly", "twice_daily"} + for forecast_type in forecast_types: + if not self._forecast_listeners[forecast_type]: + continue + + native_forecast_list: list[Forecast] | None = await getattr( + self, f"async_forecast_{forecast_type}" + )() + + if native_forecast_list is None: + for listener in self._forecast_listeners[forecast_type]: + listener(None) + continue + + if forecast_type == "twice_daily": + for fc_twice_daily in native_forecast_list: + if fc_twice_daily.get(ATTR_FORECAST_IS_DAYTIME) is None: + raise ValueError( + "is_daytime mandatory attribute for forecast_twice_daily is missing" + ) + + converted_forecast_list = self._convert_forecast(native_forecast_list) + for listener in self._forecast_listeners[forecast_type]: + listener(converted_forecast_list) diff --git a/homeassistant/components/weather/const.py b/homeassistant/components/weather/const.py index 759021741ff..c6da2c28c71 100644 --- a/homeassistant/components/weather/const.py +++ b/homeassistant/components/weather/const.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable +from enum import IntFlag from typing import Final from homeassistant.const import ( @@ -18,6 +19,15 @@ from homeassistant.util.unit_conversion import ( TemperatureConverter, ) + +class WeatherEntityFeature(IntFlag): + """Supported features of the update entity.""" + + FORECAST_DAILY = 1 + FORECAST_HOURLY = 2 + FORECAST_TWICE_DAILY = 4 + + ATTR_WEATHER_HUMIDITY = "humidity" ATTR_WEATHER_OZONE = "ozone" ATTR_WEATHER_DEW_POINT = "dew_point" diff --git a/homeassistant/components/weather/websocket_api.py b/homeassistant/components/weather/websocket_api.py index 51f129fc4a2..f2be4dfec6d 100644 --- a/homeassistant/components/weather/websocket_api.py +++ b/homeassistant/components/weather/websocket_api.py @@ -1,20 +1,29 @@ """The weather websocket API.""" from __future__ import annotations -from typing import Any +from typing import Any, Literal import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent -from .const import VALID_UNITS +from .const import DOMAIN, VALID_UNITS, WeatherEntityFeature + +FORECAST_TYPE_TO_FLAG = { + "daily": WeatherEntityFeature.FORECAST_DAILY, + "hourly": WeatherEntityFeature.FORECAST_HOURLY, + "twice_daily": WeatherEntityFeature.FORECAST_TWICE_DAILY, +} @callback def async_setup(hass: HomeAssistant) -> None: """Set up the weather websocket API.""" websocket_api.async_register_command(hass, ws_convertible_units) + websocket_api.async_register_command(hass, ws_subscribe_forecast) @callback @@ -31,3 +40,62 @@ def ws_convertible_units( key: sorted(units, key=str.casefold) for key, units in VALID_UNITS.items() } connection.send_result(msg["id"], {"units": sorted_units}) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "weather/subscribe_forecast", + vol.Required("entity_id"): cv.entity_domain(DOMAIN), + vol.Required("forecast_type"): vol.In(["daily", "hourly", "twice_daily"]), + } +) +@websocket_api.async_response +async def ws_subscribe_forecast( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Subscribe to weather forecasts.""" + from . import WeatherEntity # pylint: disable=import-outside-toplevel + + component: EntityComponent[WeatherEntity] = hass.data[DOMAIN] + entity_id: str = msg["entity_id"] + forecast_type: Literal["daily", "hourly", "twice_daily"] = msg["forecast_type"] + + if not (entity := component.get_entity(msg["entity_id"])): + connection.send_error( + msg["id"], + "invalid_entity_id", + f"Weather entity not found: {entity_id}", + ) + return + + if ( + entity.supported_features is None + or not entity.supported_features & FORECAST_TYPE_TO_FLAG[forecast_type] + ): + connection.send_error( + msg["id"], + "forecast_not_supported", + f"The weather entity does not support forecast type: {forecast_type}", + ) + return + + @callback + def forecast_listener(forecast: list[dict[str, Any]] | None) -> None: + """Push a new forecast to websocket.""" + connection.send_message( + websocket_api.event_message( + msg["id"], + { + "type": forecast_type, + "forecast": forecast, + }, + ) + ) + + connection.subscriptions[msg["id"]] = entity.async_subscribe_forecast( + forecast_type, forecast_listener + ) + connection.send_message(websocket_api.result_message(msg["id"])) + + # Push an initial forecast update + await entity.async_update_listeners({forecast_type}) diff --git a/tests/components/demo/test_weather.py b/tests/components/demo/test_weather.py index b2b789a084f..ced801a4d46 100644 --- a/tests/components/demo/test_weather.py +++ b/tests/components/demo/test_weather.py @@ -1,12 +1,13 @@ """The tests for the demo weather component.""" +import datetime +from typing import Any + +from freezegun.api import FrozenDateTimeFactory +import pytest + from homeassistant.components import weather +from homeassistant.components.demo.weather import WEATHER_UPDATE_INTERVAL from homeassistant.components.weather import ( - ATTR_FORECAST, - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_OZONE, ATTR_WEATHER_PRESSURE, @@ -19,6 +20,8 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import METRIC_SYSTEM +from tests.typing import WebSocketGenerator + async def test_attributes(hass: HomeAssistant, disable_platforms) -> None: """Test weather attributes.""" @@ -41,16 +44,120 @@ async def test_attributes(hass: HomeAssistant, disable_platforms) -> None: assert data.get(ATTR_WEATHER_WIND_BEARING) is None assert data.get(ATTR_WEATHER_OZONE) is None assert data.get(ATTR_ATTRIBUTION) == "Powered by Home Assistant" - assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_CONDITION) == "rainy" - assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_PRECIPITATION) == 1 - assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 60 - assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_TEMP) == 22 - assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_TEMP_LOW) == 15 - assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_CONDITION) == "fog" - assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_PRECIPITATION) == 0.2 - assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_TEMP) == 21 - assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_TEMP_LOW) == 12 - assert ( - data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 100 + + +TEST_TIME_ADVANCE_INTERVAL = datetime.timedelta(seconds=5 + 1) + + +@pytest.mark.parametrize( + ("forecast_type", "expected_forecast"), + [ + ( + "daily", + [ + { + "condition": "snowy", + "precipitation": 2.0, + "temperature": -23.3, + "templow": -26.1, + "precipitation_probability": 60, + }, + { + "condition": "sunny", + "precipitation": 0.0, + "temperature": -22.8, + "templow": -24.4, + "precipitation_probability": 0, + }, + ], + ), + ( + "hourly", + [ + { + "condition": "sunny", + "precipitation": 2.0, + "temperature": -23.3, + "templow": -26.1, + "precipitation_probability": 60, + }, + { + "condition": "sunny", + "precipitation": 0.0, + "temperature": -22.8, + "templow": -24.4, + "precipitation_probability": 0, + }, + ], + ), + ( + "twice_daily", + [ + { + "condition": "snowy", + "precipitation": 2.0, + "temperature": -23.3, + "templow": -26.1, + "precipitation_probability": 60, + }, + { + "condition": "sunny", + "precipitation": 0.0, + "temperature": -22.8, + "templow": -24.4, + "precipitation_probability": 0, + }, + ], + ), + ], +) +async def test_forecast( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + disable_platforms: None, + forecast_type: str, + expected_forecast: list[dict[str, Any]], +) -> None: + """Test multiple forecast.""" + assert await async_setup_component( + hass, weather.DOMAIN, {"weather": {"platform": "demo"}} ) - assert len(data.get(ATTR_FORECAST)) == 7 + hass.config.units = METRIC_SYSTEM + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": forecast_type, + "entity_id": "weather.demo_weather_north", + } + ) + 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 len(forecast1) == 7 + for key, val in expected_forecast[0].items(): + assert forecast1[0][key] == val + for key, val in expected_forecast[1].items(): + assert forecast1[6][key] == val + + freezer.tick(WEATHER_UPDATE_INTERVAL + datetime.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 != forecast1 + assert len(forecast2) == 7 diff --git a/tests/components/weather/__init__.py b/tests/components/weather/__init__.py index 24df7abb1f3..91097dfae14 100644 --- a/tests/components/weather/__init__.py +++ b/tests/components/weather/__init__.py @@ -1 +1,32 @@ """The tests for Weather platforms.""" + + +from homeassistant.components.weather import ATTR_CONDITION_SUNNY +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.testing_config.custom_components.test import weather as WeatherPlatform + + +async def create_entity(hass: HomeAssistant, **kwargs): + """Create the weather entity to run tests on.""" + kwargs = { + "native_temperature": None, + "native_temperature_unit": None, + "is_daytime": True, + **kwargs, + } + platform: WeatherPlatform = getattr(hass.components, "test.weather") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockForecast( + name="Test", condition=ATTR_CONDITION_SUNNY, **kwargs + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test"}} + ) + await hass.async_block_till_done() + return entity0 diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 53753ad4a72..92643b616c9 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -34,6 +34,7 @@ from homeassistant.components.weather import ( ROUNDING_PRECISION, Forecast, WeatherEntity, + WeatherEntityFeature, round_temperature, ) from homeassistant.components.weather.const import ( @@ -54,6 +55,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( DistanceConverter, PressureConverter, @@ -62,7 +64,10 @@ from homeassistant.util.unit_conversion import ( ) from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM +from . import create_entity + from tests.testing_config.custom_components.test import weather as WeatherPlatform +from tests.typing import WebSocketGenerator class MockWeatherEntity(WeatherEntity): @@ -86,12 +91,19 @@ class MockWeatherEntity(WeatherEntity): self._attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND self._attr_forecast = [ Forecast( - datetime=datetime(2022, 6, 20, 20, 00, 00), + datetime=datetime(2022, 6, 20, 00, 00, 00, tzinfo=dt_util.UTC), native_precipitation=1, native_temperature=20, native_dew_point=2, ) ] + self._attr_forecast_twice_daily = [ + Forecast( + datetime=datetime(2022, 6, 20, 8, 00, 00, tzinfo=dt_util.UTC), + native_precipitation=10, + native_temperature=25, + ) + ] class MockWeatherEntityPrecision(WeatherEntity): @@ -126,32 +138,13 @@ class MockWeatherEntityCompat(WeatherEntity): self._attr_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND self._attr_forecast = [ Forecast( - datetime=datetime(2022, 6, 20, 20, 00, 00), + datetime=datetime(2022, 6, 20, 0, 00, 00, tzinfo=dt_util.UTC), precipitation=1, temperature=20, ) ] -async def create_entity(hass: HomeAssistant, **kwargs): - """Create the weather entity to run tests on.""" - kwargs = {"native_temperature": None, "native_temperature_unit": None, **kwargs} - platform: WeatherPlatform = getattr(hass.components, "test.weather") - platform.init(empty=True) - platform.ENTITIES.append( - platform.MockWeatherMockForecast( - name="Test", condition=ATTR_CONDITION_SUNNY, **kwargs - ) - ) - - entity0 = platform.ENTITIES[0] - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test"}} - ) - await hass.async_block_till_done() - return entity0 - - @pytest.mark.parametrize( "native_unit", (UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS) ) @@ -192,7 +185,7 @@ async def test_temperature( ) state = hass.states.get(entity0.entity_id) - forecast = state.attributes[ATTR_FORECAST][0] + forecast_daily = state.attributes[ATTR_FORECAST][0] expected = state_value apparent_expected = apparent_state_value @@ -207,14 +200,20 @@ async def test_temperature( dew_point_expected, rel=0.1 ) assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == state_unit - assert float(forecast[ATTR_FORECAST_TEMP]) == pytest.approx(expected, rel=0.1) - assert float(forecast[ATTR_FORECAST_APPARENT_TEMP]) == pytest.approx( + assert float(forecast_daily[ATTR_FORECAST_TEMP]) == pytest.approx(expected, rel=0.1) + assert float(forecast_daily[ATTR_FORECAST_APPARENT_TEMP]) == pytest.approx( apparent_expected, rel=0.1 ) - assert float(forecast[ATTR_FORECAST_DEW_POINT]) == pytest.approx( + assert float(forecast_daily[ATTR_FORECAST_DEW_POINT]) == pytest.approx( dew_point_expected, rel=0.1 ) - assert float(forecast[ATTR_FORECAST_TEMP_LOW]) == pytest.approx(expected, rel=0.1) + assert float(forecast_daily[ATTR_FORECAST_TEMP_LOW]) == pytest.approx( + expected, rel=0.1 + ) + assert float(forecast_daily[ATTR_FORECAST_TEMP]) == pytest.approx(expected, rel=0.1) + assert float(forecast_daily[ATTR_FORECAST_TEMP_LOW]) == pytest.approx( + expected, rel=0.1 + ) @pytest.mark.parametrize("native_unit", (None,)) @@ -695,6 +694,7 @@ async def test_custom_units( native_visibility_unit=visibility_unit, native_precipitation=precipitation_value, native_precipitation_unit=precipitation_unit, + is_daytime=True, unique_id="very_unique", ) ) @@ -1031,7 +1031,7 @@ async def test_attr_compatibility(hass: HomeAssistant) -> None: forecast_entry = [ Forecast( - datetime=datetime(2022, 6, 20, 20, 00, 00), + datetime=datetime(2022, 6, 20, 0, 00, 00, tzinfo=dt_util.UTC), precipitation=1, temperature=20, ) @@ -1067,3 +1067,39 @@ async def test_precision_for_temperature(hass: HomeAssistant) -> None: assert weather.state_attributes[ATTR_WEATHER_TEMPERATURE] == 20.5 assert weather.state_attributes[ATTR_WEATHER_DEW_POINT] == 2.5 + + +async def test_forecast_twice_daily_missing_is_daytime( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + enable_custom_integrations: None, +) -> None: + """Test forecast_twice_daily missing mandatory attribute is_daytime.""" + + entity0 = await create_entity( + hass, + native_temperature=38, + native_temperature_unit=UnitOfTemperature.CELSIUS, + is_daytime=None, + supported_features=WeatherEntityFeature.FORECAST_TWICE_DAILY, + ) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "twice_daily", + "entity_id": entity0.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["error"] == {"code": "unknown_error", "message": "Unknown error"} + assert not msg["success"] + assert msg["type"] == "result" diff --git a/tests/components/weather/test_recorder.py b/tests/components/weather/test_recorder.py index 5d7928124dd..2864abf58bb 100644 --- a/tests/components/weather/test_recorder.py +++ b/tests/components/weather/test_recorder.py @@ -5,7 +5,11 @@ from datetime import timedelta from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states -from homeassistant.components.weather import ATTR_FORECAST, DOMAIN +from homeassistant.components.weather import ( + ATTR_CONDITION_SUNNY, + ATTR_FORECAST, +) +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -13,17 +17,47 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +from tests.testing_config.custom_components.test import weather as WeatherPlatform -async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def create_entity(hass: HomeAssistant, **kwargs): + """Create the weather entity to run tests on.""" + kwargs = { + "native_temperature": None, + "native_temperature_unit": None, + "is_daytime": True, + **kwargs, + } + platform: WeatherPlatform = getattr(hass.components, "test.weather") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockForecast( + name="Test", condition=ATTR_CONDITION_SUNNY, **kwargs + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test"}} + ) + await hass.async_block_till_done() + return entity0 + + +async def test_exclude_attributes( + recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None +) -> None: """Test weather attributes to be excluded.""" now = dt_util.utcnow() - await async_setup_component(hass, "homeassistant", {}) - await async_setup_component(hass, DOMAIN, {DOMAIN: {"platform": "demo"}}) + entity0 = await create_entity( + hass, + native_temperature=38, + native_temperature_unit=UnitOfTemperature.CELSIUS, + ) hass.config.units = METRIC_SYSTEM await hass.async_block_till_done() - state = hass.states.get("weather.demo_weather_south") + state = hass.states.get(entity0.entity_id) assert state.attributes[ATTR_FORECAST] await hass.async_block_till_done() diff --git a/tests/components/weather/test_websocket_api.py b/tests/components/weather/test_websocket_api.py index 760acbb2bb0..4f5223c6f79 100644 --- a/tests/components/weather/test_websocket_api.py +++ b/tests/components/weather/test_websocket_api.py @@ -1,8 +1,12 @@ """Test the weather websocket API.""" +from homeassistant.components.weather import WeatherEntityFeature from homeassistant.components.weather.const import DOMAIN +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from . import create_entity + from tests.typing import WebSocketGenerator @@ -31,3 +35,118 @@ async def test_device_class_units( "wind_speed_unit": ["ft/s", "km/h", "kn", "m/s", "mph"], } } + + +async def test_subscribe_forecast( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + enable_custom_integrations: None, +) -> None: + """Test multiple forecast.""" + + entity0 = await create_entity( + hass, + native_temperature=38, + native_temperature_unit=UnitOfTemperature.CELSIUS, + supported_features=WeatherEntityFeature.FORECAST_DAILY, + ) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "daily", + "entity_id": entity0.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" + forecast = msg["event"] + assert forecast == { + "type": "daily", + "forecast": [ + { + "cloud_coverage": None, + "temperature": 38.0, + "templow": 38.0, + "uv_index": None, + "wind_bearing": None, + } + ], + } + + await entity0.async_update_listeners(None) + msg = await client.receive_json() + assert msg["event"] == forecast + + await entity0.async_update_listeners(["daily"]) + msg = await client.receive_json() + assert msg["event"] == forecast + + entity0.forecast_list = None + await entity0.async_update_listeners(None) + msg = await client.receive_json() + assert msg["event"] == {"type": "daily", "forecast": None} + + +async def test_subscribe_forecast_unknown_entity( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + enable_custom_integrations: None, +) -> None: + """Test multiple forecast.""" + + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "daily", + "entity_id": "weather.unknown", + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "invalid_entity_id", + "message": "Weather entity not found: weather.unknown", + } + + +async def test_subscribe_forecast_unsupported( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + enable_custom_integrations: None, +) -> None: + """Test multiple forecast.""" + + entity0 = await create_entity( + hass, + native_temperature=38, + native_temperature_unit=UnitOfTemperature.CELSIUS, + ) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "daily", + "entity_id": entity0.entity_id, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "forecast_not_supported", + "message": "The weather entity does not support forecast type: daily", + } diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index df6a43ad40c..e2d026ec840 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -4,9 +4,12 @@ Call init before using it in your tests to ensure clean test data. """ from __future__ import annotations +from typing import Any + from homeassistant.components.weather import ( ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_IS_DAYTIME, ATTR_FORECAST_NATIVE_APPARENT_TEMP, ATTR_FORECAST_NATIVE_DEW_POINT, ATTR_FORECAST_NATIVE_PRECIPITATION, @@ -220,9 +223,61 @@ class MockWeatherCompat(MockEntity, WeatherEntity): class MockWeatherMockForecast(MockWeather): """Mock weather class with mocked forecast.""" + def __init__(self, **values: Any) -> None: + """Initialize.""" + super().__init__(**values) + self.forecast_list: list[Forecast] | None = [ + { + ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, + ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, + ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_UV_INDEX: self.uv_index, + ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( + "native_precipitation" + ), + ATTR_FORECAST_HUMIDITY: self.humidity, + } + ] + @property def forecast(self) -> list[Forecast] | None: """Return the forecast.""" + return self.forecast_list + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return self.forecast_list + + async def async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the forecast_twice_daily.""" + return [ + { + ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, + ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, + ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_UV_INDEX: self.uv_index, + ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( + "native_precipitation" + ), + ATTR_FORECAST_HUMIDITY: self.humidity, + ATTR_FORECAST_IS_DAYTIME: self._values.get("is_daytime"), + } + ] + + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the forecast_hourly.""" return [ { ATTR_FORECAST_NATIVE_TEMP: self.native_temperature,