Add datetime object as valid StateType (#52671)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
92ca94e915
commit
01efe1eba2
6 changed files with 162 additions and 19 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue