From 5389ff3253fd612008b1723797e459b1c53e6885 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 28 Sep 2022 12:13:49 +0200 Subject: [PATCH] Add new weight device class (#79185) --- .../components/recorder/statistics.py | 3 + .../components/recorder/websocket_api.py | 7 +++ homeassistant/components/sensor/__init__.py | 7 +++ .../components/sensor/device_condition.py | 2 + .../components/sensor/device_trigger.py | 2 + homeassistant/components/sensor/recorder.py | 2 + homeassistant/components/sensor/strings.json | 6 +- .../components/sensor/translations/en.json | 6 +- homeassistant/util/unit_conversion.py | 33 ++++++++++ tests/components/sensor/test_init.py | 27 +++++++++ tests/components/sensor/test_recorder.py | 14 +++++ tests/util/test_unit_conversion.py | 60 +++++++++++++++++++ 12 files changed, 165 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 8d679cbeae6..427bffb71dc 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -35,6 +35,7 @@ from homeassistant.util.unit_conversion import ( BaseUnitConverter, DistanceConverter, EnergyConverter, + MassConverter, PowerConverter, PressureConverter, SpeedConverter, @@ -126,6 +127,7 @@ QUERY_STATISTIC_META = [ STATISTIC_UNIT_TO_UNIT_CLASS: dict[str | None, str] = { DistanceConverter.NORMALIZED_UNIT: DistanceConverter.UNIT_CLASS, EnergyConverter.NORMALIZED_UNIT: EnergyConverter.UNIT_CLASS, + MassConverter.NORMALIZED_UNIT: MassConverter.UNIT_CLASS, PowerConverter.NORMALIZED_UNIT: PowerConverter.UNIT_CLASS, PressureConverter.NORMALIZED_UNIT: PressureConverter.UNIT_CLASS, SpeedConverter.NORMALIZED_UNIT: SpeedConverter.UNIT_CLASS, @@ -136,6 +138,7 @@ STATISTIC_UNIT_TO_UNIT_CLASS: dict[str | None, str] = { STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { DistanceConverter.NORMALIZED_UNIT: DistanceConverter, EnergyConverter.NORMALIZED_UNIT: EnergyConverter, + MassConverter.NORMALIZED_UNIT: MassConverter, PowerConverter.NORMALIZED_UNIT: PowerConverter, PressureConverter.NORMALIZED_UNIT: PressureConverter, SpeedConverter.NORMALIZED_UNIT: SpeedConverter, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 5feb51000fb..a0150702cdb 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -21,6 +21,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( DistanceConverter, EnergyConverter, + MassConverter, PowerConverter, PressureConverter, SpeedConverter, @@ -126,6 +127,7 @@ async def ws_handle_get_statistics_during_period( { vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), + vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS), vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS), vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS), @@ -335,6 +337,11 @@ async def ws_adjust_sum_statistics( ENERGY_WATT_HOUR, ): return True + if ( + statistics_unit == MassConverter.NORMALIZED_UNIT + and display_unit in MassConverter.VALID_UNITS + ): + return True if ( statistics_unit == VolumeConverter.NORMALIZED_UNIT and display_unit in VolumeConverter.VALID_UNITS diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index c7b8e9a4940..a7c552a2e9e 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -61,6 +61,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( BaseUnitConverter, DistanceConverter, + MassConverter, PressureConverter, SpeedConverter, TemperatureConverter, @@ -189,6 +190,10 @@ class SensorDeviceClass(StrEnum): # volume (VOLUME_*) VOLUME = "volume" + # weight/mass (g, kg, mg, µg, oz, lb) + WEIGHT = "weight" + """Using weight instead of mass because is fits better with every day language""" + DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorDeviceClass)) @@ -226,6 +231,7 @@ UNIT_CONVERTERS: dict[str, type[BaseUnitConverter]] = { SensorDeviceClass.SPEED: SpeedConverter, SensorDeviceClass.TEMPERATURE: TemperatureConverter, SensorDeviceClass.VOLUME: VolumeConverter, + SensorDeviceClass.WEIGHT: MassConverter, } UNIT_RATIOS: dict[str, dict[str, float]] = { @@ -238,6 +244,7 @@ UNIT_RATIOS: dict[str, dict[str, float]] = { TEMP_KELVIN: 1.0, }, SensorDeviceClass.VOLUME: VolumeConverter.UNIT_CONVERSION, + SensorDeviceClass.WEIGHT: MassConverter.UNIT_CONVERSION, } # mypy: disallow-any-generics diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 72ef4a62c48..93ba51d2668 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -62,6 +62,7 @@ CONF_IS_VALUE = "is_value" CONF_IS_VOLATILE_ORGANIC_COMPOUNDS = "is_volatile_organic_compounds" CONF_IS_VOLTAGE = "is_voltage" CONF_IS_VOLUME = "is_volume" +CONF_IS_WEIGHT = "is_weight" ENTITY_CONDITIONS = { SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_IS_APPARENT_POWER}], @@ -96,6 +97,7 @@ ENTITY_CONDITIONS = { ], SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_IS_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_IS_VOLUME}], + SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_IS_WEIGHT}], DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_IS_VALUE}], } diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index a2b92186410..9e433cea31b 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -61,6 +61,7 @@ CONF_VALUE = "value" CONF_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" CONF_VOLTAGE = "voltage" CONF_VOLUME = "volume" +CONF_WEIGHT = "weight" ENTITY_TRIGGERS = { SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_APPARENT_POWER}], @@ -95,6 +96,7 @@ ENTITY_TRIGGERS = { ], SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_VOLUME}], + SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_WEIGHT}], DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_VALUE}], } diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index a2f7e14a79d..5241b123185 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -32,6 +32,7 @@ from homeassistant.util.unit_conversion import ( BaseUnitConverter, DistanceConverter, EnergyConverter, + MassConverter, PowerConverter, PressureConverter, SpeedConverter, @@ -67,6 +68,7 @@ UNIT_CONVERTERS: dict[str, type[BaseUnitConverter]] = { SensorDeviceClass.SPEED: SpeedConverter, SensorDeviceClass.TEMPERATURE: TemperatureConverter, SensorDeviceClass.VOLUME: VolumeConverter, + SensorDeviceClass.WEIGHT: MassConverter, } # Keep track of entities for which a warning about decreasing value has been logged diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index affc1a8e3e9..3584777bb5f 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -32,7 +32,8 @@ "is_value": "Current {entity_name} value", "is_volatile_organic_compounds": "Current {entity_name} volatile organic compounds concentration level", "is_voltage": "Current {entity_name} voltage", - "is_volume": "Current {entity_name} volume" + "is_volume": "Current {entity_name} volume", + "is_weight": "Current {entity_name} weight" }, "trigger_type": { "apparent_power": "{entity_name} apparent power changes", @@ -65,7 +66,8 @@ "value": "{entity_name} value changes", "volatile_organic_compounds": "{entity_name} volatile organic compounds concentration changes", "voltage": "{entity_name} voltage changes", - "volume": "{entity_name} volume changes" + "volume": "{entity_name} volume changes", + "weight": "{entity_name} weight changes" } }, "state": { diff --git a/homeassistant/components/sensor/translations/en.json b/homeassistant/components/sensor/translations/en.json index fd801bd1416..1eeb31aa15c 100644 --- a/homeassistant/components/sensor/translations/en.json +++ b/homeassistant/components/sensor/translations/en.json @@ -31,7 +31,8 @@ "is_value": "Current {entity_name} value", "is_volatile_organic_compounds": "Current {entity_name} volatile organic compounds concentration level", "is_voltage": "Current {entity_name} voltage", - "is_volume": "Current {entity_name} volume" + "is_volume": "Current {entity_name} volume", + "is_weight": "Current {entity_name} weight" }, "trigger_type": { "apparent_power": "{entity_name} apparent power changes", @@ -64,7 +65,8 @@ "value": "{entity_name} value changes", "volatile_organic_compounds": "{entity_name} volatile organic compounds concentration changes", "voltage": "{entity_name} voltage changes", - "volume": "{entity_name} volume changes" + "volume": "{entity_name} volume changes", + "weight": "{entity_name} weight changes" } }, "state": { diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 45feb083611..30bfbed5785 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -15,6 +15,12 @@ from homeassistant.const import ( LENGTH_MILES, LENGTH_MILLIMETERS, LENGTH_YARD, + MASS_GRAMS, + MASS_KILOGRAMS, + MASS_MICROGRAMS, + MASS_MILLIGRAMS, + MASS_OUNCES, + MASS_POUNDS, POWER_KILO_WATT, POWER_WATT, PRESSURE_BAR, @@ -63,6 +69,10 @@ _NAUTICAL_MILE_TO_M = 1852 # 1 nautical mile = 1852 m _HRS_TO_SECS = 60 * 60 # 1 hr = 3600 seconds _DAYS_TO_SECS = 24 * _HRS_TO_SECS # 1 day = 24 hours = 86400 seconds +# Mass conversion constants +_POUND_TO_G = 453.59237 +_OUNCE_TO_G = _POUND_TO_G / 16 + # Volume conversion constants _L_TO_CUBIC_METER = 0.001 # 1 L = 0.001 m³ _ML_TO_CUBIC_METER = 0.001 * _L_TO_CUBIC_METER # 1 mL = 0.001 L @@ -157,6 +167,29 @@ class EnergyConverter(BaseUnitConverterWithUnitConversion): } +class MassConverter(BaseUnitConverterWithUnitConversion): + """Utility to convert mass values.""" + + UNIT_CLASS = "mass" + NORMALIZED_UNIT = MASS_GRAMS + UNIT_CONVERSION: dict[str, float] = { + MASS_MICROGRAMS: 1 * 1000 * 1000, + MASS_MILLIGRAMS: 1 * 1000, + MASS_GRAMS: 1, + MASS_KILOGRAMS: 1 / 1000, + MASS_OUNCES: 1 / _OUNCE_TO_G, + MASS_POUNDS: 1 / _POUND_TO_G, + } + VALID_UNITS = { + MASS_GRAMS, + MASS_KILOGRAMS, + MASS_MILLIGRAMS, + MASS_MICROGRAMS, + MASS_OUNCES, + MASS_POUNDS, + } + + class PowerConverter(BaseUnitConverterWithUnitConversion): """Utility to convert power values.""" diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index f9b90dc5bd5..a9ea9ce0fbe 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -12,6 +12,8 @@ from homeassistant.const import ( LENGTH_INCHES, LENGTH_KILOMETERS, LENGTH_MILES, + MASS_GRAMS, + MASS_OUNCES, PRESSURE_HPA, PRESSURE_INHG, PRESSURE_KPA, @@ -578,6 +580,31 @@ async def test_custom_unit( 100, SensorDeviceClass.VOLUME, ), + # Weight + ( + MASS_GRAMS, + MASS_OUNCES, + MASS_OUNCES, + 100, + 3.5, + SensorDeviceClass.WEIGHT, + ), + ( + MASS_OUNCES, + MASS_GRAMS, + MASS_GRAMS, + 78, + 2211, + SensorDeviceClass.WEIGHT, + ), + ( + MASS_GRAMS, + "peer_distance", + MASS_GRAMS, + 100, + 100, + SensorDeviceClass.WEIGHT, + ), ], ) async def test_custom_unit_change( diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index c23a7cd9089..9bddaa8af71 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -100,6 +100,8 @@ def set_time_zone(): ("temperature", "°F", "°F", "°C", "temperature", 13.050847, -10, 30), ("volume", "m³", "m³", "m³", "volume", 13.050847, -10, 30), ("volume", "ft³", "ft³", "m³", "volume", 13.050847, -10, 30), + ("weight", "g", "g", "g", "mass", 13.050847, -10, 30), + ("weight", "oz", "oz", "g", "mass", 13.050847, -10, 30), ], ) def test_compile_hourly_statistics( @@ -367,6 +369,8 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes (IMPERIAL_SYSTEM, "monetary", "SEK", "SEK", "SEK", None, 1), (IMPERIAL_SYSTEM, "volume", "m³", "m³", "m³", "volume", 1), (IMPERIAL_SYSTEM, "volume", "ft³", "ft³", "m³", "volume", 1), + (IMPERIAL_SYSTEM, "weight", "g", "g", "g", "mass", 1), + (IMPERIAL_SYSTEM, "weight", "oz", "oz", "g", "mass", 1), (METRIC_SYSTEM, "distance", "m", "m", "m", "distance", 1), (METRIC_SYSTEM, "distance", "mi", "mi", "m", "distance", 1), (METRIC_SYSTEM, "energy", "kWh", "kWh", "kWh", "energy", 1), @@ -377,6 +381,8 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes (METRIC_SYSTEM, "monetary", "SEK", "SEK", "SEK", None, 1), (METRIC_SYSTEM, "volume", "m³", "m³", "m³", "volume", 1), (METRIC_SYSTEM, "volume", "ft³", "ft³", "m³", "volume", 1), + (METRIC_SYSTEM, "weight", "g", "g", "g", "mass", 1), + (METRIC_SYSTEM, "weight", "oz", "oz", "g", "mass", 1), ], ) async def test_compile_hourly_sum_statistics_amount( @@ -1577,6 +1583,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): ("temperature", "°F", 30), ("volume", "m³", 30), ("volume", "ft³", 30), + ("weight", "g", 30), + ("weight", "oz", 30), ], ) def test_compile_hourly_statistics_unchanged( @@ -1670,6 +1678,8 @@ def test_compile_hourly_statistics_partially_unavailable(hass_recorder, caplog): ("temperature", "°F", 30), ("volume", "m³", 30), ("volume", "ft³", 30), + ("weight", "g", 30), + ("weight", "oz", 30), ], ) def test_compile_hourly_statistics_unavailable( @@ -1765,6 +1775,10 @@ def test_compile_hourly_statistics_fails(hass_recorder, caplog): ("measurement", "volume", "ft³", "ft³", "m³", "volume", "mean"), ("total", "volume", "m³", "m³", "m³", "volume", "sum"), ("total", "volume", "ft³", "ft³", "m³", "volume", "sum"), + ("measurement", "weight", "g", "g", "g", "mass", "mean"), + ("measurement", "weight", "oz", "oz", "g", "mass", "mean"), + ("total", "weight", "g", "g", "g", "mass", "sum"), + ("total", "weight", "oz", "oz", "g", "mass", "sum"), ], ) def test_list_statistic_ids( diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index a1835c87f85..5e24c51f6b9 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -13,6 +13,12 @@ from homeassistant.const import ( LENGTH_MILES, LENGTH_MILLIMETERS, LENGTH_YARD, + MASS_GRAMS, + MASS_KILOGRAMS, + MASS_MICROGRAMS, + MASS_MILLIGRAMS, + MASS_OUNCES, + MASS_POUNDS, POWER_KILO_WATT, POWER_WATT, PRESSURE_CBAR, @@ -46,6 +52,7 @@ from homeassistant.util.unit_conversion import ( BaseUnitConverter, DistanceConverter, EnergyConverter, + MassConverter, PowerConverter, PressureConverter, SpeedConverter, @@ -70,6 +77,12 @@ INVALID_SYMBOL = "bob" (EnergyConverter, ENERGY_WATT_HOUR), (EnergyConverter, ENERGY_KILO_WATT_HOUR), (EnergyConverter, ENERGY_MEGA_WATT_HOUR), + (MassConverter, MASS_GRAMS), + (MassConverter, MASS_KILOGRAMS), + (MassConverter, MASS_MICROGRAMS), + (MassConverter, MASS_MILLIGRAMS), + (MassConverter, MASS_OUNCES), + (MassConverter, MASS_POUNDS), (PowerConverter, POWER_WATT), (PowerConverter, POWER_KILO_WATT), (PressureConverter, PRESSURE_PA), @@ -107,6 +120,7 @@ def test_convert_same_unit(converter: type[BaseUnitConverter], valid_unit: str) [ (DistanceConverter, LENGTH_KILOMETERS), (EnergyConverter, ENERGY_KILO_WATT_HOUR), + (MassConverter, MASS_GRAMS), (PowerConverter, POWER_WATT), (PressureConverter, PRESSURE_PA), (SpeedConverter, SPEED_KILOMETERS_PER_HOUR), @@ -132,6 +146,7 @@ def test_convert_invalid_unit( [ (DistanceConverter, LENGTH_KILOMETERS, LENGTH_METERS), (EnergyConverter, ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR), + (MassConverter, MASS_GRAMS, MASS_KILOGRAMS), (PowerConverter, POWER_WATT, POWER_KILO_WATT), (PressureConverter, PRESSURE_HPA, PRESSURE_INHG), (SpeedConverter, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR), @@ -239,6 +254,51 @@ def test_energy_convert( assert EnergyConverter.convert(value, from_unit, to_unit) == expected +@pytest.mark.parametrize( + "value,from_unit,expected,to_unit", + [ + (10, MASS_KILOGRAMS, 10000, MASS_GRAMS), + (10, MASS_KILOGRAMS, 10000000, MASS_MILLIGRAMS), + (10, MASS_KILOGRAMS, 10000000000, MASS_MICROGRAMS), + (10, MASS_KILOGRAMS, pytest.approx(352.73961), MASS_OUNCES), + (10, MASS_KILOGRAMS, pytest.approx(22.046226), MASS_POUNDS), + (10, MASS_GRAMS, 0.01, MASS_KILOGRAMS), + (10, MASS_GRAMS, 10000, MASS_MILLIGRAMS), + (10, MASS_GRAMS, 10000000, MASS_MICROGRAMS), + (10, MASS_GRAMS, pytest.approx(0.35273961), MASS_OUNCES), + (10, MASS_GRAMS, pytest.approx(0.022046226), MASS_POUNDS), + (10, MASS_MILLIGRAMS, 0.00001, MASS_KILOGRAMS), + (10, MASS_MILLIGRAMS, 0.01, MASS_GRAMS), + (10, MASS_MILLIGRAMS, 10000, MASS_MICROGRAMS), + (10, MASS_MILLIGRAMS, pytest.approx(0.00035273961), MASS_OUNCES), + (10, MASS_MILLIGRAMS, pytest.approx(0.000022046226), MASS_POUNDS), + (10000, MASS_MICROGRAMS, 0.00001, MASS_KILOGRAMS), + (10000, MASS_MICROGRAMS, 0.01, MASS_GRAMS), + (10000, MASS_MICROGRAMS, 10, MASS_MILLIGRAMS), + (10000, MASS_MICROGRAMS, pytest.approx(0.00035273961), MASS_OUNCES), + (10000, MASS_MICROGRAMS, pytest.approx(0.000022046226), MASS_POUNDS), + (1, MASS_POUNDS, 0.45359237, MASS_KILOGRAMS), + (1, MASS_POUNDS, 453.59237, MASS_GRAMS), + (1, MASS_POUNDS, 453592.37, MASS_MILLIGRAMS), + (1, MASS_POUNDS, 453592370, MASS_MICROGRAMS), + (1, MASS_POUNDS, 16, MASS_OUNCES), + (16, MASS_OUNCES, 0.45359237, MASS_KILOGRAMS), + (16, MASS_OUNCES, 453.59237, MASS_GRAMS), + (16, MASS_OUNCES, 453592.37, MASS_MILLIGRAMS), + (16, MASS_OUNCES, 453592370, MASS_MICROGRAMS), + (16, MASS_OUNCES, 1, MASS_POUNDS), + ], +) +def test_mass_convert( + value: float, + from_unit: str, + expected: float, + to_unit: str, +) -> None: + """Test conversion to other units.""" + assert MassConverter.convert(value, from_unit, to_unit) == expected + + @pytest.mark.parametrize( "value,from_unit,expected,to_unit", [ -- GitLab