Add datetime object as valid StateType (#52671)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/59896/head
Franck Nijhof 2021-11-18 14:11:44 +01:00 committed by GitHub
parent 92ca94e915
commit 01efe1eba2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 162 additions and 19 deletions

View File

@ -1,5 +1,7 @@
"""Counter for the days until an HTTPS (TLS) certificate will expire."""
from datetime import timedelta
from __future__ import annotations
from datetime import datetime, timedelta
import voluptuous as vol
@ -85,8 +87,8 @@ class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity):
self._attr_unique_id = f"{coordinator.host}:{coordinator.port}-timestamp"
@property
def native_value(self):
def native_value(self) -> datetime | None:
"""Return the state of the sensor."""
if self.coordinator.data:
return self.coordinator.data.isoformat()
return self.coordinator.data
return None

View File

@ -4,11 +4,12 @@ from __future__ import annotations
from collections.abc import Mapping
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
import inspect
import logging
from typing import Any, Final, cast, final
import ciso8601
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@ -182,6 +183,9 @@ class SensorEntity(Entity):
_last_reset_reported = False
_temperature_conversion_reported = False
# Temporary private attribute to track if deprecation has been logged.
__datetime_as_string_deprecation_logged = False
@property
def state_class(self) -> str | None:
"""Return the state class of this entity, from STATE_CLASSES, if any."""
@ -236,7 +240,7 @@ class SensorEntity(Entity):
return None
@property
def native_value(self) -> StateType:
def native_value(self) -> StateType | date | datetime:
"""Return the value reported by the sensor."""
return self._attr_native_value
@ -273,6 +277,61 @@ class SensorEntity(Entity):
"""Return the state of the sensor and perform unit conversions, if needed."""
unit_of_measurement = self.native_unit_of_measurement
value = self.native_value
device_class = self.device_class
# We have an old non-datetime value, warn about it and convert it during
# the deprecation period.
if (
value is not None
and device_class in (DEVICE_CLASS_DATE, DEVICE_CLASS_TIMESTAMP)
and not isinstance(value, (date, datetime))
):
# Deprecation warning for date/timestamp device classes
if not self.__datetime_as_string_deprecation_logged:
report_issue = self._suggest_report_issue()
_LOGGER.warning(
"%s is providing a string for its state, while the device "
"class is '%s', this is not valid and will be unsupported "
"from Home Assistant 2022.2. Please %s",
self.entity_id,
device_class,
report_issue,
)
self.__datetime_as_string_deprecation_logged = True
# Anyways, lets validate the date at least..
try:
value = ciso8601.parse_datetime(str(value))
except (ValueError, IndexError) as error:
raise ValueError(
f"Invalid date/datetime: {self.entity_id} provide state '{value}', "
f"while it has device class '{device_class}'"
) from error
# Convert the date object to a standardized state string.
if device_class == DEVICE_CLASS_DATE:
return value.date().isoformat()
return value.isoformat(timespec="seconds")
# Received a datetime
if value is not None and device_class == DEVICE_CLASS_TIMESTAMP:
try:
return value.isoformat(timespec="seconds") # type: ignore
except (AttributeError, TypeError) as err:
raise ValueError(
f"Invalid datetime: {self.entity_id} has a timestamp device class"
f"but does not provide a datetime state but {type(value)}"
) from err
# Received a date value
if value is not None and device_class == DEVICE_CLASS_DATE:
try:
return value.isoformat() # type: ignore
except (AttributeError, TypeError) as err:
raise ValueError(
f"Invalid date: {self.entity_id} has a date device class"
f"but does not provide a date state but {type(value)}"
) from err
units = self.hass.config.units
if (
@ -304,7 +363,7 @@ class SensorEntity(Entity):
prec = len(value_s) - value_s.index(".") - 1 if "." in value_s else 0
# Suppress ValueError (Could not convert sensor_value to float)
with suppress(ValueError):
temp = units.temperature(float(value), unit_of_measurement)
temp = units.temperature(float(value), unit_of_measurement) # type: ignore
value = round(temp) if prec == 0 else round(temp, prec)
return value

View File

@ -210,44 +210,44 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase):
)
self._assert_sensor(
"sensor.picnic_selected_slot_start",
"2021-03-03T14:45:00.000+01:00",
"2021-03-03T14:45:00+01:00",
cls=DEVICE_CLASS_TIMESTAMP,
)
self._assert_sensor(
"sensor.picnic_selected_slot_end",
"2021-03-03T15:45:00.000+01:00",
"2021-03-03T15:45:00+01:00",
cls=DEVICE_CLASS_TIMESTAMP,
)
self._assert_sensor(
"sensor.picnic_selected_slot_max_order_time",
"2021-03-02T22:00:00.000+01:00",
"2021-03-02T22:00:00+01:00",
cls=DEVICE_CLASS_TIMESTAMP,
)
self._assert_sensor("sensor.picnic_selected_slot_min_order_value", "35.0")
self._assert_sensor(
"sensor.picnic_last_order_slot_start",
"2021-02-26T20:15:00.000+01:00",
"2021-02-26T20:15:00+01:00",
cls=DEVICE_CLASS_TIMESTAMP,
)
self._assert_sensor(
"sensor.picnic_last_order_slot_end",
"2021-02-26T21:15:00.000+01:00",
"2021-02-26T21:15:00+01:00",
cls=DEVICE_CLASS_TIMESTAMP,
)
self._assert_sensor("sensor.picnic_last_order_status", "COMPLETED")
self._assert_sensor(
"sensor.picnic_last_order_eta_start",
"2021-02-26T20:54:00.000+01:00",
"2021-02-26T20:54:00+01:00",
cls=DEVICE_CLASS_TIMESTAMP,
)
self._assert_sensor(
"sensor.picnic_last_order_eta_end",
"2021-02-26T21:14:00.000+01:00",
"2021-02-26T21:14:00+01:00",
cls=DEVICE_CLASS_TIMESTAMP,
)
self._assert_sensor(
"sensor.picnic_last_order_delivery_time",
"2021-02-26T20:54:05.221+01:00",
"2021-02-26T20:54:05+01:00",
cls=DEVICE_CLASS_TIMESTAMP,
)
self._assert_sensor(
@ -305,10 +305,10 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase):
# Assert delivery time is not available, but eta is
self._assert_sensor("sensor.picnic_last_order_delivery_time", STATE_UNAVAILABLE)
self._assert_sensor(
"sensor.picnic_last_order_eta_start", "2021-02-26T20:54:00.000+01:00"
"sensor.picnic_last_order_eta_start", "2021-02-26T20:54:00+01:00"
)
self._assert_sensor(
"sensor.picnic_last_order_eta_end", "2021-02-26T21:14:00.000+01:00"
"sensor.picnic_last_order_eta_end", "2021-02-26T21:14:00+01:00"
)
async def test_sensors_use_detailed_eta_if_available(self):
@ -333,10 +333,10 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase):
delivery_response["delivery_id"]
)
self._assert_sensor(
"sensor.picnic_last_order_eta_start", "2021-03-05T11:19:20.452+01:00"
"sensor.picnic_last_order_eta_start", "2021-03-05T11:19:20+01:00"
)
self._assert_sensor(
"sensor.picnic_last_order_eta_end", "2021-03-05T11:39:20.452+01:00"
"sensor.picnic_last_order_eta_end", "2021-03-05T11:39:20+01:00"
)
async def test_sensors_no_data(self):

View File

@ -147,7 +147,7 @@ def _check_state(hass, category, entity_id):
event_index = CATEGORIES_TO_EVENTS[category]
event = TEST_EVENTS[event_index]
state = hass.states.get(entity_id)
assert state.state == event.time
assert state.state == dt.parse_datetime(event.time).isoformat()
assert state.attributes["category_id"] == event.category_id
assert state.attributes["category_name"] == event.category_name
assert state.attributes["type_id"] == event.type_id

View File

@ -1,11 +1,16 @@
"""The test for sensor device automation."""
from datetime import date, datetime, timezone
import pytest
from pytest import approx
from homeassistant.components.sensor import SensorEntityDescription
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_DATE,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TIMESTAMP,
STATE_UNKNOWN,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
@ -107,3 +112,68 @@ async def test_deprecated_unit_of_measurement(hass, caplog, enable_custom_integr
"tests.components.sensor.test_init is setting 'unit_of_measurement' on an "
"instance of SensorEntityDescription"
) in caplog.text
async def test_datetime_conversion(hass, caplog, enable_custom_integrations):
"""Test conversion of datetime."""
test_timestamp = datetime(2017, 12, 19, 18, 29, 42, tzinfo=timezone.utc)
test_date = date(2017, 12, 19)
platform = getattr(hass.components, "test.sensor")
platform.init(empty=True)
platform.ENTITIES["0"] = platform.MockSensor(
name="Test", native_value=test_timestamp, device_class=DEVICE_CLASS_TIMESTAMP
)
platform.ENTITIES["1"] = platform.MockSensor(
name="Test", native_value=test_date, device_class=DEVICE_CLASS_DATE
)
platform.ENTITIES["2"] = platform.MockSensor(
name="Test", native_value=None, device_class=DEVICE_CLASS_TIMESTAMP
)
platform.ENTITIES["3"] = platform.MockSensor(
name="Test", native_value=None, device_class=DEVICE_CLASS_DATE
)
assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}})
await hass.async_block_till_done()
state = hass.states.get(platform.ENTITIES["0"].entity_id)
assert state.state == test_timestamp.isoformat()
state = hass.states.get(platform.ENTITIES["1"].entity_id)
assert state.state == test_date.isoformat()
state = hass.states.get(platform.ENTITIES["2"].entity_id)
assert state.state == STATE_UNKNOWN
state = hass.states.get(platform.ENTITIES["3"].entity_id)
assert state.state == STATE_UNKNOWN
@pytest.mark.parametrize(
"device_class,native_value",
[
(DEVICE_CLASS_DATE, "2021-11-09"),
(DEVICE_CLASS_TIMESTAMP, "2021-01-09T12:00:00+00:00"),
],
)
async def test_deprecated_datetime_str(
hass, caplog, enable_custom_integrations, device_class, native_value
):
"""Test warning on deprecated str for a date(time) value."""
platform = getattr(hass.components, "test.sensor")
platform.init(empty=True)
platform.ENTITIES["0"] = platform.MockSensor(
name="Test", native_value=native_value, device_class=device_class
)
entity0 = platform.ENTITIES["0"]
assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}})
await hass.async_block_till_done()
state = hass.states.get(entity0.entity_id)
assert state.state == native_value
assert (
"is providing a string for its state, while the device class is "
f"'{device_class}', this is not valid and will be unsupported "
"from Home Assistant 2022.2."
) in caplog.text

View File

@ -78,6 +78,12 @@ def mirobo_is_got_error_fixture():
mock_vacuum.status().battery = 82
mock_vacuum.status().clean_area = 123.43218
mock_vacuum.status().clean_time = timedelta(hours=2, minutes=35, seconds=34)
mock_vacuum.last_clean_details().start = datetime(
2020, 4, 1, 13, 21, 10, tzinfo=dt_util.UTC
)
mock_vacuum.last_clean_details().end = datetime(
2020, 4, 1, 13, 21, 10, tzinfo=dt_util.UTC
)
mock_vacuum.consumable_status().main_brush_left = timedelta(
hours=12, minutes=35, seconds=34
)
@ -136,6 +142,12 @@ def mirobo_old_speeds_fixture(request):
mock_vacuum.status().battery = 32
mock_vacuum.fan_speed_presets.return_value = request.param
mock_vacuum.status().fanspeed = list(request.param.values())[0]
mock_vacuum.last_clean_details().start = datetime(
2020, 4, 1, 13, 21, 10, tzinfo=dt_util.UTC
)
mock_vacuum.last_clean_details().end = datetime(
2020, 4, 1, 13, 21, 10, tzinfo=dt_util.UTC
)
with patch("homeassistant.components.xiaomi_miio.Vacuum") as mock_vacuum_cls:
mock_vacuum_cls.return_value = mock_vacuum