From 59877a034340793ec1c8d70d42916ed5116d174d Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 6 Apr 2023 12:37:02 -0700 Subject: [PATCH] Make sure context_id is 26 chars and partially conform to ULID standard (#550) --- .../adaptive_lighting/manifest.json | 2 +- custom_components/adaptive_lighting/switch.py | 72 +++++++++++++++---- tests/test_switch.py | 17 ++--- 3 files changed, 63 insertions(+), 28 deletions(-) diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json index 33564f23..166b3695 100644 --- a/custom_components/adaptive_lighting/manifest.json +++ b/custom_components/adaptive_lighting/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://github.com/basnijholt/adaptive-lighting#readme", "iot_class": "calculated", "issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues", - "requirements": [], + "requirements": ["ulid-transform"], "version": "1.10.0" } diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index dbf3c42b..5b816b67 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -88,6 +88,7 @@ from homeassistant.util.color import ( color_xy_to_RGB, ) import homeassistant.util.dt as dt_util +import ulid_transform import voluptuous as vol from .const import ( @@ -182,21 +183,58 @@ BRIGHTNESS_ATTRS = { } # Keep a short domain version for the context instances (which can only be 36 chars) -_DOMAIN_SHORT = "adapt_lgt" +_DOMAIN_SHORT = "al" -def _int_to_bytes(i: int, signed: bool = False) -> bytes: - bits = i.bit_length() - if signed: - # Make room for the sign bit. - bits += 1 - return i.to_bytes((bits + 7) // 8, "little", signed=signed) +def _int_to_base36(num: int) -> str: + """ + Convert an integer to its base-36 representation using numbers and uppercase letters. + + Base-36 encoding uses digits 0-9 and uppercase letters A-Z, providing a case-insensitive + alphanumeric representation. The function takes an integer `num` as input and returns + its base-36 representation as a string. + + Parameters + ---------- + num + The integer to convert to base-36. + + Returns + ------- + str + The base-36 representation of the input integer. + + Examples + -------- + >>> num = 123456 + >>> base36_num = int_to_base36(num) + >>> print(base36_num) + '2N9' + """ + ALPHANUMERIC_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + if num == 0: + return ALPHANUMERIC_CHARS[0] + + base36_str = "" + base = len(ALPHANUMERIC_CHARS) + + while num: + num, remainder = divmod(num, base) + base36_str = ALPHANUMERIC_CHARS[remainder] + base36_str + + return base36_str def _short_hash(string: str, length: int = 4) -> str: """Create a hash of 'string' with length 'length'.""" - str_hash_bytes = _int_to_bytes(hash(string), signed=True) - return base64.b85encode(str_hash_bytes)[:length] + return base64.b32encode(string.encode()).decode("utf-8").zfill(length)[:length] + + +def _remove_vowels(input_str: str, length: int = 4) -> str: + vowels = "aeiouAEIOU" + output_str = "".join([char for char in input_str if char not in vowels]) + return output_str.zfill(length)[:length] def create_context( @@ -204,12 +242,16 @@ def create_context( ) -> Context: """Create a context that can identify this integration.""" # Use a hash for the name because otherwise the context might become - # too long (max len == 36) to fit in the database. - name_hash = _short_hash(name) + # too long (max len == 26) to fit in the database. # Pack index with base85 to maximize the number of contexts we can create - # before we exceed the 36-character limit and are forced to wrap. - index_packed = base64.b85encode(_int_to_bytes(index, signed=False)) - context_id = f"{_DOMAIN_SHORT}:{name_hash}:{which}:{index_packed}"[:36] + # before we exceed the 26-character limit and are forced to wrap. + time_stamp = ulid_transform.ulid_now()[:10] # time part of a ULID + name_hash = _short_hash(name) + which_short = _remove_vowels(which) + context_id_start = f"{time_stamp}:{_DOMAIN_SHORT}:{name_hash}:{which_short}:" + chars_left = 26 - len(context_id_start) + index_packed = _int_to_base36(index).zfill(chars_left)[-chars_left:] + context_id = context_id_start + index_packed parent_id = parent.id if parent else None return Context(id=context_id, parent_id=parent_id) @@ -218,7 +260,7 @@ def is_our_context(context: Context | None) -> bool: """Check whether this integration created 'context'.""" if context is None: return False - return context.id.startswith(_DOMAIN_SHORT) + return f":{_DOMAIN_SHORT}:" in context.id def _split_service_data(service_data, adapt_brightness, adapt_color): diff --git a/tests/test_switch.py b/tests/test_switch.py index 6ba54a99..ae722b0d 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -4,9 +4,7 @@ import asyncio from copy import deepcopy import datetime import logging -from random import choices as random_choices from random import randint -import string from unittest.mock import patch from homeassistant.components.adaptive_lighting.const import ( @@ -76,6 +74,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util.color import color_temperature_mired_to_kelvin import homeassistant.util.dt as dt_util import pytest +import ulid_transform import voluptuous.error from tests.common import MockConfigEntry, mock_area_registry @@ -118,6 +117,10 @@ GLOBAL_TEST_DEPENDENCIES = [ ] +def create_random_context() -> str: + return Context(id=ulid_transform.ulid_now(), parent_id=None) + + @pytest.fixture def reset_time_zone(): """Reset time zone.""" @@ -219,16 +222,6 @@ async def setup_lights_and_switch(hass, extra_conf=None): return switch, lights_instances -def create_random_context() -> str: - ulid_max_length = 26 # changed from 36->26 in core2023.4.0 - return Context( - id="".join( - random_choices(string.ascii_uppercase + string.digits, k=ulid_max_length) - ), - parent_id=None, - ) - - # see https://github.com/home-assistant/core/blob/dev/homeassistant/scripts/benchmark/__init__.py # basically just search the repo for EVENT_STATE_CHANGED look for how it's fired. def create_transition_events(