Skip to content

Commit eabc285

Browse files
authored
Add Legrand devices: Connected Outlet, Micromodule Switch, Shutter Switch, Remote Shutter Switch, Switch and Dimmer (#4271)
* Add custom clusters for Legrand devices * Extend the main `LegrandCluster` to support a generic `device_mode` enum attribute that can be used in various modes. The enum values are actually taken from the Z2M project for consistency (even if following commits utilize only the `Dimmer_On` and `Dimmer_Off` values) * Add a custom Identify cluster, to support the custom identification method for Legrand devices, which consists in sending a `trigger_effect` command before the `identify` command in order to work properly * Add a custom Shutter cluster, that adds a `calibration_mode` attribute used by shutter switches Note: All information has been derived from the official Legrand documentation, which can be found at https://developer.legrand.com/local-interoperability/#Products%20cluster%20list * Add support for Legrand connected outlets Additional notes: * The BinaryInput cluster entity is removed as it is non functional * The OnOff cluster opening entity is removed as it is non functional * Additional options for controlling the device LED have been added * Add support for Legrand micromodule switches Additional notes: * The BinaryInput cluster entity is removed as it is non functional * The OnOff cluster opening entity is removed as it is non functional * Additional options for controlling the device LED have been added * Add support for Legrand shutter switches Additional notes: * The "calibration mode" attribute has been added from the custom Shutter cluster * The BinaryInput cluster entity is removed as it is non functional * The OnOff cluster opening entity is removed as it is non functional * Additional options for controlling the device LED have been added * Add support for Legrand remote shutter switch Additional notes: * The BinaryInput cluster entity is removed as it is non functional * NO Additional options for controlling the device LED have been added as they are not used in battery powered devices * Convert Legrand switch quirk to V2 Additional notes: * The BinaryInput cluster entity is removed as it is non functional * The OnOff cluster opening entity is removed as it is non functional * Additional options for controlling the device LED have been added * Convert Legrand dimmer quirk to V2 Additional notes: * Add a "dimmer mode" toggle entity to enable or disable dimmer functionality * The BinaryInput cluster entity is removed as it is non functional * The OnOff cluster opening entity is removed as it is non functional * Additional options for controlling the device LED have been added * Remove obsolete signature test for Legrand switch * Better handling of command arguments in the LegrandIdentify clusters * Add a test for the custom Legrand identify command * Clean up test syntax for the custom Legrand identify command
1 parent 84a7834 commit eabc285

File tree

8 files changed

+300
-207
lines changed

8 files changed

+300
-207
lines changed

tests/test_legrand.py

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -32,35 +32,6 @@ async def test_legrand_battery(zigpy_device_from_quirk, voltage, bpr):
3232
assert power_cluster["battery_percentage_remaining"] == bpr
3333

3434

35-
def test_light_switch_with_neutral_signature(assert_signature_matches_quirk):
36-
"""Test signature."""
37-
signature = {
38-
"node_descriptor": "NodeDescriptor(logical_type=<LogicalType.Router: 1>, complex_descriptor_available=0, user_descriptor_available=1, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.AllocateAddress|RxOnWhenIdle|MainsPowered|FullFunctionDevice: 142>, manufacturer_code=4129, maximum_buffer_size=89, maximum_incoming_transfer_size=63, server_mask=10752, maximum_outgoing_transfer_size=63, descriptor_capability_field=<DescriptorCapability.NONE: 0>, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=False, *is_full_function_device=True, *is_mains_powered=True, *is_receiver_on_when_idle=True, *is_router=True, *is_security_capable=False)",
39-
"endpoints": {
40-
"1": {
41-
"profile_id": 260,
42-
"device_type": "0x0100",
43-
"in_clusters": [
44-
"0x0000",
45-
"0x0003",
46-
"0x0004",
47-
"0x0005",
48-
"0x0006",
49-
"0x000f",
50-
"0xfc01",
51-
],
52-
"out_clusters": ["0x0000", "0x0019", "0xfc01"],
53-
}
54-
},
55-
"manufacturer": " Legrand",
56-
"model": " Light switch with neutral",
57-
"class": "zigpy.device.Device",
58-
}
59-
assert_signature_matches_quirk(
60-
zhaquirks.legrand.switch.LightSwitchWithNeutral, signature
61-
)
62-
63-
6435
async def test_legrand_wire_pilot_cluster_write_attrs(zigpy_device_from_v2_quirk):
6536
"""Test Legrand cable outlet pilot_wire_mode attr writing."""
6637

@@ -82,3 +53,36 @@ async def test_legrand_wire_pilot_cluster_write_attrs(zigpy_device_from_v2_quirk
8253
[],
8354
manufacturer=0xFC40,
8455
)
56+
57+
58+
async def test_legrand_identify_command(zigpy_device_from_v2_quirk):
59+
"""Test Legrand Identify cluster command handling."""
60+
61+
device = zigpy_device_from_v2_quirk(f" {LEGRAND}", " Light switch with neutral")
62+
identify_cluster = device.endpoints[1].identify
63+
64+
with mock.patch("zigpy.zcl.Cluster.request") as request:
65+
# Expected values for the mocked function calls
66+
IDENTIFY_TIME = 1234
67+
IDENTIFY_COMMAND = 0x00
68+
TRIGGER_EFFECT_COMMAND = 0x40
69+
EFFECT_ID = 0x00
70+
EFFECT_VARIANT = 0x00
71+
72+
# Test the identify command
73+
await identify_cluster.identify(identify_time=IDENTIFY_TIME)
74+
75+
# The identify command should produce two requests
76+
assert request.call_count == 2
77+
78+
# The first call is for the trigger effect command
79+
assert request.call_args_list[0].args[1] == TRIGGER_EFFECT_COMMAND
80+
assert request.call_args_list[0].kwargs["effect_id"] == EFFECT_ID
81+
assert request.call_args_list[0].kwargs["effect_variant"] == EFFECT_VARIANT
82+
assert "identify_time" not in request.call_args_list[0].kwargs
83+
84+
# The second call is for the identify command
85+
assert request.call_args_list[1].args[1] == IDENTIFY_COMMAND
86+
assert request.call_args_list[1].kwargs["identify_time"] == IDENTIFY_TIME
87+
assert "effect_id" not in request.call_args_list[1].kwargs
88+
assert "effect_variant" not in request.call_args_list[1].kwargs

zhaquirks/legrand/__init__.py

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,38 @@
11
"""Module for Legrand devices."""
22

3+
from typing import Any
4+
35
from zigpy.quirks import CustomCluster
46
import zigpy.types as t
5-
from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
7+
from zigpy.zcl.clusters.closures import WindowCovering
8+
from zigpy.zcl.clusters.general import EffectIdentifier, EffectVariant, Identify
9+
from zigpy.zcl.foundation import (
10+
BaseAttributeDefs,
11+
DataTypeId,
12+
GeneralCommand,
13+
ZCLAttributeDef,
14+
)
615

716
from zhaquirks import PowerConfigurationCluster
817

918
LEGRAND = "Legrand"
1019
MANUFACTURER_SPECIFIC_CLUSTER_ID = 0xFC01 # decimal = 64513
1120

1221

22+
class DeviceMode(t.enum16):
23+
"""Device modes for some Legrand devices. To be used in custom cluster.
24+
25+
Note: some values are taken from the Z2M code.
26+
"""
27+
28+
Pilot_Off = 0x0001
29+
Pilot_On = 0x0002
30+
Switch = 0x0003
31+
Auto = 0x0004
32+
Dimmer_Off = 0x0100
33+
Dimmer_On = 0x0101
34+
35+
1336
class LegrandCluster(CustomCluster):
1437
"""LegrandCluster."""
1538

@@ -20,15 +43,74 @@ class LegrandCluster(CustomCluster):
2043
class AttributeDefs(BaseAttributeDefs):
2144
"""Cluster attributes."""
2245

23-
dimmer = ZCLAttributeDef(
24-
id=0x0000, type=t.data16, is_manufacturer_specific=True
46+
device_mode = ZCLAttributeDef(
47+
id=0x0000,
48+
type=DeviceMode,
49+
zcl_type=DataTypeId.data16,
50+
is_manufacturer_specific=True,
2551
)
2652
led_dark = ZCLAttributeDef(
2753
id=0x0001, type=t.Bool, is_manufacturer_specific=True
2854
)
2955
led_on = ZCLAttributeDef(id=0x0002, type=t.Bool, is_manufacturer_specific=True)
3056

3157

58+
class LegrandIdentify(CustomCluster, Identify):
59+
"""Custom Identify cluster for Legrand devices. Replaces the identify command."""
60+
61+
async def command(
62+
self,
63+
command_id: GeneralCommand | int | t.uint8_t,
64+
*args: Any,
65+
**kwargs: Any,
66+
) -> Any:
67+
"""Override the command method to customize the identify command."""
68+
69+
if command_id == Identify.ServerCommandDefs.identify.id:
70+
# Remove identify command specific arguments
71+
identify_time = kwargs.pop("identify_time", None)
72+
73+
await super().command(
74+
command_id=Identify.ServerCommandDefs.trigger_effect.id,
75+
effect_id=EffectIdentifier.Blink,
76+
effect_variant=EffectVariant.Default,
77+
**kwargs,
78+
)
79+
80+
# Restore identify command specific arguments
81+
if identify_time is not None:
82+
kwargs["identify_time"] = identify_time
83+
84+
return await super().command(
85+
command_id,
86+
*args,
87+
**kwargs,
88+
)
89+
90+
91+
class ShutterCalibrationMode(t.enum8):
92+
"""Shutter calibration modes for Legrand devices."""
93+
94+
Classic = 0x00
95+
Specific = 0x01
96+
Up_Down_Stop = 0x02
97+
Temporal = 0x03
98+
Venetian = 0x04
99+
100+
101+
class LegrandShutterCluster(CustomCluster, WindowCovering):
102+
"""Custom Shutter cluster for Legrand devices. Adds calibration mode."""
103+
104+
class AttributeDefs(WindowCovering.AttributeDefs):
105+
"""Cluster attributes."""
106+
107+
calibration_mode = ZCLAttributeDef(
108+
id=0xF002,
109+
type=ShutterCalibrationMode,
110+
is_manufacturer_specific=True,
111+
)
112+
113+
32114
class LegrandPowerConfigurationCluster(PowerConfigurationCluster):
33115
"""PowerConfiguration conversor 'V --> %' for Legrand devices."""
34116

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Module for Legrand switches (without dimming functionality)."""
2+
3+
from zigpy.quirks.v2 import QuirkBuilder
4+
from zigpy.zcl.clusters.general import BinaryInput, OnOff
5+
6+
from zhaquirks.legrand import LEGRAND, LegrandCluster, LegrandIdentify
7+
8+
(
9+
QuirkBuilder(f" {LEGRAND}", " Connected outlet")
10+
.replaces(LegrandCluster)
11+
.replaces(LegrandIdentify)
12+
.prevent_default_entity_creation(endpoint_id=1, cluster_id=BinaryInput.cluster_id)
13+
.prevent_default_entity_creation(
14+
endpoint_id=1,
15+
cluster_id=OnOff.cluster_id,
16+
function=lambda entity: entity.device_class == "opening",
17+
)
18+
.switch(
19+
attribute_name=LegrandCluster.AttributeDefs.led_dark.name,
20+
cluster_id=LegrandCluster.cluster_id,
21+
translation_key="turn_on_led_when_off",
22+
fallback_name="Turn on LED when off",
23+
)
24+
.switch(
25+
attribute_name=LegrandCluster.AttributeDefs.led_on.name,
26+
cluster_id=LegrandCluster.cluster_id,
27+
translation_key="turn_on_led_when_on",
28+
fallback_name="Turn on LED when on",
29+
)
30+
.add_to_registry()
31+
)

zhaquirks/legrand/dimmer.py

Lines changed: 35 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from zigpy.profiles import zgp, zha
44
from zigpy.quirks import CustomDevice
5+
from zigpy.quirks.v2 import QuirkBuilder
56
from zigpy.zcl.clusters.general import (
67
Basic,
78
BinaryInput,
@@ -28,7 +29,9 @@
2829
from zhaquirks.legrand import (
2930
LEGRAND,
3031
MANUFACTURER_SPECIFIC_CLUSTER_ID,
32+
DeviceMode,
3133
LegrandCluster,
34+
LegrandIdentify,
3235
LegrandPowerConfigurationCluster,
3336
)
3437

@@ -278,108 +281,38 @@ class DimmerWithoutNeutralAndBallast(CustomDevice):
278281
}
279282

280283

281-
class DimmerWithNeutral(DimmerWithoutNeutral):
282-
"""Dimmer switch with neutral."""
283-
284-
signature = {
285-
# <SimpleDescriptor endpoint=1 profile=260 device_type=256
286-
# device_version=1
287-
# input_clusters=[0, 3, 4, 8, 6, 5, 15, 64513]
288-
# output_clusters=[0, 25, 64513]>
289-
MODELS_INFO: [(f" {LEGRAND}", " Dimmer switch with neutral")],
290-
ENDPOINTS: {
291-
1: {
292-
PROFILE_ID: zha.PROFILE_ID,
293-
DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
294-
INPUT_CLUSTERS: [
295-
Basic.cluster_id,
296-
Identify.cluster_id,
297-
Groups.cluster_id,
298-
OnOff.cluster_id,
299-
LevelControl.cluster_id,
300-
Scenes.cluster_id,
301-
BinaryInput.cluster_id,
302-
MANUFACTURER_SPECIFIC_CLUSTER_ID,
303-
],
304-
OUTPUT_CLUSTERS: [
305-
Basic.cluster_id,
306-
MANUFACTURER_SPECIFIC_CLUSTER_ID,
307-
Ota.cluster_id,
308-
],
309-
},
310-
242: {
311-
PROFILE_ID: zgp.PROFILE_ID,
312-
DEVICE_TYPE: zgp.DeviceType.COMBO_BASIC,
313-
INPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
314-
OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
315-
},
316-
},
317-
}
318-
319-
320-
class DimmerWithNeutral2(CustomDevice):
321-
"""Dimmer switch with neutral."""
322-
323-
signature = {
324-
# <SimpleDescriptor endpoint=1 profile=260 device_type=256
325-
# device_version=1
326-
# input_clusters=[0, 3, 4, 8, 6, 5, 15, 64513]
327-
# output_clusters=[0, 64513, 5, 25]>
328-
MODELS_INFO: [(f" {LEGRAND}", " Dimmer switch with neutral")],
329-
ENDPOINTS: {
330-
1: {
331-
PROFILE_ID: zha.PROFILE_ID,
332-
DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
333-
INPUT_CLUSTERS: [
334-
Basic.cluster_id,
335-
Identify.cluster_id,
336-
Groups.cluster_id,
337-
OnOff.cluster_id,
338-
LevelControl.cluster_id,
339-
Scenes.cluster_id,
340-
BinaryInput.cluster_id,
341-
MANUFACTURER_SPECIFIC_CLUSTER_ID,
342-
],
343-
OUTPUT_CLUSTERS: [
344-
Basic.cluster_id,
345-
MANUFACTURER_SPECIFIC_CLUSTER_ID,
346-
Scenes.cluster_id,
347-
Ota.cluster_id,
348-
],
349-
},
350-
242: {
351-
PROFILE_ID: zgp.PROFILE_ID,
352-
DEVICE_TYPE: zgp.DeviceType.COMBO_BASIC,
353-
INPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
354-
OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
355-
},
356-
},
357-
}
358-
359-
replacement = {
360-
ENDPOINTS: {
361-
1: {
362-
PROFILE_ID: zha.PROFILE_ID,
363-
DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
364-
INPUT_CLUSTERS: [
365-
Basic.cluster_id,
366-
Identify.cluster_id,
367-
Groups.cluster_id,
368-
OnOff.cluster_id,
369-
LevelControl.cluster_id,
370-
Scenes.cluster_id,
371-
BinaryInput.cluster_id,
372-
LegrandCluster,
373-
],
374-
OUTPUT_CLUSTERS: [
375-
Basic.cluster_id,
376-
LegrandCluster,
377-
Scenes.cluster_id,
378-
Ota.cluster_id,
379-
],
380-
}
381-
}
382-
}
284+
(
285+
QuirkBuilder(f" {LEGRAND}", " Dimmer switch with neutral")
286+
.replaces(LegrandCluster)
287+
.replaces(LegrandIdentify)
288+
.prevent_default_entity_creation(endpoint_id=1, cluster_id=BinaryInput.cluster_id)
289+
.prevent_default_entity_creation(
290+
endpoint_id=1,
291+
cluster_id=OnOff.cluster_id,
292+
function=lambda entity: entity.device_class == "opening",
293+
)
294+
.switch(
295+
attribute_name=LegrandCluster.AttributeDefs.device_mode.name,
296+
cluster_id=LegrandCluster.cluster_id,
297+
on_value=DeviceMode.Dimmer_On,
298+
off_value=DeviceMode.Dimmer_Off,
299+
translation_key="dimmer_mode",
300+
fallback_name="Dimmer mode",
301+
)
302+
.switch(
303+
attribute_name=LegrandCluster.AttributeDefs.led_dark.name,
304+
cluster_id=LegrandCluster.cluster_id,
305+
translation_key="turn_on_led_when_off",
306+
fallback_name="Turn on LED when off",
307+
)
308+
.switch(
309+
attribute_name=LegrandCluster.AttributeDefs.led_on.name,
310+
cluster_id=LegrandCluster.cluster_id,
311+
translation_key="turn_on_led_when_on",
312+
fallback_name="Turn on LED when on",
313+
)
314+
.add_to_registry()
315+
)
383316

384317

385318
class RemoteDimmer(CustomDevice):

0 commit comments

Comments
 (0)