Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify sensor state validation #85513

Merged
merged 3 commits into from
Jan 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 29 additions & 29 deletions homeassistant/components/sensor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,8 +401,33 @@ def state(self) -> Any:
value = self.native_value
device_class = self.device_class

# Sensors with device classes indicating a non-numeric value
# should not have a state class or unit of measurement
if device_class in {
SensorDeviceClass.DATE,
SensorDeviceClass.ENUM,
SensorDeviceClass.TIMESTAMP,
}:
if self.state_class:
raise ValueError(
f"Sensor {self.entity_id} has a state class and thus indicating "
"it has a numeric value; however, it has the non-numeric "
f"device class: {device_class}"
)

if unit_of_measurement:
raise ValueError(
f"Sensor {self.entity_id} has a unit of measurement and thus "
"indicating it has a numeric value; however, it has the "
f"non-numeric device class: {device_class}"
)

# Checks below only apply if there is a value
if value is None:
return None

# Received a datetime
if value is not None and device_class == SensorDeviceClass.TIMESTAMP:
if device_class == SensorDeviceClass.TIMESTAMP:
try:
# We cast the value, to avoid using isinstance, but satisfy
# typechecking. The errors are guarded in this try.
Expand All @@ -424,7 +449,7 @@ def state(self) -> Any:
) from err

# Received a date value
if value is not None and device_class == SensorDeviceClass.DATE:
if device_class == SensorDeviceClass.DATE:
try:
# We cast the value, to avoid using isinstance, but satisfy
# typechecking. The errors are guarded in this try.
Expand All @@ -436,31 +461,8 @@ def state(self) -> Any:
f"but provides state {value}:{type(value)} resulting in '{err}'"
) from err

# Sensors with device classes indicating a non-numeric value
# should not have a state class or unit of measurement
if device_class in {
SensorDeviceClass.DATE,
SensorDeviceClass.ENUM,
SensorDeviceClass.TIMESTAMP,
}:
if self.state_class:
raise ValueError(
f"Sensor {self.entity_id} has a state class and thus indicating "
"it has a numeric value; however, it has the non-numeric "
f"device class: {device_class}"
)

if unit_of_measurement:
raise ValueError(
f"Sensor {self.entity_id} has a unit of measurement and thus "
"indicating it has a numeric value; however, it has the "
f"non-numeric device class: {device_class}"
)

# Enum checks
if value is not None and (
device_class == SensorDeviceClass.ENUM or self.options is not None
):
if 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:
Expand All @@ -476,8 +478,7 @@ def state(self) -> Any:
)

if (
value is not None
and native_unit_of_measurement != unit_of_measurement
native_unit_of_measurement != unit_of_measurement
and device_class in UNIT_CONVERTERS
):
assert unit_of_measurement
Expand Down Expand Up @@ -514,7 +515,6 @@ def state(self) -> Any:
# Validate unit of measurement used for sensors with a device class
if (
not self._invalid_unit_of_measurement_reported
and value is not None
and device_class
and (units := DEVICE_CLASS_UNITS.get(device_class)) is not None
and native_unit_of_measurement not in units
Expand Down
50 changes: 35 additions & 15 deletions tests/components/sensor/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,25 +230,25 @@ async def test_reject_timezoneless_datetime_str(


RESTORE_DATA = {
"str": {"native_unit_of_measurement": "°F", "native_value": "abc123"},
"str": {"native_unit_of_measurement": None, "native_value": "abc123"},
"int": {"native_unit_of_measurement": "°F", "native_value": 123},
"float": {"native_unit_of_measurement": "°F", "native_value": 123.0},
"date": {
"native_unit_of_measurement": "°F",
"native_unit_of_measurement": None,
"native_value": {
"__type": "<class 'datetime.date'>",
"isoformat": date(2020, 2, 8).isoformat(),
},
},
"datetime": {
"native_unit_of_measurement": "°F",
"native_unit_of_measurement": None,
"native_value": {
"__type": "<class 'datetime.datetime'>",
"isoformat": datetime(2020, 2, 8, 15, tzinfo=timezone.utc).isoformat(),
},
},
"Decimal": {
"native_unit_of_measurement": "°F",
"native_unit_of_measurement": "kWh",
"native_value": {
"__type": "<class 'decimal.Decimal'>",
"decimal_str": "123.4",
Expand All @@ -266,19 +266,38 @@ async def test_reject_timezoneless_datetime_str(

# None | str | int | float | date | datetime | Decimal:
@pytest.mark.parametrize(
"native_value, native_value_type, expected_extra_data, device_class",
"native_value, native_value_type, expected_extra_data, device_class, uom",
[
("abc123", str, RESTORE_DATA["str"], None),
(123, int, RESTORE_DATA["int"], SensorDeviceClass.TEMPERATURE),
(123.0, float, RESTORE_DATA["float"], SensorDeviceClass.TEMPERATURE),
(date(2020, 2, 8), dict, RESTORE_DATA["date"], SensorDeviceClass.DATE),
("abc123", str, RESTORE_DATA["str"], None, None),
(
123,
int,
RESTORE_DATA["int"],
SensorDeviceClass.TEMPERATURE,
UnitOfTemperature.FAHRENHEIT,
),
(
123.0,
float,
RESTORE_DATA["float"],
SensorDeviceClass.TEMPERATURE,
UnitOfTemperature.FAHRENHEIT,
),
(date(2020, 2, 8), dict, RESTORE_DATA["date"], SensorDeviceClass.DATE, None),
(
datetime(2020, 2, 8, 15, tzinfo=timezone.utc),
dict,
RESTORE_DATA["datetime"],
SensorDeviceClass.TIMESTAMP,
None,
),
(
Decimal("123.4"),
dict,
RESTORE_DATA["Decimal"],
SensorDeviceClass.ENERGY,
UnitOfEnergy.KILO_WATT_HOUR,
),
(Decimal("123.4"), dict, RESTORE_DATA["Decimal"], SensorDeviceClass.ENERGY),
],
)
async def test_restore_sensor_save_state(
Expand All @@ -289,14 +308,15 @@ async def test_restore_sensor_save_state(
native_value_type,
expected_extra_data,
device_class,
uom,
):
"""Test RestoreSensor."""
platform = getattr(hass.components, "test.sensor")
platform.init(empty=True)
platform.ENTITIES["0"] = platform.MockRestoreSensor(
name="Test",
native_value=native_value,
native_unit_of_measurement=TEMP_FAHRENHEIT,
native_unit_of_measurement=uom,
device_class=device_class,
)

Expand All @@ -318,23 +338,23 @@ async def test_restore_sensor_save_state(
@pytest.mark.parametrize(
"native_value, native_value_type, extra_data, device_class, uom",
[
("abc123", str, RESTORE_DATA["str"], None, "°F"),
("abc123", str, RESTORE_DATA["str"], None, None),
(123, int, RESTORE_DATA["int"], SensorDeviceClass.TEMPERATURE, "°F"),
(123.0, float, RESTORE_DATA["float"], SensorDeviceClass.TEMPERATURE, "°F"),
(date(2020, 2, 8), date, RESTORE_DATA["date"], SensorDeviceClass.DATE, "°F"),
(date(2020, 2, 8), date, RESTORE_DATA["date"], SensorDeviceClass.DATE, None),
(
datetime(2020, 2, 8, 15, tzinfo=timezone.utc),
datetime,
RESTORE_DATA["datetime"],
SensorDeviceClass.TIMESTAMP,
"°F",
None,
),
(
Decimal("123.4"),
Decimal,
RESTORE_DATA["Decimal"],
SensorDeviceClass.ENERGY,
"°F",
"kWh",
),
(None, type(None), None, None, None),
(None, type(None), {}, None, None),
Expand Down