diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index d73d00ec9df..05a2e725e4a 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import abc +import asyncio from collections.abc import Callable, Iterable from contextlib import suppress from dataclasses import dataclass @@ -47,6 +48,8 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import 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, @@ -318,6 +321,9 @@ class WeatherEntity(Entity, PostInit): Literal["daily", "hourly", "twice_daily"], list[Callable[[list[JsonValueType] | None], None]], ] + __weather_legacy_forecast: bool = False + __weather_legacy_forecast_reported: bool = False + __report_issue: str _weather_option_temperature_unit: str | None = None _weather_option_pressure_unit: str | None = None @@ -381,6 +387,59 @@ class WeatherEntity(Entity, PostInit): cls.__name__, report_issue, ) + if any( + method in cls.__dict__ for method in ("_attr_forecast", "forecast") + ) and not any( + method in cls.__dict__ + for method in ( + "async_forecast_daily", + "async_forecast_hourly", + "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) + _reported_forecast = False + if self.__weather_legacy_forecast and not _reported_forecast: + module = inspect.getmodule(self) + if module and module.__file__ and "custom_components" in module.__file__: + # Do not report on core integrations as they are already fixed or PR is open. + report_issue = "report it to the custom integration author." + _LOGGER.warning( + ( + "%s::%s is using a forecast attribute on an instance of " + "WeatherEntity, this is deprecated and will be unsupported " + "from Home Assistant 2024.3. Please %s" + ), + self.__module__, + self.entity_id, + report_issue, + ) + 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="deprecated_weather_forecast", + translation_placeholders={ + "platform": self.platform.platform_name, + "report_issue": report_issue, + }, + ) + _reported_forecast = True async def async_internal_added_to_hass(self) -> None: """Call when the weather entity is added to hass.""" diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 5f08013684c..26388c217eb 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -98,5 +98,11 @@ } } } + }, + "issues": { + "deprecated_weather_forecast": { + "title": "The {platform} integration is using deprecated forecast", + "description": "The integration `{platform}` is using the deprecated forecast attribute.\n\nPlease {report_issue}." + } } } diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index d8636330b5e..feef335bec9 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -58,6 +58,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( @@ -71,6 +72,9 @@ 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.testing_config.custom_components.test_weather import ( + weather as NewWeatherPlatform, +) from tests.typing import WebSocketGenerator @@ -1225,3 +1229,88 @@ async def test_get_forecast_unsupported( blocking=True, return_response=True, ) + + +async def test_issue_forecast_deprecated( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the issue is raised on deprecated forecast attributes.""" + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + } + platform: WeatherPlatform = getattr(hass.components, "test.weather") + caplog.clear() + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockLegacyForecastOnly( + name="Testing", + entity_id="weather.testing", + condition=ATTR_CONDITION_SUNNY, + **kwargs, + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test", "name": "testing"}} + ) + await hass.async_block_till_done() + + assert entity0.state == ATTR_CONDITION_SUNNY + + issues = ir.async_get(hass) + issue = issues.async_get_issue("weather", "deprecated_weather_forecast_test") + assert issue + assert issue.issue_domain == "test" + assert issue.issue_id == "deprecated_weather_forecast_test" + assert issue.translation_placeholders == { + "platform": "test", + "report_issue": "report it to the custom integration author.", + } + + assert ( + "custom_components.test.weather::weather.testing is using a forecast attribute on an instance of WeatherEntity" + in caplog.text + ) + + +async def test_issue_forecast_deprecated_no_logging( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the no issue is raised on deprecated forecast attributes if new methods exist.""" + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + } + platform: NewWeatherPlatform = getattr(hass.components, "test_weather.weather") + caplog.clear() + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockForecast( + name="Test", + entity_id="weather.test", + condition=ATTR_CONDITION_SUNNY, + **kwargs, + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test_weather", "name": "test"}} + ) + await hass.async_block_till_done() + + assert entity0.state == ATTR_CONDITION_SUNNY + + assert "Setting up weather.test_weather" in caplog.text + assert ( + "custom_components.test_weather.weather::weather.test is using a forecast attribute on an instance of WeatherEntity" + not in caplog.text + ) diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index e2d026ec840..405b7b7d822 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -298,6 +298,37 @@ class MockWeatherMockForecast(MockWeather): ] +class MockWeatherMockLegacyForecastOnly(MockWeather): + """Mock weather class with mocked legacy 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 + + class MockWeatherMockForecastCompat(MockWeatherCompat): """Mock weather class with mocked forecast for compatibility check.""" diff --git a/tests/testing_config/custom_components/test_weather/__init__.py b/tests/testing_config/custom_components/test_weather/__init__.py new file mode 100644 index 00000000000..ddec081ed8b --- /dev/null +++ b/tests/testing_config/custom_components/test_weather/__init__.py @@ -0,0 +1 @@ +"""An integration with Weather platform.""" diff --git a/tests/testing_config/custom_components/test_weather/manifest.json b/tests/testing_config/custom_components/test_weather/manifest.json new file mode 100644 index 00000000000..d1238659b41 --- /dev/null +++ b/tests/testing_config/custom_components/test_weather/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "test_weather", + "name": "Test Weather", + "documentation": "http://example.com", + "requirements": [], + "dependencies": [], + "codeowners": [], + "version": "1.2.3" +} diff --git a/tests/testing_config/custom_components/test_weather/weather.py b/tests/testing_config/custom_components/test_weather/weather.py new file mode 100644 index 00000000000..68d9ccab712 --- /dev/null +++ b/tests/testing_config/custom_components/test_weather/weather.py @@ -0,0 +1,210 @@ +"""Provide a mock weather platform. + +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, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_UV_INDEX, + ATTR_FORECAST_WIND_BEARING, + Forecast, + WeatherEntity, +) + +from tests.common import MockEntity + +ENTITIES = [] + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + ENTITIES = [] if empty else [MockWeatherMockForecast()] + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) + + +class MockWeatherMockForecast(MockEntity, WeatherEntity): + """Mock weather class.""" + + 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, + 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 native_temperature(self) -> float | None: + """Return the platform temperature.""" + return self._handle("native_temperature") + + @property + def native_apparent_temperature(self) -> float | None: + """Return the platform apparent temperature.""" + return self._handle("native_apparent_temperature") + + @property + def native_dew_point(self) -> float | None: + """Return the platform dewpoint temperature.""" + return self._handle("native_dew_point") + + @property + def native_temperature_unit(self) -> str | None: + """Return the unit of measurement for temperature.""" + return self._handle("native_temperature_unit") + + @property + def native_pressure(self) -> float | None: + """Return the pressure.""" + return self._handle("native_pressure") + + @property + def native_pressure_unit(self) -> str | None: + """Return the unit of measurement for pressure.""" + return self._handle("native_pressure_unit") + + @property + def humidity(self) -> float | None: + """Return the humidity.""" + return self._handle("humidity") + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind speed.""" + return self._handle("native_wind_gust_speed") + + @property + def native_wind_speed(self) -> float | None: + """Return the wind speed.""" + return self._handle("native_wind_speed") + + @property + def native_wind_speed_unit(self) -> str | None: + """Return the unit of measurement for wind speed.""" + return self._handle("native_wind_speed_unit") + + @property + def wind_bearing(self) -> float | str | None: + """Return the wind bearing.""" + return self._handle("wind_bearing") + + @property + def ozone(self) -> float | None: + """Return the ozone level.""" + return self._handle("ozone") + + @property + def cloud_coverage(self) -> float | None: + """Return the cloud coverage in %.""" + return self._handle("cloud_coverage") + + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return self._handle("uv_index") + + @property + def native_visibility(self) -> float | None: + """Return the visibility.""" + return self._handle("native_visibility") + + @property + def native_visibility_unit(self) -> str | None: + """Return the unit of measurement for visibility.""" + return self._handle("native_visibility_unit") + + @property + def native_precipitation_unit(self) -> str | None: + """Return the native unit of measurement for accumulated precipitation.""" + return self._handle("native_precipitation_unit") + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return self._handle("condition")