1
mirror of https://github.com/home-assistant/core synced 2024-09-06 10:29:55 +02:00

Sync event timed_fired and the context ulid time (#71854)

This commit is contained in:
J. Nick Koston 2022-05-14 15:12:08 -04:00 committed by GitHub
parent 8c2743bb67
commit ebce5660e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 370 additions and 36 deletions

View File

@ -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()

View File

@ -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,

View File

@ -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:

View File

@ -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
#

View File

@ -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

View File

@ -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
):

View File

@ -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
)

View File

@ -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