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 <erik@montnemery.com>

* Apply suggestion

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Apply suggestion

Co-authored-by: Erik Montnemery <erik@montnemery.com>

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
epenet 2022-09-22 07:18:00 +02:00 committed by GitHub
parent e62e21ce46
commit 39315b7fe3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 72 additions and 66 deletions

View File

@ -28,7 +28,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401
from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity 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 homeassistant.util import temperature as temperature_util
from .const import ( from .const import (
@ -70,12 +70,8 @@ class NumberMode(StrEnum):
SLIDER = "slider" SLIDER = "slider"
UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { UNIT_CONVERTERS: dict[str, UnitConverter] = {
NumberDeviceClass.TEMPERATURE: temperature_util.convert, NumberDeviceClass.TEMPERATURE: temperature_util,
}
VALID_UNITS: dict[str, tuple[str, ...]] = {
NumberDeviceClass.TEMPERATURE: temperature_util.VALID_UNITS,
} }
# mypy: disallow-any-generics # mypy: disallow-any-generics
@ -436,7 +432,7 @@ class NumberEntity(Entity):
if ( if (
native_unit_of_measurement != unit_of_measurement 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 native_unit_of_measurement
assert unit_of_measurement assert unit_of_measurement
@ -446,7 +442,7 @@ class NumberEntity(Entity):
# Suppress ValueError (Could not convert value to float) # Suppress ValueError (Could not convert value to float)
with suppress(ValueError): with suppress(ValueError):
value_new: float = UNIT_CONVERSIONS[device_class]( value_new: float = UNIT_CONVERTERS[device_class].convert(
value, value,
native_unit_of_measurement, native_unit_of_measurement,
unit_of_measurement, unit_of_measurement,
@ -467,12 +463,12 @@ class NumberEntity(Entity):
if ( if (
value is not None value is not None
and native_unit_of_measurement != unit_of_measurement 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 native_unit_of_measurement
assert unit_of_measurement assert unit_of_measurement
value = UNIT_CONVERSIONS[device_class]( value = UNIT_CONVERTERS[device_class].convert(
value, value,
unit_of_measurement, unit_of_measurement,
native_unit_of_measurement, native_unit_of_measurement,
@ -500,9 +496,10 @@ class NumberEntity(Entity):
if ( if (
(number_options := self.registry_entry.options.get(DOMAIN)) (number_options := self.registry_entry.options.get(DOMAIN))
and (custom_unit := number_options.get(CONF_UNIT_OF_MEASUREMENT)) and (custom_unit := number_options.get(CONF_UNIT_OF_MEASUREMENT))
and (device_class := self.device_class) in UNIT_CONVERSIONS and (device_class := self.device_class) in UNIT_CONVERTERS
and self.native_unit_of_measurement in VALID_UNITS[device_class] and self.native_unit_of_measurement
and custom_unit in VALID_UNITS[device_class] 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 self._number_option_unit_of_measurement = custom_unit
return return

View File

@ -36,7 +36,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry from homeassistant.helpers import entity_registry
from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.storage import STORAGE_DIR 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 ( from homeassistant.util import (
dt as dt_util, dt as dt_util,
energy as energy_util, energy as energy_util,
@ -186,12 +186,12 @@ STATISTIC_UNIT_TO_UNIT_CLASS: dict[str | None, str] = {
VOLUME_CUBIC_METERS: "volume", VOLUME_CUBIC_METERS: "volume",
} }
STATISTIC_UNIT_TO_VALID_UNITS: dict[str | None, Iterable[str | None]] = { STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, UnitConverter] = {
ENERGY_KILO_WATT_HOUR: energy_util.VALID_UNITS, ENERGY_KILO_WATT_HOUR: energy_util,
POWER_WATT: power_util.VALID_UNITS, POWER_WATT: power_util,
PRESSURE_PA: pressure_util.VALID_UNITS, PRESSURE_PA: pressure_util,
TEMP_CELSIUS: temperature_util.VALID_UNITS, TEMP_CELSIUS: temperature_util,
VOLUME_CUBIC_METERS: volume_util.VALID_UNITS, VOLUME_CUBIC_METERS: volume_util,
} }
# Convert energy power, pressure, temperature and volume statistics from the # Convert energy power, pressure, temperature and volume statistics from the
@ -243,7 +243,8 @@ def _get_statistic_to_display_unit_converter(
else: else:
display_unit = state_unit 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 # Guard against invalid state unit in the DB
return no_conversion 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.""" """Raise if the statistics unit and state unit are not compatible."""
if statistics_unit == state_unit: if statistics_unit == state_unit:
return 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}") 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}") raise HomeAssistantError(f"Invalid units {statistics_unit},{state_unit}")

View File

@ -1,7 +1,7 @@
"""Component to interface with various sensors that can be monitored.""" """Component to interface with various sensors that can be monitored."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Mapping from collections.abc import Mapping
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone 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 import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity 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 ( from homeassistant.util import (
dt as dt_util, dt as dt_util,
pressure as pressure_util, pressure as pressure_util,
@ -207,9 +207,9 @@ STATE_CLASS_TOTAL: Final = "total"
STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing"
STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass]
UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { UNIT_CONVERTERS: dict[str, UnitConverter] = {
SensorDeviceClass.PRESSURE: pressure_util.convert, SensorDeviceClass.PRESSURE: pressure_util,
SensorDeviceClass.TEMPERATURE: temperature_util.convert, SensorDeviceClass.TEMPERATURE: temperature_util,
} }
UNIT_RATIOS: dict[str, dict[str, float]] = { 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 # mypy: disallow-any-generics
@ -431,7 +426,7 @@ class SensorEntity(Entity):
if ( if (
value is not None value is not None
and native_unit_of_measurement != unit_of_measurement 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 unit_of_measurement
assert native_unit_of_measurement assert native_unit_of_measurement
@ -453,7 +448,7 @@ class SensorEntity(Entity):
# Suppress ValueError (Could not convert sensor_value to float) # Suppress ValueError (Could not convert sensor_value to float)
with suppress(ValueError): with suppress(ValueError):
value_f = float(value) # type: ignore[arg-type] 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, value_f,
native_unit_of_measurement, native_unit_of_measurement,
unit_of_measurement, unit_of_measurement,
@ -482,9 +477,10 @@ class SensorEntity(Entity):
if ( if (
(sensor_options := self.registry_entry.options.get(DOMAIN)) (sensor_options := self.registry_entry.options.get(DOMAIN))
and (custom_unit := sensor_options.get(CONF_UNIT_OF_MEASUREMENT)) and (custom_unit := sensor_options.get(CONF_UNIT_OF_MEASUREMENT))
and (device_class := self.device_class) in UNIT_CONVERSIONS and (device_class := self.device_class) in UNIT_CONVERTERS
and self.native_unit_of_measurement in VALID_UNITS[device_class] and self.native_unit_of_measurement
and custom_unit in VALID_UNITS[device_class] 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 self._sensor_option_unit_of_measurement = custom_unit
return return

View File

@ -1,7 +1,7 @@
"""Typing Helpers for Home Assistant.""" """Typing Helpers for Home Assistant."""
from collections.abc import Mapping from collections.abc import Mapping
from enum import Enum from enum import Enum
from typing import Any, Optional, Union from typing import Any, Optional, Protocol, Union
import homeassistant.core import homeassistant.core
@ -26,6 +26,16 @@ class UndefinedType(Enum):
UNDEFINED = UndefinedType._singleton # pylint: disable=protected-access 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 # The following types should not used and
# are not present in the core code base. # are not present in the core code base.
# They are kept in order not to break custom integrations # They are kept in order not to break custom integrations

View File

@ -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.""" """Convert one unit of measurement to another."""
if unit_1 not in VALID_UNITS: if from_unit not in VALID_UNITS:
raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_1, "energy")) raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, "energy"))
if unit_2 not in VALID_UNITS: if to_unit not in VALID_UNITS:
raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_2, "energy")) raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, "energy"))
if not isinstance(value, Number): if not isinstance(value, Number):
raise TypeError(f"{value} is not of numeric type") raise TypeError(f"{value} is not of numeric type")
if unit_1 == unit_2: if from_unit == to_unit:
return value return value
watts = value / UNIT_CONVERSION[unit_1] watthours = value / UNIT_CONVERSION[from_unit]
return watts * UNIT_CONVERSION[unit_2] return watthours * UNIT_CONVERSION[to_unit]

View File

@ -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.""" """Convert one unit of measurement to another."""
if unit_1 not in VALID_UNITS: if from_unit not in VALID_UNITS:
raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_1, "power")) raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, "power"))
if unit_2 not in VALID_UNITS: if to_unit not in VALID_UNITS:
raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_2, "power")) raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, "power"))
if not isinstance(value, Number): if not isinstance(value, Number):
raise TypeError(f"{value} is not of numeric type") raise TypeError(f"{value} is not of numeric type")
if unit_1 == unit_2: if from_unit == to_unit:
return value return value
watts = value / UNIT_CONVERSION[unit_1] watts = value / UNIT_CONVERSION[from_unit]
return watts * UNIT_CONVERSION[unit_2] return watts * UNIT_CONVERSION[to_unit]

View File

@ -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.""" """Convert one unit of measurement to another."""
if unit_1 not in VALID_UNITS: if from_unit not in VALID_UNITS:
raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_1, PRESSURE)) raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, PRESSURE))
if unit_2 not in VALID_UNITS: if to_unit not in VALID_UNITS:
raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_2, PRESSURE)) raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, PRESSURE))
if not isinstance(value, Number): if not isinstance(value, Number):
raise TypeError(f"{value} is not of numeric type") raise TypeError(f"{value} is not of numeric type")
if unit_1 == unit_2: if from_unit == to_unit:
return value return value
pascals = value / UNIT_CONVERSION[unit_1] pascals = value / UNIT_CONVERSION[from_unit]
return pascals * UNIT_CONVERSION[unit_2] return pascals * UNIT_CONVERSION[to_unit]

View File

@ -46,9 +46,9 @@ def convert(
temperature: float, from_unit: str, to_unit: str, interval: bool = False temperature: float, from_unit: str, to_unit: str, interval: bool = False
) -> float: ) -> float:
"""Convert a temperature from one unit to another.""" """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)) 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)) raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, TEMPERATURE))
if from_unit == to_unit: if from_unit == to_unit: