diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 6edee252ea3..38b03eb824e 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -55,6 +55,10 @@ SCHEMA_VERSION = 28 _LOGGER = logging.getLogger(__name__) +# EPOCHORDINAL is not exposed as a constant +# https://github.com/python/cpython/blob/3.10/Lib/zoneinfo/_zoneinfo.py#L12 +EPOCHORDINAL = datetime(1970, 1, 1).toordinal() + DB_TIMEZONE = "+00:00" TABLE_EVENTS = "events" @@ -630,10 +634,22 @@ def process_timestamp_to_utc_isoformat(ts: datetime | None) -> str | None: def process_datetime_to_timestamp(ts: datetime) -> float: - """Process a timestamp into a unix timestamp.""" - if ts.tzinfo == dt_util.UTC: - return ts.timestamp() - return ts.replace(tzinfo=dt_util.UTC).timestamp() + """Process a datebase datetime to epoch. + + Mirrors the behavior of process_timestamp_to_utc_isoformat + except it returns the epoch time. + """ + if ts.tzinfo is None: + # Taken from + # https://github.com/python/cpython/blob/3.10/Lib/zoneinfo/_zoneinfo.py#L185 + return ( + (ts.toordinal() - EPOCHORDINAL) * 86400 + + ts.hour * 3600 + + ts.minute * 60 + + ts.second + + (ts.microsecond / 1000000) + ) + return ts.timestamp() class LazyState(State): diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 874d84ef2ad..ca68d5951d8 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from unittest.mock import PropertyMock +from freezegun import freeze_time import pytest from sqlalchemy import create_engine from sqlalchemy.orm import scoped_session, sessionmaker @@ -14,6 +15,7 @@ from homeassistant.components.recorder.models import ( RecorderRuns, StateAttributes, States, + process_datetime_to_timestamp, process_timestamp, process_timestamp_to_utc_isoformat, ) @@ -333,3 +335,73 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed(caplog): "last_updated": "2020-06-12T03:04:01.000323+00:00", "state": "off", } + + +@pytest.mark.parametrize( + "time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii", "UTC"] +) +def test_process_datetime_to_timestamp(time_zone, hass): + """Test we can handle processing database datatimes to timestamps.""" + hass.config.set_time_zone(time_zone) + utc_now = dt_util.utcnow() + assert process_datetime_to_timestamp(utc_now) == utc_now.timestamp() + now = dt_util.now() + assert process_datetime_to_timestamp(now) == now.timestamp() + + +@pytest.mark.parametrize( + "time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii", "UTC"] +) +def test_process_datetime_to_timestamp_freeze_time(time_zone, hass): + """Test we can handle processing database datatimes to timestamps. + + This test freezes time to make sure everything matches. + """ + hass.config.set_time_zone(time_zone) + utc_now = dt_util.utcnow() + with freeze_time(utc_now): + epoch = utc_now.timestamp() + assert process_datetime_to_timestamp(dt_util.utcnow()) == epoch + now = dt_util.now() + assert process_datetime_to_timestamp(now) == epoch + + +@pytest.mark.parametrize( + "time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii", "UTC"] +) +async def test_process_datetime_to_timestamp_mirrors_utc_isoformat_behavior( + time_zone, hass +): + """Test process_datetime_to_timestamp mirrors process_timestamp_to_utc_isoformat.""" + hass.config.set_time_zone(time_zone) + datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC) + datetime_without_tzinfo = datetime(2016, 7, 9, 11, 0, 0) + est = dt_util.get_time_zone("US/Eastern") + datetime_est_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=est) + est = dt_util.get_time_zone("US/Eastern") + datetime_est_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=est) + nst = dt_util.get_time_zone("Canada/Newfoundland") + datetime_nst_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=nst) + hst = dt_util.get_time_zone("US/Hawaii") + datetime_hst_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=hst) + + assert ( + process_datetime_to_timestamp(datetime_with_tzinfo) + == dt_util.parse_datetime("2016-07-09T11:00:00+00:00").timestamp() + ) + assert ( + process_datetime_to_timestamp(datetime_without_tzinfo) + == dt_util.parse_datetime("2016-07-09T11:00:00+00:00").timestamp() + ) + assert ( + process_datetime_to_timestamp(datetime_est_timezone) + == dt_util.parse_datetime("2016-07-09T15:00:00+00:00").timestamp() + ) + assert ( + process_datetime_to_timestamp(datetime_nst_timezone) + == dt_util.parse_datetime("2016-07-09T13:30:00+00:00").timestamp() + ) + assert ( + process_datetime_to_timestamp(datetime_hst_timezone) + == dt_util.parse_datetime("2016-07-09T21:00:00+00:00").timestamp() + )