Skip to content

Commit 5e4efe6

Browse files
authored
Debounce individual colors callback (#800)
* initial commit * debouncer when not all color values were changed * add timestamp * await after_update in final debounce * test debouncer * debouncer
1 parent 981751f commit 5e4efe6

File tree

3 files changed

+148
-5
lines changed

3 files changed

+148
-5
lines changed

changelog.md

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Devices
66

77
- Light: Only send to global switch or brightness address if individual colors are also configured
8+
- Light: Debounce individual colors callback to mitigate color flicker in visualizations
89

910
## 0.18.12 Add always callback to NumericValue and RawValue 2021-11-01
1011

test/devices_tests/light_test.py

+92
Original file line numberDiff line numberDiff line change
@@ -1414,6 +1414,98 @@ async def test_process_individual_color_rgbw(self):
14141414
await light.process(telegram)
14151415
assert light.current_color == (None, 42)
14161416

1417+
async def test_process_individual_color_debouncer(self, time_travel):
1418+
"""Test the debouncer for individual color lights."""
1419+
xknx = XKNX()
1420+
rgb_callback = AsyncMock()
1421+
rgbw_callback = AsyncMock()
1422+
1423+
rgb_light = Light(
1424+
xknx,
1425+
"TestRGBLight",
1426+
group_address_switch_red="1/1/1",
1427+
group_address_switch_red_state="1/1/2",
1428+
group_address_brightness_red="1/1/3",
1429+
group_address_brightness_red_state="1/1/4",
1430+
group_address_switch_green="1/1/5",
1431+
group_address_switch_green_state="1/1/6",
1432+
group_address_brightness_green="1/1/7",
1433+
group_address_brightness_green_state="1/1/8",
1434+
group_address_switch_blue="1/1/9",
1435+
group_address_switch_blue_state="1/1/10",
1436+
group_address_brightness_blue="1/1/11",
1437+
group_address_brightness_blue_state="1/1/12",
1438+
device_updated_cb=rgb_callback,
1439+
)
1440+
rgbw_light = Light(
1441+
xknx,
1442+
"TestRGBWLight",
1443+
group_address_switch="1/1/0",
1444+
group_address_brightness_red="1/1/3",
1445+
group_address_brightness_red_state="1/1/4",
1446+
group_address_brightness_green="1/1/7",
1447+
group_address_brightness_green_state="1/1/8",
1448+
group_address_brightness_blue="1/1/11",
1449+
group_address_brightness_blue_state="1/1/12",
1450+
group_address_brightness_white="1/1/15",
1451+
group_address_brightness_white_state="1/1/16",
1452+
device_updated_cb=rgbw_callback,
1453+
)
1454+
1455+
assert rgb_light.current_color == (None, None)
1456+
assert rgbw_light.current_color == (None, None)
1457+
1458+
red = Telegram(
1459+
destination_address=GroupAddress("1/1/4"),
1460+
payload=GroupValueWrite(DPTArray(42)),
1461+
)
1462+
green = Telegram(
1463+
destination_address=GroupAddress("1/1/8"),
1464+
payload=GroupValueWrite(DPTArray(43)),
1465+
)
1466+
blue = Telegram(
1467+
destination_address=GroupAddress("1/1/12"),
1468+
payload=GroupValueWrite(DPTArray(44)),
1469+
)
1470+
white = Telegram(
1471+
destination_address=GroupAddress("1/1/16"),
1472+
payload=GroupValueWrite(DPTArray(50)),
1473+
)
1474+
1475+
await xknx.devices.process(red)
1476+
rgb_callback.assert_not_called()
1477+
rgbw_callback.assert_not_called()
1478+
await xknx.devices.process(green)
1479+
rgb_callback.assert_not_called()
1480+
rgbw_callback.assert_not_called()
1481+
await xknx.devices.process(blue)
1482+
rgb_callback.assert_called_once()
1483+
rgbw_callback.assert_not_called()
1484+
await xknx.devices.process(white)
1485+
rgbw_callback.assert_called_once()
1486+
1487+
assert rgb_light.current_color == ((42, 43, 44), None)
1488+
assert rgbw_light.current_color == ((42, 43, 44), 50)
1489+
1490+
rgb_callback.reset_mock()
1491+
rgbw_callback.reset_mock()
1492+
# second time with only 2 telegrams
1493+
await xknx.devices.process(red)
1494+
rgb_callback.assert_not_called()
1495+
rgbw_callback.assert_not_called()
1496+
await xknx.devices.process(green)
1497+
rgb_callback.assert_not_called()
1498+
rgbw_callback.assert_not_called()
1499+
await time_travel(Light.DEBOUNCE_TIMEOUT / 2)
1500+
rgb_callback.assert_not_called()
1501+
rgbw_callback.assert_not_called()
1502+
await time_travel(Light.DEBOUNCE_TIMEOUT / 2)
1503+
rgb_callback.assert_called_once()
1504+
rgbw_callback.assert_called_once()
1505+
1506+
assert rgb_light.current_color == ((42, 43, 44), None)
1507+
assert rgbw_light.current_color == ((42, 43, 44), 50)
1508+
14171509
async def test_process_hs_color(self):
14181510
"""Test process / reading telegrams from telegram queue. Test if color is processed."""
14191511
xknx = XKNX()

xknx/devices/light.py

+55-5
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
"""
1313
from __future__ import annotations
1414

15+
import asyncio
1516
from enum import Enum
17+
from itertools import chain
1618
import logging
1719
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterator, Tuple, cast
1820

@@ -114,6 +116,7 @@ def __eq__(self, other: object) -> bool:
114116
class Light(Device):
115117
"""Class for managing a light."""
116118

119+
DEBOUNCE_TIMEOUT = 0.2
117120
DEFAULT_MIN_KELVIN = 2700 # 370 mireds
118121
DEFAULT_MAX_KELVIN = 6000 # 166 mireds
119122

@@ -266,7 +269,7 @@ def __init__(
266269
group_address_brightness_red,
267270
group_address_brightness_red_state,
268271
sync_state=sync_state,
269-
after_update_cb=self.after_update,
272+
after_update_cb=self._individual_color_callback_debounce,
270273
)
271274

272275
self.green = _SwitchAndBrightness(
@@ -278,7 +281,7 @@ def __init__(
278281
group_address_brightness_green,
279282
group_address_brightness_green_state,
280283
sync_state=sync_state,
281-
after_update_cb=self.after_update,
284+
after_update_cb=self._individual_color_callback_debounce,
282285
)
283286

284287
self.blue = _SwitchAndBrightness(
@@ -290,7 +293,7 @@ def __init__(
290293
group_address_brightness_blue,
291294
group_address_brightness_blue_state,
292295
sync_state=sync_state,
293-
after_update_cb=self.after_update,
296+
after_update_cb=self._individual_color_callback_debounce,
294297
)
295298

296299
self.white = _SwitchAndBrightness(
@@ -302,14 +305,26 @@ def __init__(
302305
group_address_brightness_white,
303306
group_address_brightness_white_state,
304307
sync_state=sync_state,
305-
after_update_cb=self.after_update,
308+
after_update_cb=self._individual_color_callback_debounce,
306309
)
307310

308311
self.min_kelvin = min_kelvin
309312
self.max_kelvin = max_kelvin
313+
self._individual_color_debounce_task_name = (
314+
f"{id(self)}_individual_color_debounce"
315+
)
316+
self._individual_color_debounce_telegram_counter: int
317+
self._reset_individual_color_debounce_telegrams()
310318

311319
def _iter_remote_values(self) -> Iterator[RemoteValue[Any, Any]]:
312320
"""Iterate the devices RemoteValue classes."""
321+
return chain(
322+
self._iter_instant_remote_values(),
323+
self._iter_debounce_remote_values(),
324+
)
325+
326+
def _iter_instant_remote_values(self) -> Iterator[RemoteValue[Any, Any]]:
327+
"""Iterate the devices RemoteValue classes calling after_update_cb immediately."""
313328
yield self.switch
314329
yield self.brightness
315330
yield self.color
@@ -319,6 +334,9 @@ def _iter_remote_values(self) -> Iterator[RemoteValue[Any, Any]]:
319334
yield self.xyy_color
320335
yield self.tunable_white
321336
yield self.color_temperature
337+
338+
def _iter_debounce_remote_values(self) -> Iterator[RemoteValue[Any, Any]]:
339+
"""Iterate the devices RemoteValue classes debouncing after_update_cb."""
322340
for color in self._iter_individual_colors():
323341
yield color.switch
324342
yield color.brightness
@@ -327,6 +345,36 @@ def _iter_individual_colors(self) -> Iterator[_SwitchAndBrightness]:
327345
"""Iterate the devices individual colors."""
328346
yield from (self.red, self.green, self.blue, self.white)
329347

348+
def _reset_individual_color_debounce_telegrams(self) -> None:
349+
"""Reset individual color debounce telegram counter."""
350+
self._individual_color_debounce_telegram_counter = sum(
351+
(
352+
self.red.switch.initialized or self.red.brightness.initialized,
353+
self.green.switch.initialized or self.green.brightness.initialized,
354+
self.blue.switch.initialized or self.blue.brightness.initialized,
355+
self.white.switch.initialized or self.white.brightness.initialized,
356+
)
357+
)
358+
359+
async def _individual_color_callback_debounce(self) -> None:
360+
"""Run callback after all individual colors were updated or timeout passed."""
361+
362+
async def debouncer() -> None:
363+
await asyncio.sleep(Light.DEBOUNCE_TIMEOUT)
364+
self._reset_individual_color_debounce_telegrams()
365+
await asyncio.shield(self.after_update())
366+
367+
self._individual_color_debounce_telegram_counter -= 1
368+
if self._individual_color_debounce_telegram_counter > 0:
369+
# task registry cancels existing task
370+
self.xknx.task_registry.register(
371+
self._individual_color_debounce_task_name, debouncer()
372+
).start()
373+
return
374+
self.xknx.task_registry.unregister(self._individual_color_debounce_task_name)
375+
self._reset_individual_color_debounce_telegrams()
376+
await self.after_update()
377+
330378
@property
331379
def supports_brightness(self) -> bool:
332380
"""Return if light supports brightness."""
@@ -549,8 +597,10 @@ async def set_color_temperature(self, color_temperature: int) -> None:
549597

550598
async def process_group_write(self, telegram: "Telegram") -> None:
551599
"""Process incoming and outgoing GROUP WRITE telegram."""
552-
for remote_value in self._iter_remote_values():
600+
for remote_value in self._iter_instant_remote_values():
553601
await remote_value.process(telegram)
602+
for remote_value in self._iter_debounce_remote_values():
603+
await remote_value.process(telegram, always_callback=True)
554604

555605
def __str__(self) -> str:
556606
"""Return object as readable string."""

0 commit comments

Comments
 (0)