Repository: zigpy/zha-device-handlers

Issue:
Ensure all device automation triggers are unique
Inspired by https://github.com/home-assistant/core/issues/65968

Some quirks do not have unique automation triggers and need to be fixed:

 - [ ] `AuroraDimmerBatteryPowered`
 - [ ] `IkeaTradfriRemote1`
 - [ ] `IkeaTradfriRemote2BtnZLL`
 - [ ] `IkeaTradfriRemote2Btn`
 - [ ] `SwitchAQ3V2`
 - [ ] `SwitchAQ3`
 - [ ] `SwitchAQ3B`


Discussion:
@puddly I now saw your merge request, will this solve the I mentioned problem? If yes, I don't have to look into it anymore.
@Tropaion I have no way of fixing any of these devices since I don't own them, I just wrote some code to check which ones have duplicate device automation triggers.
I was looking on the 2 IKEA quirks and i can redoing them by copy and past the device automation triggers to all device classes but is its also needed in some tuya quirks like this https://github.com/zigpy/zha-device-handlers/blob/dev/zhaquirks/tuya/ts0044.py that  only having one trigger class that all devices classes is using  ?

Doing 2 or more identical code block looks little "stone age" in my eyes is it better doing it  the "tuya way" ?
@puddly I thought since you referenced my issue it has something to do with it.
And I'm pretty sure this problem is not device specific.
just pulled out some test devices. 

the AQ3 remotes should be using release for the release commands: 

```json
{
  "node_descriptor": "NodeDescriptor(logical_type=<LogicalType.EndDevice: 2>, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.AllocateAddress: 128>, manufacturer_code=4151, maximum_buffer_size=127, maximum_incoming_transfer_size=100, server_mask=0, maximum_outgoing_transfer_size=100, descriptor_capability_field=<DescriptorCapability.NONE: 0>, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=True, *is_full_function_device=False, *is_mains_powered=False, *is_receiver_on_when_idle=False, *is_router=False, *is_security_capable=False)",
  "endpoints": {
    "1": {
      "profile_id": 260,
      "device_type": "0x0006",
      "in_clusters": [
        "0x0000",
        "0x0001",
        "0x0002",
        "0x0012"
      ],
      "out_clusters": [
        "0x0000",
        "0x0006"
      ]
    }
  },
  "manufacturer": "LUMI",
  "model": "lumi.sensor_swit",
  "class": "zhaquirks.xiaomi.aqara.sensor_swit.SwitchAQ3V2"
}
```

```
2022-03-12 08:51:39 DEBUG (MainThread) [homeassistant.core] Bus:Handling <Event zha_event[L]: device_ieee=00:15:8d:00:02:b0:e5:91, unique_id=00:15:8d:00:02:b0:e5:91:1:0x0012, device_id=2f8aa84162537f42690c6f6b8fac90ef, endpoint_id=1, cluster_id=18, command=release, args=value=17.0>
```


OK, we may not be able to make them all unique:

```json
{
  "node_descriptor": "NodeDescriptor(logical_type=<LogicalType.EndDevice: 2>, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.AllocateAddress: 128>, manufacturer_code=4476, maximum_buffer_size=82, maximum_incoming_transfer_size=82, server_mask=11264, maximum_outgoing_transfer_size=82, descriptor_capability_field=<DescriptorCapability.NONE: 0>, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=True, *is_full_function_device=False, *is_mains_powered=False, *is_receiver_on_when_idle=False, *is_router=False, *is_security_capable=False)",
  "endpoints": {
    "1": {
      "profile_id": 260,
      "device_type": "0x0820",
      "in_clusters": [
        "0x0000",
        "0x0001",
        "0x0003",
        "0x0009",
        "0x0020",
        "0x1000",
        "0xfc7c"
      ],
      "out_clusters": [
        "0x0003",
        "0x0004",
        "0x0006",
        "0x0008",
        "0x0019",
        "0x0102",
        "0x1000"
      ]
    }
  },
  "manufacturer": "IKEA of Sweden",
  "model": "TRADFRI on/off switch",
  "class": "zhaquirks.ikea.twobtnremote.IkeaTradfriRemote2Btn"
}
```

the long release event is the same for both buttons. This will be the case for devices that use the move* commands for long press and the stop command for long release. 

```
2022-03-12 09:03:19 DEBUG (MainThread) [homeassistant.core] Bus:Handling <Event zha_event[L]: device_ieee=14:b4:57:ff:fe:53:65:d7, unique_id=14:b4:57:ff:fe:53:65:d7:1:0x0008, device_id=f36ee2908c3bec64d3b3141b6e93735b, endpoint_id=1, cluster_id=8, command=move_with_on_off, args=[0, 83]>
2022-03-12 09:03:20 DEBUG (MainThread) [homeassistant.core] Bus:Handling <Event zha_event[L]: device_ieee=14:b4:57:ff:fe:53:65:d7, unique_id=14:b4:57:ff:fe:53:65:d7:1:0x0008, device_id=f36ee2908c3bec64d3b3141b6e93735b, endpoint_id=1, cluster_id=8, command=stop, args=[]>


2022-03-12 09:03:28 DEBUG (MainThread) [homeassistant.core] Bus:Handling <Event zha_event[L]: device_ieee=14:b4:57:ff:fe:53:65:d7, unique_id=14:b4:57:ff:fe:53:65:d7:1:0x0008, device_id=f36ee2908c3bec64d3b3141b6e93735b, endpoint_id=1, cluster_id=8, command=move, args=[1, 83]>
2022-03-12 09:03:29 DEBUG (MainThread) [homeassistant.core] Bus:Handling <Event zha_event[L]: device_ieee=14:b4:57:ff:fe:53:65:d7, unique_id=14:b4:57:ff:fe:53:65:d7:1:0x0008, device_id=f36ee2908c3bec64d3b3141b6e93735b, endpoint_id=1, cluster_id=8, command=stop, args=[]>
```

we could handle this in software in quirks but I think we have just ben exposing the event / action and then telling folks to leverage blueprints. Maybe we should explore some sort of global mapping of events. Would be a massive breaking change though. 
Its possible "saving" the last move commands direction and using it for tagging the release like the open/ close is doing but i dont have time looking at it and its no use for my.
Also its not helping your case i think.

I dont  have understanding what is making the problem in the system if the same or different 2 version or 2 different devices is using the same DA form the same quirk or from one other quirk that is having the same "pattern" then all is tagged with the device ID in HA or have i missing somthing that is messing all things up ?

Sorry for my lack of code knowledge :-((

Resolving patch:
diff --git a/zhaquirks/tuya/ts004f.py b/zhaquirks/tuya/ts004f.py
--- a/zhaquirks/tuya/ts004f.py
+++ b/zhaquirks/tuya/ts004f.py
@@ -145,25 +145,25 @@ class TuyaSmartRemote004FROK(CustomDevice):
             COMMAND: COMMAND_STEP,
             ENDPOINT_ID: 1,
             CLUSTER_ID: 8,
-            PARAMS: {"step_mode": 0},
+            PARAMS: {"step_mode": 0, "step_size": 13},
         },
         (ROTATED_SLOW, LEFT): {
             COMMAND: COMMAND_STEP,
             ENDPOINT_ID: 1,
             CLUSTER_ID: 8,
-            PARAMS: {"step_mode": 1},
+            PARAMS: {"step_mode": 1, "step_size": 13},
         },
         (ROTATED_FAST, RIGHT): {
             COMMAND: COMMAND_STEP,
             CLUSTER_ID: 8,
             ENDPOINT_ID: 1,
-            PARAMS: {"step_mode": 0},
+            PARAMS: {"step_mode": 0, "step_size": 37},
         },
         (ROTATED_FAST, LEFT): {
             COMMAND: COMMAND_STEP,
             CLUSTER_ID: 8,
             ENDPOINT_ID: 1,
-            PARAMS: {"step_mode": 1},
+            PARAMS: {"step_mode": 1, "step_size": 37},
         },
         (SHORT_PRESS, BUTTON_1): {ENDPOINT_ID: 1, COMMAND: SHORT_PRESS},
         (LONG_PRESS, BUTTON_1): {ENDPOINT_ID: 1, COMMAND: LONG_PRESS},


Test patch:
diff --git a/tests/test_quirks.py b/tests/test_quirks.py
--- a/tests/test_quirks.py
+++ b/tests/test_quirks.py
@@ -1,7 +1,9 @@
 """General quirk tests."""
 from __future__ import annotations
 
+import collections
 import importlib
+import json
 from pathlib import Path
 from unittest import mock
 
@@ -17,6 +19,7 @@
 
 import zhaquirks
 import zhaquirks.bosch.motion
+import zhaquirks.const as const
 from zhaquirks.const import (
     ARGS,
     COMMAND,
@@ -578,3 +581,80 @@ def test_migrated_lighting_automation_triggers(quirk: CustomDevice) -> None:
 
         schema = cluster.commands_by_name[command].schema
         schema(**event[PARAMS])
+
+
+KNOWN_DUPLICATE_TRIGGERS = {
+    zhaquirks.xiaomi.aqara.sensor_swit.SwitchAQ3V2: [
+        [
+            (const.LONG_PRESS, const.LONG_PRESS),
+            (const.LONG_RELEASE, const.LONG_RELEASE),
+        ]
+    ],
+    zhaquirks.xiaomi.aqara.sensor_switch_aq3.SwitchAQ3: [
+        [
+            (const.LONG_PRESS, const.LONG_PRESS),
+            (const.LONG_RELEASE, const.LONG_RELEASE),
+        ]
+    ],
+    zhaquirks.xiaomi.aqara.sensor_switch_aq3.SwitchAQ3B: [
+        [
+            (const.LONG_PRESS, const.LONG_PRESS),
+            (const.LONG_RELEASE, const.LONG_RELEASE),
+        ]
+    ],
+    zhaquirks.aurora.aurora_dimmer.AuroraDimmerBatteryPowered: [
+        [
+            # XXX: why is this constant defined in the module?
+            (zhaquirks.aurora.aurora_dimmer.COLOR_UP, const.RIGHT),
+            (zhaquirks.aurora.aurora_dimmer.COLOR_UP, const.LEFT),
+        ],
+        [
+            (zhaquirks.aurora.aurora_dimmer.COLOR_DOWN, const.RIGHT),
+            (zhaquirks.aurora.aurora_dimmer.COLOR_DOWN, const.LEFT),
+        ],
+    ],
+    zhaquirks.ikea.fourbtnremote.IkeaTradfriRemote: [
+        [
+            (const.LONG_RELEASE, const.DIM_UP),
+            (const.LONG_RELEASE, const.DIM_DOWN),
+        ]
+    ],
+    zhaquirks.thirdreality.button.Button: [
+        [
+            (const.LONG_PRESS, const.LONG_PRESS),
+            (const.LONG_RELEASE, const.LONG_RELEASE),
+        ]
+    ],
+}
+
+
+@pytest.mark.parametrize(
+    "quirk",
+    [q for q in ALL_QUIRK_CLASSES if getattr(q, "device_automation_triggers", None)],
+)
+def test_quirk_device_automation_triggers_unique(quirk):
+    """Ensure all quirks have unique device automation triggers."""
+
+    events = collections.defaultdict(list)
+
+    for trigger, event in quirk.device_automation_triggers.items():
+        # XXX: Dictionary objects are not hashable
+        frozen_event = json.dumps(event, sort_keys=True)
+        events[frozen_event].append((trigger, event))
+
+    for triggers_and_events in events.values():
+        triggers = [trigger for trigger, _ in triggers_and_events]
+
+        if len(triggers_and_events) > 1:
+            if (
+                quirk in KNOWN_DUPLICATE_TRIGGERS
+                and triggers in KNOWN_DUPLICATE_TRIGGERS[quirk]
+            ):
+                fail_func = pytest.xfail
+            else:
+                fail_func = pytest.fail
+
+            triggers_text = "\n".join(
+                [f" * {event} <- {trigger}" for trigger, event in triggers_and_events]
+            )
+            fail_func(f"Triggers are not unique for {quirk}:\n{triggers_text}")