diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 9f8c33ad6df..422ce84cc46 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -11,8 +11,10 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, + SensorDeviceClass, SensorEntity, ) +from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, @@ -186,4 +188,13 @@ class RestSensor(RestEntity, SensorEntity): value, None ) - self._state = value + if value is None or self.device_class not in ( + SensorDeviceClass.DATE, + SensorDeviceClass.TIMESTAMP, + ): + self._state = value + return + + self._state = async_parse_date_datetime( + value, self.entity_id, self.device_class + ) diff --git a/homeassistant/components/sensor/helpers.py b/homeassistant/components/sensor/helpers.py new file mode 100644 index 00000000000..a3f5e3827bf --- /dev/null +++ b/homeassistant/components/sensor/helpers.py @@ -0,0 +1,38 @@ +"""Helpers for sensor entities.""" +from __future__ import annotations + +from datetime import date, datetime +import logging + +from homeassistant.core import callback +from homeassistant.util import dt as dt_util + +from . import SensorDeviceClass + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_parse_date_datetime( + value: str, entity_id: str, device_class: SensorDeviceClass | str | None +) -> datetime | date | None: + """Parse datetime string to a data or datetime.""" + if device_class == SensorDeviceClass.TIMESTAMP: + if (parsed_timestamp := dt_util.parse_datetime(value)) is None: + _LOGGER.warning("%s rendered invalid timestamp: %s", entity_id, value) + return None + + if parsed_timestamp.tzinfo is None: + _LOGGER.warning( + "%s rendered timestamp without timezone: %s", entity_id, value + ) + return None + + return parsed_timestamp + + # Date device class + if (parsed_date := dt_util.parse_date(value)) is not None: + return parsed_date + + _LOGGER.warning("%s rendered invalid date %s", entity_id, value) + return None diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 18ae8af8569..18d0be616d4 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import date, datetime -import logging from typing import Any import voluptuous as vol @@ -17,6 +16,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) +from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_CLASS, @@ -35,7 +35,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.util import dt as dt_util from .const import ( CONF_ATTRIBUTE_TEMPLATES, @@ -89,7 +88,6 @@ LEGACY_SENSOR_SCHEMA = vol.All( } ), ) -_LOGGER = logging.getLogger(__name__) def extra_validation_checks(val): @@ -184,32 +182,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) -@callback -def _async_parse_date_datetime( - value: str, entity_id: str, device_class: SensorDeviceClass | str | None -) -> datetime | date | None: - """Parse datetime.""" - if device_class == SensorDeviceClass.TIMESTAMP: - if (parsed_timestamp := dt_util.parse_datetime(value)) is None: - _LOGGER.warning("%s rendered invalid timestamp: %s", entity_id, value) - return None - - if parsed_timestamp.tzinfo is None: - _LOGGER.warning( - "%s rendered timestamp without timezone: %s", entity_id, value - ) - return None - - return parsed_timestamp - - # Date device class - if (parsed_date := dt_util.parse_date(value)) is not None: - return parsed_date - - _LOGGER.warning("%s rendered invalid date %s", entity_id, value) - return None - - class SensorTemplate(TemplateEntity, SensorEntity): """Representation of a Template Sensor.""" @@ -269,7 +241,7 @@ class SensorTemplate(TemplateEntity, SensorEntity): self._attr_native_value = result return - self._attr_native_value = _async_parse_date_datetime( + self._attr_native_value = async_parse_date_datetime( result, self.entity_id, self.device_class ) @@ -303,6 +275,6 @@ class TriggerSensorEntity(TriggerEntity, SensorEntity): ): return - self._rendered[CONF_STATE] = _async_parse_date_datetime( + self._rendered[CONF_STATE] = async_parse_date_datetime( state, self.entity_id, self.device_class ) diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index d37fb047f8f..fb826eefd78 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONTENT_TYPE_JSON, DATA_MEGABYTES, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, SERVICE_RELOAD, STATE_UNKNOWN, TEMP_CELSIUS, @@ -218,6 +219,68 @@ async def test_setup_get(hass): assert state.attributes[sensor.ATTR_STATE_CLASS] == sensor.STATE_CLASS_MEASUREMENT +@respx.mock +async def test_setup_timestamp(hass, caplog): + """Test setup with valid configuration.""" + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, json={"key": "2021-11-11 11:39Z"} + ) + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "device_class": DEVICE_CLASS_TIMESTAMP, + "state_class": sensor.STATE_CLASS_MEASUREMENT, + } + }, + ) + await async_setup_component(hass, "homeassistant", {}) + + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 1 + + state = hass.states.get("sensor.rest_sensor") + assert state.state == "2021-11-11T11:39:00+00:00" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TIMESTAMP + assert "sensor.rest_sensor rendered invalid timestamp" not in caplog.text + assert "sensor.rest_sensor rendered timestamp without timezone" not in caplog.text + + # Bad response: Not a timestamp + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, json={"key": "invalid time stamp"} + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.rest_sensor"]}, + blocking=True, + ) + state = hass.states.get("sensor.rest_sensor") + assert state.state == "unknown" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TIMESTAMP + assert "sensor.rest_sensor rendered invalid timestamp" in caplog.text + + # Bad response: No timezone + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, json={"key": "2021-10-11 11:39"} + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.rest_sensor"]}, + blocking=True, + ) + state = hass.states.get("sensor.rest_sensor") + assert state.state == "unknown" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TIMESTAMP + assert "sensor.rest_sensor rendered timestamp without timezone" in caplog.text + + @respx.mock async def test_setup_get_templated_headers_params(hass): """Test setup with valid configuration.""" diff --git a/tests/components/sensor/test_helpers.py b/tests/components/sensor/test_helpers.py new file mode 100644 index 00000000000..d43443b85ba --- /dev/null +++ b/tests/components/sensor/test_helpers.py @@ -0,0 +1,38 @@ +"""The test for sensor helpers.""" +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor.helpers import async_parse_date_datetime + + +def test_async_parse_datetime(caplog): + """Test async_parse_date_datetime.""" + entity_id = "sensor.timestamp" + device_class = SensorDeviceClass.TIMESTAMP + assert ( + async_parse_date_datetime( + "2021-12-12 12:12Z", entity_id, device_class + ).isoformat() + == "2021-12-12T12:12:00+00:00" + ) + assert not caplog.text + + # No timezone + assert ( + async_parse_date_datetime("2021-12-12 12:12", entity_id, device_class) is None + ) + assert "sensor.timestamp rendered timestamp without timezone" in caplog.text + + # Invalid timestamp + assert async_parse_date_datetime("12 past 12", entity_id, device_class) is None + assert "sensor.timestamp rendered invalid timestamp: 12 past 12" in caplog.text + + device_class = SensorDeviceClass.DATE + caplog.clear() + assert ( + async_parse_date_datetime("2021-12-12", entity_id, device_class).isoformat() + == "2021-12-12" + ) + assert not caplog.text + + # Invalid date + assert async_parse_date_datetime("December 12th", entity_id, device_class) is None + assert "sensor.timestamp rendered invalid date December 12th" in caplog.text diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 67f750ece96..d5deee41679 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1,4 +1,4 @@ -"""The test for sensor device automation.""" +"""The test for sensor entity.""" from datetime import date, datetime, timezone import pytest