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

View file

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

View file

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

View file

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

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

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

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

View file

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