Add check for valid initial_suggested_unit (#108902)

Co-authored-by: Erik Montnemery <erik@montnemery.com>
pull/108865/head
Robert Resch 2024-01-30 18:55:59 +01:00 committed by GitHub
parent c363edad4a
commit 7d2c6a1bb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 165 additions and 39 deletions

View File

@ -219,6 +219,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_last_reset_reported = False
_sensor_option_display_precision: int | None = None
_sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED
_invalid_suggested_unit_of_measurement_reported = False
@callback
def add_to_platform_start(
@ -376,6 +377,34 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return None
def _is_valid_suggested_unit(self, suggested_unit_of_measurement: str) -> bool:
"""Validate the suggested unit.
Validate that a unit converter exists for the sensor's device class and that the
unit converter supports both the native and the suggested units of measurement.
"""
# Make sure we can convert the units
if (
(unit_converter := UNIT_CONVERTERS.get(self.device_class)) is None
or self.native_unit_of_measurement not in unit_converter.VALID_UNITS
or suggested_unit_of_measurement not in unit_converter.VALID_UNITS
):
if not self._invalid_suggested_unit_of_measurement_reported:
self._invalid_suggested_unit_of_measurement_reported = True
report_issue = self._suggest_report_issue()
# This should raise in Home Assistant Core 2024.5
_LOGGER.warning(
(
"%s sets an invalid suggested_unit_of_measurement. Please %s. "
"This warning will become an error in Home Assistant Core 2024.5"
),
type(self),
report_issue,
)
return False
return True
def _get_initial_suggested_unit(self) -> str | UndefinedType:
"""Return the initial unit."""
# Unit suggested by the integration
@ -390,6 +419,10 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if suggested_unit_of_measurement is None:
return UNDEFINED
# Make sure we can convert the units
if not self._is_valid_suggested_unit(suggested_unit_of_measurement):
return UNDEFINED
return suggested_unit_of_measurement
def get_initial_entity_options(self) -> er.EntityOptionsType | None:
@ -486,16 +519,17 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if self._sensor_option_unit_of_measurement is not UNDEFINED:
return self._sensor_option_unit_of_measurement
native_unit_of_measurement = self.native_unit_of_measurement
# Second priority, for non registered entities: unit suggested by integration
if not self.registry_entry and (
suggested_unit_of_measurement := self.suggested_unit_of_measurement
):
return suggested_unit_of_measurement
if self._is_valid_suggested_unit(suggested_unit_of_measurement):
return suggested_unit_of_measurement
# Third priority: Legacy temperature conversion, which applies
# to both registered and non registered entities
native_unit_of_measurement = self.native_unit_of_measurement
if (
native_unit_of_measurement in TEMPERATURE_UNITS
and self.device_class is SensorDeviceClass.TEMPERATURE

View File

@ -326,9 +326,6 @@
'id': <ANY>,
'name': None,
'options': dict({
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
}),
'original_device_class': None,
'original_icon': None,
@ -338,7 +335,7 @@
'supported_features': 0,
'translation_key': 'stats_time',
'unique_id': 'E1234567890000000001_stats_time',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_time_cleaned:state]
@ -468,9 +465,6 @@
'id': <ANY>,
'name': None,
'options': dict({
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
}),
}),
'original_device_class': None,
'original_icon': None,
@ -480,7 +474,7 @@
'supported_features': 0,
'translation_key': 'total_stats_time',
'unique_id': 'E1234567890000000001_total_stats_time',
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
})
# ---
# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_time_cleaned:state]

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, date, datetime
from decimal import Decimal
import logging
from types import ModuleType
from typing import Any
@ -29,6 +30,7 @@ from homeassistant.const import (
PERCENTAGE,
STATE_UNKNOWN,
EntityCategory,
UnitOfDataRate,
UnitOfEnergy,
UnitOfLength,
UnitOfMass,
@ -2604,3 +2606,120 @@ def test_deprecated_constants_sensor_device_class(
import_and_test_deprecated_constant_enum(
caplog, sensor, enum, "DEVICE_CLASS_", "2025.1"
)
@pytest.mark.parametrize(
("device_class", "native_unit"),
[
(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS),
(SensorDeviceClass.DATA_RATE, UnitOfDataRate.KILOBITS_PER_SECOND),
],
)
async def test_suggested_unit_guard_invalid_unit(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
device_class: SensorDeviceClass,
native_unit: str,
) -> None:
"""Test suggested_unit_of_measurement guard.
An invalid suggested unit creates a log entry and the suggested unit will be ignored.
"""
entity_registry = er.async_get(hass)
platform = getattr(hass.components, "test.sensor")
platform.init(empty=True)
state_value = 10
invalid_suggested_unit = "invalid_unit"
entity = platform.ENTITIES["0"] = platform.MockSensor(
name="Invalid",
device_class=device_class,
native_unit_of_measurement=native_unit,
suggested_unit_of_measurement=invalid_suggested_unit,
native_value=str(state_value),
unique_id="invalid",
)
assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}})
await hass.async_block_till_done()
# Unit of measurement should be native one
state = hass.states.get(entity.entity_id)
assert int(state.state) == state_value
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit
# Assert the suggested unit is ignored and not stored in the entity registry
entry = entity_registry.async_get(entity.entity_id)
assert entry.unit_of_measurement == native_unit
assert entry.options == {}
assert (
"homeassistant.components.sensor",
logging.WARNING,
(
"<class 'custom_components.test.sensor.MockSensor'> sets an"
" invalid suggested_unit_of_measurement. Please report it to the author"
" of the 'test' custom integration. This warning will become an error in"
" Home Assistant Core 2024.5"
),
) in caplog.record_tuples
@pytest.mark.parametrize(
("device_class", "native_unit", "native_value", "suggested_unit", "expect_value"),
[
(
SensorDeviceClass.TEMPERATURE,
UnitOfTemperature.CELSIUS,
10,
UnitOfTemperature.KELVIN,
283,
),
(
SensorDeviceClass.DATA_RATE,
UnitOfDataRate.KILOBITS_PER_SECOND,
10,
UnitOfDataRate.BITS_PER_SECOND,
10000,
),
],
)
async def test_suggested_unit_guard_valid_unit(
hass: HomeAssistant,
device_class: SensorDeviceClass,
native_unit: str,
native_value: int,
suggested_unit: str,
expect_value: float | int,
) -> None:
"""Test suggested_unit_of_measurement guard.
Suggested unit is valid and therefore should be used for unit conversion and stored
in the entity registry.
"""
entity_registry = er.async_get(hass)
platform = getattr(hass.components, "test.sensor")
platform.init(empty=True)
entity = platform.ENTITIES["0"] = platform.MockSensor(
name="Valid",
device_class=device_class,
native_unit_of_measurement=native_unit,
native_value=str(native_value),
suggested_unit_of_measurement=suggested_unit,
unique_id="valid",
)
assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}})
await hass.async_block_till_done()
# Unit of measurement should set to the suggested unit of measurement
state = hass.states.get(entity.entity_id)
assert float(state.state) == expect_value
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit
# Assert the suggested unit of measurement is stored in the registry
entry = entity_registry.async_get(entity.entity_id)
assert entry.unit_of_measurement == suggested_unit
assert entry.options == {
"sensor.private": {"suggested_unit_of_measurement": suggested_unit},
}

View File

@ -71,9 +71,6 @@
'id': <ANY>,
'name': None,
'options': dict({
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
@ -83,7 +80,7 @@
'supported_features': 0,
'translation_key': 'activity_active_duration_today',
'unique_id': 'withings_12345_activity_active_duration_today',
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_all_entities[sensor.henk_active_time_today-state]
@ -1176,9 +1173,6 @@
'id': <ANY>,
'name': None,
'options': dict({
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
@ -1188,7 +1182,7 @@
'supported_features': 0,
'translation_key': 'activity_intense_duration_today',
'unique_id': 'withings_12345_activity_intense_duration_today',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_all_entities[sensor.henk_intense_activity_today-state]
@ -1274,9 +1268,6 @@
'id': <ANY>,
'name': None,
'options': dict({
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
@ -1286,7 +1277,7 @@
'supported_features': 0,
'translation_key': 'workout_duration',
'unique_id': 'withings_12345_workout_duration',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_all_entities[sensor.henk_last_workout_duration-state]
@ -1750,9 +1741,6 @@
'id': <ANY>,
'name': None,
'options': dict({
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
@ -1762,7 +1750,7 @@
'supported_features': 0,
'translation_key': 'activity_moderate_duration_today',
'unique_id': 'withings_12345_activity_moderate_duration_today',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_all_entities[sensor.henk_moderate_activity_today-state]
@ -1851,9 +1839,6 @@
'id': <ANY>,
'name': None,
'options': dict({
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
@ -1863,7 +1848,7 @@
'supported_features': 0,
'translation_key': 'workout_pause_duration',
'unique_id': 'withings_12345_workout_pause_duration',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_all_entities[sensor.henk_pause_during_last_workout-state]
@ -2045,9 +2030,6 @@
'id': <ANY>,
'name': None,
'options': dict({
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
@ -2057,7 +2039,7 @@
'supported_features': 0,
'translation_key': 'sleep_goal',
'unique_id': 'withings_12345_sleep_goal',
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_all_entities[sensor.henk_sleep_goal-state]
@ -2235,9 +2217,6 @@
'id': <ANY>,
'name': None,
'options': dict({
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
@ -2247,7 +2226,7 @@
'supported_features': 0,
'translation_key': 'activity_soft_duration_today',
'unique_id': 'withings_12345_activity_soft_duration_today',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_all_entities[sensor.henk_soft_activity_today-state]