Add datetime object as valid StateType (#52671)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
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.""" """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 import voluptuous as vol
@ -85,8 +87,8 @@ class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity):
self._attr_unique_id = f"{coordinator.host}:{coordinator.port}-timestamp" self._attr_unique_id = f"{coordinator.host}:{coordinator.port}-timestamp"
@property @property
def native_value(self): def native_value(self) -> datetime | None:
"""Return the state of the sensor.""" """Return the state of the sensor."""
if self.coordinator.data: if self.coordinator.data:
return self.coordinator.data.isoformat() return self.coordinator.data
return None return None

View file

@ -4,11 +4,12 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
import inspect import inspect
import logging import logging
from typing import Any, Final, cast, final from typing import Any, Final, cast, final
import ciso8601
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -182,6 +183,9 @@ class SensorEntity(Entity):
_last_reset_reported = False _last_reset_reported = False
_temperature_conversion_reported = False _temperature_conversion_reported = False
# Temporary private attribute to track if deprecation has been logged.
__datetime_as_string_deprecation_logged = False
@property @property
def state_class(self) -> str | None: def state_class(self) -> str | None:
"""Return the state class of this entity, from STATE_CLASSES, if any.""" """Return the state class of this entity, from STATE_CLASSES, if any."""
@ -236,7 +240,7 @@ class SensorEntity(Entity):
return None return None
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType | date | datetime:
"""Return the value reported by the sensor.""" """Return the value reported by the sensor."""
return self._attr_native_value return self._attr_native_value
@ -273,6 +277,61 @@ class SensorEntity(Entity):
"""Return the state of the sensor and perform unit conversions, if needed.""" """Return the state of the sensor and perform unit conversions, if needed."""
unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.native_unit_of_measurement
value = self.native_value 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 units = self.hass.config.units
if ( if (
@ -304,7 +363,7 @@ class SensorEntity(Entity):
prec = len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 prec = len(value_s) - value_s.index(".") - 1 if "." in value_s else 0
# Suppress ValueError (Could not convert sensor_value to float) # Suppress ValueError (Could not convert sensor_value to float)
with suppress(ValueError): 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) value = round(temp) if prec == 0 else round(temp, prec)
return value return value

View file

@ -210,44 +210,44 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase):
) )
self._assert_sensor( self._assert_sensor(
"sensor.picnic_selected_slot_start", "sensor.picnic_selected_slot_start",
"2021-03-03T14:45:00.000+01:00", "2021-03-03T14:45:00+01:00",
cls=DEVICE_CLASS_TIMESTAMP, cls=DEVICE_CLASS_TIMESTAMP,
) )
self._assert_sensor( self._assert_sensor(
"sensor.picnic_selected_slot_end", "sensor.picnic_selected_slot_end",
"2021-03-03T15:45:00.000+01:00", "2021-03-03T15:45:00+01:00",
cls=DEVICE_CLASS_TIMESTAMP, cls=DEVICE_CLASS_TIMESTAMP,
) )
self._assert_sensor( self._assert_sensor(
"sensor.picnic_selected_slot_max_order_time", "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, cls=DEVICE_CLASS_TIMESTAMP,
) )
self._assert_sensor("sensor.picnic_selected_slot_min_order_value", "35.0") self._assert_sensor("sensor.picnic_selected_slot_min_order_value", "35.0")
self._assert_sensor( self._assert_sensor(
"sensor.picnic_last_order_slot_start", "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, cls=DEVICE_CLASS_TIMESTAMP,
) )
self._assert_sensor( self._assert_sensor(
"sensor.picnic_last_order_slot_end", "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, cls=DEVICE_CLASS_TIMESTAMP,
) )
self._assert_sensor("sensor.picnic_last_order_status", "COMPLETED") self._assert_sensor("sensor.picnic_last_order_status", "COMPLETED")
self._assert_sensor( self._assert_sensor(
"sensor.picnic_last_order_eta_start", "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, cls=DEVICE_CLASS_TIMESTAMP,
) )
self._assert_sensor( self._assert_sensor(
"sensor.picnic_last_order_eta_end", "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, cls=DEVICE_CLASS_TIMESTAMP,
) )
self._assert_sensor( self._assert_sensor(
"sensor.picnic_last_order_delivery_time", "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, cls=DEVICE_CLASS_TIMESTAMP,
) )
self._assert_sensor( self._assert_sensor(
@ -305,10 +305,10 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase):
# Assert delivery time is not available, but eta is # 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_delivery_time", STATE_UNAVAILABLE)
self._assert_sensor( 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( 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): async def test_sensors_use_detailed_eta_if_available(self):
@ -333,10 +333,10 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase):
delivery_response["delivery_id"] delivery_response["delivery_id"]
) )
self._assert_sensor( 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( 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): 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_index = CATEGORIES_TO_EVENTS[category]
event = TEST_EVENTS[event_index] event = TEST_EVENTS[event_index]
state = hass.states.get(entity_id) 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_id"] == event.category_id
assert state.attributes["category_name"] == event.category_name assert state.attributes["category_name"] == event.category_name
assert state.attributes["type_id"] == event.type_id assert state.attributes["type_id"] == event.type_id

View file

@ -1,11 +1,16 @@
"""The test for sensor device automation.""" """The test for sensor device automation."""
from datetime import date, datetime, timezone
import pytest import pytest
from pytest import approx from pytest import approx
from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.sensor import SensorEntityDescription
from homeassistant.const import ( from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_DATE,
DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TIMESTAMP,
STATE_UNKNOWN,
TEMP_CELSIUS, TEMP_CELSIUS,
TEMP_FAHRENHEIT, 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 " "tests.components.sensor.test_init is setting 'unit_of_measurement' on an "
"instance of SensorEntityDescription" "instance of SensorEntityDescription"
) in caplog.text ) 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().battery = 82
mock_vacuum.status().clean_area = 123.43218 mock_vacuum.status().clean_area = 123.43218
mock_vacuum.status().clean_time = timedelta(hours=2, minutes=35, seconds=34) 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( mock_vacuum.consumable_status().main_brush_left = timedelta(
hours=12, minutes=35, seconds=34 hours=12, minutes=35, seconds=34
) )
@ -136,6 +142,12 @@ def mirobo_old_speeds_fixture(request):
mock_vacuum.status().battery = 32 mock_vacuum.status().battery = 32
mock_vacuum.fan_speed_presets.return_value = request.param mock_vacuum.fan_speed_presets.return_value = request.param
mock_vacuum.status().fanspeed = list(request.param.values())[0] 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: with patch("homeassistant.components.xiaomi_miio.Vacuum") as mock_vacuum_cls:
mock_vacuum_cls.return_value = mock_vacuum mock_vacuum_cls.return_value = mock_vacuum