Skip to content

Commit 2c8a456

Browse files
feat: brightness prioritization (#598)
* feat: optional brightness prioritization * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove config flag and change default split order * Fix test * Fix edge case * Add tests * Backwards compatiblity * Fix another edge case --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 4683f08 commit 2c8a456

File tree

2 files changed

+189
-48
lines changed

2 files changed

+189
-48
lines changed

custom_components/adaptive_lighting/switch.py

Lines changed: 78 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@
202202
# Keep a short domain version for the context instances (which can only be 36 chars)
203203
_DOMAIN_SHORT = "al"
204204

205+
ServiceData = dict[str, Any]
206+
205207

206208
def _int_to_base36(num: int) -> str:
207209
"""
@@ -280,25 +282,39 @@ def is_our_context(context: Context | None) -> bool:
280282
return f":{_DOMAIN_SHORT}:" in context.id
281283

282284

283-
def _split_service_data(service_data, adapt_brightness, adapt_color):
284-
"""Split service_data into two dictionaries (for color and brightness)."""
285-
transition = service_data.get(ATTR_TRANSITION)
286-
if transition is not None:
287-
# Split the transition over both commands
288-
service_data[ATTR_TRANSITION] /= 2
289-
service_datas = []
290-
if adapt_color:
291-
service_data_color = service_data.copy()
292-
service_data_color.pop(ATTR_BRIGHTNESS, None)
293-
service_datas.append(service_data_color)
294-
if adapt_brightness:
295-
service_data_brightness = service_data.copy()
296-
service_data_brightness.pop(ATTR_RGB_COLOR, None)
297-
service_data_brightness.pop(ATTR_COLOR_TEMP_KELVIN, None)
298-
service_datas.append(service_data_brightness)
299-
300-
if not service_datas: # neither adapt_brightness nor adapt_color
285+
def _prepare_service_calls(service_data: ServiceData, split=False) -> list[ServiceData]:
286+
"""Prepares the service data for service calls.
287+
288+
Processes the service_data according to the config flags, optionally splitting
289+
it into multiple data items for the separate adaptation of different attributes.
290+
Returns a list of service_datas that indicates the required service calls. If
291+
no splitting is necessary, the output is a list with a single item.
292+
"""
293+
if not split:
301294
return [service_data]
295+
296+
common_attrs = {ATTR_ENTITY_ID}
297+
common_data = {k: service_data[k] for k in common_attrs if k in service_data}
298+
299+
attributes_split_sequence = [BRIGHTNESS_ATTRS, COLOR_ATTRS]
300+
service_datas = []
301+
302+
for attributes in attributes_split_sequence:
303+
split_data = {
304+
attribute: service_data[attribute]
305+
for attribute in attributes
306+
if service_data.get(attribute)
307+
}
308+
if split_data:
309+
service_datas.append(common_data | split_data)
310+
311+
# Distribute the transition duration across all service calls
312+
if service_datas and (transition := service_data.get(ATTR_TRANSITION)) is not None:
313+
transition = service_data[ATTR_TRANSITION] / len(service_datas)
314+
315+
for service_data in service_datas:
316+
service_data[ATTR_TRANSITION] = transition
317+
302318
return service_datas
303319

304320

@@ -1185,7 +1201,23 @@ async def _adapt_light( # noqa: C901
11851201
else:
11861202
self.turn_on_off_listener.last_service_data[light] = service_data
11871203

1188-
async def turn_on(service_data):
1204+
service_datas = _prepare_service_calls(
1205+
service_data, self._separate_turn_on_commands
1206+
)
1207+
await self._make_cancellable_adaptation_calls(service_datas, context, light)
1208+
1209+
async def _make_adaptation_calls(
1210+
self, service_datas: list[ServiceData], context: Context
1211+
):
1212+
"""Executes a sequence of adaptation service calls for the given service datas."""
1213+
for i, service_data in enumerate(service_datas):
1214+
is_first_call = i == 0
1215+
1216+
# Sleep _between_ multiple service calls, but not before the first or a single one.
1217+
if not is_first_call:
1218+
await asyncio.sleep(service_data.get(ATTR_TRANSITION, 0))
1219+
await asyncio.sleep(self._send_split_delay / 1000.0)
1220+
11891221
_LOGGER.debug(
11901222
"%s: Scheduling 'light.turn_on' with the following 'service_data': %s"
11911223
" with context.id='%s'",
@@ -1200,30 +1232,27 @@ async def turn_on(service_data):
12001232
context=context,
12011233
)
12021234

1203-
async def turn_on_split():
1204-
# Could be a list of length 1 or 2
1205-
service_datas = _split_service_data(
1206-
service_data, adapt_brightness, adapt_color
1207-
)
1208-
await turn_on(service_datas[0])
1209-
if len(service_datas) == 2:
1210-
transition = service_datas[0].get(ATTR_TRANSITION)
1211-
if transition is not None:
1212-
await asyncio.sleep(transition)
1213-
await asyncio.sleep(self._send_split_delay / 1000.0)
1214-
await turn_on(service_datas[1])
1235+
async def _make_cancellable_adaptation_calls(
1236+
self, service_datas: list[ServiceData], context: Context, light_id: str
1237+
):
1238+
"""Executes a cancellable sequence of adaptation service calls for the given service datas.
12151239
1216-
if not self._separate_turn_on_commands:
1217-
await turn_on(service_data)
1218-
else:
1219-
split_tasks = self.turn_on_off_listener.split_adaptation_tasks
1220-
if (previous_task := split_tasks.get(light)) is not None:
1221-
previous_task.cancel()
1222-
try:
1223-
split_tasks[light] = asyncio.ensure_future(turn_on_split())
1224-
await split_tasks[light]
1225-
except asyncio.CancelledError:
1226-
_LOGGER.debug("Split adaptation of %s cancelled", light)
1240+
Wraps the sequence of service calls in a task that can be cancelled from elsewhere, e.g.,
1241+
to cancel an ongoing adaptation when a light is turned off.
1242+
"""
1243+
# Prevent overlap of multiple adaptation sequences
1244+
self.turn_on_off_listener.cancel_ongoing_adaptation_calls(light_id)
1245+
1246+
# Execute adaptation calls within a task
1247+
try:
1248+
task = self.turn_on_off_listener.adaptation_tasks[
1249+
light_id
1250+
] = asyncio.ensure_future(
1251+
self._make_adaptation_calls(service_datas, context)
1252+
)
1253+
await task
1254+
except asyncio.CancelledError:
1255+
_LOGGER.debug("Ongoing adaptation of %s cancelled", light_id)
12271256

12281257
async def _update_attrs_and_maybe_adapt_lights(
12291258
self,
@@ -1696,7 +1725,7 @@ def __init__(self, hass: HomeAssistant):
16961725
# Track last 'service_data' to 'light.turn_on' resulting from this integration
16971726
self.last_service_data: dict[str, dict[str, Any]] = {}
16981727
# Track ongoing split adaptations to be able to cancel them
1699-
self.split_adaptation_tasks: dict[str, asyncio.Task] = {}
1728+
self.adaptation_tasks: dict[str, asyncio.Task] = {}
17001729

17011730
# Track auto reset of manual_control
17021731
self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {}
@@ -1802,6 +1831,11 @@ async def reset():
18021831

18031832
self._handle_timer(light, self.auto_reset_manual_control_timers, delay, reset)
18041833

1834+
def cancel_ongoing_adaptation_calls(self, light_id: str):
1835+
"""Cancels an ongoing sequence of adaptation service calls for a specific light entity."""
1836+
if (previous_task := self.adaptation_tasks.get(light_id)) is not None:
1837+
previous_task.cancel()
1838+
18051839
def reset(self, *lights, reset_manual_control=True) -> None:
18061840
"""Reset the 'manual_control' status of the lights."""
18071841
for light in lights:
@@ -1812,9 +1846,7 @@ def reset(self, *lights, reset_manual_control=True) -> None:
18121846
timer.cancel()
18131847
self.last_state_change.pop(light, None)
18141848
self.last_service_data.pop(light, None)
1815-
1816-
if (task := self.split_adaptation_tasks.get(light)) is not None:
1817-
task.cancel()
1849+
self.cancel_ongoing_adaptation_calls(light)
18181850

18191851
async def turn_on_off_event_listener(self, event: Event) -> None:
18201852
"""Track 'light.turn_off' and 'light.turn_on' service calls."""

tests/test_switch.py

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
_SUPPORT_OPTS,
4343
VALID_COLOR_MODES,
4444
_attributes_have_changed,
45+
_prepare_service_calls,
4546
_supported_features,
4647
color_difference_redmean,
4748
create_context,
@@ -777,8 +778,8 @@ async def turn_light(state, **kwargs):
777778
assert (
778779
switch.extra_state_attributes["autoreset_time_remaining"][light.entity_id] > 0
779780
)
780-
await asyncio.sleep(0.3) # Should be enough time for auto reset
781781
await update()
782+
await asyncio.sleep(0.3) # Should be enough time for auto reset
782783
assert not manual_control[light.entity_id], (light, manual_control)
783784
assert (
784785
light.entity_id not in switch.extra_state_attributes["autoreset_time_remaining"]
@@ -791,8 +792,8 @@ async def turn_light(state, **kwargs):
791792
await asyncio.sleep(0.05) # Less than 0.1
792793
assert manual_control[light.entity_id]
793794

794-
await asyncio.sleep(0.3) # Wait the auto reset time
795795
await update()
796+
await asyncio.sleep(0.3) # Wait the auto reset time
796797
assert not manual_control[light.entity_id]
797798

798799

@@ -1385,3 +1386,111 @@ async def change_switch_settings(**kwargs):
13851386
# testing with "configuration" should revert back to 2500
13861387
await change_switch_settings(**{CONF_USE_DEFAULTS: "configuration"})
13871388
assert switch._sun_light_settings.min_color_temp == 2500
1389+
1390+
1391+
@pytest.mark.parametrize(
1392+
"service_data_input,split,service_data_expected",
1393+
[
1394+
(
1395+
{"foo": 1, ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2},
1396+
False,
1397+
[{"foo": 1, ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}],
1398+
),
1399+
(
1400+
{"foo": 1},
1401+
True,
1402+
[],
1403+
),
1404+
(
1405+
{ATTR_BRIGHTNESS: 10},
1406+
True,
1407+
[{ATTR_BRIGHTNESS: 10}],
1408+
),
1409+
(
1410+
{ATTR_COLOR_TEMP_KELVIN: 3500},
1411+
True,
1412+
[{ATTR_COLOR_TEMP_KELVIN: 3500}],
1413+
),
1414+
(
1415+
{ATTR_ENTITY_ID: "foo", ATTR_BRIGHTNESS: 10},
1416+
True,
1417+
[{ATTR_ENTITY_ID: "foo", ATTR_BRIGHTNESS: 10}],
1418+
),
1419+
(
1420+
{ATTR_BRIGHTNESS: 10, ATTR_COLOR_TEMP_KELVIN: 3500},
1421+
True,
1422+
[{ATTR_BRIGHTNESS: 10}, {ATTR_COLOR_TEMP_KELVIN: 3500}],
1423+
),
1424+
(
1425+
{ATTR_BRIGHTNESS: 10, ATTR_COLOR_TEMP_KELVIN: 3500, ATTR_TRANSITION: 2},
1426+
True,
1427+
[
1428+
{ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 1},
1429+
{ATTR_COLOR_TEMP_KELVIN: 3500, ATTR_TRANSITION: 1},
1430+
],
1431+
),
1432+
(
1433+
{ATTR_TRANSITION: 1},
1434+
True,
1435+
[],
1436+
),
1437+
],
1438+
ids=[
1439+
"pass through when splitting is disabled",
1440+
"remove irrelevant attributes",
1441+
"brightness only yields one service call",
1442+
"color only yields one service call",
1443+
"include entity ID",
1444+
"brightness and color are split into two with brightness first",
1445+
"transition time is distributed among service calls",
1446+
"ignore transition time without service calls",
1447+
],
1448+
)
1449+
async def test_prepare_service_calls(service_data_input, split, service_data_expected):
1450+
"""Test the preparation of service calls, e.g., splitting."""
1451+
assert _prepare_service_calls(service_data_input, split) == service_data_expected
1452+
1453+
1454+
@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
1455+
async def test_cancellable_service_calls_task(hass):
1456+
"""Test the creation and execution of the task that wraps adaptation service calls."""
1457+
(light, *_) = await setup_lights(hass)
1458+
_, switch = await setup_switch(hass, {CONF_SEPARATE_TURN_ON_COMMANDS: True})
1459+
context = switch.create_context("test")
1460+
1461+
assert switch.turn_on_off_listener.adaptation_tasks.get(light.entity_id) is None
1462+
1463+
await switch._make_cancellable_adaptation_calls(
1464+
[
1465+
{
1466+
ATTR_BRIGHTNESS: 10,
1467+
ATTR_COLOR_TEMP_KELVIN: 10,
1468+
ATTR_ENTITY_ID: light.entity_id,
1469+
}
1470+
],
1471+
context,
1472+
light.entity_id,
1473+
)
1474+
1475+
task = switch.turn_on_off_listener.adaptation_tasks.get(light.entity_id)
1476+
assert task is not None
1477+
assert task.done()
1478+
1479+
1480+
@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
1481+
async def test_service_calls_task_cancellation(hass):
1482+
"""Tests if the task that wraps ongoing adaptation service calls gets cancelled."""
1483+
_, switch = await setup_switch(hass, {})
1484+
entity_id = "test_id"
1485+
1486+
task = asyncio.ensure_future(asyncio.sleep(1))
1487+
switch.turn_on_off_listener.adaptation_tasks[entity_id] = task
1488+
1489+
switch.turn_on_off_listener.cancel_ongoing_adaptation_calls(entity_id)
1490+
1491+
try:
1492+
await task
1493+
except asyncio.CancelledError:
1494+
pass
1495+
1496+
assert task.cancelled()

0 commit comments

Comments
 (0)