Add enum device_class and state options property to sensor entities (#82489)

This commit is contained in:
Franck Nijhof 2022-12-02 09:11:15 +01:00 committed by GitHub
parent 3feea879dd
commit ce00f093d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 272 additions and 8 deletions

View file

@ -126,6 +126,17 @@ async def async_setup_platform(
None, None,
"total_gas_ft3", "total_gas_ft3",
), ),
DemoSensor(
unique_id="sensor_10",
name="Thermostat mode",
state="eco",
device_class=SensorDeviceClass.ENUM,
state_class=None,
unit_of_measurement=None,
battery=None,
options=["away", "comfort", "eco", "sleep"],
translation_key="thermostat_mode",
),
] ]
) )
@ -149,10 +160,12 @@ class DemoSensor(SensorEntity):
unique_id: str, unique_id: str,
name: str, name: str,
state: StateType, state: StateType,
device_class: SensorDeviceClass, device_class: SensorDeviceClass | str,
state_class: SensorStateClass | None, state_class: SensorStateClass | None,
unit_of_measurement: str | None, unit_of_measurement: str | None,
battery: StateType, battery: StateType,
options: list[str] | None = None,
translation_key: str | None = None,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
self._attr_device_class = device_class self._attr_device_class = device_class
@ -161,6 +174,8 @@ class DemoSensor(SensorEntity):
self._attr_native_value = state self._attr_native_value = state
self._attr_state_class = state_class self._attr_state_class = state_class
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
self._attr_options = options
self._attr_translation_key = translation_key
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)}, identifiers={(DOMAIN, unique_id)},

View file

@ -61,5 +61,17 @@
} }
} }
} }
},
"entity": {
"sensor": {
"thermostat_mode": {
"state": {
"away": "Away",
"comfort": "Comfort",
"eco": "Eco",
"sleep": "Sleep"
}
}
}
} }
} }

View file

@ -1,4 +1,16 @@
{ {
"entity": {
"sensor": {
"thermostat_mode": {
"state": {
"away": "Away",
"comfort": "Comfort",
"eco": "Eco",
"sleep": "Sleep"
}
}
}
},
"issues": { "issues": {
"bad_psu": { "bad_psu": {
"fix_flow": { "fix_flow": {
@ -15,7 +27,8 @@
"fix_flow": { "fix_flow": {
"abort": { "abort": {
"not_tea_time": "Can not re-heat the tea at this time" "not_tea_time": "Can not re-heat the tea at this time"
} },
"step": {}
}, },
"title": "The tea is cold" "title": "The tea is cold"
}, },
@ -41,6 +54,9 @@
}, },
"options": { "options": {
"step": { "step": {
"init": {
"data": {}
},
"options_1": { "options_1": {
"data": { "data": {
"bool": "Optional boolean", "bool": "Optional boolean",

View file

@ -77,6 +77,7 @@ _LOGGER: Final = logging.getLogger(__name__)
ATTR_LAST_RESET: Final = "last_reset" ATTR_LAST_RESET: Final = "last_reset"
ATTR_STATE_CLASS: Final = "state_class" ATTR_STATE_CLASS: Final = "state_class"
ATTR_OPTIONS: Final = "options"
DOMAIN: Final = "sensor" DOMAIN: Final = "sensor"
@ -103,6 +104,14 @@ class SensorDeviceClass(StrEnum):
Unit of measurement: `d`, `h`, `min`, `s` Unit of measurement: `d`, `h`, `min`, `s`
""" """
ENUM = "enum"
"""Enumeration.
Provides a fixed list of options the state of the sensor can be in.
Unit of measurement: `None`
"""
TIMESTAMP = "timestamp" TIMESTAMP = "timestamp"
"""Timestamp. """Timestamp.
@ -446,6 +455,7 @@ class SensorEntityDescription(EntityDescription):
last_reset: datetime | None = None last_reset: datetime | None = None
native_unit_of_measurement: str | None = None native_unit_of_measurement: str | None = None
state_class: SensorStateClass | str | None = None state_class: SensorStateClass | str | None = None
options: list[str] | None = None
unit_of_measurement: None = None # Type override, use native_unit_of_measurement unit_of_measurement: None = None # Type override, use native_unit_of_measurement
@ -457,6 +467,7 @@ class SensorEntity(Entity):
_attr_last_reset: datetime | None _attr_last_reset: datetime | None
_attr_native_unit_of_measurement: str | None _attr_native_unit_of_measurement: str | None
_attr_native_value: StateType | date | datetime | Decimal = None _attr_native_value: StateType | date | datetime | Decimal = None
_attr_options: list[str] | None
_attr_state_class: SensorStateClass | str | None _attr_state_class: SensorStateClass | str | None
_attr_state: None = None # Subclasses of SensorEntity should not set this _attr_state: None = None # Subclasses of SensorEntity should not set this
_attr_suggested_unit_of_measurement: str | None _attr_suggested_unit_of_measurement: str | None
@ -523,6 +534,15 @@ class SensorEntity(Entity):
return self.entity_description.device_class return self.entity_description.device_class
return None return None
@property
def options(self) -> list[str] | None:
"""Return a set of possible options."""
if hasattr(self, "_attr_options"):
return self._attr_options
if hasattr(self, "entity_description"):
return self.entity_description.options
return None
@property @property
def state_class(self) -> SensorStateClass | str | None: def state_class(self) -> SensorStateClass | str | None:
"""Return the state class of this entity, if any.""" """Return the state class of this entity, if any."""
@ -547,6 +567,9 @@ class SensorEntity(Entity):
if state_class := self.state_class: if state_class := self.state_class:
return {ATTR_STATE_CLASS: state_class} return {ATTR_STATE_CLASS: state_class}
if options := self.options:
return {ATTR_OPTIONS: options}
return None return None
def _get_initial_suggested_unit(self) -> str | None: def _get_initial_suggested_unit(self) -> str | None:
@ -679,6 +702,7 @@ class SensorEntity(Entity):
unit_of_measurement = self.unit_of_measurement unit_of_measurement = self.unit_of_measurement
value = self.native_value value = self.native_value
device_class = self.device_class device_class = self.device_class
state_class = self.state_class
# Received a datetime # Received a datetime
if value is not None and device_class == DEVICE_CLASS_TIMESTAMP: if value is not None and device_class == DEVICE_CLASS_TIMESTAMP:
@ -715,6 +739,37 @@ class SensorEntity(Entity):
f"but provides state {value}:{type(value)} resulting in '{err}'" f"but provides state {value}:{type(value)} resulting in '{err}'"
) from err ) from err
# Enum checks
if value is not None and (
device_class == SensorDeviceClass.ENUM or self.options is not None
):
if device_class != SensorDeviceClass.ENUM:
reason = "is missing the enum device class"
if device_class is not None:
reason = f"has device class '{device_class}' instead of 'enum'"
raise ValueError(
f"Sensor {self.entity_id} is providing enum options, but {reason}"
)
if state_class:
raise ValueError(
f"Sensor {self.entity_id} has an state_class and thus indicating "
"it has a numeric value; however, it has the enum device class"
)
if unit_of_measurement:
raise ValueError(
f"Sensor {self.entity_id} has an unit of measurement and thus "
"indicating it has a numeric value; "
"however, it has the enum device class"
)
if (options := self.options) and value not in options:
raise ValueError(
f"Sensor {self.entity_id} provides state value '{value}', "
"which is not in the list of options provided"
)
if ( if (
value is not None value is not None
and native_unit_of_measurement != unit_of_measurement and native_unit_of_measurement != unit_of_measurement
@ -840,7 +895,7 @@ class SensorExtraStoredData(ExtraStoredData):
# native_value is a dict, but does not have all values # native_value is a dict, but does not have all values
return None return None
except DecimalInvalidOperation: except DecimalInvalidOperation:
# native_value coulnd't be returned from decimal_str # native_value couldn't be returned from decimal_str
return None return None
return cls(native_value, native_unit_of_measurement) return cls(native_value, native_unit_of_measurement)

View file

@ -29,13 +29,14 @@ from homeassistant.const import (
VOLUME_CUBIC_FEET, VOLUME_CUBIC_FEET,
VOLUME_CUBIC_METERS, VOLUME_CUBIC_METERS,
) )
from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.core import HomeAssistant, State, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import entity_sources from homeassistant.helpers.entity import entity_sources
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import ( from . import (
ATTR_LAST_RESET, ATTR_LAST_RESET,
ATTR_OPTIONS,
ATTR_STATE_CLASS, ATTR_STATE_CLASS,
DOMAIN, DOMAIN,
STATE_CLASS_MEASUREMENT, STATE_CLASS_MEASUREMENT,
@ -724,3 +725,9 @@ def validate_statistics(
) )
return validation_result return validation_result
@callback
def exclude_attributes(hass: HomeAssistant) -> set[str]:
"""Exclude attributes from being recorded in the database."""
return {ATTR_OPTIONS}

View file

@ -857,6 +857,7 @@ def test_device_classes_aligned():
non_numeric_device_classes = { non_numeric_device_classes = {
SensorDeviceClass.DATE, SensorDeviceClass.DATE,
SensorDeviceClass.DURATION, SensorDeviceClass.DURATION,
SensorDeviceClass.ENUM,
SensorDeviceClass.TIMESTAMP, SensorDeviceClass.TIMESTAMP,
} }

View file

@ -6,7 +6,7 @@ import pytest
from pytest import approx from pytest import approx
from homeassistant.components.number import NumberDeviceClass from homeassistant.components.number import NumberDeviceClass
from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import ( from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT,
LENGTH_CENTIMETERS, LENGTH_CENTIMETERS,
@ -32,8 +32,9 @@ from homeassistant.const import (
VOLUME_CUBIC_METERS, VOLUME_CUBIC_METERS,
VOLUME_FLUID_OUNCE, VOLUME_FLUID_OUNCE,
VOLUME_LITERS, VOLUME_LITERS,
UnitOfTemperature,
) )
from homeassistant.core import State from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -936,3 +937,124 @@ def test_device_classes_aligned():
for device_class in NumberDeviceClass: for device_class in NumberDeviceClass:
assert hasattr(SensorDeviceClass, device_class.name) assert hasattr(SensorDeviceClass, device_class.name)
assert getattr(SensorDeviceClass, device_class.name).value == device_class.value assert getattr(SensorDeviceClass, device_class.name).value == device_class.value
async def test_value_unknown_in_enumeration(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
enable_custom_integrations: None,
):
"""Test warning on invalid enum value."""
platform = getattr(hass.components, "test.sensor")
platform.init(empty=True)
platform.ENTITIES["0"] = platform.MockSensor(
name="Test",
native_value="invalid_option",
device_class=SensorDeviceClass.ENUM,
options=["option1", "option2"],
)
assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}})
await hass.async_block_till_done()
assert (
"Sensor sensor.test provides state value 'invalid_option', "
"which is not in the list of options provided"
) in caplog.text
async def test_invalid_enumeration_entity_with_device_class(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
enable_custom_integrations: None,
):
"""Test warning on entities that provide an enum with a device class."""
platform = getattr(hass.components, "test.sensor")
platform.init(empty=True)
platform.ENTITIES["0"] = platform.MockSensor(
name="Test",
native_value=21,
device_class=SensorDeviceClass.POWER,
options=["option1", "option2"],
)
assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}})
await hass.async_block_till_done()
assert (
"Sensor sensor.test is providing enum options, but has device class 'power' "
"instead of 'enum'"
) in caplog.text
async def test_invalid_enumeration_entity_without_device_class(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
enable_custom_integrations: None,
):
"""Test warning on entities that provide an enum without a device class."""
platform = getattr(hass.components, "test.sensor")
platform.init(empty=True)
platform.ENTITIES["0"] = platform.MockSensor(
name="Test",
native_value=21,
options=["option1", "option2"],
)
assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}})
await hass.async_block_till_done()
assert (
"Sensor sensor.test is providing enum options, but is missing "
"the enum device class"
) in caplog.text
async def test_invalid_enumeration_with_state_class(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
enable_custom_integrations: None,
):
"""Test warning on numeric entities that provide an enum."""
platform = getattr(hass.components, "test.sensor")
platform.init(empty=True)
platform.ENTITIES["0"] = platform.MockSensor(
name="Test",
native_value=42,
device_class=SensorDeviceClass.ENUM,
state_class=SensorStateClass.MEASUREMENT,
options=["option1", "option2"],
)
assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}})
await hass.async_block_till_done()
assert (
"Sensor sensor.test has an state_class and thus indicating "
"it has a numeric value; however, it has the enum device class"
) in caplog.text
async def test_invalid_enumeration_with_unit_of_measurement(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
enable_custom_integrations: None,
):
"""Test warning on numeric entities that provide an enum."""
platform = getattr(hass.components, "test.sensor")
platform.init(empty=True)
platform.ENTITIES["0"] = platform.MockSensor(
name="Test",
native_value=42,
device_class=SensorDeviceClass.ENUM,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
options=["option1", "option2"],
)
assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}})
await hass.async_block_till_done()
assert (
"Sensor sensor.test has an unit of measurement and thus indicating "
"it has a numeric value; however, it has the enum device class"
) in caplog.text

View file

@ -10,7 +10,11 @@ from pytest import approx
from homeassistant import loader from homeassistant import loader
from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, history from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, history
from homeassistant.components.recorder.db_schema import StatisticsMeta from homeassistant.components.recorder.db_schema import (
StateAttributes,
States,
StatisticsMeta,
)
from homeassistant.components.recorder.models import ( from homeassistant.components.recorder.models import (
StatisticData, StatisticData,
StatisticMetaData, StatisticMetaData,
@ -22,11 +26,14 @@ from homeassistant.components.recorder.statistics import (
list_statistic_ids, list_statistic_ids,
) )
from homeassistant.components.recorder.util import get_instance, session_scope from homeassistant.components.recorder.util import get_instance, session_scope
from homeassistant.const import STATE_UNAVAILABLE from homeassistant.components.sensor import ATTR_OPTIONS, DOMAIN
from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, State
from homeassistant.setup import async_setup_component, setup_component from homeassistant.setup import async_setup_component, setup_component
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
from tests.common import async_fire_time_changed
from tests.components.recorder.common import ( from tests.components.recorder.common import (
async_recorder_block_till_done, async_recorder_block_till_done,
async_wait_recording_done, async_wait_recording_done,
@ -4320,3 +4327,27 @@ def record_states_partially_unavailable(hass, zero, entity_id, attributes):
) )
return four, states return four, states
async def test_exclude_attributes(recorder_mock: None, hass: HomeAssistant) -> None:
"""Test sensor attributes to be excluded."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {"platform": "demo"}})
await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5))
await hass.async_block_till_done()
await async_wait_recording_done(hass)
def _fetch_states() -> list[State]:
with session_scope(hass=hass) as session:
native_states = []
for db_state, db_state_attributes in session.query(States, StateAttributes):
state = db_state.to_native()
state.attributes = db_state_attributes.to_native()
native_states.append(state)
return native_states
states: list[State] = await hass.async_add_executor_job(_fetch_states)
assert len(states) > 1
for state in states:
assert ATTR_OPTIONS not in state.attributes
assert ATTR_FRIENDLY_NAME in state.attributes

View file

@ -107,6 +107,11 @@ class MockSensor(MockEntity, SensorEntity):
"""Return the native value of this sensor.""" """Return the native value of this sensor."""
return self._handle("native_value") return self._handle("native_value")
@property
def options(self):
"""Return the options for this sensor."""
return self._handle("options")
@property @property
def state_class(self): def state_class(self):
"""Return the state class of this sensor.""" """Return the state class of this sensor."""