diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 9adc1f74cac..08386ced6de 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -86,6 +86,7 @@ from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( BaseUnitConverter, + DataRateConverter, DistanceConverter, MassConverter, PressureConverter, @@ -466,6 +467,7 @@ STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] # Note: this needs to be aligned with frontend: OVERRIDE_SENSOR_UNITS in # `entity-registry-settings.ts` UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { + SensorDeviceClass.DATA_RATE: DataRateConverter, SensorDeviceClass.DISTANCE: DistanceConverter, SensorDeviceClass.GAS: VolumeConverter, SensorDeviceClass.PRECIPITATION: DistanceConverter, diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 1aeda96b6b3..3cfd0a764e0 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.const import ( UNIT_NOT_RECOGNIZED_TEMPLATE, + UnitOfDataRate, UnitOfEnergy, UnitOfLength, UnitOfMass, @@ -86,6 +87,28 @@ class BaseUnitConverter: return cls._UNIT_CONVERSION[from_unit] / cls._UNIT_CONVERSION[to_unit] +class DataRateConverter(BaseUnitConverter): + """Utility to convert data rate values.""" + + UNIT_CLASS = "data_rate" + NORMALIZED_UNIT = UnitOfDataRate.BITS_PER_SECOND + # Units in terms of bits + _UNIT_CONVERSION: dict[str, float] = { + UnitOfDataRate.BITS_PER_SECOND: 1, + UnitOfDataRate.KILOBITS_PER_SECOND: 1 / 1e3, + UnitOfDataRate.MEGABITS_PER_SECOND: 1 / 1e6, + UnitOfDataRate.GIGABITS_PER_SECOND: 1 / 1e9, + UnitOfDataRate.BYTES_PER_SECOND: 1 / 8, + UnitOfDataRate.KILOBYTES_PER_SECOND: 1 / 8e3, + UnitOfDataRate.MEGABYTES_PER_SECOND: 1 / 8e6, + UnitOfDataRate.GIGABYTES_PER_SECOND: 1 / 8e9, + UnitOfDataRate.KIBIBYTES_PER_SECOND: 1 / 2**13, + UnitOfDataRate.MEBIBYTES_PER_SECOND: 1 / 2**23, + UnitOfDataRate.GIBIBYTES_PER_SECOND: 1 / 2**33, + } + VALID_UNITS = set(UnitOfDataRate) + + class DistanceConverter(BaseUnitConverter): """Utility to convert distance values.""" diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 0d5cb7143ec..f5c9970ca59 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -2,6 +2,7 @@ import pytest from homeassistant.const import ( + UnitOfDataRate, UnitOfEnergy, UnitOfLength, UnitOfMass, @@ -15,6 +16,7 @@ from homeassistant.const import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.util.unit_conversion import ( BaseUnitConverter, + DataRateConverter, DistanceConverter, EnergyConverter, MassConverter, @@ -31,6 +33,7 @@ INVALID_SYMBOL = "bob" @pytest.mark.parametrize( "converter,valid_unit", [ + (DataRateConverter, UnitOfDataRate.GIBIBYTES_PER_SECOND), (DistanceConverter, UnitOfLength.KILOMETERS), (DistanceConverter, UnitOfLength.METERS), (DistanceConverter, UnitOfLength.CENTIMETERS), @@ -85,6 +88,7 @@ def test_convert_same_unit(converter: type[BaseUnitConverter], valid_unit: str) @pytest.mark.parametrize( "converter,valid_unit", [ + (DataRateConverter, UnitOfDataRate.GIBIBYTES_PER_SECOND), (DistanceConverter, UnitOfLength.KILOMETERS), (EnergyConverter, UnitOfEnergy.KILO_WATT_HOUR), (MassConverter, UnitOfMass.GRAMS), @@ -111,6 +115,11 @@ def test_convert_invalid_unit( @pytest.mark.parametrize( "converter,from_unit,to_unit", [ + ( + DataRateConverter, + UnitOfDataRate.BYTES_PER_SECOND, + UnitOfDataRate.BITS_PER_SECOND, + ), (DistanceConverter, UnitOfLength.KILOMETERS, UnitOfLength.METERS), (EnergyConverter, UnitOfEnergy.WATT_HOUR, UnitOfEnergy.KILO_WATT_HOUR), (MassConverter, UnitOfMass.GRAMS, UnitOfMass.KILOGRAMS), @@ -132,6 +141,12 @@ def test_convert_nonnumeric_value( @pytest.mark.parametrize( "converter,from_unit,to_unit,expected", [ + ( + DataRateConverter, + UnitOfDataRate.BITS_PER_SECOND, + UnitOfDataRate.BYTES_PER_SECOND, + 8, + ), (DistanceConverter, UnitOfLength.KILOMETERS, UnitOfLength.METERS, 1 / 1000), (EnergyConverter, UnitOfEnergy.WATT_HOUR, UnitOfEnergy.KILO_WATT_HOUR, 1000), (PowerConverter, UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000), @@ -168,6 +183,48 @@ def test_get_unit_ratio( assert converter.get_unit_ratio(from_unit, to_unit) == expected +@pytest.mark.parametrize( + "value,from_unit,expected,to_unit", + [ + (8e3, UnitOfDataRate.BITS_PER_SECOND, 8, UnitOfDataRate.KILOBITS_PER_SECOND), + (8e6, UnitOfDataRate.BITS_PER_SECOND, 8, UnitOfDataRate.MEGABITS_PER_SECOND), + (8e9, UnitOfDataRate.BITS_PER_SECOND, 8, UnitOfDataRate.GIGABITS_PER_SECOND), + (8, UnitOfDataRate.BITS_PER_SECOND, 1, UnitOfDataRate.BYTES_PER_SECOND), + (8e3, UnitOfDataRate.BITS_PER_SECOND, 1, UnitOfDataRate.KILOBYTES_PER_SECOND), + (8e6, UnitOfDataRate.BITS_PER_SECOND, 1, UnitOfDataRate.MEGABYTES_PER_SECOND), + (8e9, UnitOfDataRate.BITS_PER_SECOND, 1, UnitOfDataRate.GIGABYTES_PER_SECOND), + ( + 8 * 2**10, + UnitOfDataRate.BITS_PER_SECOND, + 1, + UnitOfDataRate.KIBIBYTES_PER_SECOND, + ), + ( + 8 * 2**20, + UnitOfDataRate.BITS_PER_SECOND, + 1, + UnitOfDataRate.MEBIBYTES_PER_SECOND, + ), + ( + 8 * 2**30, + UnitOfDataRate.BITS_PER_SECOND, + 1, + UnitOfDataRate.GIBIBYTES_PER_SECOND, + ), + ], +) +def test_data_rate_convert( + value: float, + from_unit: str, + expected: float, + to_unit: str, +) -> None: + """Test conversion to other units.""" + assert DataRateConverter.convert(value, from_unit, to_unit) == pytest.approx( + expected + ) + + @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [