1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Update template weather platform to new template entity framework (#162569)

This commit is contained in:
Petro31
2026-02-09 08:26:19 -05:00
committed by GitHub
parent 7b2509fadb
commit a5eca0614a
2 changed files with 177 additions and 369 deletions

View File

@@ -4,9 +4,8 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import asdict, dataclass
from functools import partial
import logging
from typing import TYPE_CHECKING, Any, Literal, Self
from typing import Any, Literal, Self
import voluptuous as vol
@@ -42,8 +41,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -57,7 +55,7 @@ from homeassistant.util.unit_conversion import (
TemperatureConverter,
)
from .coordinator import TriggerUpdateCoordinator
from . import TriggerUpdateCoordinator, validators as template_validators
from .entity import AbstractTemplateEntity
from .helpers import (
async_setup_template_entry,
@@ -82,23 +80,23 @@ CHECK_FORECAST_KEYS = (
.union(("apparent_temperature", "wind_gust_speed", "dew_point"))
)
CONDITION_CLASSES = {
CONDITION_CLASSES = [
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_EXCEPTIONAL,
ATTR_CONDITION_FOG,
ATTR_CONDITION_HAIL,
ATTR_CONDITION_LIGHTNING,
ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_CONDITION_LIGHTNING,
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_POURING,
ATTR_CONDITION_RAINY,
ATTR_CONDITION_SNOWY,
ATTR_CONDITION_SNOWY_RAINY,
ATTR_CONDITION_SNOWY,
ATTR_CONDITION_SUNNY,
ATTR_CONDITION_WINDY,
ATTR_CONDITION_WINDY_VARIANT,
ATTR_CONDITION_EXCEPTIONAL,
}
ATTR_CONDITION_WINDY,
]
CONF_APPARENT_TEMPERATURE = "apparent_temperature"
CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template"
@@ -318,6 +316,75 @@ def async_create_preview_weather(
)
def validate_forecast(
entity: AbstractTemplateWeather,
option: str,
forecast_type: Literal["daily", "hourly", "twice_daily"],
) -> Callable[[Any], list[Forecast] | None]:
"""Validate a forecast."""
weather_message = (
"see Weather documentation https://www.home-assistant.io/integrations/weather/"
)
def validate(result: Any) -> list[Forecast] | None:
if template_validators.check_result_for_none(result):
return None
if not isinstance(result, list):
template_validators.log_validation_result_error(
entity,
option,
result,
f"expected a list, {weather_message}",
)
raised = False
for forecast in result:
if not isinstance(forecast, dict):
raised = True
template_validators.log_validation_result_error(
entity,
option,
result,
f"expected a list of forecast dictionaries, got {forecast}, {weather_message}",
)
continue
diff_result = set().union(forecast.keys()).difference(CHECK_FORECAST_KEYS)
if diff_result:
raised = True
template_validators.log_validation_result_error(
entity,
option,
result,
f"expected valid forecast keys, unallowed keys: ({diff_result}) for {forecast}, {weather_message}",
)
if forecast_type == "twice_daily" and "is_daytime" not in forecast:
raised = True
template_validators.log_validation_result_error(
entity,
option,
result,
f"`is_daytime` is missing in twice_daily forecast {forecast}, {weather_message}",
)
if "datetime" not in forecast:
raised = True
template_validators.log_validation_result_error(
entity,
option,
result,
f"`datetime` is missing in forecast, got {forecast}, {weather_message}",
)
if raised:
return None
return result
return validate
class AbstractTemplateWeather(AbstractTemplateEntity, WeatherEntity):
"""Representation of a template weathers features."""
@@ -327,28 +394,79 @@ class AbstractTemplateWeather(AbstractTemplateEntity, WeatherEntity):
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
def __init__( # pylint: disable=super-init-not-called
self, config: dict[str, Any], initial_state: bool | None = False
self, config: dict[str, Any]
) -> None:
"""Initialize the features."""
# Templates
self._apparent_temperature_template = config.get(CONF_APPARENT_TEMPERATURE)
self._attribution_template = config.get(CONF_ATTRIBUTION)
self._cloud_coverage_template = config.get(CONF_CLOUD_COVERAGE)
self._condition_template = config[CONF_CONDITION]
self._dew_point_template = config.get(CONF_DEW_POINT)
self._forecast_daily_template = config.get(CONF_FORECAST_DAILY)
self._forecast_hourly_template = config.get(CONF_FORECAST_HOURLY)
self._forecast_twice_daily_template = config.get(CONF_FORECAST_TWICE_DAILY)
self._humidity_template = config[CONF_HUMIDITY]
self._ozone_template = config.get(CONF_OZONE)
self._pressure_template = config.get(CONF_PRESSURE)
self._temperature_template = config[CONF_TEMPERATURE]
self._uv_index_template = config.get(CONF_UV_INDEX)
self._visibility_template = config.get(CONF_VISIBILITY)
self._wind_bearing_template = config.get(CONF_WIND_BEARING)
self._wind_gust_speed_template = config.get(CONF_WIND_GUST_SPEED)
self._wind_speed_template = config.get(CONF_WIND_SPEED)
# Required options
self.setup_template(
CONF_CONDITION,
"_attr_condition",
template_validators.item_in_list(self, CONF_CONDITION, CONDITION_CLASSES),
)
self.setup_template(
CONF_HUMIDITY,
"_attr_humidity",
template_validators.number(self, CONF_HUMIDITY, 0.0, 100.0),
)
self.setup_template(
CONF_TEMPERATURE,
"_attr_native_temperature",
template_validators.number(self, CONF_TEMPERATURE),
)
# Optional options
self.setup_template(
CONF_ATTRIBUTION,
"_attribution",
vol.Coerce(str),
)
self.setup_template(
CONF_WIND_BEARING, "_attr_wind_bearing", None, self._update_wind_bearing
)
# Optional numeric options
for option, attribute in (
(CONF_APPARENT_TEMPERATURE, "_attr_native_apparent_temperature"),
(CONF_CLOUD_COVERAGE, "_attr_cloud_coverage"),
(CONF_DEW_POINT, "_attr_native_dew_point"),
(CONF_OZONE, "_attr_ozone"),
(CONF_PRESSURE, "_attr_native_pressure"),
(CONF_UV_INDEX, "_attr_uv_index"),
(CONF_VISIBILITY, "_attr_native_visibility"),
(CONF_WIND_GUST_SPEED, "_attr_native_wind_gust_speed"),
(CONF_WIND_SPEED, "_attr_native_wind_speed"),
):
self.setup_template(
option, attribute, template_validators.number(self, option)
)
# Forecasts
self._forecast_daily: list[Forecast] | None = []
self.setup_template(
CONF_FORECAST_DAILY,
"_forecast_daily",
validate_forecast(self, CONF_FORECAST_DAILY, "daily"),
self._update_forecast("daily"),
)
self._forecast_hourly: list[Forecast] | None = []
self.setup_template(
CONF_FORECAST_HOURLY,
"_forecast_hourly",
validate_forecast(self, CONF_FORECAST_HOURLY, "hourly"),
self._update_forecast("hourly"),
)
self._forecast_twice_daily: list[Forecast] | None = []
self.setup_template(
CONF_FORECAST_TWICE_DAILY,
"_forecast_twice_daily",
validate_forecast(self, CONF_FORECAST_TWICE_DAILY, "twice_daily"),
self._update_forecast("twice_daily"),
)
# Legacy support
self._attribution: str | None = None
@@ -362,11 +480,11 @@ class AbstractTemplateWeather(AbstractTemplateEntity, WeatherEntity):
# Supported Features
self._attr_supported_features = 0
if self._forecast_daily_template:
if CONF_FORECAST_DAILY in self._templates:
self._attr_supported_features |= WeatherEntityFeature.FORECAST_DAILY
if self._forecast_hourly_template:
if CONF_FORECAST_HOURLY in self._templates:
self._attr_supported_features |= WeatherEntityFeature.FORECAST_HOURLY
if self._forecast_twice_daily_template:
if CONF_FORECAST_TWICE_DAILY in self._templates:
self._attr_supported_features |= WeatherEntityFeature.FORECAST_TWICE_DAILY
@property
@@ -376,62 +494,6 @@ class AbstractTemplateWeather(AbstractTemplateEntity, WeatherEntity):
return "Powered by Home Assistant"
return self._attribution
def _validate[T](
self,
validator: Callable[[Any], T],
result: Any,
) -> T | None:
try:
return validator(result)
except vol.Invalid:
return None
@callback
def _update_apparent_temperature(self, result: Any) -> None:
self._attr_native_apparent_temperature = self._validate(
vol.Coerce(float), result
)
@callback
def _update_attribution(self, result: Any) -> None:
self._attribution = vol.Coerce(str)(result)
@callback
def _update_condition(self, result: Any) -> None:
self._attr_condition = result if result in CONDITION_CLASSES else None
@callback
def _update_coverage(self, result: Any) -> None:
self._attr_cloud_coverage = self._validate(vol.Coerce(float), result)
@callback
def _update_dew_point(self, result: Any) -> None:
self._attr_native_dew_point = self._validate(vol.Coerce(float), result)
@callback
def _update_humidity(self, result: Any) -> None:
self._attr_humidity = self._validate(vol.Coerce(float), result)
@callback
def _update_ozone(self, result: Any) -> None:
self._attr_ozone = self._validate(vol.Coerce(float), result)
@callback
def _update_pressure(self, result: Any) -> None:
self._attr_native_pressure = self._validate(vol.Coerce(float), result)
@callback
def _update_temperature(self, result: Any) -> None:
self._attr_native_temperature = self._validate(vol.Coerce(float), result)
@callback
def _update_uv_index(self, result: Any) -> None:
self._attr_uv_index = self._validate(vol.Coerce(float), result)
@callback
def _update_visibility(self, result: Any) -> None:
self._attr_native_visibility = self._validate(vol.Coerce(float), result)
@callback
def _update_wind_bearing(self, result: Any) -> None:
try:
@@ -440,48 +502,31 @@ class AbstractTemplateWeather(AbstractTemplateEntity, WeatherEntity):
self._attr_wind_bearing = vol.Coerce(str)(result)
@callback
def _update_wind_gust_speed(self, result: Any) -> None:
self._attr_native_wind_gust_speed = self._validate(vol.Coerce(float), result)
@callback
def _update_wind_speed(self, result: Any) -> None:
self._attr_native_wind_speed = self._validate(vol.Coerce(float), result)
@callback
def _validate_forecast(
def _update_forecast(
self,
forecast_type: Literal["daily", "hourly", "twice_daily"],
result: Any,
) -> list[Forecast] | None:
"""Validate the forecasts."""
if result is None:
return None
) -> Callable[[list[Forecast] | None], None]:
"""Save template result and trigger forecast listener."""
if not isinstance(result, list):
raise vol.Invalid(
"Forecasts is not a list, see Weather documentation https://www.home-assistant.io/integrations/weather/"
def update(result: list[Forecast] | None) -> None:
setattr(self, f"_forecast_{forecast_type}", result)
self.hass.async_create_task(
self.async_update_listeners([forecast_type]), eager_start=True
)
for forecast in result:
if not isinstance(forecast, dict):
raise vol.Invalid(
"Forecast in list is not a dict, see Weather documentation https://www.home-assistant.io/integrations/weather/"
)
diff_result = set().union(forecast.keys()).difference(CHECK_FORECAST_KEYS)
if diff_result:
raise vol.Invalid(
f"Only valid keys in Forecast are allowed, unallowed keys: ({diff_result}), "
"see Weather documentation https://www.home-assistant.io/integrations/weather/"
)
if forecast_type == "twice_daily" and "is_daytime" not in forecast:
raise vol.Invalid(
"`is_daytime` is missing in twice_daily forecast, see Weather documentation https://www.home-assistant.io/integrations/weather/"
)
if "datetime" not in forecast:
raise vol.Invalid(
"`datetime` is required in forecasts, see Weather documentation https://www.home-assistant.io/integrations/weather/"
)
continue
return result
return update
async def async_forecast_daily(self) -> list[Forecast]:
"""Return the daily forecast in native units."""
return self._forecast_daily or []
async def async_forecast_hourly(self) -> list[Forecast]:
"""Return the daily forecast in native units."""
return self._forecast_hourly or []
async def async_forecast_twice_daily(self) -> list[Forecast]:
"""Return the daily forecast in native units."""
return self._forecast_twice_daily or []
class StateWeatherEntity(TemplateEntity, AbstractTemplateWeather):
@@ -499,152 +544,6 @@ class StateWeatherEntity(TemplateEntity, AbstractTemplateWeather):
TemplateEntity.__init__(self, hass, config, unique_id)
AbstractTemplateWeather.__init__(self, config)
name = self._attr_name
if TYPE_CHECKING:
assert name is not None
# Forecasts
self._forecast_daily: list[Forecast] | None = []
self._forecast_hourly: list[Forecast] | None = []
self._forecast_twice_daily: list[Forecast] | None = []
@callback
def _async_setup_templates(self) -> None:
"""Set up templates."""
if self._apparent_temperature_template:
self.add_template_attribute(
"_attr_native_apparent_temperature",
self._apparent_temperature_template,
on_update=self._update_apparent_temperature,
)
if self._attribution_template:
self.add_template_attribute(
"_attribution",
self._attribution_template,
on_update=self._update_attribution,
)
if self._cloud_coverage_template:
self.add_template_attribute(
"_attr_cloud_coverage",
self._cloud_coverage_template,
on_update=self._update_coverage,
)
if self._condition_template:
self.add_template_attribute(
"_attr_condition",
self._condition_template,
on_update=self._update_condition,
)
if self._dew_point_template:
self.add_template_attribute(
"_attr_native_dew_point",
self._dew_point_template,
on_update=self._update_dew_point,
)
if self._forecast_daily_template:
self.add_template_attribute(
"_forecast_daily",
self._forecast_daily_template,
on_update=partial(self._update_forecast, "daily"),
validator=partial(self._validate_forecast, "daily"),
)
if self._forecast_hourly_template:
self.add_template_attribute(
"_forecast_hourly",
self._forecast_hourly_template,
on_update=partial(self._update_forecast, "hourly"),
validator=partial(self._validate_forecast, "hourly"),
)
if self._forecast_twice_daily_template:
self.add_template_attribute(
"_forecast_twice_daily",
self._forecast_twice_daily_template,
on_update=partial(self._update_forecast, "twice_daily"),
validator=partial(self._validate_forecast, "twice_daily"),
)
if self._humidity_template:
self.add_template_attribute(
"_attr_humidity",
self._humidity_template,
on_update=self._update_humidity,
)
if self._ozone_template:
self.add_template_attribute(
"_attr_ozone",
self._ozone_template,
on_update=self._update_ozone,
)
if self._pressure_template:
self.add_template_attribute(
"_attr_native_pressure",
self._pressure_template,
on_update=self._update_pressure,
)
if self._temperature_template:
self.add_template_attribute(
"_attr_native_temperature",
self._temperature_template,
on_update=self._update_temperature,
)
if self._uv_index_template:
self.add_template_attribute(
"_attr_uv_index",
self._uv_index_template,
on_update=self._update_uv_index,
)
if self._visibility_template:
self.add_template_attribute(
"_attr_native_visibility",
self._visibility_template,
on_update=self._update_visibility,
)
if self._wind_bearing_template:
self.add_template_attribute(
"_attr_wind_bearing",
self._wind_bearing_template,
on_update=self._update_wind_bearing,
)
if self._wind_gust_speed_template:
self.add_template_attribute(
"_attr_native_wind_gust_speed",
self._wind_gust_speed_template,
on_update=self._update_wind_gust_speed,
)
if self._wind_speed_template:
self.add_template_attribute(
"_attr_native_wind_speed",
self._wind_speed_template,
on_update=self._update_wind_speed,
)
super()._async_setup_templates()
async def async_forecast_daily(self) -> list[Forecast]:
"""Return the daily forecast in native units."""
return self._forecast_daily or []
async def async_forecast_hourly(self) -> list[Forecast]:
"""Return the daily forecast in native units."""
return self._forecast_hourly or []
async def async_forecast_twice_daily(self) -> list[Forecast]:
"""Return the daily forecast in native units."""
return self._forecast_twice_daily or []
@callback
def _update_forecast(
self,
forecast_type: Literal["daily", "hourly", "twice_daily"],
result: list[Forecast] | TemplateError,
) -> None:
"""Save template result and trigger forecast listener."""
attr_result = None if isinstance(result, TemplateError) else result
setattr(self, f"_forecast_{forecast_type}", attr_result)
self.hass.async_create_task(
self.async_update_listeners([forecast_type]), eager_start=True
)
@dataclass(kw_only=True)
class WeatherExtraStoredData(ExtraStoredData):
@@ -711,11 +610,6 @@ class TriggerWeatherEntity(TriggerEntity, AbstractTemplateWeather, RestoreEntity
"""Weather entity based on trigger data."""
domain = WEATHER_DOMAIN
extra_template_keys = (
CONF_CONDITION,
CONF_TEMPERATURE,
CONF_HUMIDITY,
)
def __init__(
self,
@@ -725,27 +619,7 @@ class TriggerWeatherEntity(TriggerEntity, AbstractTemplateWeather, RestoreEntity
) -> None:
"""Initialize."""
TriggerEntity.__init__(self, hass, coordinator, config)
AbstractTemplateWeather.__init__(self, config, None)
for key in (
CONF_APPARENT_TEMPERATURE,
CONF_ATTRIBUTION,
CONF_CLOUD_COVERAGE,
CONF_DEW_POINT,
CONF_FORECAST_DAILY,
CONF_FORECAST_HOURLY,
CONF_FORECAST_TWICE_DAILY,
CONF_OZONE,
CONF_PRESSURE,
CONF_UV_INDEX,
CONF_VISIBILITY,
CONF_WIND_BEARING,
CONF_WIND_GUST_SPEED,
CONF_WIND_SPEED,
):
if isinstance(config.get(key), template.Template):
self._to_render_simple.append(key)
self._parse_result.add(key)
AbstractTemplateWeather.__init__(self, config)
async def async_added_to_hass(self) -> None:
"""Restore last state."""
@@ -772,72 +646,6 @@ class TriggerWeatherEntity(TriggerEntity, AbstractTemplateWeather, RestoreEntity
self._attr_native_wind_gust_speed = weather_data.last_wind_gust_speed
self._attr_native_wind_speed = weather_data.last_wind_speed
@callback
def _handle_coordinator_update(self) -> None:
"""Handle update of the data."""
self._process_data()
if not self.available:
return
write_ha_state = False
for key, updater in (
(CONF_APPARENT_TEMPERATURE, self._update_apparent_temperature),
(CONF_ATTRIBUTION, self._update_attribution),
(CONF_CLOUD_COVERAGE, self._update_coverage),
(CONF_CONDITION, self._update_condition),
(CONF_DEW_POINT, self._update_dew_point),
(CONF_HUMIDITY, self._update_humidity),
(CONF_OZONE, self._update_ozone),
(CONF_PRESSURE, self._update_pressure),
(CONF_TEMPERATURE, self._update_temperature),
(CONF_UV_INDEX, self._update_uv_index),
(CONF_VISIBILITY, self._update_visibility),
(CONF_WIND_BEARING, self._update_wind_bearing),
(CONF_WIND_GUST_SPEED, self._update_wind_gust_speed),
(CONF_WIND_SPEED, self._update_wind_speed),
):
if (rendered := self._rendered.get(key)) is not None:
updater(rendered)
write_ha_state = True
if write_ha_state:
self.async_write_ha_state()
def _check_forecast(
self,
forecast_type: Literal["daily", "hourly", "twice_daily"],
key: str,
) -> list[Forecast]:
result = self._rendered.get(key)
try:
return self._validate_forecast(forecast_type, result) or []
except vol.Invalid as err:
_LOGGER.error(
(
"Error validating template result '%s' "
"for attribute '%s' in entity %s "
"validation message '%s'"
),
result,
key,
self.entity_id,
err.msg,
)
return []
async def async_forecast_daily(self) -> list[Forecast]:
"""Return the daily forecast in native units."""
return self._check_forecast("daily", CONF_FORECAST_DAILY)
async def async_forecast_hourly(self) -> list[Forecast]:
"""Return the daily forecast in native units."""
return self._check_forecast("hourly", CONF_FORECAST_HOURLY)
async def async_forecast_twice_daily(self) -> list[Forecast]:
"""Return the daily forecast in native units."""
return self._check_forecast("twice_daily", CONF_FORECAST_TWICE_DAILY)
@property
def extra_restore_state_data(self) -> WeatherExtraStoredData:
"""Return weather specific state data to be restored."""

View File

@@ -469,7 +469,7 @@ async def test_forecasts_invalid(
return_response=True,
)
assert response == expected
assert "Only valid keys in Forecast are allowed" in caplog.text
assert "expected valid forecast keys, unallowed keys:" in caplog.text
# Test twice daily missing is_daytime
hass.states.async_set(
@@ -521,7 +521,7 @@ async def test_forecasts_invalid(
return_response=True,
)
assert response == expected
assert "`datetime` is required in forecasts" in caplog.text
assert "`datetime` is missing in forecast" in caplog.text
@pytest.mark.parametrize(
@@ -612,7 +612,7 @@ async def test_forecast_format_error(
blocking=True,
return_response=True,
)
assert "Forecast in list is not a dict, see Weather documentation" in caplog.text
assert "expected a list of forecast dictionaries, got" in caplog.text
await hass.services.async_call(
WEATHER_DOMAIN,
SERVICE_GET_FORECASTS,
@@ -620,7 +620,7 @@ async def test_forecast_format_error(
blocking=True,
return_response=True,
)
assert "Forecasts is not a list, see Weather documentation" in caplog.text
assert "expected a list, " in caplog.text
SAVED_EXTRA_DATA = {