From b409933d19f188730928619231cb8f8a1f903d5b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 30 Jan 2024 23:08:12 +0100 Subject: [PATCH] Add DurationConverter (#108865) * Add DurationConverter * Update withings snapshots * Add sensor test * Fix tests * Update snapshots after #108902 was merged --- .../components/recorder/statistics.py | 2 + .../components/recorder/websocket_api.py | 2 + homeassistant/components/sensor/const.py | 2 + homeassistant/util/unit_conversion.py | 29 ++++++++- tests/components/sensor/test_init.py | 17 +++++ .../withings/snapshots/test_sensor.ambr | 63 ++++++++++++------- tests/util/test_unit_conversion.py | 48 ++++++++++++++ 7 files changed, 141 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 5786c9ee542..5abe395a8d7 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -30,6 +30,7 @@ from homeassistant.util.unit_conversion import ( BaseUnitConverter, DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -126,6 +127,7 @@ QUERY_STATISTICS_SUMMARY_SUM = ( STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, **{unit: DistanceConverter for unit in DistanceConverter.VALID_UNITS}, + **{unit: DurationConverter for unit in DurationConverter.VALID_UNITS}, **{unit: ElectricCurrentConverter for unit in ElectricCurrentConverter.VALID_UNITS}, **{ unit: ElectricPotentialConverter diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 11271d1e0cd..39821cb9699 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -17,6 +17,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -57,6 +58,7 @@ UNIT_SCHEMA = vol.Schema( { vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS), vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional("duration"): vol.In(DurationConverter.VALID_UNITS), vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS), vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS), vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 861338f257a..aad882821d6 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -47,6 +47,7 @@ from homeassistant.util.unit_conversion import ( BaseUnitConverter, DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -485,6 +486,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.DATA_RATE: DataRateConverter, SensorDeviceClass.DATA_SIZE: InformationConverter, SensorDeviceClass.DISTANCE: DistanceConverter, + SensorDeviceClass.DURATION: DurationConverter, SensorDeviceClass.ENERGY: EnergyConverter, SensorDeviceClass.ENERGY_STORAGE: EnergyConverter, SensorDeviceClass.GAS: VolumeConverter, diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 15912fa2f6e..be356a8ad5f 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -20,6 +20,7 @@ from homeassistant.const import ( UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfTime, UnitOfVolume, UnitOfVolumeFlowRate, UnitOfVolumetricFlux, @@ -39,8 +40,9 @@ _MILE_TO_M = _YARD_TO_M * 1760 # 1760 yard = 1 mile (1609.344 m) _NAUTICAL_MILE_TO_M = 1852 # 1 nautical mile = 1852 m # Duration conversion constants -_HRS_TO_SECS = 60 * 60 # 1 hr = 3600 seconds +_MIN_TO_SEC = 60 # 1 min = 60 seconds _HRS_TO_MINUTES = 60 # 1 hr = 60 minutes +_HRS_TO_SECS = _HRS_TO_MINUTES * _MIN_TO_SEC # 1 hr = 60 minutes = 3600 seconds _DAYS_TO_SECS = 24 * _HRS_TO_SECS # 1 day = 24 hours = 86400 seconds # Mass conversion constants @@ -541,3 +543,28 @@ class VolumeFlowRateConverter(BaseUnitConverter): UnitOfVolumeFlowRate.LITERS_PER_MINUTE, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, } + + +class DurationConverter(BaseUnitConverter): + """Utility to convert duration values.""" + + UNIT_CLASS = "duration" + NORMALIZED_UNIT = UnitOfTime.SECONDS + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfTime.MICROSECONDS: 1000000, + UnitOfTime.MILLISECONDS: 1000, + UnitOfTime.SECONDS: 1, + UnitOfTime.MINUTES: 1 / _MIN_TO_SEC, + UnitOfTime.HOURS: 1 / _HRS_TO_SECS, + UnitOfTime.DAYS: 1 / _DAYS_TO_SECS, + UnitOfTime.WEEKS: 1 / (7 * _DAYS_TO_SECS), + } + VALID_UNITS = { + UnitOfTime.MICROSECONDS, + UnitOfTime.MILLISECONDS, + UnitOfTime.SECONDS, + UnitOfTime.MINUTES, + UnitOfTime.HOURS, + UnitOfTime.DAYS, + UnitOfTime.WEEKS, + } diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 98b3f2423cc..a120ad8db78 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -37,6 +37,7 @@ from homeassistant.const import ( UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfTime, UnitOfVolume, UnitOfVolumeFlowRate, UnitOfVolumetricFlux, @@ -599,6 +600,22 @@ async def test_restore_sensor_restore_state( 13.0, "49.2", ), + ( + SensorDeviceClass.DURATION, + UnitOfTime.SECONDS, + UnitOfTime.HOURS, + UnitOfTime.HOURS, + 5400.0, + "1.5000", + ), + ( + SensorDeviceClass.DURATION, + UnitOfTime.DAYS, + UnitOfTime.MINUTES, + UnitOfTime.MINUTES, + 0.5, + "720.0", + ), ], ) async def test_custom_unit( diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 8e3866a7561..f84fe05bb78 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -71,6 +71,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -80,7 +83,7 @@ 'supported_features': 0, 'translation_key': 'activity_active_duration_today', 'unique_id': 'withings_12345_activity_active_duration_today', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_active_time_today-state] @@ -90,13 +93,13 @@ 'friendly_name': 'henk Active time today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_active_time_today', 'last_changed': , 'last_updated': , - 'state': '1907', + 'state': '0.530', }) # --- # name: test_all_entities[sensor.henk_average_heart_rate-entry] @@ -1173,6 +1176,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1182,7 +1188,7 @@ 'supported_features': 0, 'translation_key': 'activity_intense_duration_today', 'unique_id': 'withings_12345_activity_intense_duration_today', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_intense_activity_today-state] @@ -1192,13 +1198,13 @@ 'friendly_name': 'henk Intense activity today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_intense_activity_today', 'last_changed': , 'last_updated': , - 'state': '420', + 'state': '7.0', }) # --- # name: test_all_entities[sensor.henk_intracellular_water-entry] @@ -1268,6 +1274,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1277,7 +1286,7 @@ 'supported_features': 0, 'translation_key': 'workout_duration', 'unique_id': 'withings_12345_workout_duration', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_last_workout_duration-state] @@ -1285,13 +1294,13 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Last workout duration', - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_last_workout_duration', 'last_changed': , 'last_updated': , - 'state': '255.0', + 'state': '4.25', }) # --- # name: test_all_entities[sensor.henk_last_workout_intensity-entry] @@ -1741,6 +1750,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1750,7 +1762,7 @@ 'supported_features': 0, 'translation_key': 'activity_moderate_duration_today', 'unique_id': 'withings_12345_activity_moderate_duration_today', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_moderate_activity_today-state] @@ -1760,13 +1772,13 @@ 'friendly_name': 'henk Moderate activity today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_moderate_activity_today', 'last_changed': , 'last_updated': , - 'state': '1487', + 'state': '24.8', }) # --- # name: test_all_entities[sensor.henk_muscle_mass-entry] @@ -1839,6 +1851,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1848,7 +1863,7 @@ 'supported_features': 0, 'translation_key': 'workout_pause_duration', 'unique_id': 'withings_12345_workout_pause_duration', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_pause_during_last_workout-state] @@ -1856,13 +1871,13 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Pause during last workout', - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_pause_during_last_workout', 'last_changed': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_all_entities[sensor.henk_pulse_wave_velocity-entry] @@ -2030,6 +2045,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2039,7 +2057,7 @@ 'supported_features': 0, 'translation_key': 'sleep_goal', 'unique_id': 'withings_12345_sleep_goal', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_sleep_goal-state] @@ -2048,13 +2066,13 @@ 'device_class': 'duration', 'friendly_name': 'henk Sleep goal', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_sleep_goal', 'last_changed': , 'last_updated': , - 'state': '28800', + 'state': '8.000', }) # --- # name: test_all_entities[sensor.henk_sleep_score-entry] @@ -2217,6 +2235,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2226,7 +2247,7 @@ 'supported_features': 0, 'translation_key': 'activity_soft_duration_today', 'unique_id': 'withings_12345_activity_soft_duration_today', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_soft_activity_today-state] @@ -2236,13 +2257,13 @@ 'friendly_name': 'henk Soft activity today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_soft_activity_today', 'last_changed': , 'last_updated': , - 'state': '1516', + 'state': '25.3', }) # --- # name: test_all_entities[sensor.henk_spo2-entry] diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 08d362072d4..d4649671f47 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -21,6 +21,7 @@ from homeassistant.const import ( UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfTime, UnitOfVolume, UnitOfVolumeFlowRate, UnitOfVolumetricFlux, @@ -31,6 +32,7 @@ from homeassistant.util.unit_conversion import ( BaseUnitConverter, DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -56,6 +58,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { for converter in ( DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -79,6 +82,7 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo 8, ), DistanceConverter: (UnitOfLength.KILOMETERS, UnitOfLength.METERS, 0.001), + DurationConverter: (UnitOfTime.MINUTES, UnitOfTime.SECONDS, 1 / 60), ElectricCurrentConverter: ( UnitOfElectricCurrent.AMPERE, UnitOfElectricCurrent.MILLIAMPERE, @@ -202,6 +206,50 @@ _CONVERTED_VALUE: dict[ (5000000, UnitOfLength.MILLIMETERS, 16404.2, UnitOfLength.FEET), (5000000, UnitOfLength.MILLIMETERS, 196850.5, UnitOfLength.INCHES), ], + DurationConverter: [ + (5, UnitOfTime.MICROSECONDS, 0.005, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.MICROSECONDS, 5e-6, UnitOfTime.SECONDS), + (5, UnitOfTime.MICROSECONDS, 8.333333333333333e-8, UnitOfTime.MINUTES), + (5, UnitOfTime.MICROSECONDS, 1.388888888888889e-9, UnitOfTime.HOURS), + (5, UnitOfTime.MICROSECONDS, 5.787e-11, UnitOfTime.DAYS), + (5, UnitOfTime.MICROSECONDS, 8.267195767195767e-12, UnitOfTime.WEEKS), + (5, UnitOfTime.MILLISECONDS, 5000, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.MILLISECONDS, 0.005, UnitOfTime.SECONDS), + (5, UnitOfTime.MILLISECONDS, 8.333333333333333e-5, UnitOfTime.MINUTES), + (5, UnitOfTime.MILLISECONDS, 1.388888888888889e-6, UnitOfTime.HOURS), + (5, UnitOfTime.MILLISECONDS, 5.787e-8, UnitOfTime.DAYS), + (5, UnitOfTime.MILLISECONDS, 8.267195767195767e-9, UnitOfTime.WEEKS), + (5, UnitOfTime.SECONDS, 5e6, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.SECONDS, 5000, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.SECONDS, 0.0833333, UnitOfTime.MINUTES), + (5, UnitOfTime.SECONDS, 0.00138889, UnitOfTime.HOURS), + (5, UnitOfTime.SECONDS, 5.787037037037037e-5, UnitOfTime.DAYS), + (5, UnitOfTime.SECONDS, 8.267195767195768e-06, UnitOfTime.WEEKS), + (5, UnitOfTime.MINUTES, 3e8, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.MINUTES, 300000, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.MINUTES, 300, UnitOfTime.SECONDS), + (5, UnitOfTime.MINUTES, 0.0833333, UnitOfTime.HOURS), + (5, UnitOfTime.MINUTES, 0.00347222, UnitOfTime.DAYS), + (5, UnitOfTime.MINUTES, 0.000496031746031746, UnitOfTime.WEEKS), + (5, UnitOfTime.HOURS, 18000000000, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.HOURS, 18000000, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.HOURS, 18000, UnitOfTime.SECONDS), + (5, UnitOfTime.HOURS, 300, UnitOfTime.MINUTES), + (5, UnitOfTime.HOURS, 0.208333333, UnitOfTime.DAYS), + (5, UnitOfTime.HOURS, 0.02976190476190476, UnitOfTime.WEEKS), + (5, UnitOfTime.DAYS, 4.32e11, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.DAYS, 4.32e8, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.DAYS, 432000, UnitOfTime.SECONDS), + (5, UnitOfTime.DAYS, 7200, UnitOfTime.MINUTES), + (5, UnitOfTime.DAYS, 120, UnitOfTime.HOURS), + (5, UnitOfTime.DAYS, 0.7142857142857143, UnitOfTime.WEEKS), + (5, UnitOfTime.WEEKS, 3.024e12, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.WEEKS, 3.024e9, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.WEEKS, 3024000, UnitOfTime.SECONDS), + (5, UnitOfTime.WEEKS, 50400, UnitOfTime.MINUTES), + (5, UnitOfTime.WEEKS, 840, UnitOfTime.HOURS), + (5, UnitOfTime.WEEKS, 35, UnitOfTime.DAYS), + ], ElectricCurrentConverter: [ (5, UnitOfElectricCurrent.AMPERE, 5000, UnitOfElectricCurrent.MILLIAMPERE), (5, UnitOfElectricCurrent.MILLIAMPERE, 0.005, UnitOfElectricCurrent.AMPERE),