Add unit compatibility to number entity platform

This commit is contained in:
jbouwh 2025-05-28 07:08:29 +00:00
parent b132aef4b5
commit 9c859dbc3b
6 changed files with 78 additions and 7 deletions

View File

@ -31,6 +31,7 @@ from homeassistant.loader import async_suggest_report_issue
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
from .const import ( # noqa: F401 from .const import ( # noqa: F401
AMBIGUOUS_UNITS,
ATTR_MAX, ATTR_MAX,
ATTR_MIN, ATTR_MIN,
ATTR_STEP, ATTR_STEP,
@ -367,6 +368,15 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return self.entity_description.native_unit_of_measurement return self.entity_description.native_unit_of_measurement
return None return None
@final
@property
def __native_unit_of_measurement_compat(self) -> str | None:
"""Process ambiguous units."""
native_unit_of_measurement = self.native_unit_of_measurement
return AMBIGUOUS_UNITS.get(
native_unit_of_measurement, native_unit_of_measurement
)
@property @property
@final @final
def unit_of_measurement(self) -> str | None: def unit_of_measurement(self) -> str | None:
@ -374,7 +384,7 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if self._number_option_unit_of_measurement: if self._number_option_unit_of_measurement:
return self._number_option_unit_of_measurement return self._number_option_unit_of_measurement
native_unit_of_measurement = self.native_unit_of_measurement native_unit_of_measurement = self.__native_unit_of_measurement_compat
# device_class is checked after native_unit_of_measurement since most # device_class is checked after native_unit_of_measurement since most
# of the time we can avoid the device_class check # of the time we can avoid the device_class check
if ( if (
@ -441,7 +451,7 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if device_class not in UNIT_CONVERTERS: if device_class not in UNIT_CONVERTERS:
return value return value
native_unit_of_measurement = self.native_unit_of_measurement native_unit_of_measurement = self.__native_unit_of_measurement_compat
unit_of_measurement = self.unit_of_measurement unit_of_measurement = self.unit_of_measurement
if native_unit_of_measurement != unit_of_measurement: if native_unit_of_measurement != unit_of_measurement:
if TYPE_CHECKING: if TYPE_CHECKING:
@ -470,7 +480,7 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if value is None or (device_class := self.device_class) not in UNIT_CONVERTERS: if value is None or (device_class := self.device_class) not in UNIT_CONVERTERS:
return value return value
native_unit_of_measurement = self.native_unit_of_measurement native_unit_of_measurement = self.__native_unit_of_measurement_compat
unit_of_measurement = self.unit_of_measurement unit_of_measurement = self.unit_of_measurement
if native_unit_of_measurement != unit_of_measurement: if native_unit_of_measurement != unit_of_measurement:
if TYPE_CHECKING: if TYPE_CHECKING:
@ -493,7 +503,7 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
(number_options := self.registry_entry.options.get(DOMAIN)) (number_options := self.registry_entry.options.get(DOMAIN))
and (custom_unit := number_options.get(CONF_UNIT_OF_MEASUREMENT)) and (custom_unit := number_options.get(CONF_UNIT_OF_MEASUREMENT))
and (device_class := self.device_class) in UNIT_CONVERTERS and (device_class := self.device_class) in UNIT_CONVERTERS
and self.native_unit_of_measurement and self.__native_unit_of_measurement_compat
in UNIT_CONVERTERS[device_class].VALID_UNITS in UNIT_CONVERTERS[device_class].VALID_UNITS
and custom_unit in UNIT_CONVERTERS[device_class].VALID_UNITS and custom_unit in UNIT_CONVERTERS[device_class].VALID_UNITS
): ):

View File

@ -8,6 +8,7 @@ from typing import Final
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ( from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_BILLION,
@ -546,3 +547,14 @@ UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = {
NumberDeviceClass.TEMPERATURE: TemperatureConverter, NumberDeviceClass.TEMPERATURE: TemperatureConverter,
NumberDeviceClass.VOLUME_FLOW_RATE: VolumeFlowRateConverter, NumberDeviceClass.VOLUME_FLOW_RATE: VolumeFlowRateConverter,
} }
AMBIGUOUS_UNITS: dict[str | None, str] = {
"\u00b5Sv/h": "μSv/h", # aranet: radiation rate
"\u00b5S/cm": UnitOfConductivity.MICROSIEMENS_PER_CM,
"\u00b5V": UnitOfElectricPotential.MICROVOLT,
"\u00b5g/ft³": CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
"\u00b5g/m³": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"\u00b5mol/s⋅m²": "μmol/s⋅m²", # fyta: light
"\u00b5g": UnitOfMass.MICROGRAMS,
"\u00b5s": UnitOfTime.MICROSECONDS,
}

View File

@ -476,7 +476,8 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Process ambiguous units.""" """Process ambiguous units."""
native_unit_of_measurement = self.native_unit_of_measurement native_unit_of_measurement = self.native_unit_of_measurement
return AMBIGUOUS_UNITS.get( return AMBIGUOUS_UNITS.get(
native_unit_of_measurement, native_unit_of_measurement native_unit_of_measurement,
native_unit_of_measurement,
) )
@cached_property @cached_property

View File

@ -772,7 +772,7 @@ STATE_CLASS_UNITS: dict[SensorStateClass | str, set[type[StrEnum] | str | None]]
SensorStateClass.MEASUREMENT_ANGLE: {DEGREE}, SensorStateClass.MEASUREMENT_ANGLE: {DEGREE},
} }
AMBIGUOUS_UNITS: dict[str, str] = { AMBIGUOUS_UNITS: dict[str | None, str] = {
"\u00b5Sv/h": "μSv/h", # aranet: radiation rate "\u00b5Sv/h": "μSv/h", # aranet: radiation rate
"\u00b5S/cm": UnitOfConductivity.MICROSIEMENS_PER_CM, "\u00b5S/cm": UnitOfConductivity.MICROSIEMENS_PER_CM,
"\u00b5V": UnitOfElectricPotential.MICROVOLT, "\u00b5V": UnitOfElectricPotential.MICROVOLT,

View File

@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from homeassistant.components.number import ( from homeassistant.components.number import (
AMBIGUOUS_UNITS,
ATTR_MAX, ATTR_MAX,
ATTR_MIN, ATTR_MIN,
ATTR_MODE, ATTR_MODE,
@ -48,6 +49,7 @@ from . import common
from tests.common import ( from tests.common import (
MockConfigEntry, MockConfigEntry,
MockEntity,
MockModule, MockModule,
MockPlatform, MockPlatform,
async_mock_restore_state_shutdown_restart, async_mock_restore_state_shutdown_restart,
@ -61,6 +63,25 @@ from tests.common import (
TEST_DOMAIN = "test" TEST_DOMAIN = "test"
class MockNumber(MockEntity, NumberEntity):
"""Mock NumberEntity class to test unit of measurement."""
@property
def device_class(self):
"""Return the class of this sensor."""
return self._handle("device_class")
@property
def native_unit_of_measurement(self):
"""Return the native unit_of_measurement of this sensor."""
return self._handle("native_unit_of_measurement")
@property
def native_value(self):
"""Return the native value of this sensor."""
return self._handle("native_value")
class MockDefaultNumberEntity(NumberEntity): class MockDefaultNumberEntity(NumberEntity):
"""Mock NumberEntity device to use in tests. """Mock NumberEntity device to use in tests.
@ -900,6 +921,33 @@ async def test_translated_unit_with_native_unit_raises(
assert entity0.entity_id is None assert entity0.entity_id is None
@pytest.mark.parametrize(
("ambiguous_unit", "normalized_unit"),
[
(ambiguous_unit, normalized_unit)
for ambiguous_unit, normalized_unit in AMBIGUOUS_UNITS.items()
],
)
async def test_ambiguous_unit_of_measurement_compat(
hass: HomeAssistant, ambiguous_unit: str, normalized_unit: str
) -> None:
"""Test ambiguous native_unit_of_measurement values are corrected."""
entity0 = MockNumber(
name="Test",
native_value="0.0",
native_unit_of_measurement=ambiguous_unit,
)
setup_test_component_platform(hass, DOMAIN, [entity0])
assert await async_setup_component(hass, "number", {"number": {"platform": "test"}})
await hass.async_block_till_done()
# Check compatible unit is applied
state = hass.states.get(entity0.entity_id)
assert state.state == "0.0"
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == normalized_unit
def test_device_classes_aligned() -> None: def test_device_classes_aligned() -> None:
"""Make sure all sensor device classes are also available in NumberDeviceClass.""" """Make sure all sensor device classes are also available in NumberDeviceClass."""

View File

@ -159,7 +159,7 @@ async def test_temperature_conversion_wrong_device_class(
assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}})
await hass.async_block_till_done() await hass.async_block_till_done()
# Check temperature is not converted # Check compatible unit is applied
state = hass.states.get(entity0.entity_id) state = hass.states.get(entity0.entity_id)
assert state.state == "0.0" assert state.state == "0.0"
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.FAHRENHEIT assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.FAHRENHEIT