diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index f2ae6ee5e70..4e77ba84401 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -30,7 +30,7 @@ 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.util import dt as dt_util, temperature as temperature_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( BaseUnitConverter, EnergyConverter, @@ -157,7 +157,9 @@ def _convert_temperature_from_c(to_unit: str, value: float | None) -> float | No """Convert temperature in °C to to_unit.""" if value is None: return None - return temperature_util.convert(value, temperature_util.NORMALIZED_UNIT, to_unit) + return TemperatureConverter.convert( + value, TemperatureConverter.NORMALIZED_UNIT, to_unit + ) def _convert_volume_from_m3(to_unit: str, value: float | None) -> float | None: @@ -176,15 +178,15 @@ STATISTIC_UNIT_TO_UNIT_CLASS: dict[str | None, str] = { EnergyConverter.NORMALIZED_UNIT: EnergyConverter.UNIT_CLASS, PowerConverter.NORMALIZED_UNIT: PowerConverter.UNIT_CLASS, PressureConverter.NORMALIZED_UNIT: PressureConverter.UNIT_CLASS, - temperature_util.NORMALIZED_UNIT: "temperature", - VolumeConverter.NORMALIZED_UNIT: "volume", + TemperatureConverter.NORMALIZED_UNIT: TemperatureConverter.UNIT_CLASS, + VolumeConverter.NORMALIZED_UNIT: VolumeConverter.UNIT_CLASS, } STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { EnergyConverter.NORMALIZED_UNIT: EnergyConverter, PowerConverter.NORMALIZED_UNIT: PowerConverter, PressureConverter.NORMALIZED_UNIT: PressureConverter, - temperature_util.NORMALIZED_UNIT: TemperatureConverter, + TemperatureConverter.NORMALIZED_UNIT: TemperatureConverter, VolumeConverter.NORMALIZED_UNIT: VolumeConverter, } @@ -196,7 +198,7 @@ STATISTIC_UNIT_TO_DISPLAY_UNIT_FUNCTIONS: dict[ EnergyConverter.NORMALIZED_UNIT: _convert_energy_from_kwh, PowerConverter.NORMALIZED_UNIT: _convert_power_from_w, PressureConverter.NORMALIZED_UNIT: _convert_pressure_from_pa, - temperature_util.NORMALIZED_UNIT: _convert_temperature_from_c, + TemperatureConverter.NORMALIZED_UNIT: _convert_temperature_from_c, VolumeConverter.NORMALIZED_UNIT: _convert_volume_from_m3, } diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 0c1ede5eb0a..69c96a86c10 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -19,11 +19,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import JSON_DUMP -from homeassistant.util import dt as dt_util, temperature as temperature_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( EnergyConverter, PowerConverter, PressureConverter, + TemperatureConverter, ) from .const import MAX_QUEUE_BACKLOG @@ -123,7 +124,7 @@ async def ws_handle_get_statistics_during_period( vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS), - vol.Optional("temperature"): vol.In(temperature_util.VALID_UNITS), + vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS), vol.Optional("volume"): vol.Any(VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS), } ), diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 0f028214b25..5ce764cfc90 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -47,7 +47,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import entity_sources -from homeassistant.util import dt as dt_util, temperature as temperature_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( BaseUnitConverter, EnergyConverter, @@ -111,11 +111,11 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { PRESSURE_PSI: lambda x: x / PressureConverter.UNIT_CONVERSION[PRESSURE_PSI], }, # Convert temperature to °C - # Note: temperature_util.convert is bypassed to avoid redundant error checking + # Note: TemperatureConverter.convert is bypassed to avoid redundant error checking SensorDeviceClass.TEMPERATURE: { TEMP_CELSIUS: lambda x: x, - TEMP_FAHRENHEIT: temperature_util.fahrenheit_to_celsius, - TEMP_KELVIN: temperature_util.kelvin_to_celsius, + TEMP_FAHRENHEIT: TemperatureConverter.fahrenheit_to_celsius, + TEMP_KELVIN: TemperatureConverter.kelvin_to_celsius, }, # Convert volume to cubic meter SensorDeviceClass.GAS: { diff --git a/homeassistant/util/temperature.py b/homeassistant/util/temperature.py index c89ce90ecf9..26ce3863519 100644 --- a/homeassistant/util/temperature.py +++ b/homeassistant/util/temperature.py @@ -1,5 +1,5 @@ """Temperature util functions.""" -from homeassistant.const import ( +from homeassistant.const import ( # pylint: disable=unused-import # noqa: F401 TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_KELVIN, @@ -7,69 +7,40 @@ from homeassistant.const import ( UNIT_NOT_RECOGNIZED_TEMPLATE, ) -VALID_UNITS: tuple[str, ...] = ( - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_KELVIN, -) +from .unit_conversion import TemperatureConverter -NORMALIZED_UNIT = TEMP_CELSIUS +VALID_UNITS = TemperatureConverter.VALID_UNITS def fahrenheit_to_celsius(fahrenheit: float, interval: bool = False) -> float: """Convert a temperature in Fahrenheit to Celsius.""" - if interval: - return fahrenheit / 1.8 - return (fahrenheit - 32.0) / 1.8 + # Need to add warning when core migration finished + return TemperatureConverter.fahrenheit_to_celsius(fahrenheit, interval) def kelvin_to_celsius(kelvin: float, interval: bool = False) -> float: """Convert a temperature in Kelvin to Celsius.""" - if interval: - return kelvin - return kelvin - 273.15 + # Need to add warning when core migration finished + return TemperatureConverter.kelvin_to_celsius(kelvin, interval) def celsius_to_fahrenheit(celsius: float, interval: bool = False) -> float: """Convert a temperature in Celsius to Fahrenheit.""" - if interval: - return celsius * 1.8 - return celsius * 1.8 + 32.0 + # Need to add warning when core migration finished + return TemperatureConverter.celsius_to_fahrenheit(celsius, interval) def celsius_to_kelvin(celsius: float, interval: bool = False) -> float: """Convert a temperature in Celsius to Fahrenheit.""" - if interval: - return celsius - return celsius + 273.15 + # Need to add warning when core migration finished + return TemperatureConverter.celsius_to_kelvin(celsius, interval) 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 VALID_UNITS: - raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, TEMPERATURE)) - if to_unit not in VALID_UNITS: - raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, TEMPERATURE)) - - if from_unit == to_unit: - return temperature - - if from_unit == TEMP_CELSIUS: - if to_unit == TEMP_FAHRENHEIT: - return celsius_to_fahrenheit(temperature, interval) - # kelvin - return celsius_to_kelvin(temperature, interval) - - if from_unit == TEMP_FAHRENHEIT: - if to_unit == TEMP_CELSIUS: - return fahrenheit_to_celsius(temperature, interval) - # kelvin - return celsius_to_kelvin(fahrenheit_to_celsius(temperature, interval), interval) - - # from_unit == kelvin - if to_unit == TEMP_CELSIUS: - return kelvin_to_celsius(temperature, interval) - # fahrenheit - return celsius_to_fahrenheit(kelvin_to_celsius(temperature, interval), interval) + # Need to add warning when core migration finished + return TemperatureConverter.convert( + temperature, from_unit, to_unit, interval=interval + ) diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index b2b4a8fda63..fbc5c05b706 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -1,7 +1,7 @@ """Typing Helpers for Home Assistant.""" from __future__ import annotations -from collections.abc import Callable +from abc import abstractmethod from numbers import Number from homeassistant.const import ( @@ -20,6 +20,8 @@ from homeassistant.const import ( PRESSURE_PA, PRESSURE_PSI, TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_KELVIN, UNIT_NOT_RECOGNIZED_TEMPLATE, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, @@ -29,7 +31,6 @@ from homeassistant.const import ( VOLUME_MILLILITERS, ) -from . import temperature as temperature_util from .distance import FOOT_TO_M, IN_TO_M # Volume conversion constants @@ -43,20 +44,13 @@ _CUBIC_FOOT_TO_CUBIC_METER = pow(FOOT_TO_M, 3) class BaseUnitConverter: """Define the format of a conversion utility.""" + UNIT_CLASS: str NORMALIZED_UNIT: str VALID_UNITS: tuple[str, ...] - convert: Callable[[float, str, str], float] - - -class BaseUnitConverterWithUnitConversion(BaseUnitConverter): - """Define the format of a conversion utility.""" - - UNIT_CLASS: str - UNIT_CONVERSION: dict[str, float] @classmethod - def convert(cls, value: float, from_unit: str, to_unit: str) -> float: - """Convert one unit of measurement to another.""" + def _check_arguments(cls, value: float, from_unit: str, to_unit: str) -> None: + """Check that arguments are all valid.""" if from_unit not in cls.VALID_UNITS: raise ValueError( UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, cls.UNIT_CLASS) @@ -69,6 +63,22 @@ class BaseUnitConverterWithUnitConversion(BaseUnitConverter): if not isinstance(value, Number): raise TypeError(f"{value} is not of numeric type") + @classmethod + @abstractmethod + def convert(cls, value: float, from_unit: str, to_unit: str) -> float: + """Convert one unit of measurement to another.""" + + +class BaseUnitConverterWithUnitConversion(BaseUnitConverter): + """Define the format of a conversion utility.""" + + UNIT_CONVERSION: dict[str, float] + + @classmethod + def convert(cls, value: float, from_unit: str, to_unit: str) -> float: + """Convert one unit of measurement to another.""" + cls._check_arguments(value, from_unit, to_unit) + if from_unit == to_unit: return value @@ -140,9 +150,73 @@ class PressureConverter(BaseUnitConverterWithUnitConversion): class TemperatureConverter(BaseUnitConverter): """Utility to convert temperature values.""" + UNIT_CLASS = "temperature" NORMALIZED_UNIT = TEMP_CELSIUS - VALID_UNITS = temperature_util.VALID_UNITS - convert = temperature_util.convert + VALID_UNITS: tuple[str, ...] = ( + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_KELVIN, + ) + + @classmethod + def convert( + cls, value: float, from_unit: str, to_unit: str, *, interval: bool = False + ) -> float: + """Convert a temperature from one unit to another.""" + cls._check_arguments(value, from_unit, to_unit) + + if from_unit == to_unit: + return value + + if from_unit == TEMP_CELSIUS: + if to_unit == TEMP_FAHRENHEIT: + return cls.celsius_to_fahrenheit(value, interval) + # kelvin + return cls.celsius_to_kelvin(value, interval) + + if from_unit == TEMP_FAHRENHEIT: + if to_unit == TEMP_CELSIUS: + return cls.fahrenheit_to_celsius(value, interval) + # kelvin + return cls.celsius_to_kelvin( + cls.fahrenheit_to_celsius(value, interval), interval + ) + + # from_unit == kelvin + if to_unit == TEMP_CELSIUS: + return cls.kelvin_to_celsius(value, interval) + # fahrenheit + return cls.celsius_to_fahrenheit( + cls.kelvin_to_celsius(value, interval), interval + ) + + @classmethod + def fahrenheit_to_celsius(cls, fahrenheit: float, interval: bool = False) -> float: + """Convert a temperature in Fahrenheit to Celsius.""" + if interval: + return fahrenheit / 1.8 + return (fahrenheit - 32.0) / 1.8 + + @classmethod + def kelvin_to_celsius(cls, kelvin: float, interval: bool = False) -> float: + """Convert a temperature in Kelvin to Celsius.""" + if interval: + return kelvin + return kelvin - 273.15 + + @classmethod + def celsius_to_fahrenheit(cls, celsius: float, interval: bool = False) -> float: + """Convert a temperature in Celsius to Fahrenheit.""" + if interval: + return celsius * 1.8 + return celsius * 1.8 + 32.0 + + @classmethod + def celsius_to_kelvin(cls, celsius: float, interval: bool = False) -> float: + """Convert a temperature in Celsius to Kelvin.""" + if interval: + return celsius + return celsius + 273.15 class VolumeConverter(BaseUnitConverterWithUnitConversion): diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index b4183375926..3f06393be3e 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -15,6 +15,9 @@ from homeassistant.const import ( PRESSURE_MMHG, PRESSURE_PA, PRESSURE_PSI, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_KELVIN, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, VOLUME_FLUID_OUNCE, @@ -27,6 +30,7 @@ from homeassistant.util.unit_conversion import ( EnergyConverter, PowerConverter, PressureConverter, + TemperatureConverter, VolumeConverter, ) @@ -49,6 +53,9 @@ INVALID_SYMBOL = "bob" (PressureConverter, PRESSURE_CBAR), (PressureConverter, PRESSURE_MMHG), (PressureConverter, PRESSURE_PSI), + (TemperatureConverter, TEMP_CELSIUS), + (TemperatureConverter, TEMP_FAHRENHEIT), + (TemperatureConverter, TEMP_KELVIN), (VolumeConverter, VOLUME_LITERS), (VolumeConverter, VOLUME_MILLILITERS), (VolumeConverter, VOLUME_GALLONS), @@ -66,6 +73,7 @@ def test_convert_same_unit(converter: type[BaseUnitConverter], valid_unit: str) (EnergyConverter, ENERGY_KILO_WATT_HOUR), (PowerConverter, POWER_WATT), (PressureConverter, PRESSURE_PA), + (TemperatureConverter, TEMP_CELSIUS), (VolumeConverter, VOLUME_LITERS), ], ) @@ -86,6 +94,7 @@ def test_convert_invalid_unit( (EnergyConverter, ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR), (PowerConverter, POWER_WATT, POWER_KILO_WATT), (PressureConverter, PRESSURE_HPA, PRESSURE_INHG), + (TemperatureConverter, TEMP_CELSIUS, TEMP_FAHRENHEIT), (VolumeConverter, VOLUME_GALLONS, VOLUME_LITERS), ], ) @@ -176,6 +185,45 @@ def test_pressure_convert( assert PressureConverter.convert(value, from_unit, to_unit) == expected +@pytest.mark.parametrize( + "value,from_unit,expected,to_unit", + [ + (100, TEMP_CELSIUS, 212, TEMP_FAHRENHEIT), + (100, TEMP_CELSIUS, 373.15, TEMP_KELVIN), + (100, TEMP_FAHRENHEIT, pytest.approx(37.77777777777778), TEMP_CELSIUS), + (100, TEMP_FAHRENHEIT, pytest.approx(310.92777777777775), TEMP_KELVIN), + (100, TEMP_KELVIN, pytest.approx(-173.15), TEMP_CELSIUS), + (100, TEMP_KELVIN, pytest.approx(-279.66999999999996), TEMP_FAHRENHEIT), + ], +) +def test_temperature_convert( + value: float, from_unit: str, expected: float, to_unit: str +) -> None: + """Test conversion to other units.""" + assert TemperatureConverter.convert(value, from_unit, to_unit) == expected + + +@pytest.mark.parametrize( + "value,from_unit,expected,to_unit", + [ + (100, TEMP_CELSIUS, 180, TEMP_FAHRENHEIT), + (100, TEMP_CELSIUS, 100, TEMP_KELVIN), + (100, TEMP_FAHRENHEIT, pytest.approx(55.55555555555556), TEMP_CELSIUS), + (100, TEMP_FAHRENHEIT, pytest.approx(55.55555555555556), TEMP_KELVIN), + (100, TEMP_KELVIN, 100, TEMP_CELSIUS), + (100, TEMP_KELVIN, 180, TEMP_FAHRENHEIT), + ], +) +def test_temperature_convert_with_interval( + value: float, from_unit: str, expected: float, to_unit: str +) -> None: + """Test conversion to other units.""" + assert ( + TemperatureConverter.convert(value, from_unit, to_unit, interval=True) + == expected + ) + + @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [