diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 11e10f0d7f9..8361869c18a 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -34,7 +34,6 @@ from homeassistant.const import ( PRESSURE_INHG, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, - TEMP_CELSIUS, TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant @@ -42,7 +41,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from homeassistant.util.distance import convert as distance_convert from homeassistant.util.pressure import convert as pressure_convert -from homeassistant.util.temperature import convert as temp_convert from . import TomorrowioDataUpdateCoordinator, TomorrowioEntity from .const import ( @@ -81,48 +79,61 @@ class TomorrowioSensorEntityDescription(SensorEntityDescription): unit_imperial: str | None = None unit_metric: str | None = None - metric_conversion: Callable[[float], float] | float = 1.0 - is_metric_check: bool | None = None + multiplication_factor: Callable[[float], float] | float | None = None + metric_conversion: Callable[[float], float] | float | None = None value_map: Any | None = None + def __post_init__(self) -> None: + """Handle post init.""" + if (self.unit_imperial is None and self.unit_metric is not None) or ( + self.unit_imperial is not None and self.unit_metric is None + ): + raise ValueError( + "Entity descriptions must include both imperial and metric units or " + "they must both be None" + ) + + +# From https://cfpub.epa.gov/ncer_abstracts/index.cfm/fuseaction/display.files/fileID/14285 +# x ug/m^3 = y ppb * molecular weight / 24.45 +def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], float]: + """Return function to convert ppb to ug/m^3.""" + return lambda x: (x * molecular_weight) / 24.45 + SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_FEELS_LIKE, name="Feels Like", - unit_imperial=TEMP_FAHRENHEIT, - unit_metric=TEMP_CELSIUS, - metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), - is_metric_check=True, + native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_DEW_POINT, name="Dew Point", - unit_imperial=TEMP_FAHRENHEIT, - unit_metric=TEMP_CELSIUS, - metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), - is_metric_check=True, + native_unit_of_measurement=TEMP_FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, ), + # Data comes in as inHg TomorrowioSensorEntityDescription( key=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, name="Pressure (Surface Level)", - unit_metric=PRESSURE_HPA, - metric_conversion=lambda val: pressure_convert( + native_unit_of_measurement=PRESSURE_HPA, + multiplication_factor=lambda val: pressure_convert( val, PRESSURE_INHG, PRESSURE_HPA ), - is_metric_check=True, device_class=SensorDeviceClass.PRESSURE, ), + # Data comes in as BTUs/(hr * ft^2) + # https://www.theunitconverter.com/watt-square-meter-to-btu-hour-square-foot-conversion/ TomorrowioSensorEntityDescription( key=TMRW_ATTR_SOLAR_GHI, name="Global Horizontal Irradiance", unit_imperial=IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, unit_metric=IRRADIATION_WATTS_PER_SQUARE_METER, metric_conversion=3.15459, - is_metric_check=True, ), + # Data comes in as miles TomorrowioSensorEntityDescription( key=TMRW_ATTR_CLOUD_BASE, name="Cloud Base", @@ -131,8 +142,8 @@ SENSOR_TYPES = ( metric_conversion=lambda val: distance_convert( val, LENGTH_MILES, LENGTH_KILOMETERS ), - is_metric_check=True, ), + # Data comes in as miles TomorrowioSensorEntityDescription( key=TMRW_ATTR_CLOUD_CEILING, name="Cloud Ceiling", @@ -141,14 +152,13 @@ SENSOR_TYPES = ( metric_conversion=lambda val: distance_convert( val, LENGTH_MILES, LENGTH_KILOMETERS ), - is_metric_check=True, ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_CLOUD_COVER, name="Cloud Cover", - unit_imperial=PERCENTAGE, - unit_metric=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, ), + # Data comes in as MPH TomorrowioSensorEntityDescription( key=TMRW_ATTR_WIND_GUST, name="Wind Gust", @@ -156,7 +166,6 @@ SENSOR_TYPES = ( unit_metric=SPEED_METERS_PER_SECOND, metric_conversion=lambda val: distance_convert(val, LENGTH_MILES, LENGTH_METERS) / 3600, - is_metric_check=True, ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_PRECIPITATION_TYPE, @@ -165,51 +174,54 @@ SENSOR_TYPES = ( device_class="tomorrowio__precipitation_type", icon="mdi:weather-snowy-rainy", ), + # Data comes in as ppb + # Molecular weight of Ozone is 48 TomorrowioSensorEntityDescription( key=TMRW_ATTR_OZONE, name="Ozone", - unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - metric_conversion=2.03, - is_metric_check=True, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + multiplication_factor=convert_ppb_to_ugm3(48), device_class=SensorDeviceClass.OZONE, ), + # Data comes in as ug/ft^3 TomorrowioSensorEntityDescription( key=TMRW_ATTR_PARTICULATE_MATTER_25, name="Particulate Matter < 2.5 μm", - unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - metric_conversion=3.2808399**3, - is_metric_check=True, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + multiplication_factor=3.2808399**3, device_class=SensorDeviceClass.PM25, ), + # Data comes in as ug/ft^3 TomorrowioSensorEntityDescription( key=TMRW_ATTR_PARTICULATE_MATTER_10, name="Particulate Matter < 10 μm", - unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - metric_conversion=3.2808399**3, - is_metric_check=True, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + multiplication_factor=3.2808399**3, device_class=SensorDeviceClass.PM10, ), + # Data comes in as ppb + # Molecular weight of Nitrogen Dioxide is 46.01 TomorrowioSensorEntityDescription( key=TMRW_ATTR_NITROGEN_DIOXIDE, name="Nitrogen Dioxide", - unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - metric_conversion=1.95, - is_metric_check=True, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + multiplication_factor=convert_ppb_to_ugm3(46.01), device_class=SensorDeviceClass.NITROGEN_DIOXIDE, ), + # Data comes in as ppb TomorrowioSensorEntityDescription( key=TMRW_ATTR_CARBON_MONOXIDE, name="Carbon Monoxide", - unit_imperial=CONCENTRATION_PARTS_PER_MILLION, - unit_metric=CONCENTRATION_PARTS_PER_MILLION, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + multiplication_factor=1 / 1000, device_class=SensorDeviceClass.CO, ), + # Molecular weight of Sulphur Dioxide is 64.07 TomorrowioSensorEntityDescription( key=TMRW_ATTR_SULPHUR_DIOXIDE, name="Sulphur Dioxide", - unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - metric_conversion=2.71, - is_metric_check=True, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + multiplication_factor=convert_ppb_to_ugm3(64.07), device_class=SensorDeviceClass.SULPHUR_DIOXIDE, ), TomorrowioSensorEntityDescription( @@ -289,6 +301,16 @@ async def async_setup_entry( async_add_entities(entities) +def handle_conversion( + value: float | int, conversion: Callable[[float], float] | float +) -> float: + """Handle conversion of a value based on conversion type.""" + if callable(conversion): + return round(conversion(float(value)), 2) + + return round(float(value) * conversion, 2) + + class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): """Base Tomorrow.io sensor entity.""" @@ -311,47 +333,42 @@ class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): f"{self._config_entry.unique_id}_{slugify(description.name)}" ) self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: self.attribution} - # Fallback to metric always in case imperial isn't defined (for metric only - # sensors) - self._attr_native_unit_of_measurement = ( - description.unit_metric - if hass.config.units.is_metric - else description.unit_imperial - ) or description.unit_metric + if self.entity_description.native_unit_of_measurement is None: + self._attr_native_unit_of_measurement = ( + description.unit_metric + if hass.config.units.is_metric + else description.unit_imperial + ) @property @abstractmethod - def _state(self) -> str | int | float | None: + def _state(self) -> int | float | None: """Return the raw state.""" @property def native_value(self) -> str | int | float | None: """Return the state.""" state = self._state + desc = self.entity_description + + if state is None: + return state + + if desc.value_map is not None: + return desc.value_map(state).name.lower() + + if desc.multiplication_factor is not None: + state = handle_conversion(state, desc.multiplication_factor) # If an imperial unit isn't provided, we always want to convert to metric since # that is what the UI expects - if state is not None and ( - ( - self.entity_description.metric_conversion != 1.0 - and self.entity_description.is_metric_check is not None - and self.hass.config.units.is_metric - == self.entity_description.is_metric_check - ) - or ( - self.entity_description.unit_imperial is None - and self.entity_description.unit_metric is not None - ) + if ( + desc.metric_conversion + and desc.unit_imperial is not None + and desc.unit_imperial != desc.unit_metric + and self.hass.config.units.is_metric ): - conversion = self.entity_description.metric_conversion - # When conversion is a callable, we assume it's a single input function - if callable(conversion): - return round(conversion(float(state)), 2) - - return round(float(state) * conversion, 2) - - if self.entity_description.value_map is not None and state is not None: - return self.entity_description.value_map(state).name.lower() + return handle_conversion(state, desc.metric_conversion) return state @@ -360,6 +377,8 @@ class TomorrowioSensorEntity(BaseTomorrowioSensorEntity): """Sensor entity that talks to Tomorrow.io v4 API to retrieve non-weather data.""" @property - def _state(self) -> str | int | float | None: + def _state(self) -> int | float | None: """Return the raw state.""" - return self._get_current_property(self.entity_description.key) + val = self._get_current_property(self.entity_description.key) + assert not isinstance(val, str) + return val diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index 09316ed2518..91a4a2eabc5 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -5,6 +5,8 @@ from datetime import datetime from typing import Any from unittest.mock import patch +import pytest + from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.tomorrowio.config_flow import ( _get_config_schema, @@ -17,6 +19,7 @@ from homeassistant.components.tomorrowio.const import ( DEFAULT_TIMESTEP, DOMAIN, ) +from homeassistant.components.tomorrowio.sensor import TomorrowioSensorEntityDescription from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME from homeassistant.core import HomeAssistant, State, callback @@ -139,10 +142,10 @@ def check_sensor_state(hass: HomeAssistant, entity_name: str, value: str): async def test_v4_sensor(hass: HomeAssistant) -> None: """Test v4 sensor data.""" await _setup(hass, V4_FIELDS, API_V4_ENTRY_DATA) - check_sensor_state(hass, O3, "94.46") - check_sensor_state(hass, CO, "0.63") - check_sensor_state(hass, NO2, "20.81") - check_sensor_state(hass, SO2, "4.47") + check_sensor_state(hass, O3, "91.35") + check_sensor_state(hass, CO, "0.0") + check_sensor_state(hass, NO2, "20.08") + check_sensor_state(hass, SO2, "4.32") check_sensor_state(hass, PM25, "5.3") check_sensor_state(hass, PM10, "20.13") check_sensor_state(hass, MEP_AQI, "23") @@ -164,3 +167,9 @@ async def test_v4_sensor(hass: HomeAssistant) -> None: check_sensor_state(hass, CLOUD_CEILING, "1.19") check_sensor_state(hass, WIND_GUST, "5.65") check_sensor_state(hass, PRECIPITATION_TYPE, "rain") + + +async def test_entity_description() -> None: + """Test improper entity description raises.""" + with pytest.raises(ValueError): + TomorrowioSensorEntityDescription("a", unit_imperial="b")