Add native unit types for weather entities (#59533)

* Add native unit types for weather entities

* Update weatherentity and change precision in climacell test

* Move weather test to demo tests

* Add weather test for temperature conversion

* Add more unit conversion tests

* Remove extra native_ methods

* Remove extra properties and save precision change for another PR

* Remove visibility_unit from metoffice component

The vibility values given by metoffice are formatted into strings,
which means they can't automatically be converted.

* Improve docstrings and convert pressures in forecast

* Add precipitation and wind speed units

* Clean up tests

* Round converted weather values

* Round weather values to 2 decimal places

* Move number of rounding decimal places to constant

* Docstring and styles
This commit is contained in:
rianadon 2021-11-29 05:44:44 -08:00 committed by GitHub
parent 5a97db6685
commit 09af85c6a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 378 additions and 17 deletions

View File

@ -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."""

View File

@ -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

View File

@ -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,

View File

@ -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)

View File

@ -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"),
}
]