From 01efe1eba29d291f1b783e502a32bbd11992b67b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 18 Nov 2021 14:11:44 +0100 Subject: [PATCH] Add datetime object as valid StateType (#52671) Co-authored-by: Martin Hjelmare --- .../components/cert_expiry/sensor.py | 8 ++- homeassistant/components/sensor/__init__.py | 65 ++++++++++++++++- tests/components/picnic/test_sensor.py | 24 +++---- tests/components/risco/test_sensor.py | 2 +- tests/components/sensor/test_init.py | 70 +++++++++++++++++++ tests/components/xiaomi_miio/test_vacuum.py | 12 ++++ 6 files changed, 162 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 7b6445a2f35..0aa67993180 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -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 diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 4ae6e2129ed..58a9fe4022e 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -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 diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index 2f8fb4cec53..58426e310ed 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -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): diff --git a/tests/components/risco/test_sensor.py b/tests/components/risco/test_sensor.py index eb7ed990bd9..4286a7d09c9 100644 --- a/tests/components/risco/test_sensor.py +++ b/tests/components/risco/test_sensor.py @@ -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 diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index c3808b44b06..d3fb6e89229 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -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 diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index 10f1dd649c8..f2fef4bba4b 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -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