From 39315b7fe3b13be5c026a5e7d7180ec3715ab882 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 22 Sep 2022 07:18:00 +0200 Subject: [PATCH] Introduce UnitConverter protocol (#78888) * Introduce ConversionUtility * Use ConversionUtility in number * Use ConversionUtility in sensor * Use ConversionUtility in sensor recorder * Add normalise to ConversionUtility * Revert changes to recorder.py * Reduce size of PR * Adjust recorder statistics * Rename variable * Rename * Apply suggestion Co-authored-by: Erik Montnemery * Apply suggestion Co-authored-by: Erik Montnemery * Apply suggestion Co-authored-by: Erik Montnemery Co-authored-by: Erik Montnemery --- homeassistant/components/number/__init__.py | 25 ++++++++---------- .../components/recorder/statistics.py | 23 +++++++++------- homeassistant/components/sensor/__init__.py | 26 ++++++++----------- homeassistant/helpers/typing.py | 12 ++++++++- homeassistant/util/energy.py | 16 ++++++------ homeassistant/util/power.py | 16 ++++++------ homeassistant/util/pressure.py | 16 ++++++------ homeassistant/util/temperature.py | 4 +-- 8 files changed, 72 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 5f92bdfed8b..f3e9a1d9da1 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -28,7 +28,7 @@ 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.restore_state import ExtraStoredData, RestoreEntity -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, UnitConverter from homeassistant.util import temperature as temperature_util from .const import ( @@ -70,12 +70,8 @@ class NumberMode(StrEnum): SLIDER = "slider" -UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { - NumberDeviceClass.TEMPERATURE: temperature_util.convert, -} - -VALID_UNITS: dict[str, tuple[str, ...]] = { - NumberDeviceClass.TEMPERATURE: temperature_util.VALID_UNITS, +UNIT_CONVERTERS: dict[str, UnitConverter] = { + NumberDeviceClass.TEMPERATURE: temperature_util, } # mypy: disallow-any-generics @@ -436,7 +432,7 @@ class NumberEntity(Entity): if ( native_unit_of_measurement != unit_of_measurement - and device_class in UNIT_CONVERSIONS + and device_class in UNIT_CONVERTERS ): assert native_unit_of_measurement assert unit_of_measurement @@ -446,7 +442,7 @@ class NumberEntity(Entity): # Suppress ValueError (Could not convert value to float) with suppress(ValueError): - value_new: float = UNIT_CONVERSIONS[device_class]( + value_new: float = UNIT_CONVERTERS[device_class].convert( value, native_unit_of_measurement, unit_of_measurement, @@ -467,12 +463,12 @@ class NumberEntity(Entity): if ( value is not None and native_unit_of_measurement != unit_of_measurement - and device_class in UNIT_CONVERSIONS + and device_class in UNIT_CONVERTERS ): assert native_unit_of_measurement assert unit_of_measurement - value = UNIT_CONVERSIONS[device_class]( + value = UNIT_CONVERTERS[device_class].convert( value, unit_of_measurement, native_unit_of_measurement, @@ -500,9 +496,10 @@ class NumberEntity(Entity): if ( (number_options := self.registry_entry.options.get(DOMAIN)) and (custom_unit := number_options.get(CONF_UNIT_OF_MEASUREMENT)) - and (device_class := self.device_class) in UNIT_CONVERSIONS - and self.native_unit_of_measurement in VALID_UNITS[device_class] - and custom_unit in VALID_UNITS[device_class] + and (device_class := self.device_class) in UNIT_CONVERTERS + and self.native_unit_of_measurement + in UNIT_CONVERTERS[device_class].VALID_UNITS + and custom_unit in UNIT_CONVERTERS[device_class].VALID_UNITS ): self._number_option_unit_of_measurement = custom_unit return diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index be568adbb25..15309d5ab46 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -36,7 +36,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.storage import STORAGE_DIR -from homeassistant.helpers.typing import UNDEFINED, UndefinedType +from homeassistant.helpers.typing import UNDEFINED, UndefinedType, UnitConverter from homeassistant.util import ( dt as dt_util, energy as energy_util, @@ -186,12 +186,12 @@ STATISTIC_UNIT_TO_UNIT_CLASS: dict[str | None, str] = { VOLUME_CUBIC_METERS: "volume", } -STATISTIC_UNIT_TO_VALID_UNITS: dict[str | None, Iterable[str | None]] = { - ENERGY_KILO_WATT_HOUR: energy_util.VALID_UNITS, - POWER_WATT: power_util.VALID_UNITS, - PRESSURE_PA: pressure_util.VALID_UNITS, - TEMP_CELSIUS: temperature_util.VALID_UNITS, - VOLUME_CUBIC_METERS: volume_util.VALID_UNITS, +STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, UnitConverter] = { + ENERGY_KILO_WATT_HOUR: energy_util, + POWER_WATT: power_util, + PRESSURE_PA: pressure_util, + TEMP_CELSIUS: temperature_util, + VOLUME_CUBIC_METERS: volume_util, } # Convert energy power, pressure, temperature and volume statistics from the @@ -243,7 +243,8 @@ def _get_statistic_to_display_unit_converter( else: display_unit = state_unit - if display_unit not in STATISTIC_UNIT_TO_VALID_UNITS[statistic_unit]: + unit_converter = STATISTIC_UNIT_TO_UNIT_CONVERTER[statistic_unit] + if display_unit not in unit_converter.VALID_UNITS: # Guard against invalid state unit in the DB return no_conversion @@ -1514,9 +1515,11 @@ def _validate_units(statistics_unit: str | None, state_unit: str | None) -> None """Raise if the statistics unit and state unit are not compatible.""" if statistics_unit == state_unit: return - if (valid_units := STATISTIC_UNIT_TO_VALID_UNITS.get(statistics_unit)) is None: + if ( + unit_converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistics_unit) + ) is None: raise HomeAssistantError(f"Invalid units {statistics_unit},{state_unit}") - if state_unit not in valid_units: + if state_unit not in unit_converter.VALID_UNITS: raise HomeAssistantError(f"Invalid units {statistics_unit},{state_unit}") diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index a6bfd4189f8..530769f2873 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -1,7 +1,7 @@ """Component to interface with various sensors that can be monitored.""" from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Mapping from contextlib import suppress from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone @@ -56,7 +56,7 @@ 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.restore_state import ExtraStoredData, RestoreEntity -from homeassistant.helpers.typing import ConfigType, StateType +from homeassistant.helpers.typing import ConfigType, StateType, UnitConverter from homeassistant.util import ( dt as dt_util, pressure as pressure_util, @@ -207,9 +207,9 @@ STATE_CLASS_TOTAL: Final = "total" STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] -UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { - SensorDeviceClass.PRESSURE: pressure_util.convert, - SensorDeviceClass.TEMPERATURE: temperature_util.convert, +UNIT_CONVERTERS: dict[str, UnitConverter] = { + SensorDeviceClass.PRESSURE: pressure_util, + SensorDeviceClass.TEMPERATURE: temperature_util, } UNIT_RATIOS: dict[str, dict[str, float]] = { @@ -221,11 +221,6 @@ UNIT_RATIOS: dict[str, dict[str, float]] = { }, } -VALID_UNITS: dict[str, tuple[str, ...]] = { - SensorDeviceClass.PRESSURE: pressure_util.VALID_UNITS, - SensorDeviceClass.TEMPERATURE: temperature_util.VALID_UNITS, -} - # mypy: disallow-any-generics @@ -431,7 +426,7 @@ class SensorEntity(Entity): if ( value is not None and native_unit_of_measurement != unit_of_measurement - and device_class in UNIT_CONVERSIONS + and device_class in UNIT_CONVERTERS ): assert unit_of_measurement assert native_unit_of_measurement @@ -453,7 +448,7 @@ class SensorEntity(Entity): # Suppress ValueError (Could not convert sensor_value to float) with suppress(ValueError): value_f = float(value) # type: ignore[arg-type] - value_f_new = UNIT_CONVERSIONS[device_class]( + value_f_new = UNIT_CONVERTERS[device_class].convert( value_f, native_unit_of_measurement, unit_of_measurement, @@ -482,9 +477,10 @@ class SensorEntity(Entity): if ( (sensor_options := self.registry_entry.options.get(DOMAIN)) and (custom_unit := sensor_options.get(CONF_UNIT_OF_MEASUREMENT)) - and (device_class := self.device_class) in UNIT_CONVERSIONS - and self.native_unit_of_measurement in VALID_UNITS[device_class] - and custom_unit in VALID_UNITS[device_class] + and (device_class := self.device_class) in UNIT_CONVERTERS + and self.native_unit_of_measurement + in UNIT_CONVERTERS[device_class].VALID_UNITS + and custom_unit in UNIT_CONVERTERS[device_class].VALID_UNITS ): self._sensor_option_unit_of_measurement = custom_unit return diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py index a7430d1fe69..c679de288b1 100644 --- a/homeassistant/helpers/typing.py +++ b/homeassistant/helpers/typing.py @@ -1,7 +1,7 @@ """Typing Helpers for Home Assistant.""" from collections.abc import Mapping from enum import Enum -from typing import Any, Optional, Union +from typing import Any, Optional, Protocol, Union import homeassistant.core @@ -26,6 +26,16 @@ class UndefinedType(Enum): UNDEFINED = UndefinedType._singleton # pylint: disable=protected-access + +class UnitConverter(Protocol): + """Define the format of a conversion utility.""" + + VALID_UNITS: tuple[str, ...] + + def convert(self, value: float, from_unit: str, to_unit: str) -> float: + """Convert one unit of measurement to another.""" + + # The following types should not used and # are not present in the core code base. # They are kept in order not to break custom integrations diff --git a/homeassistant/util/energy.py b/homeassistant/util/energy.py index 4d1bd10f4b2..00695704751 100644 --- a/homeassistant/util/energy.py +++ b/homeassistant/util/energy.py @@ -23,18 +23,18 @@ UNIT_CONVERSION: dict[str, float] = { } -def convert(value: float, unit_1: str, unit_2: str) -> float: +def convert(value: float, from_unit: str, to_unit: str) -> float: """Convert one unit of measurement to another.""" - if unit_1 not in VALID_UNITS: - raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_1, "energy")) - if unit_2 not in VALID_UNITS: - raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_2, "energy")) + if from_unit not in VALID_UNITS: + raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, "energy")) + if to_unit not in VALID_UNITS: + raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, "energy")) if not isinstance(value, Number): raise TypeError(f"{value} is not of numeric type") - if unit_1 == unit_2: + if from_unit == to_unit: return value - watts = value / UNIT_CONVERSION[unit_1] - return watts * UNIT_CONVERSION[unit_2] + watthours = value / UNIT_CONVERSION[from_unit] + return watthours * UNIT_CONVERSION[to_unit] diff --git a/homeassistant/util/power.py b/homeassistant/util/power.py index 74be6d55377..ae4f4c249b6 100644 --- a/homeassistant/util/power.py +++ b/homeassistant/util/power.py @@ -20,18 +20,18 @@ UNIT_CONVERSION: dict[str, float] = { } -def convert(value: float, unit_1: str, unit_2: str) -> float: +def convert(value: float, from_unit: str, to_unit: str) -> float: """Convert one unit of measurement to another.""" - if unit_1 not in VALID_UNITS: - raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_1, "power")) - if unit_2 not in VALID_UNITS: - raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_2, "power")) + if from_unit not in VALID_UNITS: + raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, "power")) + if to_unit not in VALID_UNITS: + raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, "power")) if not isinstance(value, Number): raise TypeError(f"{value} is not of numeric type") - if unit_1 == unit_2: + if from_unit == to_unit: return value - watts = value / UNIT_CONVERSION[unit_1] - return watts * UNIT_CONVERSION[unit_2] + watts = value / UNIT_CONVERSION[from_unit] + return watts * UNIT_CONVERSION[to_unit] diff --git a/homeassistant/util/pressure.py b/homeassistant/util/pressure.py index dca94764de3..a07f9d777c3 100644 --- a/homeassistant/util/pressure.py +++ b/homeassistant/util/pressure.py @@ -42,18 +42,18 @@ UNIT_CONVERSION: dict[str, float] = { } -def convert(value: float, unit_1: str, unit_2: str) -> float: +def convert(value: float, from_unit: str, to_unit: str) -> float: """Convert one unit of measurement to another.""" - if unit_1 not in VALID_UNITS: - raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_1, PRESSURE)) - if unit_2 not in VALID_UNITS: - raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_2, PRESSURE)) + if from_unit not in VALID_UNITS: + raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, PRESSURE)) + if to_unit not in VALID_UNITS: + raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, PRESSURE)) if not isinstance(value, Number): raise TypeError(f"{value} is not of numeric type") - if unit_1 == unit_2: + if from_unit == to_unit: return value - pascals = value / UNIT_CONVERSION[unit_1] - return pascals * UNIT_CONVERSION[unit_2] + pascals = value / UNIT_CONVERSION[from_unit] + return pascals * UNIT_CONVERSION[to_unit] diff --git a/homeassistant/util/temperature.py b/homeassistant/util/temperature.py index d7b7597d6d0..06febd600e7 100644 --- a/homeassistant/util/temperature.py +++ b/homeassistant/util/temperature.py @@ -46,9 +46,9 @@ def convert( temperature: float, from_unit: str, to_unit: str, interval: bool = False ) -> float: """Convert a temperature from one unit to another.""" - if from_unit not in (TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_KELVIN): + if from_unit not in VALID_UNITS: raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, TEMPERATURE)) - if to_unit not in (TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_KELVIN): + if to_unit not in VALID_UNITS: raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, TEMPERATURE)) if from_unit == to_unit: