Calculate device class as soon as it is known in integral (#119940)
This commit is contained in:
parent
af9f4f310b
commit
6420837d58
3 changed files with 169 additions and 11 deletions
|
@ -13,6 +13,7 @@ from typing import Any, Final, Self
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
|
DEVICE_CLASS_UNITS,
|
||||||
PLATFORM_SCHEMA,
|
PLATFORM_SCHEMA,
|
||||||
RestoreSensor,
|
RestoreSensor,
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
|
@ -75,6 +76,10 @@ UNIT_TIME = {
|
||||||
UnitOfTime.DAYS: 24 * 60 * 60,
|
UnitOfTime.DAYS: 24 * 60 * 60,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DEVICE_CLASS_MAP = {
|
||||||
|
SensorDeviceClass.POWER: SensorDeviceClass.ENERGY,
|
||||||
|
}
|
||||||
|
|
||||||
DEFAULT_ROUND = 3
|
DEFAULT_ROUND = 3
|
||||||
|
|
||||||
PLATFORM_SCHEMA = vol.All(
|
PLATFORM_SCHEMA = vol.All(
|
||||||
|
@ -381,6 +386,22 @@ class IntegrationSensor(RestoreSensor):
|
||||||
|
|
||||||
return f"{self._unit_prefix_string}{integral_unit}"
|
return f"{self._unit_prefix_string}{integral_unit}"
|
||||||
|
|
||||||
|
def _calculate_device_class(
|
||||||
|
self,
|
||||||
|
source_device_class: SensorDeviceClass | None,
|
||||||
|
unit_of_measurement: str | None,
|
||||||
|
) -> SensorDeviceClass | None:
|
||||||
|
"""Deduce device class if possible from source device class and target unit."""
|
||||||
|
if source_device_class is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if (device_class := DEVICE_CLASS_MAP.get(source_device_class)) is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if unit_of_measurement not in DEVICE_CLASS_UNITS.get(device_class, set()):
|
||||||
|
return None
|
||||||
|
return device_class
|
||||||
|
|
||||||
def _derive_and_set_attributes_from_state(self, source_state: State) -> None:
|
def _derive_and_set_attributes_from_state(self, source_state: State) -> None:
|
||||||
source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||||
if source_unit is not None:
|
if source_unit is not None:
|
||||||
|
@ -389,13 +410,13 @@ class IntegrationSensor(RestoreSensor):
|
||||||
# If the source has no defined unit we cannot derive a unit for the integral
|
# If the source has no defined unit we cannot derive a unit for the integral
|
||||||
self._unit_of_measurement = None
|
self._unit_of_measurement = None
|
||||||
|
|
||||||
if (
|
self._attr_device_class = self._calculate_device_class(
|
||||||
self.device_class is None
|
source_state.attributes.get(ATTR_DEVICE_CLASS), self.unit_of_measurement
|
||||||
and source_state.attributes.get(ATTR_DEVICE_CLASS)
|
)
|
||||||
== SensorDeviceClass.POWER
|
if self._attr_device_class:
|
||||||
):
|
self._attr_icon = None # Remove this sensors icon default and allow to fallback to the device class default
|
||||||
self._attr_device_class = SensorDeviceClass.ENERGY
|
else:
|
||||||
self._attr_icon = None # Remove this sensors icon default and allow to fallback to the ENERGY default
|
self._attr_icon = "mdi:chart-histogram"
|
||||||
|
|
||||||
def _update_integral(self, area: Decimal) -> None:
|
def _update_integral(self, area: Decimal) -> None:
|
||||||
area_scaled = area / (self._unit_prefix * self._unit_time)
|
area_scaled = area / (self._unit_prefix * self._unit_time)
|
||||||
|
@ -436,6 +457,11 @@ class IntegrationSensor(RestoreSensor):
|
||||||
else:
|
else:
|
||||||
handle_state_change = self._integrate_on_state_change_callback
|
handle_state_change = self._integrate_on_state_change_callback
|
||||||
|
|
||||||
|
if (
|
||||||
|
state := self.hass.states.get(self._source_entity)
|
||||||
|
) and state.state != STATE_UNAVAILABLE:
|
||||||
|
self._derive_and_set_attributes_from_state(state)
|
||||||
|
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
async_track_state_change_event(
|
async_track_state_change_event(
|
||||||
self.hass,
|
self.hass,
|
||||||
|
@ -477,7 +503,7 @@ class IntegrationSensor(RestoreSensor):
|
||||||
def _integrate_on_state_change(
|
def _integrate_on_state_change(
|
||||||
self, old_state: State | None, new_state: State | None
|
self, old_state: State | None, new_state: State | None
|
||||||
) -> None:
|
) -> None:
|
||||||
if old_state is None or new_state is None:
|
if new_state is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if new_state.state == STATE_UNAVAILABLE:
|
if new_state.state == STATE_UNAVAILABLE:
|
||||||
|
@ -488,6 +514,10 @@ class IntegrationSensor(RestoreSensor):
|
||||||
self._attr_available = True
|
self._attr_available = True
|
||||||
self._derive_and_set_attributes_from_state(new_state)
|
self._derive_and_set_attributes_from_state(new_state)
|
||||||
|
|
||||||
|
if old_state is None:
|
||||||
|
self.async_write_ha_state()
|
||||||
|
return
|
||||||
|
|
||||||
if not (states := self._method.validate_states(old_state, new_state)):
|
if not (states := self._method.validate_states(old_state, new_state)):
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
return
|
return
|
||||||
|
|
69
tests/components/integration/snapshots/test_sensor.ambr
Normal file
69
tests/components/integration/snapshots/test_sensor.ambr
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
# serializer version: 1
|
||||||
|
# name: test_initial_state[BTU/h-power-h]
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'friendly_name': 'integration',
|
||||||
|
'icon': 'mdi:chart-histogram',
|
||||||
|
'source': 'sensor.source',
|
||||||
|
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||||
|
'unit_of_measurement': 'BTU',
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'sensor.integration',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': 'unknown',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_initial_state[ft\xb3/min-volume_flow_rate-min]
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'friendly_name': 'integration',
|
||||||
|
'icon': 'mdi:chart-histogram',
|
||||||
|
'source': 'sensor.source',
|
||||||
|
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||||
|
'unit_of_measurement': 'ft³',
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'sensor.integration',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': 'unknown',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_initial_state[kW-None-h]
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'friendly_name': 'integration',
|
||||||
|
'icon': 'mdi:chart-histogram',
|
||||||
|
'source': 'sensor.source',
|
||||||
|
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||||
|
'unit_of_measurement': 'kWh',
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'sensor.integration',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': 'unknown',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_initial_state[kW-power-h]
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'device_class': 'energy',
|
||||||
|
'friendly_name': 'integration',
|
||||||
|
'source': 'sensor.source',
|
||||||
|
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||||
|
'unit_of_measurement': 'kWh',
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'sensor.integration',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': 'unknown',
|
||||||
|
})
|
||||||
|
# ---
|
|
@ -5,10 +5,12 @@ from typing import Any
|
||||||
|
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
import pytest
|
import pytest
|
||||||
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
from homeassistant.components.integration.const import DOMAIN
|
from homeassistant.components.integration.const import DOMAIN
|
||||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
|
ATTR_DEVICE_CLASS,
|
||||||
ATTR_UNIT_OF_MEASUREMENT,
|
ATTR_UNIT_OF_MEASUREMENT,
|
||||||
STATE_UNAVAILABLE,
|
STATE_UNAVAILABLE,
|
||||||
STATE_UNKNOWN,
|
STATE_UNKNOWN,
|
||||||
|
@ -17,6 +19,7 @@ from homeassistant.const import (
|
||||||
UnitOfInformation,
|
UnitOfInformation,
|
||||||
UnitOfPower,
|
UnitOfPower,
|
||||||
UnitOfTime,
|
UnitOfTime,
|
||||||
|
UnitOfVolumeFlowRate,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, State
|
from homeassistant.core import HomeAssistant, State
|
||||||
from homeassistant.helpers import (
|
from homeassistant.helpers import (
|
||||||
|
@ -36,6 +39,52 @@ from tests.common import (
|
||||||
DEFAULT_MAX_SUB_INTERVAL = {"minutes": 1}
|
DEFAULT_MAX_SUB_INTERVAL = {"minutes": 1}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("unit_of_measurement", "device_class", "unit_time"),
|
||||||
|
[
|
||||||
|
(UnitOfPower.KILO_WATT, SensorDeviceClass.POWER, "h"),
|
||||||
|
(UnitOfPower.KILO_WATT, None, "h"),
|
||||||
|
(UnitOfPower.BTU_PER_HOUR, SensorDeviceClass.POWER, "h"),
|
||||||
|
(
|
||||||
|
UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE,
|
||||||
|
SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||||
|
"min",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_initial_state(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
unit_of_measurement: str,
|
||||||
|
device_class: SensorDeviceClass,
|
||||||
|
unit_time: str,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
) -> None:
|
||||||
|
"""Test integration sensor state."""
|
||||||
|
config = {
|
||||||
|
"sensor": {
|
||||||
|
"platform": "integration",
|
||||||
|
"name": "integration",
|
||||||
|
"source": "sensor.source",
|
||||||
|
"round": 2,
|
||||||
|
"method": "left",
|
||||||
|
"unit_time": unit_time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, "sensor", config)
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.source",
|
||||||
|
"1",
|
||||||
|
{
|
||||||
|
ATTR_DEVICE_CLASS: device_class,
|
||||||
|
ATTR_UNIT_OF_MEASUREMENT: unit_of_measurement,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert hass.states.get("sensor.integration") == snapshot
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("method", ["trapezoidal", "left", "right"])
|
@pytest.mark.parametrize("method", ["trapezoidal", "left", "right"])
|
||||||
async def test_state(hass: HomeAssistant, method) -> None:
|
async def test_state(hass: HomeAssistant, method) -> None:
|
||||||
"""Test integration sensor state."""
|
"""Test integration sensor state."""
|
||||||
|
@ -49,13 +98,23 @@ async def test_state(hass: HomeAssistant, method) -> None:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, "sensor", config)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.integration")
|
||||||
|
assert state is not None
|
||||||
|
assert state.attributes.get("state_class") is SensorStateClass.TOTAL
|
||||||
|
assert "device_class" not in state.attributes
|
||||||
|
|
||||||
now = dt_util.utcnow()
|
now = dt_util.utcnow()
|
||||||
with freeze_time(now):
|
with freeze_time(now):
|
||||||
assert await async_setup_component(hass, "sensor", config)
|
|
||||||
|
|
||||||
entity_id = config["sensor"]["source"]
|
entity_id = config["sensor"]["source"]
|
||||||
hass.states.async_set(
|
hass.states.async_set(
|
||||||
entity_id, 1, {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}
|
entity_id,
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue