Skip to content

Commit 59877a0

Browse files
authored
Make sure context_id is 26 chars and partially conform to ULID standard (#550)
1 parent cb967ae commit 59877a0

File tree

3 files changed

+63
-28
lines changed

3 files changed

+63
-28
lines changed

custom_components/adaptive_lighting/manifest.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
"documentation": "https://github.com/basnijholt/adaptive-lighting#readme",
88
"iot_class": "calculated",
99
"issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues",
10-
"requirements": [],
10+
"requirements": ["ulid-transform"],
1111
"version": "1.10.0"
1212
}

custom_components/adaptive_lighting/switch.py

+57-15
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
color_xy_to_RGB,
8989
)
9090
import homeassistant.util.dt as dt_util
91+
import ulid_transform
9192
import voluptuous as vol
9293

9394
from .const import (
@@ -182,34 +183,75 @@
182183
}
183184

184185
# Keep a short domain version for the context instances (which can only be 36 chars)
185-
_DOMAIN_SHORT = "adapt_lgt"
186+
_DOMAIN_SHORT = "al"
186187

187188

188-
def _int_to_bytes(i: int, signed: bool = False) -> bytes:
189-
bits = i.bit_length()
190-
if signed:
191-
# Make room for the sign bit.
192-
bits += 1
193-
return i.to_bytes((bits + 7) // 8, "little", signed=signed)
189+
def _int_to_base36(num: int) -> str:
190+
"""
191+
Convert an integer to its base-36 representation using numbers and uppercase letters.
192+
193+
Base-36 encoding uses digits 0-9 and uppercase letters A-Z, providing a case-insensitive
194+
alphanumeric representation. The function takes an integer `num` as input and returns
195+
its base-36 representation as a string.
196+
197+
Parameters
198+
----------
199+
num
200+
The integer to convert to base-36.
201+
202+
Returns
203+
-------
204+
str
205+
The base-36 representation of the input integer.
206+
207+
Examples
208+
--------
209+
>>> num = 123456
210+
>>> base36_num = int_to_base36(num)
211+
>>> print(base36_num)
212+
'2N9'
213+
"""
214+
ALPHANUMERIC_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
215+
216+
if num == 0:
217+
return ALPHANUMERIC_CHARS[0]
218+
219+
base36_str = ""
220+
base = len(ALPHANUMERIC_CHARS)
221+
222+
while num:
223+
num, remainder = divmod(num, base)
224+
base36_str = ALPHANUMERIC_CHARS[remainder] + base36_str
225+
226+
return base36_str
194227

195228

196229
def _short_hash(string: str, length: int = 4) -> str:
197230
"""Create a hash of 'string' with length 'length'."""
198-
str_hash_bytes = _int_to_bytes(hash(string), signed=True)
199-
return base64.b85encode(str_hash_bytes)[:length]
231+
return base64.b32encode(string.encode()).decode("utf-8").zfill(length)[:length]
232+
233+
234+
def _remove_vowels(input_str: str, length: int = 4) -> str:
235+
vowels = "aeiouAEIOU"
236+
output_str = "".join([char for char in input_str if char not in vowels])
237+
return output_str.zfill(length)[:length]
200238

201239

202240
def create_context(
203241
name: str, which: str, index: int, parent: Context | None = None
204242
) -> Context:
205243
"""Create a context that can identify this integration."""
206244
# Use a hash for the name because otherwise the context might become
207-
# too long (max len == 36) to fit in the database.
208-
name_hash = _short_hash(name)
245+
# too long (max len == 26) to fit in the database.
209246
# Pack index with base85 to maximize the number of contexts we can create
210-
# before we exceed the 36-character limit and are forced to wrap.
211-
index_packed = base64.b85encode(_int_to_bytes(index, signed=False))
212-
context_id = f"{_DOMAIN_SHORT}:{name_hash}:{which}:{index_packed}"[:36]
247+
# before we exceed the 26-character limit and are forced to wrap.
248+
time_stamp = ulid_transform.ulid_now()[:10] # time part of a ULID
249+
name_hash = _short_hash(name)
250+
which_short = _remove_vowels(which)
251+
context_id_start = f"{time_stamp}:{_DOMAIN_SHORT}:{name_hash}:{which_short}:"
252+
chars_left = 26 - len(context_id_start)
253+
index_packed = _int_to_base36(index).zfill(chars_left)[-chars_left:]
254+
context_id = context_id_start + index_packed
213255
parent_id = parent.id if parent else None
214256
return Context(id=context_id, parent_id=parent_id)
215257

@@ -218,7 +260,7 @@ def is_our_context(context: Context | None) -> bool:
218260
"""Check whether this integration created 'context'."""
219261
if context is None:
220262
return False
221-
return context.id.startswith(_DOMAIN_SHORT)
263+
return f":{_DOMAIN_SHORT}:" in context.id
222264

223265

224266
def _split_service_data(service_data, adapt_brightness, adapt_color):

tests/test_switch.py

+5-12
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
from copy import deepcopy
55
import datetime
66
import logging
7-
from random import choices as random_choices
87
from random import randint
9-
import string
108
from unittest.mock import patch
119

1210
from homeassistant.components.adaptive_lighting.const import (
@@ -76,6 +74,7 @@
7674
from homeassistant.util.color import color_temperature_mired_to_kelvin
7775
import homeassistant.util.dt as dt_util
7876
import pytest
77+
import ulid_transform
7978
import voluptuous.error
8079

8180
from tests.common import MockConfigEntry, mock_area_registry
@@ -118,6 +117,10 @@
118117
]
119118

120119

120+
def create_random_context() -> str:
121+
return Context(id=ulid_transform.ulid_now(), parent_id=None)
122+
123+
121124
@pytest.fixture
122125
def reset_time_zone():
123126
"""Reset time zone."""
@@ -219,16 +222,6 @@ async def setup_lights_and_switch(hass, extra_conf=None):
219222
return switch, lights_instances
220223

221224

222-
def create_random_context() -> str:
223-
ulid_max_length = 26 # changed from 36->26 in core2023.4.0
224-
return Context(
225-
id="".join(
226-
random_choices(string.ascii_uppercase + string.digits, k=ulid_max_length)
227-
),
228-
parent_id=None,
229-
)
230-
231-
232225
# see https://github.com/home-assistant/core/blob/dev/homeassistant/scripts/benchmark/__init__.py
233226
# basically just search the repo for EVENT_STATE_CHANGED look for how it's fired.
234227
def create_transition_events(

0 commit comments

Comments
 (0)