1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-08 17:49:37 +01:00

Improve derivative units and auto-device_class (#157369)

This commit is contained in:
karwosts
2026-02-17 08:08:59 -08:00
committed by GitHub
parent 0b8312d942
commit 9f551f3d5b
2 changed files with 223 additions and 19 deletions
+64 -7
View File
@@ -10,13 +10,16 @@ import voluptuous as vol
from homeassistant.components.sensor import (
ATTR_STATE_CLASS,
DEVICE_CLASS_UNITS,
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
RestoreSensor,
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
CONF_NAME,
CONF_SOURCE,
@@ -83,6 +86,17 @@ UNIT_TIME = {
UnitOfTime.DAYS: 24 * 60 * 60,
}
DERIVED_CLASS = {
SensorDeviceClass.ENERGY: SensorDeviceClass.POWER,
SensorDeviceClass.ENERGY_STORAGE: SensorDeviceClass.POWER,
SensorDeviceClass.DATA_SIZE: SensorDeviceClass.DATA_RATE,
SensorDeviceClass.DISTANCE: SensorDeviceClass.SPEED,
SensorDeviceClass.WATER: SensorDeviceClass.VOLUME_FLOW_RATE,
SensorDeviceClass.GAS: SensorDeviceClass.VOLUME_FLOW_RATE,
SensorDeviceClass.VOLUME: SensorDeviceClass.VOLUME_FLOW_RATE,
SensorDeviceClass.VOLUME_STORAGE: SensorDeviceClass.VOLUME_FLOW_RATE,
}
DEFAULT_ROUND = 3
DEFAULT_TIME_WINDOW = 0
@@ -203,10 +217,11 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
self._attr_name = name if name is not None else f"{source_entity} derivative"
self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity}
self._unit_template: str | None = None
self._string_unit_prefix: str | None = None
self._string_unit_time: str | None = None
if unit_of_measurement is None:
final_unit_prefix = "" if unit_prefix is None else unit_prefix
self._unit_template = f"{final_unit_prefix}{{}}/{unit_time}"
self._string_unit_prefix = "" if unit_prefix is None else unit_prefix
self._string_unit_time = unit_time
# we postpone the definition of unit_of_measurement to later
self._attr_native_unit_of_measurement = None
else:
@@ -225,12 +240,40 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
)
def _derive_and_set_attributes_from_state(self, source_state: State | None) -> None:
if self._unit_template and source_state:
if not source_state:
return
source_class_raw = source_state.attributes.get(ATTR_DEVICE_CLASS)
source_class: SensorDeviceClass | None = None
if isinstance(source_class_raw, str):
try:
source_class = SensorDeviceClass(source_class_raw)
except ValueError:
source_class = None
if self._string_unit_prefix is not None and self._string_unit_time is not None:
original_unit = self._attr_native_unit_of_measurement
source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
self._attr_native_unit_of_measurement = self._unit_template.format(
"" if source_unit is None else source_unit
)
if (
(
source_class
in (SensorDeviceClass.ENERGY, SensorDeviceClass.ENERGY_STORAGE)
)
and self._string_unit_time == UnitOfTime.HOURS
and source_unit
and source_unit.endswith("Wh")
):
self._attr_native_unit_of_measurement = (
f"{self._string_unit_prefix}{source_unit[:-1]}"
)
else:
unit_template = (
f"{self._string_unit_prefix}{{}}/{self._string_unit_time}"
)
self._attr_native_unit_of_measurement = unit_template.format(
"" if source_unit is None else source_unit
)
if original_unit != self._attr_native_unit_of_measurement:
_LOGGER.debug(
"%s: Derivative sensor switched UoM from %s to %s, resetting state to 0",
@@ -241,6 +284,16 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
self._state_list = []
self._attr_native_value = round(Decimal(0), self._round_digits)
self._attr_device_class = None
if source_class:
derived_class = DERIVED_CLASS.get(source_class)
if (
derived_class
and self._attr_native_unit_of_measurement
in DEVICE_CLASS_UNITS[derived_class]
):
self._attr_device_class = derived_class
def _calc_derivative_from_state_list(self, current_time: datetime) -> Decimal:
def calculate_weight(start: datetime, end: datetime, now: datetime) -> float:
window_start = now - timedelta(seconds=self._time_window)
@@ -309,6 +362,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
except InvalidOperation, TypeError:
self._attr_native_value = None
last_state = await self.async_get_last_state()
if last_state:
self._attr_device_class = last_state.attributes.get(ATTR_DEVICE_CLASS)
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
await super().async_added_to_hass()
+159 -12
View File
@@ -11,13 +11,22 @@ import pytest
from homeassistant import config as hass_config, core as ha
from homeassistant.components.derivative.const import DOMAIN
from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass
from homeassistant.components.sensor import (
ATTR_STATE_CLASS,
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.const import (
ATTR_DEVICE_CLASS,
SERVICE_RELOAD,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfDataRate,
UnitOfEnergy,
UnitOfPower,
UnitOfSpeed,
UnitOfTime,
UnitOfVolumeFlowRate,
)
from homeassistant.core import (
Event,
@@ -642,6 +651,137 @@ async def test_sub_intervals_with_time_window(hass: HomeAssistant) -> None:
assert expect_min <= derivative <= expect_max, f"Failed at time {time}"
@pytest.mark.parametrize(
("extra_config", "source_unit", "source_class", "derived_unit", "derived_class"),
[
(
{},
UnitOfEnergy.KILO_WATT_HOUR,
SensorDeviceClass.ENERGY,
UnitOfPower.KILO_WATT,
SensorDeviceClass.POWER,
),
(
{},
UnitOfEnergy.TERA_WATT_HOUR,
SensorDeviceClass.ENERGY,
UnitOfPower.TERA_WATT,
SensorDeviceClass.POWER,
),
(
{"unit_prefix": "m"},
UnitOfEnergy.WATT_HOUR,
SensorDeviceClass.ENERGY_STORAGE,
UnitOfPower.MILLIWATT,
SensorDeviceClass.POWER,
),
(
{"unit_prefix": "k"},
UnitOfEnergy.WATT_HOUR,
SensorDeviceClass.ENERGY,
UnitOfPower.KILO_WATT,
SensorDeviceClass.POWER,
),
(
{"unit_prefix": "n"},
UnitOfEnergy.WATT_HOUR,
SensorDeviceClass.ENERGY,
"nW",
None,
),
(
{},
"GB",
SensorDeviceClass.DATA_SIZE,
"GB/h",
None,
),
(
{"unit_time": "s"},
"GB",
SensorDeviceClass.DATA_SIZE,
UnitOfDataRate.GIGABYTES_PER_SECOND,
SensorDeviceClass.DATA_RATE,
),
(
{},
"km",
SensorDeviceClass.DISTANCE,
UnitOfSpeed.KILOMETERS_PER_HOUR,
SensorDeviceClass.SPEED,
),
(
{},
"",
SensorDeviceClass.GAS,
UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
SensorDeviceClass.VOLUME_FLOW_RATE,
),
(
{"unit_time": "min"},
"gal",
SensorDeviceClass.WATER,
UnitOfVolumeFlowRate.GALLONS_PER_MINUTE,
SensorDeviceClass.VOLUME_FLOW_RATE,
),
(
{},
UnitOfEnergy.KILO_WATT_HOUR,
"not_a_real_device_class",
"kWh/h",
None,
),
],
)
async def test_device_classes(
extra_config: dict[str, Any],
source_unit: str,
source_class: str,
derived_unit: str,
derived_class: str,
hass: HomeAssistant,
) -> None:
"""Test derivative sensor handles unit conversions and device classes."""
config = {
"sensor": {
"platform": "derivative",
"name": "derivative",
"source": "sensor.source",
"round": 2,
"unit_time": "h",
**extra_config,
}
}
assert await async_setup_component(hass, "sensor", config)
entity_id = config["sensor"]["source"]
base = dt_util.utcnow()
with freeze_time(base) as freezer:
hass.states.async_set(
entity_id,
1000,
{
"unit_of_measurement": source_unit,
"device_class": source_class,
},
)
await hass.async_block_till_done()
freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600))
hass.states.async_set(
entity_id,
2000,
{
"unit_of_measurement": source_unit,
"device_class": source_class,
},
)
await hass.async_block_till_done()
state = hass.states.get("sensor.derivative")
assert state is not None
assert state.attributes.get("unit_of_measurement") == derived_unit
assert state.attributes.get("device_class") == derived_class
async def test_prefix(hass: HomeAssistant) -> None:
"""Test derivative sensor state using a power source."""
config = {
@@ -885,13 +1025,11 @@ async def test_unavailable_boot(
State(
"sensor.power",
restore_state,
{
"unit_of_measurement": "kWh/s",
},
{"unit_of_measurement": "kW", "device_class": "power"},
),
{
"native_value": restore_state,
"native_unit_of_measurement": "kWh/s",
"native_unit_of_measurement": "kW",
},
),
],
@@ -902,12 +1040,16 @@ async def test_unavailable_boot(
"name": "power",
"source": "sensor.energy",
"round": 2,
"unit_time": "s",
"unit_time": "h",
}
config = {"sensor": config}
entity_id = config["sensor"]["source"]
hass.states.async_set(entity_id, STATE_UNAVAILABLE, {"unit_of_measurement": "kWh"})
hass.states.async_set(
entity_id,
STATE_UNAVAILABLE,
{"unit_of_measurement": "kWh", "device_class": "energy"},
)
await hass.async_block_till_done()
assert await async_setup_component(hass, "sensor", config)
@@ -917,11 +1059,14 @@ async def test_unavailable_boot(
assert state is not None
# Sensor is unavailable as source is unavailable
assert state.state == STATE_UNAVAILABLE
assert state.attributes.get(ATTR_DEVICE_CLASS) == "power"
base = dt_util.utcnow()
with freeze_time(base) as freezer:
freezer.move_to(base + timedelta(seconds=1))
hass.states.async_set(entity_id, 10, {"unit_of_measurement": "kWh"})
freezer.move_to(base + timedelta(hours=1))
hass.states.async_set(
entity_id, 10, {"unit_of_measurement": "kWh", "device_class": "energy"}
)
await hass.async_block_till_done()
state = hass.states.get("sensor.power")
@@ -930,15 +1075,17 @@ async def test_unavailable_boot(
# so just hold until the next tick
assert state.state == restore_state
freezer.move_to(base + timedelta(seconds=2))
hass.states.async_set(entity_id, 15, {"unit_of_measurement": "kWh"})
freezer.move_to(base + timedelta(hours=2))
hass.states.async_set(
entity_id, 15, {"unit_of_measurement": "kWh", "device_class": "energy"}
)
await hass.async_block_till_done()
state = hass.states.get("sensor.power")
assert state is not None
# Now that the source sensor has two valid datapoints, we can calculate derivative
assert state.state == "5.00"
assert state.attributes.get("unit_of_measurement") == "kWh/s"
assert state.attributes.get("unit_of_measurement") == "kW"
async def test_source_unit_change(