diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 9faedbbdb1e..2f71324fac0 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -55,10 +55,6 @@ 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" @@ -649,16 +645,8 @@ def process_datetime_to_timestamp(ts: datetime) -> float: 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) - ) + if ts.tzinfo is None or ts.tzinfo == dt_util.UTC: + return dt_util.utc_to_timestamp(ts) return ts.timestamp() diff --git a/homeassistant/core.py b/homeassistant/core.py index 916dd0c6f72..061695ade6a 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -734,7 +734,9 @@ class Event: self.data = data or {} self.origin = origin self.time_fired = time_fired or dt_util.utcnow() - self.context: Context = context or Context() + self.context: Context = context or Context( + id=ulid_util.ulid(dt_util.utc_to_timestamp(self.time_fired)) + ) def __hash__(self) -> int: """Make hashable.""" @@ -1363,11 +1365,11 @@ class StateMachine: if same_state and same_attr: return - if context is None: - context = Context() - now = dt_util.utcnow() + if context is None: + context = Context(id=ulid_util.ulid(dt_util.utc_to_timestamp(now))) + state = State( entity_id, new_state, diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 4b4b798a2d8..7c0a4923e71 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -14,6 +14,10 @@ DATE_STR_FORMAT = "%Y-%m-%d" UTC = dt.timezone.utc DEFAULT_TIME_ZONE: dt.tzinfo = dt.timezone.utc +# EPOCHORDINAL is not exposed as a constant +# https://github.com/python/cpython/blob/3.10/Lib/zoneinfo/_zoneinfo.py#L12 +EPOCHORDINAL = dt.datetime(1970, 1, 1).toordinal() + # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. # https://github.com/django/django/blob/master/LICENSE @@ -98,6 +102,19 @@ def utc_from_timestamp(timestamp: float) -> dt.datetime: return dt.datetime.utcfromtimestamp(timestamp).replace(tzinfo=UTC) +def utc_to_timestamp(utc_dt: dt.datetime) -> float: + """Fast conversion of a datetime in UTC to a timestamp.""" + # Taken from + # https://github.com/python/cpython/blob/3.10/Lib/zoneinfo/_zoneinfo.py#L185 + return ( + (utc_dt.toordinal() - EPOCHORDINAL) * 86400 + + utc_dt.hour * 3600 + + utc_dt.minute * 60 + + utc_dt.second + + (utc_dt.microsecond / 1000000) + ) + + def start_of_local_day(dt_or_d: dt.date | dt.datetime | None = None) -> dt.datetime: """Return local datetime object of start of day from date or datetime.""" if dt_or_d is None: diff --git a/homeassistant/util/ulid.py b/homeassistant/util/ulid.py index c38d95bd169..d40b0f48e16 100644 --- a/homeassistant/util/ulid.py +++ b/homeassistant/util/ulid.py @@ -1,4 +1,5 @@ """Helpers to generate ulids.""" +from __future__ import annotations from random import getrandbits import time @@ -17,7 +18,7 @@ def ulid_hex() -> str: return f"{int(time.time()*1000):012x}{getrandbits(80):020x}" -def ulid() -> str: +def ulid(timestamp: float | None = None) -> str: """Generate a ULID. This ulid should not be used for cryptographically secure @@ -34,9 +35,9 @@ def ulid() -> str: import ulid ulid.parse(ulid_util.ulid()) """ - ulid_bytes = int(time.time() * 1000).to_bytes(6, byteorder="big") + int( - getrandbits(80) - ).to_bytes(10, byteorder="big") + ulid_bytes = int((timestamp or time.time()) * 1000).to_bytes( + 6, byteorder="big" + ) + int(getrandbits(80)).to_bytes(10, byteorder="big") # This is base32 crockford encoding with the loop unrolled for performance # diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index d0412731c78..b27b6324f86 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -811,7 +811,7 @@ async def test_humidity_change_dry_trigger_on_not_long_enough(hass, setup_comp_4 async def test_humidity_change_dry_trigger_on_long_enough(hass, setup_comp_4): """Test if humidity change turn dry on.""" fake_changed = datetime.datetime( - 1918, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc + 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc ) with patch( "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed @@ -845,7 +845,7 @@ async def test_humidity_change_dry_trigger_off_not_long_enough(hass, setup_comp_ async def test_humidity_change_dry_trigger_off_long_enough(hass, setup_comp_4): """Test if humidity change turn dry on.""" fake_changed = datetime.datetime( - 1918, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc + 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc ) with patch( "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed @@ -967,7 +967,7 @@ async def test_humidity_change_humidifier_trigger_on_not_long_enough( async def test_humidity_change_humidifier_trigger_on_long_enough(hass, setup_comp_6): """Test if humidity change turn humidifier on after min cycle.""" fake_changed = datetime.datetime( - 1918, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc + 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc ) with patch( "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed @@ -989,7 +989,7 @@ async def test_humidity_change_humidifier_trigger_on_long_enough(hass, setup_com async def test_humidity_change_humidifier_trigger_off_long_enough(hass, setup_comp_6): """Test if humidity change turn humidifier off after min cycle.""" fake_changed = datetime.datetime( - 1918, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc + 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc ) with patch( "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index d607c6dbb61..c4d23b41579 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -749,7 +749,7 @@ async def test_temp_change_ac_trigger_on_not_long_enough(hass, setup_comp_4): async def test_temp_change_ac_trigger_on_long_enough(hass, setup_comp_4): """Test if temperature change turn ac on.""" - fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) + fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) with patch( "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed ): @@ -775,7 +775,7 @@ async def test_temp_change_ac_trigger_off_not_long_enough(hass, setup_comp_4): async def test_temp_change_ac_trigger_off_long_enough(hass, setup_comp_4): """Test if temperature change turn ac on.""" - fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) + fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) with patch( "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed ): @@ -855,7 +855,7 @@ async def test_temp_change_ac_trigger_on_not_long_enough_2(hass, setup_comp_5): async def test_temp_change_ac_trigger_on_long_enough_2(hass, setup_comp_5): """Test if temperature change turn ac on.""" - fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) + fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) with patch( "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed ): @@ -881,7 +881,7 @@ async def test_temp_change_ac_trigger_off_not_long_enough_2(hass, setup_comp_5): async def test_temp_change_ac_trigger_off_long_enough_2(hass, setup_comp_5): """Test if temperature change turn ac on.""" - fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) + fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) with patch( "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed ): @@ -969,7 +969,7 @@ async def test_temp_change_heater_trigger_on_not_long_enough(hass, setup_comp_6) async def test_temp_change_heater_trigger_on_long_enough(hass, setup_comp_6): """Test if temperature change turn heater on after min cycle.""" - fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) + fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) with patch( "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed ): @@ -986,7 +986,7 @@ async def test_temp_change_heater_trigger_on_long_enough(hass, setup_comp_6): async def test_temp_change_heater_trigger_off_long_enough(hass, setup_comp_6): """Test if temperature change turn heater off after min cycle.""" - fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) + fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) with patch( "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed ): diff --git a/tests/test_core.py b/tests/test_core.py index c870605fc01..104828f64e1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,5 +1,8 @@ """Test to verify that Home Assistant core works.""" +from __future__ import annotations + # pylint: disable=protected-access +import array import asyncio from datetime import datetime, timedelta import functools @@ -28,6 +31,7 @@ from homeassistant.const import ( __version__, ) import homeassistant.core as ha +from homeassistant.core import State from homeassistant.exceptions import ( InvalidEntityFormatError, InvalidStateError, @@ -1489,9 +1493,300 @@ async def test_reserving_states(hass): assert hass.states.async_available("light.bedroom") is True -async def test_state_change_events_match_state_time(hass): - """Test last_updated and timed_fired only call utcnow once.""" +def _ulid_timestamp(ulid: str) -> int: + encoded = ulid[:10].encode("ascii") + # This unpacks the time from the ulid + # Copied from + # https://github.com/ahawker/ulid/blob/06289583e9de4286b4d80b4ad000d137816502ca/ulid/base32.py#L296 + decoding = array.array( + "B", + ( + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0x00, + 0x01, + 0x02, + 0x03, + 0x04, + 0x05, + 0x06, + 0x07, + 0x08, + 0x09, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0x0A, + 0x0B, + 0x0C, + 0x0D, + 0x0E, + 0x0F, + 0x10, + 0x11, + 0x01, + 0x12, + 0x13, + 0x01, + 0x14, + 0x15, + 0x00, + 0x16, + 0x17, + 0x18, + 0x19, + 0x1A, + 0xFF, + 0x1B, + 0x1C, + 0x1D, + 0x1E, + 0x1F, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0x0A, + 0x0B, + 0x0C, + 0x0D, + 0x0E, + 0x0F, + 0x10, + 0x11, + 0x01, + 0x12, + 0x13, + 0x01, + 0x14, + 0x15, + 0x00, + 0x16, + 0x17, + 0x18, + 0x19, + 0x1A, + 0xFF, + 0x1B, + 0x1C, + 0x1D, + 0x1E, + 0x1F, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + ), + ) + return int.from_bytes( + bytes( + ( + ((decoding[encoded[0]] << 5) | decoding[encoded[1]]) & 0xFF, + ((decoding[encoded[2]] << 3) | (decoding[encoded[3]] >> 2)) & 0xFF, + ( + (decoding[encoded[3]] << 6) + | (decoding[encoded[4]] << 1) + | (decoding[encoded[5]] >> 4) + ) + & 0xFF, + ((decoding[encoded[5]] << 4) | (decoding[encoded[6]] >> 1)) & 0xFF, + ( + (decoding[encoded[6]] << 7) + | (decoding[encoded[7]] << 2) + | (decoding[encoded[8]] >> 3) + ) + & 0xFF, + ((decoding[encoded[8]] << 5) | (decoding[encoded[9]])) & 0xFF, + ) + ), + byteorder="big", + ) + + +async def test_state_change_events_context_id_match_state_time(hass): + """Test last_updated, timed_fired, and the ulid all have the same time.""" events = [] @ha.callback @@ -1502,6 +1797,31 @@ async def test_state_change_events_match_state_time(hass): hass.states.async_set("light.bedroom", "on") await hass.async_block_till_done() - state = hass.states.get("light.bedroom") - + state: State = hass.states.get("light.bedroom") assert state.last_updated == events[0].time_fired + assert len(state.context.id) == 26 + # ULIDs store time to 3 decimal places compared to python timestamps + assert _ulid_timestamp(state.context.id) == int( + state.last_updated.timestamp() * 1000 + ) + + +async def test_state_firing_event_matches_context_id_ulid_time(hass): + """Test timed_fired and the ulid have the same time.""" + events = [] + + @ha.callback + def _event_listener(event): + events.append(event) + + hass.bus.async_listen(EVENT_HOMEASSISTANT_STARTED, _event_listener) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + event = events[0] + assert len(event.context.id) == 26 + # ULIDs store time to 3 decimal places compared to python timestamps + assert _ulid_timestamp(event.context.id) == int( + events[0].time_fired.timestamp() * 1000 + ) diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index d2c453f070d..6e499e6e6f1 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -106,6 +106,12 @@ def test_utc_from_timestamp(): ) +def test_timestamp_to_utc(): + """Test we can convert a utc datetime to a timestamp.""" + utc_now = dt_util.utcnow() + assert dt_util.utc_to_timestamp(utc_now) == utc_now.timestamp() + + def test_as_timestamp(): """Test as_timestamp method.""" ts = 1462401234