diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index 56d94409db5..6ce1a49e6f1 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -8,7 +8,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) -from homeassistant.const import LENGTH_KILOMETERS, TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -117,11 +117,6 @@ class MetOfficeWeather(CoordinatorEntity, WeatherEntity): _visibility = f"{visibility_class} - {visibility_distance}" return _visibility - @property - def visibility_unit(self): - """Return the unit of measurement.""" - return LENGTH_KILOMETERS - @property def pressure(self): """Return the mean sea-level pressure.""" diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 81d245c19bb..b9fa7e2ae39 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -61,6 +61,8 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}" SCAN_INTERVAL = timedelta(seconds=30) +ROUNDING_PRECISION = 2 + class Forecast(TypedDict, total=False): """Typed weather forecast dict.""" @@ -112,38 +114,52 @@ class WeatherEntity(Entity): _attr_ozone: float | None = None _attr_precision: float _attr_pressure: float | None = None + _attr_pressure_unit: str | None = None _attr_state: None = None _attr_temperature_unit: str _attr_temperature: float | None _attr_visibility: float | None = None + _attr_visibility_unit: str | None = None + _attr_precipitation_unit: str | None = None _attr_wind_bearing: float | str | None = None _attr_wind_speed: float | None = None + _attr_wind_speed_unit: str | None = None @property def temperature(self) -> float | None: - """Return the platform temperature.""" + """Return the platform temperature in native units (i.e. not converted).""" return self._attr_temperature @property def temperature_unit(self) -> str: - """Return the unit of measurement.""" + """Return the native unit of measurement for temperature.""" return self._attr_temperature_unit @property def pressure(self) -> float | None: - """Return the pressure.""" + """Return the pressure in native units.""" return self._attr_pressure + @property + def pressure_unit(self) -> str | None: + """Return the native unit of measurement for pressure.""" + return self._attr_pressure_unit + @property def humidity(self) -> float | None: - """Return the humidity.""" + """Return the humidity in native units.""" return self._attr_humidity @property def wind_speed(self) -> float | None: - """Return the wind speed.""" + """Return the wind speed in native units.""" return self._attr_wind_speed + @property + def wind_speed_unit(self) -> str | None: + """Return the native unit of measurement for wind speed.""" + return self._attr_wind_speed_unit + @property def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" @@ -156,17 +172,27 @@ class WeatherEntity(Entity): @property def visibility(self) -> float | None: - """Return the visibility.""" + """Return the visibility in native units.""" return self._attr_visibility + @property + def visibility_unit(self) -> str | None: + """Return the native unit of measurement for visibility.""" + return self._attr_visibility_unit + @property def forecast(self) -> list[Forecast] | None: - """Return the forecast.""" + """Return the forecast in native units.""" return self._attr_forecast + @property + def precipitation_unit(self) -> str | None: + """Return the native unit of measurement for accumulated precipitation.""" + return self._attr_precipitation_unit + @property def precision(self) -> float: - """Return the precision of the temperature value.""" + """Return the precision of the temperature value, after unit conversion.""" if hasattr(self, "_attr_precision"): return self._attr_precision return ( @@ -178,11 +204,14 @@ class WeatherEntity(Entity): @final @property def state_attributes(self): - """Return the state attributes.""" + """Return the state attributes, converted from native units to user-configured units.""" data = {} if self.temperature is not None: data[ATTR_WEATHER_TEMPERATURE] = show_temp( - self.hass, self.temperature, self.temperature_unit, self.precision + self.hass, + self.temperature, + self.temperature_unit, + self.precision, ) if (humidity := self.humidity) is not None: @@ -192,15 +221,28 @@ class WeatherEntity(Entity): data[ATTR_WEATHER_OZONE] = ozone if (pressure := self.pressure) is not None: + if (unit := self.pressure_unit) is not None: + pressure = round( + self.hass.config.units.pressure(pressure, unit), ROUNDING_PRECISION + ) data[ATTR_WEATHER_PRESSURE] = pressure if (wind_bearing := self.wind_bearing) is not None: data[ATTR_WEATHER_WIND_BEARING] = wind_bearing if (wind_speed := self.wind_speed) is not None: + if (unit := self.wind_speed_unit) is not None: + wind_speed = round( + self.hass.config.units.wind_speed(wind_speed, unit), + ROUNDING_PRECISION, + ) data[ATTR_WEATHER_WIND_SPEED] = wind_speed if (visibility := self.visibility) is not None: + if (unit := self.visibility_unit) is not None: + visibility = round( + self.hass.config.units.length(visibility, unit), ROUNDING_PRECISION + ) data[ATTR_WEATHER_VISIBILITY] = visibility if self.forecast is not None: @@ -220,6 +262,34 @@ class WeatherEntity(Entity): self.temperature_unit, self.precision, ) + if ATTR_FORECAST_PRESSURE in forecast_entry: + if (unit := self.pressure_unit) is not None: + pressure = round( + self.hass.config.units.pressure( + forecast_entry[ATTR_FORECAST_PRESSURE], unit + ), + ROUNDING_PRECISION, + ) + forecast_entry[ATTR_FORECAST_PRESSURE] = pressure + if ATTR_FORECAST_WIND_SPEED in forecast_entry: + if (unit := self.wind_speed_unit) is not None: + wind_speed = round( + self.hass.config.units.wind_speed( + forecast_entry[ATTR_FORECAST_WIND_SPEED], unit + ), + ROUNDING_PRECISION, + ) + forecast_entry[ATTR_FORECAST_WIND_SPEED] = wind_speed + if ATTR_FORECAST_PRECIPITATION in forecast_entry: + if (unit := self.precipitation_unit) is not None: + precipitation = round( + self.hass.config.units.accumulated_precipitation( + forecast_entry[ATTR_FORECAST_PRECIPITATION], unit + ), + ROUNDING_PRECISION, + ) + forecast_entry[ATTR_FORECAST_PRECIPITATION] = precipitation + forecast.append(forecast_entry) data[ATTR_FORECAST] = forecast diff --git a/tests/components/weather/test_weather.py b/tests/components/demo/test_weather.py similarity index 98% rename from tests/components/weather/test_weather.py rename to tests/components/demo/test_weather.py index 3057532668a..c4ae8fcd79c 100644 --- a/tests/components/weather/test_weather.py +++ b/tests/components/demo/test_weather.py @@ -1,4 +1,4 @@ -"""The tests for the Weather component.""" +"""The tests for the demo weather component.""" from homeassistant.components import weather from homeassistant.components.weather import ( ATTR_FORECAST, diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py new file mode 100644 index 00000000000..4125e94749a --- /dev/null +++ b/tests/components/weather/test_init.py @@ -0,0 +1,170 @@ +"""The test for weather entity.""" +import pytest +from pytest import approx + +from homeassistant.components.weather import ( + ATTR_CONDITION_SUNNY, + ATTR_FORECAST, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRESSURE, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_WIND_SPEED, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_SPEED, +) +from homeassistant.const import ( + LENGTH_MILES, + LENGTH_MILLIMETERS, + PRESSURE_INHG, + SPEED_METERS_PER_SECOND, + TEMP_FAHRENHEIT, +) +from homeassistant.setup import async_setup_component +from homeassistant.util.distance import convert as convert_distance +from homeassistant.util.pressure import convert as convert_pressure +from homeassistant.util.speed import convert as convert_speed +from homeassistant.util.temperature import convert as convert_temperature +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM + + +async def create_entity(hass, **kwargs): + """Create the weather entity to run tests on.""" + kwargs = {"temperature": None, "temperature_unit": None, **kwargs} + platform = 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("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) +async def test_temperature_conversion( + hass, + enable_custom_integrations, + unit_system, +): + """Test temperature conversion.""" + hass.config.units = unit_system + native_value = 38 + native_unit = TEMP_FAHRENHEIT + + entity0 = await create_entity( + hass, temperature=native_value, temperature_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = convert_temperature( + native_value, native_unit, unit_system.temperature_unit + ) + assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == approx( + expected, rel=0.1 + ) + assert float(forecast[ATTR_FORECAST_TEMP]) == approx(expected, rel=0.1) + assert float(forecast[ATTR_FORECAST_TEMP_LOW]) == approx(expected, rel=0.1) + + +@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) +async def test_pressure_conversion( + hass, + enable_custom_integrations, + unit_system, +): + """Test pressure conversion.""" + hass.config.units = unit_system + native_value = 30 + native_unit = PRESSURE_INHG + + entity0 = await create_entity( + hass, pressure=native_value, pressure_unit=native_unit + ) + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = convert_pressure(native_value, native_unit, unit_system.pressure_unit) + assert float(state.attributes[ATTR_WEATHER_PRESSURE]) == approx(expected, rel=1e-2) + assert float(forecast[ATTR_FORECAST_PRESSURE]) == approx(expected, rel=1e-2) + + +@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) +async def test_wind_speed_conversion( + hass, + enable_custom_integrations, + unit_system, +): + """Test wind speed conversion.""" + hass.config.units = unit_system + native_value = 10 + native_unit = SPEED_METERS_PER_SECOND + + entity0 = await create_entity( + hass, wind_speed=native_value, wind_speed_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = convert_speed(native_value, native_unit, unit_system.wind_speed_unit) + assert float(state.attributes[ATTR_WEATHER_WIND_SPEED]) == approx( + expected, rel=1e-2 + ) + assert float(forecast[ATTR_FORECAST_WIND_SPEED]) == approx(expected, rel=1e-2) + + +@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) +async def test_visibility_conversion( + hass, + enable_custom_integrations, + unit_system, +): + """Test visibility conversion.""" + hass.config.units = unit_system + native_value = 10 + native_unit = LENGTH_MILES + + entity0 = await create_entity( + hass, visibility=native_value, visibility_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + expected = convert_distance(native_value, native_unit, unit_system.length_unit) + assert float(state.attributes[ATTR_WEATHER_VISIBILITY]) == approx( + expected, rel=1e-2 + ) + + +@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) +async def test_precipitation_conversion( + hass, + enable_custom_integrations, + unit_system, +): + """Test precipitation conversion.""" + hass.config.units = unit_system + native_value = 30 + native_unit = LENGTH_MILLIMETERS + + entity0 = await create_entity( + hass, precipitation=native_value, precipitation_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = convert_distance( + native_value, native_unit, unit_system.accumulated_precipitation_unit + ) + assert float(forecast[ATTR_FORECAST_PRECIPITATION]) == approx(expected, rel=1e-2) diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py new file mode 100644 index 00000000000..224d6495548 --- /dev/null +++ b/tests/testing_config/custom_components/test/weather.py @@ -0,0 +1,126 @@ +""" +Provide a mock weather platform. + +Call init before using it in your tests to ensure clean test data. +""" +from __future__ import annotations + +from homeassistant.components.weather import ( + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRESSURE, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + Forecast, + WeatherEntity, +) + +from tests.common import MockEntity + +ENTITIES = [] + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + ENTITIES = [] if empty else [MockWeather()] + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) + + +class MockWeather(MockEntity, WeatherEntity): + """Mock weather class.""" + + @property + def temperature(self) -> float | None: + """Return the platform temperature.""" + return self._handle("temperature") + + @property + def temperature_unit(self) -> str | None: + """Return the unit of measurement for temperature.""" + return self._handle("temperature_unit") + + @property + def pressure(self) -> float | None: + """Return the pressure.""" + return self._handle("pressure") + + @property + def pressure_unit(self) -> str | None: + """Return the unit of measurement for pressure.""" + return self._handle("pressure_unit") + + @property + def humidity(self) -> float | None: + """Return the humidity.""" + return self._handle("humidity") + + @property + def wind_speed(self) -> float | None: + """Return the wind speed.""" + return self._handle("wind_speed") + + @property + def wind_speed_unit(self) -> str | None: + """Return the unit of measurement for wind speed.""" + return self._handle("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 visibility(self) -> float | None: + """Return the visibility.""" + return self._handle("visibility") + + @property + def visibility_unit(self) -> str | None: + """Return the unit of measurement for visibility.""" + return self._handle("visibility_unit") + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self._handle("forecast") + + @property + def precipitation_unit(self) -> str | None: + """Return the native unit of measurement for accumulated precipitation.""" + return self._handle("precipitation_unit") + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return self._handle("condition") + + +class MockWeatherMockForecast(MockWeather): + """Mock weather class with mocked forecast.""" + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return [ + { + ATTR_FORECAST_TEMP: self.temperature, + ATTR_FORECAST_TEMP_LOW: self.temperature, + ATTR_FORECAST_PRESSURE: self.pressure, + ATTR_FORECAST_WIND_SPEED: self.wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_PRECIPITATION: self._values.get("precipitation"), + } + ]