Skip to content

Commit d2895d5

Browse files
Support multi-button presses on Philips remotes (#3592)
Co-authored-by: TheJulianJES <[email protected]>
1 parent 6e01719 commit d2895d5

File tree

3 files changed

+119
-71
lines changed

3 files changed

+119
-71
lines changed

tests/test_philips.py

Lines changed: 78 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -317,15 +317,10 @@ def reset(self):
317317

318318
self._click_counter = 0
319319
self._callback = None
320-
self._button = None
321320

322-
def press(self, callback, button):
321+
def press(self, callback):
323322
"""Process a button press."""
324-
if button != self._button:
325-
self._click_counter = 1
326-
else:
327-
self._click_counter += 1
328-
self._button = button
323+
self._click_counter += 1
329324
self._callback = callback
330325

331326

@@ -374,11 +369,14 @@ def test_PhilipsRemoteCluster_short_press(
374369
cluster = device.endpoints[ep].philips_remote_cluster
375370
listener = mock.MagicMock()
376371
cluster.add_listener(listener)
377-
cluster.button_press_queue = ManuallyFiredButtonPressQueue()
372+
cluster.button_press_queue = {
373+
k: ManuallyFiredButtonPressQueue() for k in cluster.BUTTONS
374+
}
378375

379376
cluster.handle_cluster_request(ZCLHeader(), [1, 0, 0, 0, 0])
380377
cluster.handle_cluster_request(ZCLHeader(), [1, 0, 2, 0, 0])
381-
cluster.button_press_queue.fire()
378+
for q in cluster.button_press_queue.values():
379+
q.fire()
382380

383381
assert listener.zha_send_event.call_count == 2
384382

@@ -462,14 +460,17 @@ def test_PhilipsRemoteCluster_multi_press(
462460
cluster = device.endpoints[ep].philips_remote_cluster
463461
listener = mock.MagicMock()
464462
cluster.add_listener(listener)
465-
cluster.button_press_queue = ManuallyFiredButtonPressQueue()
463+
cluster.button_press_queue = {
464+
k: ManuallyFiredButtonPressQueue() for k in cluster.BUTTONS
465+
}
466466

467467
for _ in range(0, count):
468468
# btn1 short press
469469
cluster.handle_cluster_request(ZCLHeader(), [1, 0, 0, 0, 0])
470470
# btn1 short release
471471
cluster.handle_cluster_request(ZCLHeader(), [1, 0, 2, 0, 0])
472-
cluster.button_press_queue.fire()
472+
for q in cluster.button_press_queue.values():
473+
q.fire()
473474

474475
assert listener.zha_send_event.call_count == 1
475476
args_button_id = count + 2
@@ -620,30 +621,21 @@ def test_PhilipsRemoteCluster_long_press(
620621

621622

622623
@pytest.mark.parametrize(
623-
"button_presses, result_count",
624+
"button_presses",
624625
(
625-
(
626-
[1],
627-
1,
628-
),
629-
(
630-
[1, 1],
631-
2,
632-
),
633-
(
634-
[1, 1, 3, 3, 3, 2, 2, 2, 2],
635-
4,
636-
),
626+
(1),
627+
(2),
628+
(4),
637629
),
638630
)
639-
def test_ButtonPressQueue_presses_without_pause(button_presses, result_count):
631+
def test_ButtonPressQueue_presses_without_pause(button_presses):
640632
"""Test ButtonPressQueue presses without pause in between presses."""
641633

642634
q = ButtonPressQueue()
643635
q._ms_threshold = 50
644636
cb = mock.MagicMock()
645-
for btn in button_presses:
646-
q.press(cb, btn)
637+
for _ in range(button_presses):
638+
q.press(cb)
647639

648640
# await cluster.button_press_queue._task
649641
# Instead of awaiting the job, significantly extending the time
@@ -653,48 +645,32 @@ def test_ButtonPressQueue_presses_without_pause(button_presses, result_count):
653645
q._task.cancel()
654646
q._ms_last_click = 0
655647
q._callback(q._click_counter)
656-
cb.assert_called_once_with(result_count)
648+
cb.assert_called_once_with(button_presses)
657649

658650

659651
@pytest.mark.parametrize(
660-
"press_sequence, results",
652+
"press_sequence",
661653
(
662-
(
663-
# switch buttons within a sequence,
664-
# new sequence start with different button
665-
(
666-
[1, 1, 3, 3],
667-
[2, 2, 2],
668-
),
669-
(2, 3),
670-
),
671-
(
672-
# no button switch within a sequence,
673-
# new sequence with same button
674-
(
675-
[1, 1, 1],
676-
[1],
677-
),
678-
(3, 1),
679-
),
654+
((2, 3)),
655+
((3, 1)),
680656
),
681657
)
682-
async def test_ButtonPressQueue_presses_with_pause(press_sequence, results):
658+
async def test_ButtonPressQueue_presses_with_pause(press_sequence):
683659
"""Test ButtonPressQueue with pauses in between button press sequences."""
684660

685661
q = ButtonPressQueue()
686662
q._ms_threshold = 50
687663
cb = mock.MagicMock()
688664

689665
for seq in press_sequence:
690-
for btn in seq:
691-
q.press(cb, btn)
666+
for _ in range(seq):
667+
q.press(cb)
692668
await q._task
693669

694-
assert cb.call_count == len(results)
670+
assert cb.call_count == len(press_sequence)
695671

696672
calls = []
697-
for res in results:
673+
for res in press_sequence:
698674
calls.append(mock.call(res))
699675

700676
cb.assert_has_calls(calls)
@@ -768,3 +744,53 @@ def test_contact_sensor(zigpy_device_from_v2_quirk):
768744
# update again with the same value and except no new update
769745
hue_cluster.update_attribute(hue_cluster.AttributeDefs.contact.id, 1)
770746
assert len(on_off_listener.attribute_updates) == 2
747+
748+
749+
@pytest.mark.parametrize(
750+
"dev, ep, button_events, expected_actions",
751+
(
752+
(
753+
PhilipsWallSwitch,
754+
1,
755+
(
756+
[
757+
b"\x1d\x0b\x106\x00\x01\x00\x000\x00!\x00\x00",
758+
b"\x1d\x0b\x107\x00\x01\x00\x000\x02!\x01\x00",
759+
],
760+
[
761+
b"\x1d\x0b\x108\x00\x02\x00\x000\x00!\x00\x00",
762+
b"\x1d\x0b\x109\x00\x02\x00\x000\x02!\x01\x00",
763+
],
764+
),
765+
["left_press", "left_short_release", "right_press", "right_short_release"],
766+
),
767+
),
768+
)
769+
def test_PhilipsRemoteCluster_multi_button_press(
770+
zigpy_device_from_quirk, dev, ep, button_events, expected_actions
771+
):
772+
"""Test PhilipsRemoteCluster short button press logic."""
773+
774+
device = zigpy_device_from_quirk(dev)
775+
776+
remote_cluster = device.endpoints[ep].philips_remote_cluster
777+
remote_cluster.button_press_queue = {
778+
k: ManuallyFiredButtonPressQueue() for k in remote_cluster.BUTTONS
779+
}
780+
remote_listener = mock.MagicMock()
781+
remote_cluster.add_listener(remote_listener)
782+
783+
expected_event_count = 0
784+
for button in button_events:
785+
for eventData in button:
786+
hdr, args = remote_cluster.deserialize(eventData)
787+
remote_cluster.handle_message(hdr, args)
788+
expected_event_count += 1
789+
790+
for q in remote_cluster.button_press_queue.values():
791+
q.fire()
792+
793+
assert remote_listener.zha_send_event.call_count == expected_event_count
794+
795+
for i, expected_action in enumerate(expected_actions):
796+
assert remote_listener.zha_send_event.call_args_list[i][0][0] == expected_action

zhaquirks/philips/__init__.py

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -82,27 +82,18 @@ def __init__(self):
8282
self._ms_threshold = 300
8383
self._ms_last_click = 0
8484
self._click_counter = 1
85-
self._button = None
8685
self._callback = lambda x: None
8786
self._task = None
8887

8988
async def _job(self):
9089
await asyncio.sleep(self._ms_threshold / 1000)
9190
self._callback(self._click_counter)
9291

93-
def _reset(self, button):
94-
if self._task:
95-
self._task.cancel()
96-
self._click_counter = 1
97-
self._button = button
98-
99-
def press(self, callback, button):
92+
def press(self, callback):
10093
"""Process a button press."""
10194
self._callback = callback
10295
now_ms = time.time() * 1000
103-
if self._button != button:
104-
self._reset(button)
105-
elif now_ms - self._ms_last_click > self._ms_threshold:
96+
if now_ms - self._ms_last_click > self._ms_threshold:
10697
self._click_counter = 1
10798
else:
10899
self._task.cancel()
@@ -188,7 +179,10 @@ class PhilipsRemoteCluster(CustomCluster):
188179
PressType(SHORT_RELEASE, COMMAND_M_SHORT_RELEASE),
189180
]
190181

191-
button_press_queue = ButtonPressQueue()
182+
def __init__(self, endpoint, is_server=True):
183+
"""Initialize button press queue for each button."""
184+
super().__init__(endpoint, is_server)
185+
self.button_press_queue = {k: ButtonPressQueue() for k in self.BUTTONS}
192186

193187
def handle_cluster_request(
194188
self,
@@ -209,15 +203,20 @@ def handle_cluster_request(
209203
)
210204

211205
button = self.BUTTONS.get(args[0])
206+
# Bail on unknown buttons. (This gets rid of dial button "presses")
207+
if button is None:
208+
_LOGGER.debug(
209+
"%s - handle_cluster_request unknown button id [%s]",
210+
self.__class__.__name__,
211+
args[0],
212+
)
213+
return
212214
_LOGGER.debug(
213215
"%s - handle_cluster_request button id: [%s], button name: [%s]",
214216
self.__class__.__name__,
215217
args[0],
216218
button,
217219
)
218-
# Bail on unknown buttons. (This gets rid of dial button "presses")
219-
if button is None:
220-
return
221220

222221
press_type = self.PRESS_TYPES.get(args[2])
223222
if (
@@ -227,6 +226,11 @@ def handle_cluster_request(
227226
):
228227
press_type = self.SIMULATE_SHORT_EVENTS[1]
229228
if press_type is None:
229+
_LOGGER.debug(
230+
"%s - handle_cluster_request unknown button press type: [%s]",
231+
self.__class__.__name__,
232+
press_type,
233+
)
230234
return
231235

232236
duration = args[4]
@@ -288,15 +292,21 @@ def send_press_event(click_count):
288292
sim_event_args[ARGS][2] = 2
289293
action = f"{button.action}_{press_type.action}"
290294
_LOGGER.debug(
291-
"%s - send_press_event emitting simulated action: [%s]",
295+
"%s - send_press_event emitting simulated action: [%s], event_args: %s",
292296
self.__class__.__name__,
293297
action,
298+
sim_event_args,
294299
)
295300
self.listener_event(ZHA_SEND_EVENT, action, sim_event_args)
296301

297302
# Derive Multiple Presses
298303
if press_type.name == SHORT_RELEASE:
299-
self.button_press_queue.press(send_press_event, button.id)
304+
_LOGGER.debug(
305+
"%s - handle_cluster_request handling short release. Push to button press queue for button %s",
306+
self.__class__.__name__,
307+
args[0],
308+
)
309+
self.button_press_queue[args[0]].press(send_press_event)
300310
else:
301311
action = f"{button.action}_{press_type.action}"
302312
self.listener_event(ZHA_SEND_EVENT, action, event_args)

zhaquirks/philips/wall_switch.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@
4141
)
4242

4343

44+
class SwitchMode(t.enum8):
45+
"""Wall switch modes. See https://zigbee.blakadder.com/Philips_RDM001.html."""
46+
47+
SingleRocker = 0x00
48+
SinglePush = 0x01
49+
DoubleRocker = 0x02
50+
DoublePush = 0x03
51+
52+
4453
class PhilipsWallSwitchBasicCluster(PhilipsBasicCluster):
4554
"""Philips wall switch Basic cluster."""
4655

@@ -49,11 +58,14 @@ class AttributeDefs(PhilipsBasicCluster.AttributeDefs):
4958

5059
mode: Final = ZCLAttributeDef(
5160
id=0x0034,
52-
type=t.enum8,
61+
type=SwitchMode,
5362
is_manufacturer_specific=True,
5463
)
5564

56-
attr_config = {**PhilipsBasicCluster.attr_config, AttributeDefs.mode.id: 0x02}
65+
attr_config = {
66+
**PhilipsBasicCluster.attr_config,
67+
AttributeDefs.mode.id: SwitchMode.DoublePush,
68+
}
5769

5870

5971
class PhilipsWallSwitchRemoteCluster(PhilipsRemoteCluster):

0 commit comments

Comments
 (0)