diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index bf81d42d2fa..6f82626dc37 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -33,6 +33,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( BaseUnitConverter, + DistanceConverter, EnergyConverter, PowerConverter, PressureConverter, @@ -122,6 +123,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, PowerConverter.NORMALIZED_UNIT: PowerConverter.UNIT_CLASS, PressureConverter.NORMALIZED_UNIT: PressureConverter.UNIT_CLASS, @@ -130,6 +132,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, PowerConverter.NORMALIZED_UNIT: PowerConverter, PressureConverter.NORMALIZED_UNIT: PressureConverter, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index a4bb1da59e8..2b500fb428a 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -21,6 +21,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import JSON_DUMP from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( + DistanceConverter, EnergyConverter, PowerConverter, PressureConverter, @@ -123,6 +124,7 @@ async def ws_handle_get_statistics_during_period( vol.Required("period"): vol.Any("5minute", "hour", "day", "month"), vol.Optional("units"): vol.Schema( { + vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS), @@ -299,8 +301,8 @@ async def ws_adjust_sum_statistics( ) -> None: """Adjust sum statistics. - If the statistics is stored as kWh, it's allowed to make an adjustment in Wh or MWh - If the statistics is stored as m³, it's allowed to make an adjustment in ft³ + If the statistics is stored as NORMALIZED_UNIT, + it's allowed to make an adjustment in VALID_UNIT """ start_time_str = msg["start_time"] @@ -322,6 +324,11 @@ async def ws_adjust_sum_statistics( def valid_units(statistics_unit: str | None, display_unit: str | None) -> bool: if statistics_unit == display_unit: return True + if ( + statistics_unit == DistanceConverter.NORMALIZED_UNIT + and display_unit in DistanceConverter.VALID_UNITS + ): + return True if statistics_unit == ENERGY_KILO_WATT_HOUR and display_unit in ( ENERGY_MEGA_WATT_HOUR, ENERGY_WATT_HOUR, diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index ab19f053a22..29157d01660 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -60,6 +60,7 @@ from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( BaseUnitConverter, + DistanceConverter, PressureConverter, TemperatureConverter, ) @@ -102,6 +103,9 @@ class SensorDeviceClass(StrEnum): # date (ISO8601) DATE = "date" + # distance (LENGTH_*) + DISTANCE = "distance" + # fixed duration (TIME_DAYS, TIME_HOURS, TIME_MINUTES, TIME_SECONDS) DURATION = "duration" @@ -209,11 +213,13 @@ STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] UNIT_CONVERTERS: dict[str, type[BaseUnitConverter]] = { + SensorDeviceClass.DISTANCE: DistanceConverter, SensorDeviceClass.PRESSURE: PressureConverter, SensorDeviceClass.TEMPERATURE: TemperatureConverter, } UNIT_RATIOS: dict[str, dict[str, float]] = { + SensorDeviceClass.DISTANCE: DistanceConverter.UNIT_CONVERSION, SensorDeviceClass.PRESSURE: PressureConverter.UNIT_CONVERSION, SensorDeviceClass.TEMPERATURE: { TEMP_CELSIUS: 1.0, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 437671a7e30..6ecce8b1a13 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -36,6 +36,7 @@ CONF_IS_BATTERY_LEVEL = "is_battery_level" CONF_IS_CO = "is_carbon_monoxide" CONF_IS_CO2 = "is_carbon_dioxide" CONF_IS_CURRENT = "is_current" +CONF_IS_DISTANCE = "is_distance" CONF_IS_ENERGY = "is_energy" CONF_IS_FREQUENCY = "is_frequency" CONF_IS_HUMIDITY = "is_humidity" @@ -66,6 +67,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.CO: [{CONF_TYPE: CONF_IS_CO}], SensorDeviceClass.CO2: [{CONF_TYPE: CONF_IS_CO2}], SensorDeviceClass.CURRENT: [{CONF_TYPE: CONF_IS_CURRENT}], + SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_IS_DISTANCE}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_IS_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_IS_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_IS_GAS}], @@ -104,6 +106,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_CO, CONF_IS_CO2, CONF_IS_CURRENT, + CONF_IS_DISTANCE, CONF_IS_ENERGY, CONF_IS_FREQUENCY, CONF_IS_GAS, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 3cce6b74a81..cd009842b97 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -35,6 +35,7 @@ CONF_BATTERY_LEVEL = "battery_level" CONF_CO = "carbon_monoxide" CONF_CO2 = "carbon_dioxide" CONF_CURRENT = "current" +CONF_DISTANCE = "distance" CONF_ENERGY = "energy" CONF_FREQUENCY = "frequency" CONF_GAS = "gas" @@ -65,6 +66,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.CO: [{CONF_TYPE: CONF_CO}], SensorDeviceClass.CO2: [{CONF_TYPE: CONF_CO2}], SensorDeviceClass.CURRENT: [{CONF_TYPE: CONF_CURRENT}], + SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_DISTANCE}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_GAS}], @@ -104,6 +106,7 @@ TRIGGER_SCHEMA = vol.All( CONF_CO, CONF_CO2, CONF_CURRENT, + CONF_DISTANCE, CONF_ENERGY, CONF_FREQUENCY, CONF_GAS, diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index cfa6d5a36fe..5196dc562df 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -30,6 +30,7 @@ from homeassistant.helpers.entity import entity_sources from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( BaseUnitConverter, + DistanceConverter, EnergyConverter, PowerConverter, PressureConverter, @@ -57,6 +58,7 @@ DEFAULT_STATISTICS = { } UNIT_CONVERTERS: dict[str, type[BaseUnitConverter]] = { + SensorDeviceClass.DISTANCE: DistanceConverter, SensorDeviceClass.ENERGY: EnergyConverter, SensorDeviceClass.POWER: PowerConverter, SensorDeviceClass.PRESSURE: PressureConverter, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index bac12bd8cb2..d79f1035f62 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -6,6 +6,7 @@ "is_battery_level": "Current {entity_name} battery level", "is_carbon_monoxide": "Current {entity_name} carbon monoxide concentration level", "is_carbon_dioxide": "Current {entity_name} carbon dioxide concentration level", + "is_distance": "Current {entity_name} distance", "is_gas": "Current {entity_name} gas", "is_humidity": "Current {entity_name} humidity", "is_illuminance": "Current {entity_name} illuminance", @@ -36,6 +37,7 @@ "battery_level": "{entity_name} battery level changes", "carbon_monoxide": "{entity_name} carbon monoxide concentration changes", "carbon_dioxide": "{entity_name} carbon dioxide concentration changes", + "distance": "{entity_name} distance changes", "gas": "{entity_name} gas changes", "humidity": "{entity_name} humidity changes", "illuminance": "{entity_name} illuminance changes", diff --git a/homeassistant/components/sensor/translations/en.json b/homeassistant/components/sensor/translations/en.json index 5c38db03687..7e2223521eb 100644 --- a/homeassistant/components/sensor/translations/en.json +++ b/homeassistant/components/sensor/translations/en.json @@ -6,6 +6,7 @@ "is_carbon_dioxide": "Current {entity_name} carbon dioxide concentration level", "is_carbon_monoxide": "Current {entity_name} carbon monoxide concentration level", "is_current": "Current {entity_name} current", + "is_distance": "Current {entity_name} distance", "is_energy": "Current {entity_name} energy", "is_frequency": "Current {entity_name} frequency", "is_gas": "Current {entity_name} gas", @@ -36,6 +37,7 @@ "carbon_dioxide": "{entity_name} carbon dioxide concentration changes", "carbon_monoxide": "{entity_name} carbon monoxide concentration changes", "current": "{entity_name} current changes", + "distance": "{entity_name} distance changes", "energy": "{entity_name} energy changes", "frequency": "{entity_name} frequency changes", "gas": "{entity_name} gas changes", diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 39a50010f1b..4790889951b 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -30,6 +30,16 @@ from .common import ( from tests.common import async_fire_time_changed +DISTANCE_SENSOR_FT_ATTRIBUTES = { + "device_class": "distance", + "state_class": "measurement", + "unit_of_measurement": "ft", +} +DISTANCE_SENSOR_M_ATTRIBUTES = { + "device_class": "distance", + "state_class": "measurement", + "unit_of_measurement": "m", +} ENERGY_SENSOR_KWH_ATTRIBUTES = { "device_class": "energy", "state_class": "total", @@ -141,6 +151,9 @@ async def test_statistics_during_period(hass, hass_ws_client, recorder_mock): @pytest.mark.parametrize( "attributes, state, value, custom_units, converted_value", [ + (DISTANCE_SENSOR_M_ATTRIBUTES, 10, 10, {"distance": "cm"}, 1000), + (DISTANCE_SENSOR_M_ATTRIBUTES, 10, 10, {"distance": "m"}, 10), + (DISTANCE_SENSOR_M_ATTRIBUTES, 10, 10, {"distance": "in"}, 10 / 0.0254), (POWER_SENSOR_KW_ATTRIBUTES, 10, 10, {"power": "W"}, 10000), (POWER_SENSOR_KW_ATTRIBUTES, 10, 10, {"power": "kW"}, 10), (PRESSURE_SENSOR_HPA_ATTRIBUTES, 10, 10, {"pressure": "Pa"}, 1000), @@ -327,6 +340,7 @@ async def test_sum_statistics_during_period_unit_conversion( @pytest.mark.parametrize( "custom_units", [ + {"distance": "L"}, {"energy": "W"}, {"power": "Pa"}, {"pressure": "K"}, @@ -538,6 +552,10 @@ async def test_statistics_during_period_bad_end_time( @pytest.mark.parametrize( "units, attributes, display_unit, statistics_unit, unit_class", [ + (IMPERIAL_SYSTEM, DISTANCE_SENSOR_M_ATTRIBUTES, "m", "m", "distance"), + (METRIC_SYSTEM, DISTANCE_SENSOR_M_ATTRIBUTES, "m", "m", "distance"), + (IMPERIAL_SYSTEM, DISTANCE_SENSOR_FT_ATTRIBUTES, "ft", "m", "distance"), + (METRIC_SYSTEM, DISTANCE_SENSOR_FT_ATTRIBUTES, "ft", "m", "distance"), (IMPERIAL_SYSTEM, ENERGY_SENSOR_WH_ATTRIBUTES, "Wh", "kWh", "energy"), (METRIC_SYSTEM, ENERGY_SENSOR_WH_ATTRIBUTES, "Wh", "kWh", "energy"), (IMPERIAL_SYSTEM, GAS_SENSOR_FT3_ATTRIBUTES, "ft³", "m³", "volume"), diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 3a593b0e6cc..ad672b56801 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -8,6 +8,10 @@ from pytest import approx from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, + LENGTH_CENTIMETERS, + LENGTH_INCHES, + LENGTH_KILOMETERS, + LENGTH_MILES, PRESSURE_HPA, PRESSURE_INHG, PRESSURE_KPA, @@ -455,14 +459,67 @@ async def test_custom_unit( @pytest.mark.parametrize( - "native_unit,custom_unit,state_unit,native_value,custom_value", + "native_unit,custom_unit,state_unit,native_value,custom_value,device_class", [ + # Distance + ( + LENGTH_KILOMETERS, + LENGTH_MILES, + LENGTH_MILES, + 1000, + 621, + SensorDeviceClass.DISTANCE, + ), + ( + LENGTH_CENTIMETERS, + LENGTH_INCHES, + LENGTH_INCHES, + 7.24, + 2.85, + SensorDeviceClass.DISTANCE, + ), + ( + LENGTH_KILOMETERS, + "peer_distance", + LENGTH_KILOMETERS, + 1000, + 1000, + SensorDeviceClass.DISTANCE, + ), # Smaller to larger unit, InHg is ~33x larger than hPa -> 1 more decimal - (PRESSURE_HPA, PRESSURE_INHG, PRESSURE_INHG, 1000.0, 29.53), - (PRESSURE_KPA, PRESSURE_HPA, PRESSURE_HPA, 1.234, 12.34), - (PRESSURE_HPA, PRESSURE_MMHG, PRESSURE_MMHG, 1000, 750), + ( + PRESSURE_HPA, + PRESSURE_INHG, + PRESSURE_INHG, + 1000.0, + 29.53, + SensorDeviceClass.PRESSURE, + ), + ( + PRESSURE_KPA, + PRESSURE_HPA, + PRESSURE_HPA, + 1.234, + 12.34, + SensorDeviceClass.PRESSURE, + ), + ( + PRESSURE_HPA, + PRESSURE_MMHG, + PRESSURE_MMHG, + 1000, + 750, + SensorDeviceClass.PRESSURE, + ), # Not a supported pressure unit - (PRESSURE_HPA, "peer_pressure", PRESSURE_HPA, 1000, 1000), + ( + PRESSURE_HPA, + "peer_pressure", + PRESSURE_HPA, + 1000, + 1000, + SensorDeviceClass.PRESSURE, + ), ], ) async def test_custom_unit_change( @@ -473,6 +530,7 @@ async def test_custom_unit_change( state_unit, native_value, custom_value, + device_class, ): """Test custom unit changes are picked up.""" entity_registry = er.async_get(hass) @@ -482,7 +540,7 @@ async def test_custom_unit_change( name="Test", native_value=str(native_value), native_unit_of_measurement=native_unit, - device_class=SensorDeviceClass.PRESSURE, + device_class=device_class, unique_id="very_unique", ) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 8b9671fbe9c..1bb95888e92 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -85,6 +85,8 @@ def set_time_zone(): (None, "%", "%", "%", None, 13.050847, -10, 30), ("battery", "%", "%", "%", None, 13.050847, -10, 30), ("battery", None, None, None, None, 13.050847, -10, 30), + ("distance", "m", "m", "m", "distance", 13.050847, -10, 30), + ("distance", "mi", "mi", "m", "distance", 13.050847, -10, 30), ("humidity", "%", "%", "%", None, 13.050847, -10, 30), ("humidity", None, None, None, None, 13.050847, -10, 30), ("pressure", "Pa", "Pa", "Pa", "pressure", 13.050847, -10, 30), @@ -351,12 +353,16 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes @pytest.mark.parametrize( "units, device_class, state_unit, display_unit, statistics_unit, unit_class, factor", [ + (IMPERIAL_SYSTEM, "distance", "m", "m", "m", "distance", 1), + (IMPERIAL_SYSTEM, "distance", "mi", "mi", "m", "distance", 1), (IMPERIAL_SYSTEM, "energy", "kWh", "kWh", "kWh", "energy", 1), (IMPERIAL_SYSTEM, "energy", "Wh", "Wh", "kWh", "energy", 1), (IMPERIAL_SYSTEM, "monetary", "EUR", "EUR", "EUR", None, 1), (IMPERIAL_SYSTEM, "monetary", "SEK", "SEK", "SEK", None, 1), (IMPERIAL_SYSTEM, "gas", "m³", "m³", "m³", "volume", 1), (IMPERIAL_SYSTEM, "gas", "ft³", "ft³", "m³", "volume", 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), (METRIC_SYSTEM, "energy", "Wh", "Wh", "kWh", "energy", 1), (METRIC_SYSTEM, "monetary", "EUR", "EUR", "EUR", None, 1), @@ -1548,6 +1554,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): [ ("battery", "%", 30), ("battery", None, 30), + ("distance", "m", 30), + ("distance", "mi", 30), ("humidity", "%", 30), ("humidity", None, 30), ("pressure", "Pa", 30), @@ -1635,6 +1643,8 @@ def test_compile_hourly_statistics_partially_unavailable(hass_recorder, caplog): [ ("battery", "%", 30), ("battery", None, 30), + ("distance", "m", 30), + ("distance", "mi", 30), ("humidity", "%", 30), ("humidity", None, 30), ("pressure", "Pa", 30), @@ -1708,6 +1718,10 @@ def test_compile_hourly_statistics_fails(hass_recorder, caplog): [ ("measurement", "battery", "%", "%", "%", None, "mean"), ("measurement", "battery", None, None, None, None, "mean"), + ("measurement", "distance", "m", "m", "m", "distance", "mean"), + ("measurement", "distance", "mi", "mi", "m", "distance", "mean"), + ("total", "distance", "m", "m", "m", "distance", "sum"), + ("total", "distance", "mi", "mi", "m", "distance", "sum"), ("total", "energy", "Wh", "Wh", "kWh", "energy", "sum"), ("total", "energy", "kWh", "kWh", "kWh", "energy", "sum"), ("measurement", "energy", "Wh", "Wh", "kWh", "energy", "mean"),