"""Weather component that handles meteorological data for your location.""" from __future__ import annotations import abc import asyncio from collections.abc import Callable, Iterable from contextlib import suppress from datetime import timedelta from functools import partial import logging from typing import ( TYPE_CHECKING, Any, Final, Generic, Literal, Required, TypedDict, TypeVar, cast, final, ) import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, ) from homeassistant.core import ( CALLBACK_TYPE, HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse, callback, ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) from homeassistant.helpers.entity import ABCCachedProperties, Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform import homeassistant.helpers.issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, TimestampDataUpdateCoordinator, ) from homeassistant.loader import async_get_issue_tracker, async_suggest_report_issue from homeassistant.util.dt import utcnow from homeassistant.util.json import JsonValueType from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( # noqa: F401 ATTR_WEATHER_APPARENT_TEMPERATURE, ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_DEW_POINT, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_OZONE, ATTR_WEATHER_PRECIPITATION_UNIT, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_PRESSURE_UNIT, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_TEMPERATURE_UNIT, ATTR_WEATHER_UV_INDEX, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_VISIBILITY_UNIT, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN, UNIT_CONVERSIONS, VALID_UNITS, WeatherEntityFeature, ) from .websocket_api import async_setup as async_setup_ws_api if TYPE_CHECKING: from functools import cached_property else: from homeassistant.backports.functools import cached_property _LOGGER = logging.getLogger(__name__) ATTR_CONDITION_CLASS = "condition_class" ATTR_CONDITION_CLEAR_NIGHT = "clear-night" ATTR_CONDITION_CLOUDY = "cloudy" ATTR_CONDITION_EXCEPTIONAL = "exceptional" ATTR_CONDITION_FOG = "fog" ATTR_CONDITION_HAIL = "hail" ATTR_CONDITION_LIGHTNING = "lightning" ATTR_CONDITION_LIGHTNING_RAINY = "lightning-rainy" ATTR_CONDITION_PARTLYCLOUDY = "partlycloudy" ATTR_CONDITION_POURING = "pouring" ATTR_CONDITION_RAINY = "rainy" ATTR_CONDITION_SNOWY = "snowy" ATTR_CONDITION_SNOWY_RAINY = "snowy-rainy" 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" ATTR_FORECAST_PRECIPITATION: Final = "precipitation" ATTR_FORECAST_PRECIPITATION_PROBABILITY: Final = "precipitation_probability" ATTR_FORECAST_NATIVE_PRESSURE: Final = "native_pressure" ATTR_FORECAST_PRESSURE: Final = "pressure" ATTR_FORECAST_NATIVE_APPARENT_TEMP: Final = "native_apparent_temperature" ATTR_FORECAST_APPARENT_TEMP: Final = "apparent_temperature" ATTR_FORECAST_NATIVE_TEMP: Final = "native_temperature" ATTR_FORECAST_TEMP: Final = "temperature" ATTR_FORECAST_NATIVE_TEMP_LOW: Final = "native_templow" ATTR_FORECAST_TEMP_LOW: Final = "templow" ATTR_FORECAST_TIME: Final = "datetime" ATTR_FORECAST_WIND_BEARING: Final = "wind_bearing" ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: Final = "native_wind_gust_speed" ATTR_FORECAST_WIND_GUST_SPEED: Final = "wind_gust_speed" ATTR_FORECAST_NATIVE_WIND_SPEED: Final = "native_wind_speed" ATTR_FORECAST_WIND_SPEED: Final = "wind_speed" ATTR_FORECAST_NATIVE_DEW_POINT: Final = "native_dew_point" ATTR_FORECAST_DEW_POINT: Final = "dew_point" ATTR_FORECAST_CLOUD_COVERAGE: Final = "cloud_coverage" ATTR_FORECAST_UV_INDEX: Final = "uv_index" ENTITY_ID_FORMAT = DOMAIN + ".{}" SCAN_INTERVAL = timedelta(seconds=30) ROUNDING_PRECISION = 2 LEGACY_SERVICE_GET_FORECAST: Final = "get_forecast" """Deprecated: please use SERVICE_GET_FORECASTS.""" SERVICE_GET_FORECASTS: Final = "get_forecasts" _ObservationUpdateCoordinatorT = TypeVar( "_ObservationUpdateCoordinatorT", bound="DataUpdateCoordinator[Any]" ) # Note: # Mypy bug https://github.com/python/mypy/issues/9424 prevents us from making the # forecast cooordinators optional, bound=TimestampDataUpdateCoordinator[Any] | None _DailyForecastUpdateCoordinatorT = TypeVar( "_DailyForecastUpdateCoordinatorT", bound="TimestampDataUpdateCoordinator[Any]" ) _HourlyForecastUpdateCoordinatorT = TypeVar( "_HourlyForecastUpdateCoordinatorT", bound="TimestampDataUpdateCoordinator[Any]" ) _TwiceDailyForecastUpdateCoordinatorT = TypeVar( "_TwiceDailyForecastUpdateCoordinatorT", bound="TimestampDataUpdateCoordinator[Any]" ) # mypy: disallow-any-generics def round_temperature(temperature: float | None, precision: float) -> float | None: """Convert temperature into preferred precision for display.""" if temperature is None: return None # Round in the units appropriate if precision == PRECISION_HALVES: temperature = round(temperature * 2) / 2.0 elif precision == PRECISION_TENTHS: temperature = round(temperature, 1) # Integer as a fall back (PRECISION_WHOLE) else: temperature = round(temperature) return temperature class Forecast(TypedDict, total=False): """Typed weather forecast dict. All attributes are in native units and old attributes kept for backwards compatibility. """ condition: str | None datetime: Required[str] humidity: float | None precipitation_probability: int | None cloud_coverage: int | None native_precipitation: float | None precipitation: None native_pressure: float | None pressure: None native_temperature: float | None temperature: None native_templow: float | None templow: None native_apparent_temperature: float | None wind_bearing: float | str | None native_wind_gust_speed: float | None native_wind_speed: float | None 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: """Set up the weather component.""" component = hass.data[DOMAIN] = EntityComponent[WeatherEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) component.async_register_legacy_entity_service( LEGACY_SERVICE_GET_FORECAST, {vol.Required("type"): vol.In(("daily", "hourly", "twice_daily"))}, async_get_forecast_service, required_features=[ WeatherEntityFeature.FORECAST_DAILY, WeatherEntityFeature.FORECAST_HOURLY, WeatherEntityFeature.FORECAST_TWICE_DAILY, ], supports_response=SupportsResponse.ONLY, ) component.async_register_entity_service( SERVICE_GET_FORECASTS, {vol.Required("type"): vol.In(("daily", "hourly", "twice_daily"))}, async_get_forecasts_service, required_features=[ WeatherEntityFeature.FORECAST_DAILY, WeatherEntityFeature.FORECAST_HOURLY, WeatherEntityFeature.FORECAST_TWICE_DAILY, ], supports_response=SupportsResponse.ONLY, ) async_setup_ws_api(hass) await component.async_setup(config) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" component: EntityComponent[WeatherEntity] = hass.data[DOMAIN] return await component.async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" component: EntityComponent[WeatherEntity] = hass.data[DOMAIN] return await component.async_unload_entry(entry) class WeatherEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes weather entities.""" class PostInitMeta(ABCCachedProperties): """Meta class which calls __post_init__ after __new__ and __init__.""" def __call__(cls, *args: Any, **kwargs: Any) -> Any: # noqa: N805 ruff bug, ruff does not understand this is a metaclass """Create an instance.""" instance: PostInit = super().__call__(*args, **kwargs) instance.__post_init__(*args, **kwargs) return instance class PostInit(metaclass=PostInitMeta): """Class which calls __post_init__ after __new__ and __init__.""" @abc.abstractmethod def __post_init__(self, *args: Any, **kwargs: Any) -> None: """Finish initializing.""" CACHED_PROPERTIES_WITH_ATTR_ = { "native_apparent_temperature", "native_temperature", "native_temperature_unit", "native_dew_point", "native_pressure", "native_pressure_unit", "humidity", "native_wind_gust_speed", "native_wind_speed", "native_wind_speed_unit", "wind_bearing", "ozone", "cloud_coverage", "uv_index", "native_visibility", "native_visibility_unit", "native_precipitation_unit", "condition", } class WeatherEntity(Entity, PostInit, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ABC for weather data.""" _entity_component_unrecorded_attributes = frozenset({ATTR_FORECAST}) entity_description: WeatherEntityDescription _attr_condition: str | None = 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 _attr_cloud_coverage: int | None = None _attr_uv_index: float | None = None _attr_precision: float _attr_state: None = None _attr_wind_bearing: float | str | None = None _attr_native_pressure: float | None = None _attr_native_pressure_unit: str | None = None _attr_native_apparent_temperature: float | None = None _attr_native_temperature: float | None = None _attr_native_temperature_unit: str | None = None _attr_native_visibility: float | None = None _attr_native_visibility_unit: str | None = None _attr_native_precipitation_unit: str | None = None _attr_native_wind_gust_speed: float | None = None _attr_native_wind_speed: float | None = None _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[JsonValueType] | None], None]], ] __weather_reported_legacy_forecast = False __weather_legacy_forecast = False _weather_option_temperature_unit: str | None = None _weather_option_pressure_unit: str | None = None _weather_option_visibility_unit: str | None = None _weather_option_precipitation_unit: str | None = None _weather_option_wind_speed_unit: str | None = None def __post_init__(self, *args: Any, **kwargs: Any) -> None: """Finish initializing.""" self._forecast_listeners = {"daily": [], "hourly": [], "twice_daily": []} def __init_subclass__(cls, **kwargs: Any) -> None: """Post initialisation processing.""" super().__init_subclass__(**kwargs) if ( "forecast" in cls.__dict__ and cls.async_forecast_daily is WeatherEntity.async_forecast_daily and cls.async_forecast_hourly is WeatherEntity.async_forecast_hourly and cls.async_forecast_twice_daily is WeatherEntity.async_forecast_twice_daily ): cls.__weather_legacy_forecast = True @callback def add_to_platform_start( self, hass: HomeAssistant, platform: EntityPlatform, parallel_updates: asyncio.Semaphore | None, ) -> None: """Start adding an entity to a platform.""" super().add_to_platform_start(hass, platform, parallel_updates) if self.__weather_legacy_forecast: self._report_legacy_forecast(hass) def _report_legacy_forecast(self, hass: HomeAssistant) -> None: """Log warning and create an issue if the entity imlpements legacy forecast.""" if "custom_components" not in type(self).__module__: # Do not report core integrations as they are already fixed or PR is open. return report_issue = async_suggest_report_issue( hass, integration_domain=self.platform.platform_name, module=type(self).__module__, ) _LOGGER.warning( ( "%s::%s implements the `forecast` property or sets " "`self._attr_forecast` in a subclass of WeatherEntity, this is " "deprecated and will be unsupported from Home Assistant 2024.3." " Please %s" ), self.platform.platform_name, self.__class__.__name__, report_issue, ) translation_placeholders = {"platform": self.platform.platform_name} translation_key = "deprecated_weather_forecast_no_url" issue_tracker = async_get_issue_tracker( hass, integration_domain=self.platform.platform_name, module=type(self).__module__, ) if issue_tracker: translation_placeholders["issue_tracker"] = issue_tracker translation_key = "deprecated_weather_forecast_url" ir.async_create_issue( self.hass, DOMAIN, f"deprecated_weather_forecast_{self.platform.platform_name}", breaks_in_ha_version="2024.3.0", is_fixable=False, is_persistent=False, issue_domain=self.platform.platform_name, severity=ir.IssueSeverity.WARNING, translation_key=translation_key, translation_placeholders=translation_placeholders, ) self.__weather_reported_legacy_forecast = True async def async_internal_added_to_hass(self) -> None: """Call when the weather entity is added to hass.""" await super().async_internal_added_to_hass() if not self.registry_entry: return self.async_registry_entry_updated() @cached_property def native_apparent_temperature(self) -> float | None: """Return the apparent temperature in native units.""" return self._attr_native_apparent_temperature @cached_property def native_temperature(self) -> float | None: """Return the temperature in native units.""" return self._attr_native_temperature @cached_property def native_temperature_unit(self) -> str | None: """Return the native unit of measurement for temperature.""" return self._attr_native_temperature_unit @cached_property def native_dew_point(self) -> float | None: """Return the dew point temperature in native units.""" return self._attr_native_dew_point @final @property def _default_temperature_unit(self) -> str: """Return the default unit of measurement for temperature. Should not be set by integrations. """ return self.hass.config.units.temperature_unit @final @property def _temperature_unit(self) -> str: """Return the converted unit of measurement for temperature. Should not be set by integrations. """ if ( weather_option_temperature_unit := self._weather_option_temperature_unit ) is not None: return weather_option_temperature_unit return self._default_temperature_unit @cached_property def native_pressure(self) -> float | None: """Return the pressure in native units.""" return self._attr_native_pressure @cached_property def native_pressure_unit(self) -> str | None: """Return the native unit of measurement for pressure.""" return self._attr_native_pressure_unit @final @property def _default_pressure_unit(self) -> str: """Return the default unit of measurement for pressure. Should not be set by integrations. """ if self.hass.config.units is US_CUSTOMARY_SYSTEM: return UnitOfPressure.INHG return UnitOfPressure.HPA @final @property def _pressure_unit(self) -> str: """Return the converted unit of measurement for pressure. Should not be set by integrations. """ if ( weather_option_pressure_unit := self._weather_option_pressure_unit ) is not None: return weather_option_pressure_unit return self._default_pressure_unit @cached_property def humidity(self) -> float | None: """Return the humidity in native units.""" return self._attr_humidity @cached_property def native_wind_gust_speed(self) -> float | None: """Return the wind gust speed in native units.""" return self._attr_native_wind_gust_speed @cached_property def native_wind_speed(self) -> float | None: """Return the wind speed in native units.""" return self._attr_native_wind_speed @cached_property def native_wind_speed_unit(self) -> str | None: """Return the native unit of measurement for wind speed.""" return self._attr_native_wind_speed_unit @final @property def _default_wind_speed_unit(self) -> str: """Return the default unit of measurement for wind speed. Should not be set by integrations. """ if self.hass.config.units is US_CUSTOMARY_SYSTEM: return UnitOfSpeed.MILES_PER_HOUR return UnitOfSpeed.KILOMETERS_PER_HOUR @final @property def _wind_speed_unit(self) -> str: """Return the converted unit of measurement for wind speed. Should not be set by integrations. """ if ( weather_option_wind_speed_unit := self._weather_option_wind_speed_unit ) is not None: return weather_option_wind_speed_unit return self._default_wind_speed_unit @cached_property def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" return self._attr_wind_bearing @cached_property def ozone(self) -> float | None: """Return the ozone level.""" return self._attr_ozone @cached_property def cloud_coverage(self) -> float | None: """Return the Cloud coverage in %.""" return self._attr_cloud_coverage @cached_property def uv_index(self) -> float | None: """Return the UV index.""" return self._attr_uv_index @cached_property def native_visibility(self) -> float | None: """Return the visibility in native units.""" return self._attr_native_visibility @cached_property def native_visibility_unit(self) -> str | None: """Return the native unit of measurement for visibility.""" return self._attr_native_visibility_unit @final @property def _default_visibility_unit(self) -> str: """Return the default unit of measurement for visibility. Should not be set by integrations. """ return self.hass.config.units.length_unit @final @property def _visibility_unit(self) -> str: """Return the converted unit of measurement for visibility. Should not be set by integrations. """ if ( weather_option_visibility_unit := self._weather_option_visibility_unit ) is not None: return weather_option_visibility_unit return self._default_visibility_unit @property def forecast(self) -> list[Forecast] | None: """Return the forecast in native units. Should not be overridden by integrations. Kept for backwards compatibility. """ if ( self._attr_forecast is not None and type(self).async_forecast_daily is WeatherEntity.async_forecast_daily and type(self).async_forecast_hourly is WeatherEntity.async_forecast_hourly and type(self).async_forecast_twice_daily is WeatherEntity.async_forecast_twice_daily and not self.__weather_reported_legacy_forecast ): self._report_legacy_forecast(self.hass) 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 @cached_property def native_precipitation_unit(self) -> str | None: """Return the native unit of measurement for accumulated precipitation.""" return self._attr_native_precipitation_unit @final @property def _default_precipitation_unit(self) -> str: """Return the default unit of measurement for precipitation. Should not be set by integrations. """ return self.hass.config.units.accumulated_precipitation_unit @final @property def _precipitation_unit(self) -> str: """Return the converted unit of measurement for precipitation. Should not be set by integrations. """ if ( weather_option_precipitation_unit := self._weather_option_precipitation_unit ) is not None: return weather_option_precipitation_unit return self._default_precipitation_unit @property def precision(self) -> float: """Return the precision of the temperature value, after unit conversion.""" if hasattr(self, "_attr_precision"): return self._attr_precision return ( PRECISION_TENTHS if self._temperature_unit == UnitOfTemperature.CELSIUS else PRECISION_WHOLE ) @final @property def state_attributes(self) -> dict[str, Any]: # noqa: C901 """Return the state attributes, converted. Attributes are configured from native units to user-configured units. """ data: dict[str, Any] = {} precision = self.precision if (temperature := self.native_temperature) is not None: from_unit = self.native_temperature_unit or self._default_temperature_unit to_unit = self._temperature_unit try: temperature_f = float(temperature) value_temp = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( temperature_f, from_unit, to_unit ) data[ATTR_WEATHER_TEMPERATURE] = round_temperature( value_temp, precision ) except (TypeError, ValueError): data[ATTR_WEATHER_TEMPERATURE] = temperature if (apparent_temperature := self.native_apparent_temperature) is not None: from_unit = self.native_temperature_unit or self._default_temperature_unit to_unit = self._temperature_unit try: apparent_temperature_f = float(apparent_temperature) value_apparent_temp = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( apparent_temperature_f, from_unit, to_unit ) data[ATTR_WEATHER_APPARENT_TEMPERATURE] = round_temperature( value_apparent_temp, precision ) except (TypeError, ValueError): data[ATTR_WEATHER_APPARENT_TEMPERATURE] = apparent_temperature if (dew_point := self.native_dew_point) is not None: from_unit = self.native_temperature_unit or self._default_temperature_unit to_unit = self._temperature_unit try: dew_point_f = float(dew_point) value_dew_point = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( dew_point_f, from_unit, to_unit ) data[ATTR_WEATHER_DEW_POINT] = round_temperature( value_dew_point, precision ) except (TypeError, ValueError): data[ATTR_WEATHER_DEW_POINT] = dew_point data[ATTR_WEATHER_TEMPERATURE_UNIT] = self._temperature_unit if (humidity := self.humidity) is not None: data[ATTR_WEATHER_HUMIDITY] = round(humidity) if (ozone := self.ozone) is not None: data[ATTR_WEATHER_OZONE] = ozone if (cloud_coverage := self.cloud_coverage) is not None: data[ATTR_WEATHER_CLOUD_COVERAGE] = cloud_coverage if (uv_index := self.uv_index) is not None: data[ATTR_WEATHER_UV_INDEX] = uv_index if (pressure := self.native_pressure) is not None: from_unit = self.native_pressure_unit or self._default_pressure_unit to_unit = self._pressure_unit try: pressure_f = float(pressure) value_pressure = UNIT_CONVERSIONS[ATTR_WEATHER_PRESSURE_UNIT]( pressure_f, from_unit, to_unit ) data[ATTR_WEATHER_PRESSURE] = round(value_pressure, ROUNDING_PRECISION) except (TypeError, ValueError): data[ATTR_WEATHER_PRESSURE] = pressure data[ATTR_WEATHER_PRESSURE_UNIT] = self._pressure_unit if (wind_bearing := self.wind_bearing) is not None: data[ATTR_WEATHER_WIND_BEARING] = wind_bearing if (wind_gust_speed := self.native_wind_gust_speed) is not None: from_unit = self.native_wind_speed_unit or self._default_wind_speed_unit to_unit = self._wind_speed_unit try: wind_gust_speed_f = float(wind_gust_speed) value_wind_gust_speed = UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( wind_gust_speed_f, from_unit, to_unit ) data[ATTR_WEATHER_WIND_GUST_SPEED] = round( value_wind_gust_speed, ROUNDING_PRECISION ) except (TypeError, ValueError): data[ATTR_WEATHER_WIND_GUST_SPEED] = wind_gust_speed if (wind_speed := self.native_wind_speed) is not None: from_unit = self.native_wind_speed_unit or self._default_wind_speed_unit to_unit = self._wind_speed_unit try: wind_speed_f = float(wind_speed) value_wind_speed = UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( wind_speed_f, from_unit, to_unit ) data[ATTR_WEATHER_WIND_SPEED] = round( value_wind_speed, ROUNDING_PRECISION ) except (TypeError, ValueError): data[ATTR_WEATHER_WIND_SPEED] = wind_speed data[ATTR_WEATHER_WIND_SPEED_UNIT] = self._wind_speed_unit if (visibility := self.native_visibility) is not None: from_unit = self.native_visibility_unit or self._default_visibility_unit to_unit = self._visibility_unit try: visibility_f = float(visibility) value_visibility = UNIT_CONVERSIONS[ATTR_WEATHER_VISIBILITY_UNIT]( visibility_f, from_unit, to_unit ) data[ATTR_WEATHER_VISIBILITY] = round( value_visibility, ROUNDING_PRECISION ) except (TypeError, ValueError): data[ATTR_WEATHER_VISIBILITY] = visibility data[ATTR_WEATHER_VISIBILITY_UNIT] = self._visibility_unit data[ATTR_WEATHER_PRECIPITATION_UNIT] = self._precipitation_unit if self.forecast: data[ATTR_FORECAST] = self._convert_forecast(self.forecast) return data @final def _convert_forecast( self, native_forecast_list: list[Forecast] ) -> list[JsonValueType]: """Convert a forecast in native units to the unit configured by the user.""" converted_forecast_list: list[JsonValueType] = [] 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: """Return the current state.""" return self.condition @cached_property def condition(self) -> str | None: """Return the current condition.""" return self._attr_condition @callback def async_registry_entry_updated(self) -> None: """Run when the entity registry entry has been updated.""" assert self.registry_entry self._weather_option_temperature_unit = None self._weather_option_pressure_unit = None self._weather_option_precipitation_unit = None self._weather_option_wind_speed_unit = None self._weather_option_visibility_unit = None if weather_options := self.registry_entry.options.get(DOMAIN): if ( custom_unit_temperature := weather_options.get( ATTR_WEATHER_TEMPERATURE_UNIT ) ) and custom_unit_temperature in VALID_UNITS[ATTR_WEATHER_TEMPERATURE_UNIT]: self._weather_option_temperature_unit = custom_unit_temperature if ( custom_unit_pressure := weather_options.get(ATTR_WEATHER_PRESSURE_UNIT) ) and custom_unit_pressure in VALID_UNITS[ATTR_WEATHER_PRESSURE_UNIT]: self._weather_option_pressure_unit = custom_unit_pressure if ( custom_unit_precipitation := weather_options.get( ATTR_WEATHER_PRECIPITATION_UNIT ) ) and custom_unit_precipitation in VALID_UNITS[ ATTR_WEATHER_PRECIPITATION_UNIT ]: self._weather_option_precipitation_unit = custom_unit_precipitation if ( custom_unit_wind_speed := weather_options.get( ATTR_WEATHER_WIND_SPEED_UNIT ) ) and custom_unit_wind_speed in VALID_UNITS[ATTR_WEATHER_WIND_SPEED_UNIT]: self._weather_option_wind_speed_unit = custom_unit_wind_speed if ( custom_unit_visibility := weather_options.get( ATTR_WEATHER_VISIBILITY_UNIT ) ) 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( self, forecast_type: Literal["daily", "hourly", "twice_daily"], forecast_listener: Callable[[list[JsonValueType] | None], None], ) -> CALLBACK_TYPE: """Subscribe to forecast updates. 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 @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) def raise_unsupported_forecast(entity_id: str, forecast_type: str) -> None: """Raise error on attempt to get an unsupported forecast.""" raise HomeAssistantError( f"Weather entity '{entity_id}' does not support '{forecast_type}' forecast" ) async def async_get_forecast_service( weather: WeatherEntity, service_call: ServiceCall ) -> ServiceResponse: """Get weather forecast. Deprecated: please use async_get_forecasts_service. """ _LOGGER.warning( "Detected use of service 'weather.get_forecast'. " "This is deprecated and will stop working in Home Assistant 2024.6. " "Use 'weather.get_forecasts' instead which supports multiple entities", ) ir.async_create_issue( weather.hass, DOMAIN, "deprecated_service_weather_get_forecast", breaks_in_ha_version="2024.6.0", is_fixable=True, is_persistent=False, issue_domain=weather.platform.platform_name, severity=ir.IssueSeverity.WARNING, translation_key="deprecated_service_weather_get_forecast", ) return await async_get_forecasts_service(weather, service_call) async def async_get_forecasts_service( weather: WeatherEntity, service_call: ServiceCall ) -> ServiceResponse: """Get weather forecast.""" forecast_type = service_call.data["type"] supported_features = weather.supported_features or 0 if forecast_type == "daily": if (supported_features & WeatherEntityFeature.FORECAST_DAILY) == 0: raise_unsupported_forecast(weather.entity_id, forecast_type) native_forecast_list = await weather.async_forecast_daily() elif forecast_type == "hourly": if (supported_features & WeatherEntityFeature.FORECAST_HOURLY) == 0: raise_unsupported_forecast(weather.entity_id, forecast_type) native_forecast_list = await weather.async_forecast_hourly() else: if (supported_features & WeatherEntityFeature.FORECAST_TWICE_DAILY) == 0: raise_unsupported_forecast(weather.entity_id, forecast_type) native_forecast_list = await weather.async_forecast_twice_daily() if native_forecast_list is None: converted_forecast_list = [] else: # pylint: disable-next=protected-access converted_forecast_list = weather._convert_forecast(native_forecast_list) return { "forecast": converted_forecast_list, } class CoordinatorWeatherEntity( CoordinatorEntity[_ObservationUpdateCoordinatorT], WeatherEntity, Generic[ _ObservationUpdateCoordinatorT, _DailyForecastUpdateCoordinatorT, _HourlyForecastUpdateCoordinatorT, _TwiceDailyForecastUpdateCoordinatorT, ], ): """A class for weather entities using DataUpdateCoordinators.""" def __init__( self, observation_coordinator: _ObservationUpdateCoordinatorT, *, context: Any = None, daily_coordinator: _DailyForecastUpdateCoordinatorT | None = None, hourly_coordinator: _DailyForecastUpdateCoordinatorT | None = None, twice_daily_coordinator: _DailyForecastUpdateCoordinatorT | None = None, daily_forecast_valid: timedelta | None = None, hourly_forecast_valid: timedelta | None = None, twice_daily_forecast_valid: timedelta | None = None, ) -> None: """Initialize.""" super().__init__(observation_coordinator, context) self.forecast_coordinators = { "daily": daily_coordinator, "hourly": hourly_coordinator, "twice_daily": twice_daily_coordinator, } self.forecast_valid = { "daily": daily_forecast_valid, "hourly": hourly_forecast_valid, "twice_daily": twice_daily_forecast_valid, } self.unsub_forecast: dict[str, Callable[[], None] | None] = { "daily": None, "hourly": None, "twice_daily": None, } async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() self.async_on_remove(partial(self._remove_forecast_listener, "daily")) self.async_on_remove(partial(self._remove_forecast_listener, "hourly")) self.async_on_remove(partial(self._remove_forecast_listener, "twice_daily")) def _remove_forecast_listener( self, forecast_type: Literal["daily", "hourly", "twice_daily"] ) -> None: """Remove weather forecast listener.""" if unsub_fn := self.unsub_forecast[forecast_type]: unsub_fn() self.unsub_forecast[forecast_type] = None @callback def _async_subscription_started( self, forecast_type: Literal["daily", "hourly", "twice_daily"], ) -> None: """Start subscription to forecast_type.""" if not (coordinator := self.forecast_coordinators[forecast_type]): return self.unsub_forecast[forecast_type] = coordinator.async_add_listener( partial(self._handle_forecast_update, forecast_type) ) @callback def _handle_daily_forecast_coordinator_update(self) -> None: """Handle updated data from the daily forecast coordinator.""" @callback def _handle_hourly_forecast_coordinator_update(self) -> None: """Handle updated data from the hourly forecast coordinator.""" @callback def _handle_twice_daily_forecast_coordinator_update(self) -> None: """Handle updated data from the twice daily forecast coordinator.""" @final @callback def _handle_forecast_update( self, forecast_type: Literal["daily", "hourly", "twice_daily"] ) -> None: """Update forecast data.""" coordinator = self.forecast_coordinators[forecast_type] assert coordinator assert coordinator.config_entry is not None getattr(self, f"_handle_{forecast_type}_forecast_coordinator_update")() coordinator.config_entry.async_create_task( self.hass, self.async_update_listeners((forecast_type,)) ) @callback def _async_subscription_ended( self, forecast_type: Literal["daily", "hourly", "twice_daily"], ) -> None: """End subscription to forecast_type.""" self._remove_forecast_listener(forecast_type) @final async def _async_refresh_forecast( self, coordinator: TimestampDataUpdateCoordinator[Any], forecast_valid_time: timedelta | None, ) -> bool: """Refresh stale forecast if needed.""" if coordinator.update_interval is None: return True if forecast_valid_time is None: forecast_valid_time = coordinator.update_interval if ( not (last_success_time := coordinator.last_update_success_time) or utcnow() - last_success_time >= coordinator.update_interval ): await coordinator.async_refresh() if ( not (last_success_time := coordinator.last_update_success_time) or utcnow() - last_success_time >= forecast_valid_time ): return False return True @callback def _async_forecast_daily(self) -> list[Forecast] | None: """Return the daily forecast in native units.""" raise NotImplementedError @callback def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" raise NotImplementedError @callback def _async_forecast_twice_daily(self) -> list[Forecast] | None: """Return the twice daily forecast in native units.""" raise NotImplementedError @final async def _async_forecast( self, forecast_type: Literal["daily", "hourly", "twice_daily"] ) -> list[Forecast] | None: """Return the forecast in native units.""" coordinator = self.forecast_coordinators[forecast_type] if coordinator and not await self._async_refresh_forecast( coordinator, self.forecast_valid[forecast_type] ): return None return cast( list[Forecast] | None, getattr(self, f"_async_forecast_{forecast_type}")() ) @final async def async_forecast_daily(self) -> list[Forecast] | None: """Return the daily forecast in native units.""" return await self._async_forecast("daily") @final async def async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" return await self._async_forecast("hourly") @final async def async_forecast_twice_daily(self) -> list[Forecast] | None: """Return the twice daily forecast in native units.""" return await self._async_forecast("twice_daily") class SingleCoordinatorWeatherEntity( CoordinatorWeatherEntity[ _ObservationUpdateCoordinatorT, TimestampDataUpdateCoordinator[None], TimestampDataUpdateCoordinator[None], TimestampDataUpdateCoordinator[None], ], ): """A class for weather entities using a single DataUpdateCoordinators. This class is added as a convenience because: - Deriving from CoordinatorWeatherEntity requires specifying all type parameters until we upgrade to Python 3.12 which supports defaults - Mypy bug https://github.com/python/mypy/issues/9424 prevents us from making the forecast cooordinator type vars optional """ def __init__( self, coordinator: _ObservationUpdateCoordinatorT, context: Any = None, ) -> None: """Initialize.""" super().__init__(coordinator, context=context) @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" super()._handle_coordinator_update() assert self.coordinator.config_entry self.coordinator.config_entry.async_create_task( self.hass, self.async_update_listeners(None) )