From 72e6ca55ba8eb5886e3cd032d8522ee2727b7fe8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 29 Jan 2026 20:34:09 +0100 Subject: [PATCH] Fix use of ambiguous units for reactive power and energy (#161810) --- homeassistant/components/number/const.py | 8 +- homeassistant/components/sensor/const.py | 8 +- tests/components/mqtt/test_sensor.py | 128 +++++++++++++++++++---- tests/components/sensor/test_recorder.py | 35 +++++++ 4 files changed, 156 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 225870d82726..c36f1ba3d052 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -594,7 +594,8 @@ UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = { } # We translate units that were using using the legacy coding of μ \u00b5 -# to units using recommended coding of μ \u03bc +# to units using recommended coding of μ \u03bc and +# we convert alternative accepted units to the preferred unit. AMBIGUOUS_UNITS: dict[str | None, str] = { "\u00b5Sv/h": "μSv/h", # aranet: radiation rate "\u00b5S/cm": UnitOfConductivity.MICROSIEMENS_PER_CM, @@ -604,4 +605,9 @@ AMBIGUOUS_UNITS: dict[str | None, str] = { "\u00b5mol/s⋅m²": "μmol/s⋅m²", # fyta: light "\u00b5g": UnitOfMass.MICROGRAMS, "\u00b5s": UnitOfTime.MICROSECONDS, + "mVAr": UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + "VAr": UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + "kVAr": UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + "VArh": UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, + "kVArh": UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, } diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index e1109089c276..108ed318ccb6 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -840,7 +840,8 @@ STATE_CLASS_UNITS: dict[SensorStateClass | str, set[type[StrEnum] | str | None]] } # We translate units that were using using the legacy coding of μ \u00b5 -# to units using recommended coding of μ \u03bc +# to units using recommended coding of μ \u03bc and +# we convert alternative accepted units to the preferred unit. AMBIGUOUS_UNITS: dict[str | None, str] = { "\u00b5Sv/h": "μSv/h", # aranet: radiation rate "\u00b5S/cm": UnitOfConductivity.MICROSIEMENS_PER_CM, @@ -850,4 +851,9 @@ AMBIGUOUS_UNITS: dict[str | None, str] = { "\u00b5mol/s⋅m²": "μmol/s⋅m²", # fyta: light "\u00b5g": UnitOfMass.MICROGRAMS, "\u00b5s": UnitOfTime.MICROSECONDS, + "mVAr": UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + "VAr": UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + "kVAr": UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + "VArh": UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, + "kVArh": UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, } diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index f7198095aa2a..3bdd8ee10b2b 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -2,6 +2,7 @@ import copy from datetime import datetime, timedelta +from enum import StrEnum import json import logging from pathlib import Path @@ -20,6 +21,8 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfElectricPotential, + UnitOfReactiveEnergy, + UnitOfReactivePower, UnitOfTemperature, ) from homeassistant.core import Event, HomeAssistant, State, callback @@ -909,24 +912,113 @@ async def test_invalid_unit_of_measurement( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "device_class", "unit", "equivalent_unit"), [ - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_topic": "test-topic", - "device_class": "voltage", - "unit_of_measurement": "\u00b5V", # microVolt + pytest.param( + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "voltage", + "unit_of_measurement": "\u00b5V", # microVolt + } } - } - } + }, + "voltage", + UnitOfElectricPotential.MICROVOLT, + "\u00b5V", + id="microvolt", + ), + pytest.param( + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "reactive_power", + "unit_of_measurement": "mVAr", + } + } + }, + "reactive_power", + UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE, + "mVAr", + id="mvar", + ), + pytest.param( + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "reactive_power", + "unit_of_measurement": "VAr", + } + } + }, + "reactive_power", + UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + "VAr", + id="var", + ), + pytest.param( + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "reactive_power", + "unit_of_measurement": "kVAr", + } + } + }, + "reactive_power", + UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + "kVAr", + id="kvar", + ), + pytest.param( + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "reactive_energy", + "unit_of_measurement": "VArh", + } + } + }, + "reactive_energy", + UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, + "VArh", + id="varh", + ), + pytest.param( + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "reactive_energy", + "unit_of_measurement": "kVArh", + } + } + }, + "reactive_energy", + UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, + "kVArh", + id="kvarh", + ), ], ) async def test_device_class_with_equivalent_unit_of_measurement_received( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, + device_class: str, + unit: StrEnum, + equivalent_unit: str, ) -> None: """Test device_class with equivalent unit of measurement.""" assert await mqtt_mock_entry() @@ -935,20 +1027,17 @@ async def test_device_class_with_equivalent_unit_of_measurement_received( state = hass.states.get("sensor.test") assert state is not None assert state.state == "100" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - is UnitOfElectricPotential.MICROVOLT - ) + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is unit caplog.clear() discovery_payload = { "name": "bla", "state_topic": "test-topic2", - "device_class": "voltage", - "unit_of_measurement": "\u00b5V", + "device_class": device_class, + "unit_of_measurement": equivalent_unit, } - # Now discover a sensor with an altarantive mu char + # Now discover a sensor with an ambiguous unit async_fire_mqtt_message( hass, "homeassistant/sensor/bla/config", json.dumps(discovery_payload) ) @@ -958,10 +1047,7 @@ async def test_device_class_with_equivalent_unit_of_measurement_received( state = hass.states.get("sensor.bla") assert state is not None assert state.state == "21" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - is UnitOfElectricPotential.MICROVOLT - ) + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is unit @pytest.mark.parametrize( diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 2d74c14c1feb..333b7c243ec5 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -3892,6 +3892,21 @@ async def test_compile_hourly_statistics_convert_units_1( ), (None, "\u00b5g", "\u03bcg", None, "mass", 13.050847, 13.333333, -10, 30), (None, "\u00b5s", "\u03bcs", None, "duration", 13.050847, 13.333333, -10, 30), + (None, "mVAr", "mvar", None, "reactive_power", 13.050847, 13.333333, -10, 30), + (None, "VAr", "var", None, "reactive_power", 13.050847, 13.333333, -10, 30), + (None, "kVAr", "kvar", None, "reactive_power", 13.050847, 13.333333, -10, 30), + (None, "VArh", "varh", None, "reactive_energy", 13.050847, 13.333333, -10, 30), + ( + None, + "kVArh", + "kvarh", + None, + "reactive_energy", + 13.050847, + 13.333333, + -10, + 30, + ), ], ) async def test_compile_hourly_statistics_equivalent_units_1( @@ -4032,6 +4047,16 @@ async def test_compile_hourly_statistics_equivalent_units_1( (SensorDeviceClass.WEIGHT, "\u00b5g", "\u03bcg", None, 13.333333, -10, 30), (None, "\u00b5s", "\u03bcs", None, 13.333333, -10, 30), (SensorDeviceClass.DURATION, "\u00b5s", "\u03bcs", None, 13.333333, -10, 30), + (None, "mVAr", "mvar", None, 13.333333, -10, 30), + (None, "VAr", "var", None, 13.333333, -10, 30), + (None, "kVAr", "kvar", None, 13.333333, -10, 30), + (None, "VArh", "varh", None, 13.333333, -10, 30), + (None, "kVArh", "kvarh", None, 13.333333, -10, 30), + (SensorDeviceClass.REACTIVE_POWER, "mVAr", "mvar", None, 13.333333, -10, 30), + (SensorDeviceClass.REACTIVE_POWER, "VAr", "var", None, 13.333333, -10, 30), + (SensorDeviceClass.REACTIVE_POWER, "kVAr", "kvar", None, 13.333333, -10, 30), + (SensorDeviceClass.REACTIVE_ENERGY, "VArh", "varh", None, 13.333333, -10, 30), + (SensorDeviceClass.REACTIVE_ENERGY, "kVArh", "kvarh", None, 13.333333, -10, 30), ], ) async def test_compile_hourly_statistics_equivalent_units_2( @@ -6008,6 +6033,11 @@ async def test_validate_statistics_unit_change_no_conversion( (NONE_SENSOR_ATTRIBUTES, "\u00b5mol/s⋅m²", "\u03bcmol/s⋅m²"), (NONE_SENSOR_ATTRIBUTES, "\u00b5g", "\u03bcg"), (NONE_SENSOR_ATTRIBUTES, "\u00b5s", "\u03bcs"), + (NONE_SENSOR_ATTRIBUTES, "mVAr", "mvar"), + (NONE_SENSOR_ATTRIBUTES, "VAr", "var"), + (NONE_SENSOR_ATTRIBUTES, "kVAr", "kvar"), + (NONE_SENSOR_ATTRIBUTES, "VArh", "varh"), + (NONE_SENSOR_ATTRIBUTES, "kVArh", "kvarh"), ], ) async def test_validate_statistics_unit_change_equivalent_units( @@ -6105,6 +6135,11 @@ async def test_validate_statistics_unit_change_equivalent_units( "\u00b5s", "d, h, min, ms, s, w, \u03bcs", ), + (NONE_SENSOR_ATTRIBUTES, "reactive_power", "mvar", "mVAr", "kvar, mvar, var"), + (NONE_SENSOR_ATTRIBUTES, "reactive_power", "var", "VAr", "kvar, mvar, var"), + (NONE_SENSOR_ATTRIBUTES, "reactive_power", "kvar", "kVAr", "kvar, mvar, var"), + (NONE_SENSOR_ATTRIBUTES, "reactive_energy", "varh", "VArh", "kvarh, varh"), + (NONE_SENSOR_ATTRIBUTES, "reactive_energy", "kvarh", "kVArh", "kvarh, varh"), ], ) async def test_validate_statistics_unit_change_equivalent_units_2(