Skip to content

Commit 78f97ec

Browse files
committed
feat: add ZHA quirk for Sunricher SR-ZG9002KR12-Pro remote
1 parent 7713d81 commit 78f97ec

File tree

3 files changed

+328
-0
lines changed

3 files changed

+328
-0
lines changed

tests/test_sunricher.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Tests for Sunricher remote device."""
2+
3+
from unittest import mock
4+
5+
import pytest
6+
from zigpy.zcl.foundation import ZCLHeader
7+
8+
import zhaquirks
9+
from zhaquirks.const import BUTTON, COMMAND, PRESS_TYPE
10+
from zhaquirks.sunricher.remote import ZG9002KR12ProRemoteCluster
11+
12+
zhaquirks.setup()
13+
14+
15+
@pytest.mark.parametrize(
16+
"button_id, press_type_id, expected_action",
17+
[
18+
(1, 1, "k1_short_press"),
19+
(2, 1, "k2_short_press"),
20+
],
21+
)
22+
def test_button_press_events(
23+
zigpy_device_from_v2_quirk, button_id, press_type_id, expected_action
24+
):
25+
"""Test button press events are correctly generated."""
26+
27+
device = zigpy_device_from_v2_quirk(
28+
manufacturer="Sunricher",
29+
model="HK-ZRC-K12&RS-E",
30+
)
31+
32+
cluster = device.endpoints[1].sunricher_remote_cluster
33+
listener = mock.MagicMock()
34+
cluster.add_listener(listener)
35+
36+
button_mask = 1 << (button_id - 1)
37+
high_byte = (button_mask >> 8) & 0xFF
38+
low_byte = button_mask & 0xFF
39+
40+
args = [0x01, high_byte, low_byte, press_type_id]
41+
42+
cluster.handle_cluster_request(ZCLHeader(), args)
43+
44+
assert listener.zha_send_event.call_count == 1
45+
assert listener.zha_send_event.call_args[0][0] == expected_action
46+
47+
event_data = listener.zha_send_event.call_args[0][1]
48+
press_type_info = cluster.PRESS_TYPES.get(press_type_id)
49+
50+
assert event_data[BUTTON] == button_id
51+
assert event_data[PRESS_TYPE] == press_type_info.action
52+
assert event_data[COMMAND] == "button_press"
53+
54+
55+
def test_multiple_buttons_pressed(zigpy_device_from_v2_quirk):
56+
"""Test multiple buttons pressed at the same time."""
57+
58+
device = zigpy_device_from_v2_quirk(
59+
manufacturer="Sunricher",
60+
model="HK-ZRC-K12&RS-E",
61+
)
62+
63+
cluster = device.endpoints[1].sunricher_remote_cluster
64+
listener = mock.MagicMock()
65+
cluster.add_listener(listener)
66+
67+
button_mask = 3
68+
high_byte = (button_mask >> 8) & 0xFF
69+
low_byte = button_mask & 0xFF
70+
71+
press_type_id = 1
72+
args = [0x01, high_byte, low_byte, press_type_id]
73+
74+
cluster.handle_cluster_request(ZCLHeader(), args)
75+
76+
assert listener.zha_send_event.call_count == 2
77+
78+
event_names = [call[0][0] for call in listener.zha_send_event.call_args_list]
79+
assert "k1_short_press" in event_names
80+
assert "k2_short_press" in event_names
81+
82+
83+
@pytest.mark.parametrize(
84+
"direction_id, speed, expected_action, expected_direction",
85+
[
86+
(1, 10, "clockwise_rotation", "clockwise"),
87+
(2, 5, "anti_clockwise_rotation", "anti_clockwise"),
88+
],
89+
)
90+
def test_knob_rotation_events(
91+
zigpy_device_from_v2_quirk, direction_id, speed, expected_action, expected_direction
92+
):
93+
"""Test knob rotation events are correctly generated."""
94+
95+
device = zigpy_device_from_v2_quirk(
96+
manufacturer="Sunricher",
97+
model="HK-ZRC-K12&RS-E",
98+
)
99+
100+
cluster = device.endpoints[1].sunricher_remote_cluster
101+
listener = mock.MagicMock()
102+
cluster.add_listener(listener)
103+
104+
args = [0x03, direction_id, 0x00, speed]
105+
106+
cluster.handle_cluster_request(ZCLHeader(), args)
107+
108+
assert listener.zha_send_event.call_count == 1
109+
assert listener.zha_send_event.call_args[0][0] == expected_action
110+
111+
event_data = listener.zha_send_event.call_args[0][1]
112+
113+
assert event_data[BUTTON] == 9
114+
assert event_data[PRESS_TYPE] == expected_action
115+
assert event_data["speed"] == speed
116+
117+
118+
def test_unknown_message_type(zigpy_device_from_v2_quirk):
119+
"""Test handling of unknown message types."""
120+
121+
device = zigpy_device_from_v2_quirk(
122+
manufacturer="Sunricher",
123+
model="HK-ZRC-K12&RS-E",
124+
)
125+
126+
cluster = device.endpoints[1].sunricher_remote_cluster
127+
listener = mock.MagicMock()
128+
cluster.add_listener(listener)
129+
130+
args = [0x99, 0x00, 0x00, 0x00]
131+
132+
cluster.handle_cluster_request(ZCLHeader(), args)
133+
134+
assert listener.zha_send_event.call_count == 0
135+
136+
137+
def test_generate_device_automation_triggers():
138+
"""Test generation of device automation triggers."""
139+
140+
triggers = ZG9002KR12ProRemoteCluster.generate_device_automation_triggers()
141+
142+
for button in ZG9002KR12ProRemoteCluster.BUTTONS.values():
143+
for press_type in ZG9002KR12ProRemoteCluster.PRESS_TYPES.values():
144+
trigger_key = (press_type.trigger, button.trigger)
145+
assert trigger_key in triggers
146+
assert (
147+
triggers[trigger_key][COMMAND] == f"{button.action}_{press_type.action}"
148+
)
149+
150+
knob_button = ZG9002KR12ProRemoteCluster.BUTTONS.get(9)
151+
for direction in ZG9002KR12ProRemoteCluster.KNOB_DIRECTIONS.values():
152+
trigger_key = (direction.trigger, knob_button.trigger)
153+
assert trigger_key in triggers
154+
assert triggers[trigger_key][COMMAND] == direction.action

zhaquirks/sunricher/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Quirks for Sunricher devices."""

zhaquirks/sunricher/remote.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Sunricher remote device."""
2+
3+
import logging
4+
from typing import Any, Final, NamedTuple, Optional, Union
5+
6+
from zigpy.quirks.v2 import QuirkBuilder
7+
import zigpy.types as t
8+
from zigpy.zcl import foundation
9+
10+
from zhaquirks import CustomCluster
11+
from zhaquirks.const import (
12+
BUTTON,
13+
COMMAND,
14+
DOUBLE_PRESS,
15+
LONG_PRESS,
16+
LONG_RELEASE,
17+
PRESS_TYPE,
18+
SHORT_PRESS,
19+
ZHA_SEND_EVENT,
20+
)
21+
22+
_LOGGER = logging.getLogger(__name__)
23+
SUNRICHER = "Sunricher"
24+
25+
26+
class Button(NamedTuple):
27+
"""Button class."""
28+
29+
id: int
30+
action: str
31+
trigger: str
32+
33+
34+
class PressType(NamedTuple):
35+
"""Button press type."""
36+
37+
name: str
38+
action: str
39+
trigger: str = None
40+
41+
42+
class ZG9002KR12ProRemoteCluster(CustomCluster):
43+
"""Sunricher manufacturer specific cluster for remote."""
44+
45+
cluster_id: Final[t.uint16_t] = 0xFF03
46+
name: Final = "Sunricher remote cluster"
47+
ep_attribute: Final = "sunricher_remote_cluster"
48+
49+
# Button mapping
50+
BUTTONS: dict[int, Button] = {
51+
1: Button(1, "k1", "K1"),
52+
2: Button(2, "k2", "K2"),
53+
3: Button(3, "k3", "K3"),
54+
4: Button(4, "k4", "K4"),
55+
5: Button(5, "k5", "K5"),
56+
6: Button(6, "k6", "K6"),
57+
7: Button(7, "k7", "K7"),
58+
8: Button(8, "k8", "K8"),
59+
9: Button(9, "knob", "Knob"),
60+
11: Button(11, "k9", "K9"),
61+
12: Button(12, "k10", "K10"),
62+
15: Button(15, "k11", "K11"),
63+
16: Button(16, "k12", "K12"),
64+
}
65+
66+
# Press types
67+
PRESS_TYPES: dict[int, PressType] = {
68+
1: PressType(SHORT_PRESS, "short_press", "Short Press"),
69+
2: PressType(DOUBLE_PRESS, "double_press", "Double Press"),
70+
3: PressType(LONG_PRESS, "hold", "Hold"),
71+
4: PressType(LONG_RELEASE, "hold_released", "Hold Released"),
72+
}
73+
74+
# Knob directions
75+
KNOB_DIRECTIONS: dict[int, PressType] = {
76+
1: PressType("clockwise", "clockwise_rotation", "Clockwise Rotation"),
77+
2: PressType(
78+
"anti_clockwise", "anti_clockwise_rotation", "Anti-clockwise Rotation"
79+
),
80+
}
81+
82+
def handle_cluster_request(
83+
self,
84+
hdr: foundation.ZCLHeader,
85+
args: list[Any],
86+
*,
87+
dst_addressing: Optional[
88+
Union[t.Addressing.Group, t.Addressing.IEEE, t.Addressing.NWK]
89+
] = None,
90+
):
91+
"""Handle the cluster command."""
92+
93+
message_type = args[0]
94+
if message_type == 0x01:
95+
# Button press event
96+
press_type_mask = args[3]
97+
button_mask = (args[1] << 8) | args[2]
98+
99+
press_type = self.PRESS_TYPES.get(press_type_mask)
100+
action_buttons = []
101+
for i in range(16):
102+
if (button_mask >> i) & 1:
103+
button_id = i + 1
104+
button = self.BUTTONS.get(button_id)
105+
action_buttons.append(button)
106+
107+
_LOGGER.debug(
108+
"Button event: action=%s, action_buttons=%s",
109+
press_type.action,
110+
[b.action for b in action_buttons],
111+
)
112+
113+
for button in action_buttons:
114+
action = f"{button.action}_{press_type.action}"
115+
event_data = {
116+
BUTTON: button.id,
117+
PRESS_TYPE: press_type.action,
118+
COMMAND: "button_press",
119+
}
120+
self.listener_event(ZHA_SEND_EVENT, action, event_data)
121+
122+
elif message_type == 0x03:
123+
# Knob rotation event
124+
direction_mask = args[1]
125+
action_speed = args[3]
126+
direction = self.KNOB_DIRECTIONS.get(direction_mask)
127+
128+
_LOGGER.debug(
129+
"Knob event: action=%s, action_speed=%s", direction.action, action_speed
130+
)
131+
132+
event_data = {
133+
BUTTON: 9, # knob
134+
PRESS_TYPE: direction.action,
135+
"speed": action_speed,
136+
}
137+
self.listener_event(ZHA_SEND_EVENT, direction.action, event_data)
138+
139+
@classmethod
140+
def generate_device_automation_triggers(cls):
141+
"""Generate automation triggers based on device buttons and press-types."""
142+
triggers = {}
143+
# Generate button triggers
144+
for button in cls.BUTTONS.values():
145+
for press_type in cls.PRESS_TYPES.values():
146+
triggers[(press_type.trigger, button.trigger)] = {
147+
COMMAND: f"{button.action}_{press_type.action}"
148+
}
149+
150+
# Generate knob triggers
151+
button = cls.BUTTONS.get(9) # knob
152+
if button:
153+
for direction in cls.KNOB_DIRECTIONS.values():
154+
triggers[(direction.trigger, button.trigger)] = {
155+
COMMAND: direction.action
156+
}
157+
158+
_LOGGER.debug("Generated triggers: %s", triggers)
159+
return triggers
160+
161+
162+
(
163+
QuirkBuilder(SUNRICHER, "HK-ZRC-K12&RS-E")
164+
.friendly_name(
165+
model="SR-ZG9002KR12-Pro",
166+
manufacturer=SUNRICHER,
167+
)
168+
.replaces(ZG9002KR12ProRemoteCluster, endpoint_id=1)
169+
.device_automation_triggers(
170+
ZG9002KR12ProRemoteCluster.generate_device_automation_triggers()
171+
)
172+
.add_to_registry()
173+
)

0 commit comments

Comments
 (0)