Repository: zigpy/zha-device-handlers
Pull request #3433 (2024-10-16 00:52:04)

Add Python 3.13 to CI
## Proposed change
Since this repo overrides the `PYTHON_MATRIX`, we didn't automatically get Python 3.13 CI with https://github.com/zigpy/workflows/pull/23.
So, this PR adds Python 3.13 to the GitHub CI by adding it to the overridden `PYTHON_MATRIX`.



codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3433?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 88.71%. Comparing base [(`a29ad2f`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/a29ad2f7599e09f1d1b52b801ff0d9e7677b0c5d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`59b2b67`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/59b2b6730ba4fadbacf744a31c1d66624a5d5a84?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##              dev    #3433   +/-   ##
=======================================
  Coverage   88.71%   88.71%           
=======================================
  Files         306      306           
  Lines        9820     9820           
=======================================
  Hits         8712     8712           
  Misses       1108     1108           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3433?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7071490..b7ecea9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,6 +16,6 @@ jobs:
       PYTHON_VERSION_DEFAULT: 3.12
       PRE_COMMIT_CACHE_PATH:  ~/.cache/pre-commit
       MINIMUM_COVERAGE_PERCENTAGE: 80
-      PYTHON_MATRIX: "3.12"
+      PYTHON_MATRIX: '"3.12", "3.13"'
     secrets:
       CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
diff --git a/zhaquirks/philips/__init__.py b/zhaquirks/philips/__init__.py
index 3719590..05a3f6f 100644
--- a/zhaquirks/philips/__init__.py
+++ b/zhaquirks/philips/__init__.py
@@ -1,6 +1,7 @@
 """Module for Philips quirks implementations."""
 
 import asyncio
+import itertools
 import logging
 import time
 from typing import Any, Optional, Union
@@ -15,7 +16,11 @@ from zhaquirks.const import (
     ARGS,
     BUTTON,
     COMMAND,
+    COMMAND_HOLD,
     COMMAND_ID,
+    COMMAND_M_LONG_RELEASE,
+    COMMAND_M_SHORT_RELEASE,
+    COMMAND_PRESS,
     DIM_DOWN,
     DIM_UP,
     DOUBLE_PRESS,
@@ -36,41 +41,6 @@ PHILIPS = "Philips"
 SIGNIFY = "Signify Netherlands B.V."
 _LOGGER = logging.getLogger(__name__)
 
-HUE_REMOTE_DEVICE_TRIGGERS = {
-    (SHORT_PRESS, TURN_ON): {COMMAND: "on_press"},
-    (SHORT_PRESS, TURN_OFF): {COMMAND: "off_press"},
-    (SHORT_PRESS, DIM_UP): {COMMAND: "up_press"},
-    (SHORT_PRESS, DIM_DOWN): {COMMAND: "down_press"},
-    (LONG_PRESS, TURN_ON): {COMMAND: "on_hold"},
-    (LONG_PRESS, TURN_OFF): {COMMAND: "off_hold"},
-    (LONG_PRESS, DIM_UP): {COMMAND: "up_hold"},
-    (LONG_PRESS, DIM_DOWN): {COMMAND: "down_hold"},
-    (DOUBLE_PRESS, TURN_ON): {COMMAND: "on_double_press"},
-    (DOUBLE_PRESS, TURN_OFF): {COMMAND: "off_double_press"},
-    (DOUBLE_PRESS, DIM_UP): {COMMAND: "up_double_press"},
-    (DOUBLE_PRESS, DIM_DOWN): {COMMAND: "down_double_press"},
-    (TRIPLE_PRESS, TURN_ON): {COMMAND: "on_triple_press"},
-    (TRIPLE_PRESS, TURN_OFF): {COMMAND: "off_triple_press"},
-    (TRIPLE_PRESS, DIM_UP): {COMMAND: "up_triple_press"},
-    (TRIPLE_PRESS, DIM_DOWN): {COMMAND: "down_triple_press"},
-    (QUADRUPLE_PRESS, TURN_ON): {COMMAND: "on_quadruple_press"},
-    (QUADRUPLE_PRESS, TURN_OFF): {COMMAND: "off_quadruple_press"},
-    (QUADRUPLE_PRESS, DIM_UP): {COMMAND: "up_quadruple_press"},
-    (QUADRUPLE_PRESS, DIM_DOWN): {COMMAND: "down_quadruple_press"},
-    (QUINTUPLE_PRESS, TURN_ON): {COMMAND: "on_quintuple_press"},
-    (QUINTUPLE_PRESS, TURN_OFF): {COMMAND: "off_quintuple_press"},
-    (QUINTUPLE_PRESS, DIM_UP): {COMMAND: "up_quintuple_press"},
-    (QUINTUPLE_PRESS, DIM_DOWN): {COMMAND: "down_quintuple_press"},
-    (SHORT_RELEASE, TURN_ON): {COMMAND: "on_short_release"},
-    (SHORT_RELEASE, TURN_OFF): {COMMAND: "off_short_release"},
-    (SHORT_RELEASE, DIM_UP): {COMMAND: "up_short_release"},
-    (SHORT_RELEASE, DIM_DOWN): {COMMAND: "down_short_release"},
-    (LONG_RELEASE, TURN_ON): {COMMAND: "on_long_release"},
-    (LONG_RELEASE, TURN_OFF): {COMMAND: "off_long_release"},
-    (LONG_RELEASE, DIM_UP): {COMMAND: "up_long_release"},
-    (LONG_RELEASE, DIM_DOWN): {COMMAND: "down_long_release"},
-}
-
 
 class PhilipsOccupancySensing(CustomCluster):
     """Philips occupancy cluster."""
@@ -138,6 +108,39 @@ class ButtonPressQueue:
         self._task = asyncio.ensure_future(self._job())
 
 
+class Button:
+    """Represents a remote button, including string literals used in triggers and actions."""
+
+    def __init__(
+        self, id: str, trigger: str | None = None, action: str | None = None
+    ) -> None:
+        """Init."""
+        self.id = id
+        self.trigger = trigger or id
+        self.action = action or id
+
+    def __repr__(self) -> str:
+        """Repr."""
+        return f"<Button id={self.id}, trigger={self.trigger}, action={self.action}>"
+
+
+class PressType:
+    """Represents a remote button press-type, including string literals used in triggers and actions."""
+
+    def __init__(
+        self, name: str, action: str, arg: str | None = None, trigger: str | None = None
+    ) -> None:
+        """Init."""
+        self.name = name
+        self.action = action
+        self.arg = arg or action
+        self.trigger = trigger or name
+
+    def __repr__(self) -> str:
+        """Repr."""
+        return f"<PressType name={self.name}, action={self.action}, trigger={self.trigger}, arg={self.arg}>"
+
+
 class PhilipsRemoteCluster(CustomCluster):
     """Philips remote cluster."""
 
@@ -152,15 +155,35 @@ class PhilipsRemoteCluster(CustomCluster):
                 "param2": t.uint24_t,
                 "press_type": t.uint8_t,
                 "param4": t.uint8_t,
-                "param5": t.uint8_t,
-                "param6": t.uint8_t,
+                "param5": t.uint16_t,
             },
             False,
             is_manufacturer_specific=True,
         )
     }
-    BUTTONS = {1: "on", 2: "up", 3: "down", 4: "off"}
-    PRESS_TYPES = {0: "press", 1: "hold", 2: "short_release", 3: "long_release"}
+
+    BUTTONS: dict[int, Button] = {}
+
+    PRESS_TYPES: dict[int, PressType] = {
+        # We omit "short_press" and "short_release" on purpose, so it
+        # won't interfere with simulated multi-press events. We emit
+        # them in the multi-press code later on.
+        # 0: SHORT_PRESS,
+        1: PressType(LONG_PRESS, COMMAND_HOLD),
+        # 2: SHORT_RELEASE,
+        3: PressType(LONG_RELEASE, COMMAND_M_LONG_RELEASE),
+    }
+
+    MULTI_PRESS_EVENTS: dict[int, PressType] = {
+        2: PressType(DOUBLE_PRESS, "double_press"),
+        3: PressType(TRIPLE_PRESS, "triple_press"),
+        4: PressType(QUADRUPLE_PRESS, "quadruple_press"),
+        5: PressType(QUINTUPLE_PRESS, "quintuple_press"),
+    }
+    SIMULATE_SHORT_EVENTS = [
+        PressType(SHORT_PRESS, COMMAND_PRESS),
+        PressType(SHORT_RELEASE, COMMAND_M_SHORT_RELEASE),
+    ]
 
     button_press_queue = ButtonPressQueue()
 
@@ -175,47 +198,132 @@ class PhilipsRemoteCluster(CustomCluster):
     ):
         """Handle the cluster command."""
         _LOGGER.debug(
-            "PhilipsRemoteCluster - handle_cluster_request tsn: [%s] command id: %s - args: [%s]",
+            "%s - handle_cluster_request tsn: [%s] command id: %s - args: [%s]",
+            self.__class__.__name__,
             hdr.tsn,
             hdr.command_id,
             args,
         )
 
-        button = self.BUTTONS.get(args[0], args[0])
-        press_type = self.PRESS_TYPES.get(args[2], args[2])
+        button = self.BUTTONS.get(args[0])
+        _LOGGER.debug(
+            "%s - handle_cluster_request button id: [%s], button name: [%s]",
+            self.__class__.__name__,
+            args[0],
+            button,
+        )
+        # Bail on unknown buttons. (This gets rid of dial button "presses")
+        if button is None:
+            return
+
+        press_type = self.PRESS_TYPES.get(args[2])
+        if (
+            press_type is None
+            and self.SIMULATE_SHORT_EVENTS is not None
+            and args[2] == 2
+        ):
+            press_type = self.SIMULATE_SHORT_EVENTS[1]
+        if press_type is None:
+            return
+
+        duration = args[4]
+        _LOGGER.debug(
+            "%s - handle_cluster_request button press type: [%s], duration: [%s]",
+            self.__class__.__name__,
+            press_type,
+            duration,
+        )
 
         event_args = {
-            BUTTON: button,
-            PRESS_TYPE: press_type,
+            BUTTON: button.id,
+            PRESS_TYPE: press_type.arg,
             COMMAND_ID: hdr.command_id,
+            "duration": duration,
             ARGS: args,
         }
 
         def send_press_event(click_count):
             _LOGGER.debug(
-                "PhilipsRemoteCluster - send_press_event click_count: [%s]", click_count
+                "%s - send_press_event click_count: [%s]",
+                self.__class__.__name__,
+                click_count,
             )
             press_type = None
             if click_count == 1:
-                press_type = "press"
-            elif click_count == 2:
-                press_type = "double_press"
-            elif click_count == 3:
-                press_type = "triple_press"
-            elif click_count == 4:
-                press_type = "quadruple_press"
-            elif click_count > 4:
-                press_type = "quintuple_press"
-
-            if press_type:
+                press_type = self.PRESS_TYPES.get(0) or self.SIMULATE_SHORT_EVENTS[0]
+            elif click_count > 1:
+                press_type = self.MULTI_PRESS_EVENTS[min(click_count, 5)]
+
+            _LOGGER.debug(
+                "%s - send_press_event evaluated press_type: [%s]",
+                self.__class__.__name__,
+                press_type,
+            )
+            if press_type is not None:
                 # Override PRESS_TYPE
-                event_args[PRESS_TYPE] = press_type
-                action = f"{button}_{press_type}"
+                event_args[PRESS_TYPE] = press_type.arg
+                event_args[ARGS] = list(event_args[ARGS])
+                event_args[ARGS][2] = 0 if click_count < 2 else 2 + min(click_count, 5)
+                action = f"{button.action}_{press_type.action}"
+                _LOGGER.debug(
+                    "%s - send_press_event emitting action: [%s] event_args: %s",
+                    self.__class__.__name__,
+                    action,
+                    event_args,
+                )
                 self.listener_event(ZHA_SEND_EVENT, action, event_args)
 
+            # simulate short release event, if needed for this device type
+            if (
+                press_type.name == SHORT_PRESS
+                and self.SIMULATE_SHORT_EVENTS is not None
+            ):
+                press_type = self.PRESS_TYPES.get(2) or self.SIMULATE_SHORT_EVENTS[1]
+                sim_event_args = event_args.copy()
+                sim_event_args[PRESS_TYPE] = press_type.arg
+                sim_event_args[ARGS] = sim_event_args[ARGS].copy()
+                sim_event_args[ARGS][2] = 2
+                action = f"{button.action}_{press_type.action}"
+                _LOGGER.debug(
+                    "%s - send_press_event emitting simulated action: [%s]",
+                    self.__class__.__name__,
+                    action,
+                )
+                self.listener_event(ZHA_SEND_EVENT, action, sim_event_args)
+
         # Derive Multiple Presses
-        if press_type == "press":
-            self.button_press_queue.press(send_press_event, button)
+        if press_type.name == SHORT_RELEASE:
+            self.button_press_queue.press(send_press_event, button.id)
         else:
-            action = f"{button}_{press_type}"
+            action = f"{button.action}_{press_type.action}"
             self.listener_event(ZHA_SEND_EVENT, action, event_args)
+
+    @classmethod
+    def generate_device_automation_triggers(cls, additional=None):
+        """Generate automation triggers based on device buttons and press-types."""
+        triggers = {}
+        for button in cls.BUTTONS.values():
+            press_types = [cls.PRESS_TYPES.values()]
+            if cls.SIMULATE_SHORT_EVENTS is not None:
+                press_types.append(cls.SIMULATE_SHORT_EVENTS)
+                press_types.append(cls.MULTI_PRESS_EVENTS.values())
+            for press_type in itertools.chain(*press_types):
+                triggers[(press_type.trigger, button.trigger)] = {
+                    COMMAND: f"{button.action}_{press_type.action}"
+                }
+
+        if additional:
+            triggers.update(additional)
+
+        return triggers
+
+
+class PhilipsRwlRemoteCluster(PhilipsRemoteCluster):
+    """Philips remote cluster for RWL devices."""
+
+    BUTTONS = {
+        1: Button("on", TURN_ON),
+        2: Button("up", DIM_UP),
+        3: Button("down", DIM_DOWN),
+        4: Button("off", TURN_OFF),
+    }
diff --git a/zhaquirks/philips/rdm001.py b/zhaquirks/philips/rdm001.py
deleted file mode 100644
index 2efe774..0000000
--- a/zhaquirks/philips/rdm001.py
+++ /dev/null
@@ -1,197 +0,0 @@
-"""Signify RDM001 device."""
-
-import logging
-from typing import Any, Optional, Union
-
-from zigpy.profiles import zha
-from zigpy.quirks import CustomCluster, CustomDevice
-import zigpy.types as t
-from zigpy.zcl import foundation
-from zigpy.zcl.clusters.general import (
-    Basic,
-    Groups,
-    Identify,
-    LevelControl,
-    OnOff,
-    Ota,
-    PowerConfiguration,
-)
-
-from zhaquirks.const import (
-    ARGS,
-    BUTTON,
-    COMMAND,
-    COMMAND_ID,
-    DEVICE_TYPE,
-    DOUBLE_PRESS,
-    ENDPOINTS,
-    INPUT_CLUSTERS,
-    LONG_PRESS,
-    LONG_RELEASE,
-    MODELS_INFO,
-    OUTPUT_CLUSTERS,
-    PRESS_TYPE,
-    PROFILE_ID,
-    QUADRUPLE_PRESS,
-    QUINTUPLE_PRESS,
-    RIGHT,
-    SHORT_PRESS,
-    SHORT_RELEASE,
-    TRIPLE_PRESS,
-    TURN_ON,
-    ZHA_SEND_EVENT,
-)
-from zhaquirks.philips import PHILIPS, SIGNIFY
-
-DEVICE_SPECIFIC_UNKNOWN = 64512
-_LOGGER = logging.getLogger(__name__)
-
-
-class PhilipsBasicCluster(CustomCluster, Basic):
-    """Philips Basic cluster."""
-
-    attributes = Basic.attributes.copy()
-    attributes.update(
-        {
-            0x0031: ("philips", t.bitmap16, True),
-            0x0034: ("mode", t.enum8, True),
-        }
-    )
-
-    attr_config = {0x0031: 0x000B, 0x0034: 0x02}
-
-    async def bind(self):
-        """Bind cluster."""
-        result = await super().bind()
-        await self.write_attributes(self.attr_config, manufacturer=0x100B)
-        return result
-
-
-class PhilipsRemoteCluster(CustomCluster):
-    """Philips remote cluster."""
-
-    cluster_id = 64512
-    name = "PhilipsRemoteCluster"
-    ep_attribute = "philips_remote_cluster"
-    client_commands = {
-        0x00: foundation.ZCLCommandDef(
-            "notification",
-            {
-                "param1": t.uint8_t,
-                "param2": t.uint24_t,
-                "param3": t.uint8_t,
-                "param4": t.uint8_t,
-                "param5": t.uint8_t,
-                "param6": t.uint8_t,
-            },
-            is_manufacturer_specific=True,
-            direction=foundation.Direction.Client_to_Server,
-        )
-    }
-    BUTTONS = {
-        1: "left",
-        2: "right",
-    }
-    PRESS_TYPES = {0: "press", 1: "hold", 2: "press_release", 3: "hold_release"}
-
-    def handle_cluster_request(
-        self,
-        hdr: foundation.ZCLHeader,
-        args: list[Any],
-        *,
-        dst_addressing: Optional[
-            Union[t.Addressing.Group, t.Addressing.IEEE, t.Addressing.NWK]
-        ] = None,
-    ):
-        """Handle the cluster command."""
-        _LOGGER.debug(
-            "PhilipsRemoteCluster - handle_cluster_request tsn: [%s] command id: %s - args: [%s]",
-            hdr.tsn,
-            hdr.command_id,
-            args,
-        )
-
-        button = self.BUTTONS.get(args[0], args[0])
-        press_type = self.PRESS_TYPES.get(args[2], args[2])
-
-        event_args = {
-            BUTTON: button,
-            PRESS_TYPE: press_type,
-            COMMAND_ID: hdr.command_id,
-            ARGS: args,
-        }
-
-        action = f"{button}_{press_type}"
-        self.listener_event(ZHA_SEND_EVENT, action, event_args)
-
-
-class PhilipsROM001(CustomDevice):
-    """Philips ROM001 device."""
-
-    signature = {
-        #  <SimpleDescriptor endpoint=1 profile=260 device_type=2080
-        #  device_version=1
-        #  input_clusters=[0, 1, 3, 64512]
-        #  output_clusters=[3, 4, 6, 8, 25]>
-        MODELS_INFO: [(PHILIPS, "RDM001"), (SIGNIFY, "RDM001")],
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    PowerConfiguration.cluster_id,
-                    Identify.cluster_id,
-                    DEVICE_SPECIFIC_UNKNOWN,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Identify.cluster_id,
-                    Groups.cluster_id,
-                    OnOff.cluster_id,
-                    LevelControl.cluster_id,
-                    Ota.cluster_id,
-                ],
-            }
-        },
-    }
-
-    replacement = {
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER,
-                INPUT_CLUSTERS: [
-                    PhilipsBasicCluster,
-                    PowerConfiguration.cluster_id,
-                    Identify.cluster_id,
-                    PhilipsRemoteCluster,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Ota.cluster_id,
-                    Identify.cluster_id,
-                    Groups.cluster_id,
-                    OnOff.cluster_id,
-                    LevelControl.cluster_id,
-                ],
-            }
-        }
-    }
-
-    device_automation_triggers = {
-        (SHORT_PRESS, TURN_ON): {COMMAND: "left_press"},
-        (LONG_PRESS, TURN_ON): {COMMAND: "left_hold"},
-        (DOUBLE_PRESS, TURN_ON): {COMMAND: "left_double_press"},
-        (TRIPLE_PRESS, TURN_ON): {COMMAND: "left_triple_press"},
-        (QUADRUPLE_PRESS, TURN_ON): {COMMAND: "left_quadruple_press"},
-        (QUINTUPLE_PRESS, TURN_ON): {COMMAND: "left_quintuple_press"},
-        (SHORT_RELEASE, TURN_ON): {COMMAND: "left_short_release"},
-        (LONG_RELEASE, TURN_ON): {COMMAND: "left_long_release"},
-        (SHORT_PRESS, RIGHT): {COMMAND: "right_press"},
-        (LONG_PRESS, RIGHT): {COMMAND: "right_hold"},
-        (DOUBLE_PRESS, RIGHT): {COMMAND: "right_double_press"},
-        (TRIPLE_PRESS, RIGHT): {COMMAND: "right_triple_press"},
-        (QUADRUPLE_PRESS, RIGHT): {COMMAND: "right_quadruple_press"},
-        (QUINTUPLE_PRESS, RIGHT): {COMMAND: "right_quintuple_press"},
-        (SHORT_RELEASE, RIGHT): {COMMAND: "right_short_release"},
-        (LONG_RELEASE, RIGHT): {COMMAND: "right_long_release"},
-    }
diff --git a/zhaquirks/philips/rdm002.py b/zhaquirks/philips/rdm002.py
new file mode 100644
index 0000000..b4ae655
--- /dev/null
+++ b/zhaquirks/philips/rdm002.py
@@ -0,0 +1,134 @@
+"""Signify RDM002 device."""
+
+from zigpy.profiles import zha
+from zigpy.quirks import CustomDevice
+from zigpy.zcl.clusters.general import (
+    Basic,
+    Groups,
+    Identify,
+    LevelControl,
+    OnOff,
+    Ota,
+    PowerConfiguration,
+    Scenes,
+)
+from zigpy.zcl.clusters.lightlink import LightLink
+
+from zhaquirks.const import (
+    BUTTON_1,
+    BUTTON_2,
+    BUTTON_3,
+    BUTTON_4,
+    CLUSTER_ID,
+    COMMAND,
+    COMMAND_STEP_ON_OFF,
+    DEVICE_TYPE,
+    DIM_DOWN,
+    DIM_UP,
+    ENDPOINT_ID,
+    ENDPOINTS,
+    INPUT_CLUSTERS,
+    MODELS_INFO,
+    OUTPUT_CLUSTERS,
+    PARAMS,
+    PROFILE_ID,
+    SHORT_PRESS,
+)
+from zhaquirks.philips import (
+    PHILIPS,
+    SIGNIFY,
+    Button,
+    PhilipsBasicCluster,
+    PhilipsRemoteCluster,
+)
+
+DIAL_TRIGGERS = {
+    (SHORT_PRESS, DIM_UP): {
+        COMMAND: COMMAND_STEP_ON_OFF,
+        CLUSTER_ID: 8,
+        ENDPOINT_ID: 1,
+        PARAMS: {"step_mode": 0},
+    },
+    (SHORT_PRESS, DIM_DOWN): {
+        COMMAND: COMMAND_STEP_ON_OFF,
+        CLUSTER_ID: 8,
+        ENDPOINT_ID: 1,
+        PARAMS: {"step_mode": 1},
+    },
+}
+
+
+class PhilipsRdm002RemoteCluster(PhilipsRemoteCluster):
+    """Philips remote cluster for RDM002."""
+
+    BUTTONS = {
+        1: Button(BUTTON_1),
+        2: Button(BUTTON_2),
+        3: Button(BUTTON_3),
+        4: Button(BUTTON_4),
+    }
+
+
+class PhilipsRDM002(CustomDevice):
+    """Philips RDM002 device."""
+
+    signature = {
+        #  <SimpleDescriptor endpoint=1 profile=260 device_type=2096
+        #  device_version=1
+        #  input_clusters=[0, 1, 3, 64512, 4096]
+        #  output_clusters=[25, 0, 3, 4, 6, 8, 5, 4096]>
+        MODELS_INFO: [(PHILIPS, "RDM002"), (SIGNIFY, "RDM002")],
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.NON_COLOR_SCENE_CONTROLLER,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    PowerConfiguration.cluster_id,
+                    Identify.cluster_id,
+                    PhilipsRdm002RemoteCluster.cluster_id,
+                    LightLink.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Ota.cluster_id,
+                    Basic.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    Scenes.cluster_id,
+                    LightLink.cluster_id,
+                ],
+            }
+        },
+    }
+
+    replacement = {
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER,
+                INPUT_CLUSTERS: [
+                    PhilipsBasicCluster,
+                    PowerConfiguration.cluster_id,
+                    Identify.cluster_id,
+                    PhilipsRdm002RemoteCluster,
+                    LightLink.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Ota.cluster_id,
+                    Basic.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    Scenes.cluster_id,
+                    LightLink.cluster_id,
+                ],
+            }
+        }
+    }
+
+    device_automation_triggers = (
+        PhilipsRdm002RemoteCluster.generate_device_automation_triggers(DIAL_TRIGGERS)
+    )
diff --git a/zhaquirks/philips/rom001.py b/zhaquirks/philips/rom001.py
index b5acb97..0588ebc 100644
--- a/zhaquirks/philips/rom001.py
+++ b/zhaquirks/philips/rom001.py
@@ -15,9 +15,9 @@ from zigpy.zcl.clusters.general import (
 from zigpy.zcl.clusters.lightlink import LightLink
 
 from zhaquirks.const import (
-    COMMAND,
+    COMMAND_HOLD,
+    COMMAND_ON,
     DEVICE_TYPE,
-    DOUBLE_PRESS,
     ENDPOINTS,
     INPUT_CLUSTERS,
     LONG_PRESS,
@@ -25,23 +25,38 @@ from zhaquirks.const import (
     MODELS_INFO,
     OUTPUT_CLUSTERS,
     PROFILE_ID,
-    QUADRUPLE_PRESS,
-    QUINTUPLE_PRESS,
-    SHORT_PRESS,
-    SHORT_RELEASE,
-    TRIPLE_PRESS,
     TURN_ON,
 )
 from zhaquirks.philips import (
     PHILIPS,
     SIGNIFY,
+    Button,
     PhilipsBasicCluster,
     PhilipsRemoteCluster,
+    PressType,
 )
 
 DEVICE_SPECIFIC_UNKNOWN = 64512
 
 
+class PhilipsRom001RemoteCluster(PhilipsRemoteCluster):
+    """Philips remote cluster for ROM001."""
+
+    BUTTONS = {
+        1: Button(COMMAND_ON, TURN_ON, COMMAND_ON),
+    }
+
+    PRESS_TYPES: dict[int, PressType] = {
+        # We omit "short_press" and "short_release" on purpose, so it
+        # won't interfere with simulated multi-press events. We emit
+        # them in the multi-press code later on.
+        # 0: SHORT_PRESS,
+        1: PressType(LONG_PRESS, COMMAND_HOLD),
+        # 2: SHORT_RELEASE,
+        3: PressType(LONG_RELEASE, "long_release", "hold_release"),
+    }
+
+
 class PhilipsROM001(CustomDevice):
     """Philips ROM001 device."""
 
@@ -85,7 +100,7 @@ class PhilipsROM001(CustomDevice):
                     PhilipsBasicCluster,
                     PowerConfiguration.cluster_id,
                     Identify.cluster_id,
-                    PhilipsRemoteCluster,
+                    PhilipsRom001RemoteCluster,
                     LightLink.cluster_id,
                 ],
                 OUTPUT_CLUSTERS: [
@@ -102,13 +117,6 @@ class PhilipsROM001(CustomDevice):
         }
     }
 
-    device_automation_triggers = {
-        (SHORT_PRESS, TURN_ON): {COMMAND: "on_press"},
-        (LONG_PRESS, TURN_ON): {COMMAND: "on_hold"},
-        (DOUBLE_PRESS, TURN_ON): {COMMAND: "on_double_press"},
-        (TRIPLE_PRESS, TURN_ON): {COMMAND: "on_triple_press"},
-        (QUADRUPLE_PRESS, TURN_ON): {COMMAND: "on_quadruple_press"},
-        (QUINTUPLE_PRESS, TURN_ON): {COMMAND: "on_quintuple_press"},
-        (SHORT_RELEASE, TURN_ON): {COMMAND: "on_short_release"},
-        (LONG_RELEASE, TURN_ON): {COMMAND: "on_long_release"},
-    }
+    device_automation_triggers = (
+        PhilipsRom001RemoteCluster.generate_device_automation_triggers()
+    )
diff --git a/zhaquirks/philips/rwl022.py b/zhaquirks/philips/rwl022.py
index 4d221b4..7001d74 100644
--- a/zhaquirks/philips/rwl022.py
+++ b/zhaquirks/philips/rwl022.py
@@ -22,12 +22,7 @@ from zhaquirks.const import (
     OUTPUT_CLUSTERS,
     PROFILE_ID,
 )
-from zhaquirks.philips import (
-    HUE_REMOTE_DEVICE_TRIGGERS,
-    SIGNIFY,
-    PhilipsBasicCluster,
-    PhilipsRemoteCluster,
-)
+from zhaquirks.philips import SIGNIFY, PhilipsBasicCluster, PhilipsRwlRemoteCluster
 
 DEVICE_SPECIFIC_UNKNOWN = 64512
 
@@ -75,7 +70,7 @@ class PhilipsRWL022(CustomDevice):
                     PhilipsBasicCluster,
                     PowerConfiguration.cluster_id,
                     Identify.cluster_id,
-                    PhilipsRemoteCluster,
+                    PhilipsRwlRemoteCluster,
                     LightLink.cluster_id,
                 ],
                 OUTPUT_CLUSTERS: [
@@ -92,4 +87,6 @@ class PhilipsRWL022(CustomDevice):
         }
     }
 
-    device_automation_triggers = HUE_REMOTE_DEVICE_TRIGGERS
+    device_automation_triggers = (
+        PhilipsRwlRemoteCluster.generate_device_automation_triggers()
+    )
diff --git a/zhaquirks/philips/rwlfirstgen.py b/zhaquirks/philips/rwlfirstgen.py
index c0ef399..b928c64 100644
--- a/zhaquirks/philips/rwlfirstgen.py
+++ b/zhaquirks/philips/rwlfirstgen.py
@@ -23,11 +23,10 @@ from zhaquirks.const import (
     PROFILE_ID,
 )
 from zhaquirks.philips import (
-    HUE_REMOTE_DEVICE_TRIGGERS,
     PHILIPS,
     SIGNIFY,
     PhilipsBasicCluster,
-    PhilipsRemoteCluster,
+    PhilipsRwlRemoteCluster,
 )
 
 DEVICE_SPECIFIC_UNKNOWN = 64512
@@ -99,14 +98,16 @@ class PhilipsRWLFirstGen(CustomDevice):
                     PowerConfiguration.cluster_id,
                     Identify.cluster_id,
                     BinaryInput.cluster_id,
-                    PhilipsRemoteCluster,
+                    PhilipsRwlRemoteCluster,
                 ],
                 OUTPUT_CLUSTERS: [Ota.cluster_id],
             },
         }
     }
 
-    device_automation_triggers = HUE_REMOTE_DEVICE_TRIGGERS
+    device_automation_triggers = (
+        PhilipsRwlRemoteCluster.generate_device_automation_triggers()
+    )
 
 
 class PhilipsRWLFirstGen2(CustomDevice):
@@ -173,11 +174,13 @@ class PhilipsRWLFirstGen2(CustomDevice):
                     PowerConfiguration.cluster_id,
                     Identify.cluster_id,
                     BinaryInput.cluster_id,
-                    PhilipsRemoteCluster,
+                    PhilipsRwlRemoteCluster,
                 ],
                 OUTPUT_CLUSTERS: [Ota.cluster_id],
             },
         }
     }
 
-    device_automation_triggers = HUE_REMOTE_DEVICE_TRIGGERS
+    device_automation_triggers = (
+        PhilipsRwlRemoteCluster.generate_device_automation_triggers()
+    )
diff --git a/zhaquirks/philips/wall_switch.py b/zhaquirks/philips/wall_switch.py
new file mode 100644
index 0000000..deaee28
--- /dev/null
+++ b/zhaquirks/philips/wall_switch.py
@@ -0,0 +1,134 @@
+"""Signify wall switch devices (RDM001 and RDM004)."""
+
+from zigpy.profiles import zha
+from zigpy.quirks import CustomCluster, CustomDevice
+import zigpy.types as t
+from zigpy.zcl.clusters.general import (
+    Basic,
+    Groups,
+    Identify,
+    LevelControl,
+    OnOff,
+    Ota,
+    PowerConfiguration,
+)
+
+from zhaquirks.const import (
+    DEVICE_TYPE,
+    ENDPOINTS,
+    INPUT_CLUSTERS,
+    LEFT,
+    LONG_PRESS,
+    LONG_RELEASE,
+    MODELS_INFO,
+    OUTPUT_CLUSTERS,
+    PROFILE_ID,
+    RIGHT,
+    SHORT_PRESS,
+    SHORT_RELEASE,
+    TURN_ON,
+)
+from zhaquirks.philips import PHILIPS, SIGNIFY, Button, PhilipsRemoteCluster, PressType
+
+DEVICE_SPECIFIC_UNKNOWN = 64512
+
+
+class PhilipsBasicCluster(CustomCluster, Basic):
+    """Philips Basic cluster."""
+
+    attributes = Basic.attributes.copy()
+    attributes.update(
+        {
+            0x0031: ("philips", t.bitmap16, True),
+            0x0034: ("mode", t.enum8, True),
+        }
+    )
+
+    attr_config = {0x0031: 0x000B, 0x0034: 0x02}
+
+    async def bind(self):
+        """Bind cluster."""
+        result = await super().bind()
+        await self.write_attributes(self.attr_config, manufacturer=0x100B)
+        return result
+
+
+class PhilipsWallSwitchRemoteCluster(PhilipsRemoteCluster):
+    """Philips wall switch remote cluster."""
+
+    BUTTONS = {
+        1: Button(LEFT, TURN_ON),
+        2: Button(RIGHT),
+    }
+
+    PRESS_TYPES: dict[int, PressType] = {
+        1: PressType(LONG_PRESS, "hold"),
+        3: PressType(LONG_RELEASE, "long_release", "hold_release"),
+    }
+
+    SIMULATE_SHORT_EVENTS = [
+        PressType(SHORT_PRESS, "press"),
+        PressType(SHORT_RELEASE, "short_release"),
+    ]
+
+
+class PhilipsWallSwitch(CustomDevice):
+    """Philips RDM001 or RDM004 device."""
+
+    signature = {
+        #  <SimpleDescriptor endpoint=1 profile=260 device_type=2080
+        #  device_version=1
+        #  input_clusters=[0, 1, 3, 64512]
+        #  output_clusters=[3, 4, 6, 8, 25]>
+        MODELS_INFO: [
+            (PHILIPS, "RDM001"),
+            (SIGNIFY, "RDM001"),
+            (PHILIPS, "RDM004"),  # likely not needed
+            (SIGNIFY, "RDM004"),
+        ],
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    PowerConfiguration.cluster_id,
+                    Identify.cluster_id,
+                    DEVICE_SPECIFIC_UNKNOWN,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    Ota.cluster_id,
+                ],
+            }
+        },
+    }
+
+    replacement = {
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER,
+                INPUT_CLUSTERS: [
+                    PhilipsBasicCluster,
+                    PowerConfiguration.cluster_id,
+                    Identify.cluster_id,
+                    PhilipsWallSwitchRemoteCluster,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Ota.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                ],
+            }
+        }
+    }
+
+    device_automation_triggers = (
+        PhilipsWallSwitchRemoteCluster.generate_device_automation_triggers()
+    )


Test patch:
diff --git a/tests/test_philips.py b/tests/test_philips.py
new file mode 100644
index 0000000..3fdebf0
--- /dev/null
+++ b/tests/test_philips.py
@@ -0,0 +1,740 @@
+"""Tests for Philips quirks."""
+
+from unittest import mock
+
+import pytest
+from zigpy.zcl.foundation import ZCLHeader
+
+import zhaquirks
+from zhaquirks.const import (
+    BUTTON_1,
+    BUTTON_2,
+    BUTTON_3,
+    BUTTON_4,
+    CLUSTER_ID,
+    COMMAND,
+    COMMAND_HOLD,
+    DIM_DOWN,
+    DIM_UP,
+    DOUBLE_PRESS,
+    ENDPOINT_ID,
+    LONG_PRESS,
+    LONG_RELEASE,
+    PARAMS,
+    QUADRUPLE_PRESS,
+    QUINTUPLE_PRESS,
+    RIGHT,
+    SHORT_PRESS,
+    SHORT_RELEASE,
+    TRIPLE_PRESS,
+    TURN_OFF,
+    TURN_ON,
+)
+import zhaquirks.philips
+from zhaquirks.philips import Button, ButtonPressQueue, PhilipsRemoteCluster, PressType
+from zhaquirks.philips.rdm002 import PhilipsRDM002
+from zhaquirks.philips.rom001 import PhilipsROM001
+from zhaquirks.philips.rwl022 import PhilipsRWL022
+from zhaquirks.philips.rwlfirstgen import PhilipsRWLFirstGen, PhilipsRWLFirstGen2
+from zhaquirks.philips.wall_switch import PhilipsWallSwitch
+
+zhaquirks.setup()
+
+
+@pytest.mark.parametrize(
+    "classes, triggers",
+    (
+        (
+            [PhilipsRWLFirstGen, PhilipsRWLFirstGen2, PhilipsRWL022],
+            {
+                (SHORT_PRESS, TURN_ON): {COMMAND: "on_press"},
+                (SHORT_PRESS, TURN_OFF): {COMMAND: "off_press"},
+                (SHORT_PRESS, DIM_UP): {COMMAND: "up_press"},
+                (SHORT_PRESS, DIM_DOWN): {COMMAND: "down_press"},
+                (LONG_PRESS, TURN_ON): {COMMAND: "on_hold"},
+                (LONG_PRESS, TURN_OFF): {COMMAND: "off_hold"},
+                (LONG_PRESS, DIM_UP): {COMMAND: "up_hold"},
+                (LONG_PRESS, DIM_DOWN): {COMMAND: "down_hold"},
+                (DOUBLE_PRESS, TURN_ON): {COMMAND: "on_double_press"},
+                (DOUBLE_PRESS, TURN_OFF): {COMMAND: "off_double_press"},
+                (DOUBLE_PRESS, DIM_UP): {COMMAND: "up_double_press"},
+                (DOUBLE_PRESS, DIM_DOWN): {COMMAND: "down_double_press"},
+                (TRIPLE_PRESS, TURN_ON): {COMMAND: "on_triple_press"},
+                (TRIPLE_PRESS, TURN_OFF): {COMMAND: "off_triple_press"},
+                (TRIPLE_PRESS, DIM_UP): {COMMAND: "up_triple_press"},
+                (TRIPLE_PRESS, DIM_DOWN): {COMMAND: "down_triple_press"},
+                (QUADRUPLE_PRESS, TURN_ON): {COMMAND: "on_quadruple_press"},
+                (QUADRUPLE_PRESS, TURN_OFF): {COMMAND: "off_quadruple_press"},
+                (QUADRUPLE_PRESS, DIM_UP): {COMMAND: "up_quadruple_press"},
+                (QUADRUPLE_PRESS, DIM_DOWN): {COMMAND: "down_quadruple_press"},
+                (QUINTUPLE_PRESS, TURN_ON): {COMMAND: "on_quintuple_press"},
+                (QUINTUPLE_PRESS, TURN_OFF): {COMMAND: "off_quintuple_press"},
+                (QUINTUPLE_PRESS, DIM_UP): {COMMAND: "up_quintuple_press"},
+                (QUINTUPLE_PRESS, DIM_DOWN): {COMMAND: "down_quintuple_press"},
+                (SHORT_RELEASE, TURN_ON): {COMMAND: "on_short_release"},
+                (SHORT_RELEASE, TURN_OFF): {COMMAND: "off_short_release"},
+                (SHORT_RELEASE, DIM_UP): {COMMAND: "up_short_release"},
+                (SHORT_RELEASE, DIM_DOWN): {COMMAND: "down_short_release"},
+                (LONG_RELEASE, TURN_ON): {COMMAND: "on_long_release"},
+                (LONG_RELEASE, TURN_OFF): {COMMAND: "off_long_release"},
+                (LONG_RELEASE, DIM_UP): {COMMAND: "up_long_release"},
+                (LONG_RELEASE, DIM_DOWN): {COMMAND: "down_long_release"},
+            },
+        ),
+        (
+            [PhilipsROM001],
+            {
+                (SHORT_PRESS, TURN_ON): {COMMAND: "on_press"},
+                (LONG_PRESS, TURN_ON): {COMMAND: "on_hold"},
+                (DOUBLE_PRESS, TURN_ON): {COMMAND: "on_double_press"},
+                (TRIPLE_PRESS, TURN_ON): {COMMAND: "on_triple_press"},
+                (QUADRUPLE_PRESS, TURN_ON): {COMMAND: "on_quadruple_press"},
+                (QUINTUPLE_PRESS, TURN_ON): {COMMAND: "on_quintuple_press"},
+                (SHORT_RELEASE, TURN_ON): {COMMAND: "on_short_release"},
+                (LONG_RELEASE, TURN_ON): {COMMAND: "on_long_release"},
+            },
+        ),
+        (
+            [PhilipsWallSwitch],
+            {
+                (SHORT_PRESS, TURN_ON): {COMMAND: "left_press"},
+                (LONG_PRESS, TURN_ON): {COMMAND: "left_hold"},
+                (DOUBLE_PRESS, TURN_ON): {COMMAND: "left_double_press"},
+                (TRIPLE_PRESS, TURN_ON): {COMMAND: "left_triple_press"},
+                (QUADRUPLE_PRESS, TURN_ON): {COMMAND: "left_quadruple_press"},
+                (QUINTUPLE_PRESS, TURN_ON): {COMMAND: "left_quintuple_press"},
+                (SHORT_RELEASE, TURN_ON): {COMMAND: "left_short_release"},
+                (LONG_RELEASE, TURN_ON): {COMMAND: "left_long_release"},
+                (SHORT_PRESS, RIGHT): {COMMAND: "right_press"},
+                (LONG_PRESS, RIGHT): {COMMAND: "right_hold"},
+                (DOUBLE_PRESS, RIGHT): {COMMAND: "right_double_press"},
+                (TRIPLE_PRESS, RIGHT): {COMMAND: "right_triple_press"},
+                (QUADRUPLE_PRESS, RIGHT): {COMMAND: "right_quadruple_press"},
+                (QUINTUPLE_PRESS, RIGHT): {COMMAND: "right_quintuple_press"},
+                (SHORT_RELEASE, RIGHT): {COMMAND: "right_short_release"},
+                (LONG_RELEASE, RIGHT): {COMMAND: "right_long_release"},
+            },
+        ),
+    ),
+)
+def test_legacy_remote_automation_triggers(classes, triggers):
+    """Ensure we don't break any automation triggers by changing their values."""
+
+    for cls in classes:
+        assert cls.device_automation_triggers == triggers
+
+
+class _SimpleRemote(PhilipsRemoteCluster):
+    BUTTONS = {
+        1: Button("turn_on"),
+        2: Button("right"),
+    }
+    PRESS_TYPES = {
+        0: PressType("remote_button_long_release", "remote_button_long_release"),
+    }
+    SIMULATE_SHORT_EVENTS = None
+
+
+class _RemoteWithTriggerOverrides(_SimpleRemote):
+    BUTTONS = {
+        1: Button("turn_on", "turn_on", "left"),
+        2: Button("right"),
+    }
+
+
+class _RemoteWithSimulatedRelease(_SimpleRemote):
+    SIMULATE_SHORT_EVENTS = [
+        PressType("remote_button_short_press", "remote_button_short_press"),
+        PressType("remote_button_short_release", "remote_button_short_release"),
+    ]
+
+
+class _RemoteWithCommandOverrides(_SimpleRemote):
+    PRESS_TYPES: dict[int, PressType] = {
+        1: PressType(LONG_PRESS, COMMAND_HOLD),
+        3: PressType(LONG_RELEASE, "release"),
+    }
+
+
+class _RemoteWithSimulatedReleaseAndCommandOverride(_SimpleRemote):
+    SIMULATE_SHORT_EVENTS = [
+        PressType("remote_button_short_press", "short_press"),
+        PressType("remote_button_short_release", "remote_button_short_release"),
+    ]
+
+
+@pytest.mark.parametrize(
+    "cls, expected_value",
+    (
+        (
+            _SimpleRemote,
+            {
+                ("remote_button_long_release", "turn_on"): {
+                    COMMAND: "turn_on_remote_button_long_release"
+                },
+                ("remote_button_long_release", "right"): {
+                    COMMAND: "right_remote_button_long_release"
+                },
+            },
+        ),
+        (
+            _RemoteWithTriggerOverrides,
+            {
+                ("remote_button_long_release", "turn_on"): {
+                    COMMAND: "left_remote_button_long_release"
+                },
+                ("remote_button_long_release", "right"): {
+                    COMMAND: "right_remote_button_long_release"
+                },
+            },
+        ),
+        (
+            _RemoteWithSimulatedRelease,
+            {
+                ("remote_button_long_release", "turn_on"): {
+                    COMMAND: "turn_on_remote_button_long_release"
+                },
+                ("remote_button_long_release", "right"): {
+                    COMMAND: "right_remote_button_long_release"
+                },
+                ("remote_button_short_press", "turn_on"): {
+                    COMMAND: "turn_on_remote_button_short_press"
+                },
+                ("remote_button_short_press", "right"): {
+                    COMMAND: "right_remote_button_short_press"
+                },
+                ("remote_button_short_release", "turn_on"): {
+                    COMMAND: "turn_on_remote_button_short_release"
+                },
+                ("remote_button_short_release", "right"): {
+                    COMMAND: "right_remote_button_short_release"
+                },
+                ("remote_button_double_press", "turn_on"): {
+                    COMMAND: "turn_on_double_press"
+                },
+                ("remote_button_double_press", "right"): {
+                    COMMAND: "right_double_press"
+                },
+                ("remote_button_triple_press", "turn_on"): {
+                    COMMAND: "turn_on_triple_press"
+                },
+                ("remote_button_triple_press", "right"): {
+                    COMMAND: "right_triple_press"
+                },
+                ("remote_button_quadruple_press", "turn_on"): {
+                    COMMAND: "turn_on_quadruple_press"
+                },
+                ("remote_button_quadruple_press", "right"): {
+                    COMMAND: "right_quadruple_press"
+                },
+                ("remote_button_quintuple_press", "turn_on"): {
+                    COMMAND: "turn_on_quintuple_press"
+                },
+                ("remote_button_quintuple_press", "right"): {
+                    COMMAND: "right_quintuple_press"
+                },
+            },
+        ),
+        (
+            _RemoteWithCommandOverrides,
+            {
+                ("remote_button_long_press", "turn_on"): {COMMAND: "turn_on_hold"},
+                ("remote_button_long_press", "right"): {COMMAND: "right_hold"},
+                ("remote_button_long_release", "turn_on"): {COMMAND: "turn_on_release"},
+                ("remote_button_long_release", "right"): {COMMAND: "right_release"},
+            },
+        ),
+        (
+            _RemoteWithSimulatedReleaseAndCommandOverride,
+            {
+                ("remote_button_long_release", "turn_on"): {
+                    COMMAND: "turn_on_remote_button_long_release"
+                },
+                ("remote_button_long_release", "right"): {
+                    COMMAND: "right_remote_button_long_release"
+                },
+                ("remote_button_short_press", "turn_on"): {
+                    COMMAND: "turn_on_short_press"
+                },
+                ("remote_button_short_press", "right"): {COMMAND: "right_short_press"},
+                ("remote_button_short_release", "turn_on"): {
+                    COMMAND: "turn_on_remote_button_short_release"
+                },
+                ("remote_button_short_release", "right"): {
+                    COMMAND: "right_remote_button_short_release"
+                },
+                ("remote_button_double_press", "turn_on"): {
+                    COMMAND: "turn_on_double_press"
+                },
+                ("remote_button_double_press", "right"): {
+                    COMMAND: "right_double_press"
+                },
+                ("remote_button_triple_press", "turn_on"): {
+                    COMMAND: "turn_on_triple_press"
+                },
+                ("remote_button_triple_press", "right"): {
+                    COMMAND: "right_triple_press"
+                },
+                ("remote_button_quadruple_press", "turn_on"): {
+                    COMMAND: "turn_on_quadruple_press"
+                },
+                ("remote_button_quadruple_press", "right"): {
+                    COMMAND: "right_quadruple_press"
+                },
+                ("remote_button_quintuple_press", "turn_on"): {
+                    COMMAND: "turn_on_quintuple_press"
+                },
+                ("remote_button_quintuple_press", "right"): {
+                    COMMAND: "right_quintuple_press"
+                },
+            },
+        ),
+    ),
+)
+def test_generate_device_automation_triggers(cls, expected_value):
+    """Test trigger generation and button overrides."""
+
+    assert cls.generate_device_automation_triggers() == expected_value
+
+
+class ManuallyFiredButtonPressQueue:
+    """Philips button queue to derive multiple press events."""
+
+    def __init__(self):
+        """Init."""
+        self.reset()
+
+    def fire(self):
+        """Fire the callback. Trigger after all inputs, before running assertions."""
+
+        if self._callback is not None:
+            self._callback(self._click_counter)
+
+    def reset(self):
+        """Reset the button press queue."""
+
+        self._click_counter = 0
+        self._callback = None
+        self._button = None
+
+    def press(self, callback, button):
+        """Process a button press."""
+        if button != self._button:
+            self._click_counter = 1
+        else:
+            self._click_counter += 1
+        self._button = button
+        self._callback = callback
+
+
+@pytest.mark.parametrize(
+    "dev, ep, button, events",
+    (
+        (
+            PhilipsWallSwitch,
+            1,
+            "left",
+            ["press", "short_release"],
+        ),
+        (
+            PhilipsROM001,
+            1,
+            "on",
+            ["press", "short_release"],
+        ),
+        (
+            PhilipsRWLFirstGen,
+            2,
+            "on",
+            ["press", "short_release"],
+        ),
+        (
+            PhilipsRWLFirstGen2,
+            2,
+            "on",
+            ["press", "short_release"],
+        ),
+        (
+            PhilipsRWL022,
+            1,
+            "on",
+            ["press", "short_release"],
+        ),
+    ),
+)
+def test_PhilipsRemoteCluster_short_press(
+    zigpy_device_from_quirk, dev, ep, button, events
+):
+    """Test PhilipsRemoteCluster short button press logic."""
+
+    device = zigpy_device_from_quirk(dev)
+
+    cluster = device.endpoints[ep].philips_remote_cluster
+    listener = mock.MagicMock()
+    cluster.add_listener(listener)
+    cluster.button_press_queue = ManuallyFiredButtonPressQueue()
+
+    cluster.handle_cluster_request(ZCLHeader(), [1, 0, 0, 0, 0])
+    cluster.handle_cluster_request(ZCLHeader(), [1, 0, 2, 0, 0])
+    cluster.button_press_queue.fire()
+
+    assert listener.zha_send_event.call_count == 2
+
+    calls = [
+        mock.call(
+            f"{button}_{events[0]}",
+            {
+                "button": button,
+                "press_type": events[0],
+                "command_id": None,
+                "duration": 0,
+                "args": [1, 0, 0, 0, 0],
+            },
+        ),
+        mock.call(
+            f"{button}_{events[1]}",
+            {
+                "button": button,
+                "press_type": events[1],
+                "command_id": None,
+                "duration": 0,
+                "args": [1, 0, 2, 0, 0],
+            },
+        ),
+    ]
+
+    listener.zha_send_event.assert_has_calls(calls)
+
+
+@pytest.mark.parametrize(
+    "dev, ep, button",
+    (
+        (
+            PhilipsROM001,
+            1,
+            "on",
+        ),
+        (
+            PhilipsWallSwitch,
+            1,
+            "left",
+        ),
+        (
+            PhilipsRWLFirstGen,
+            2,
+            "on",
+        ),
+        (
+            PhilipsRWLFirstGen2,
+            2,
+            "on",
+        ),
+        (
+            PhilipsRWL022,
+            1,
+            "on",
+        ),
+    ),
+)
+@pytest.mark.parametrize(
+    "count, action_press_type",
+    (
+        (2, "double_press"),
+        (3, "triple_press"),
+        (4, "quadruple_press"),
+        (5, "quintuple_press"),
+    ),
+)
+def test_PhilipsRemoteCluster_multi_press(
+    zigpy_device_from_quirk,
+    dev,
+    ep,
+    button,
+    count,
+    action_press_type,
+):
+    """Test PhilipsRemoteCluster button multi-press logic."""
+
+    device = zigpy_device_from_quirk(dev)
+
+    cluster = device.endpoints[ep].philips_remote_cluster
+    listener = mock.MagicMock()
+    cluster.add_listener(listener)
+    cluster.button_press_queue = ManuallyFiredButtonPressQueue()
+
+    for _ in range(0, count):
+        # btn1 short press
+        cluster.handle_cluster_request(ZCLHeader(), [1, 0, 0, 0, 0])
+        # btn1 short release
+        cluster.handle_cluster_request(ZCLHeader(), [1, 0, 2, 0, 0])
+    cluster.button_press_queue.fire()
+
+    assert listener.zha_send_event.call_count == 1
+    args_button_id = count + 2
+    listener.zha_send_event.assert_has_calls(
+        [
+            mock.call(
+                f"{button}_{action_press_type}",
+                {
+                    "button": button,
+                    "press_type": action_press_type,
+                    "command_id": None,
+                    "duration": 0,
+                    "args": [1, 0, args_button_id, 0, 0],
+                },
+            ),
+        ]
+    )
+
+
+@pytest.mark.parametrize(
+    "dev, ep",
+    (
+        (PhilipsWallSwitch, 1),
+        (PhilipsROM001, 1),
+        (PhilipsRWLFirstGen, 2),
+        (PhilipsRWLFirstGen2, 2),
+        (PhilipsRWL022, 1),
+    ),
+)
+def test_PhilipsRemoteCluster_ignore_unknown_buttons(zigpy_device_from_quirk, dev, ep):
+    """Ensure PhilipsRemoteCluster ignores unknown buttons."""
+
+    device = zigpy_device_from_quirk(dev)
+
+    cluster = device.endpoints[ep].philips_remote_cluster
+    listener = mock.MagicMock()
+    cluster.add_listener(listener)
+
+    cluster.handle_cluster_request(ZCLHeader(), [99, 0, 0, 0, 0])
+
+    assert listener.zha_send_event.call_count == 0
+
+
+@pytest.mark.parametrize(
+    "dev, ep, button, hold_press_type, release_press_type, release_press_type_arg",
+    (
+        (PhilipsROM001, 1, "on", "hold", "long_release", "hold_release"),
+        (
+            PhilipsWallSwitch,
+            1,
+            "left",
+            "hold",
+            "long_release",
+            "hold_release",
+        ),
+        (
+            PhilipsRWLFirstGen,
+            2,
+            "on",
+            "hold",
+            "long_release",
+            "long_release",
+        ),
+        (
+            PhilipsRWLFirstGen2,
+            2,
+            "on",
+            "hold",
+            "long_release",
+            "long_release",
+        ),
+        (
+            PhilipsRWL022,
+            1,
+            "on",
+            "hold",
+            "long_release",
+            "long_release",
+        ),
+    ),
+)
+@pytest.mark.parametrize(
+    "count",
+    (
+        (1),
+        (2),
+        (3),
+    ),
+)
+def test_PhilipsRemoteCluster_long_press(
+    zigpy_device_from_quirk,
+    dev,
+    ep,
+    button,
+    hold_press_type,
+    release_press_type,
+    release_press_type_arg,
+    count,
+):
+    """Test PhilipsRemoteCluster button long press logic."""
+
+    device = zigpy_device_from_quirk(dev)
+
+    cluster = device.endpoints[ep].philips_remote_cluster
+    listener = mock.MagicMock()
+    cluster.add_listener(listener)
+    cluster.button_press_queue = ManuallyFiredButtonPressQueue()
+
+    cluster.handle_cluster_request(ZCLHeader(), [1, 0, 0, 0, 0])
+    for i in range(0, count):
+        # btn1 long press
+        cluster.handle_cluster_request(ZCLHeader(), [1, 0, 1, 0, (i + 1) * 40])
+
+    # btn1 long release
+    cluster.handle_cluster_request(ZCLHeader(), [1, 0, 3, 0, count * 40 + 10])
+    cluster.button_press_queue.fire()
+
+    assert listener.zha_send_event.call_count == count + 1
+
+    calls = []
+    for i in range(0, count):
+        calls.append(
+            mock.call(
+                f"{button}_{hold_press_type}",
+                {
+                    "button": button,
+                    "press_type": hold_press_type,
+                    "command_id": None,
+                    "duration": (i + 1) * 40,
+                    "args": [1, 0, 1, 0, (i + 1) * 40],
+                },
+            )
+        )
+    calls.append(
+        mock.call(
+            f"{button}_{release_press_type}",
+            {
+                "button": button,
+                "press_type": release_press_type_arg,
+                "command_id": None,
+                "duration": count * 40 + 10,
+                "args": [1, 0, 3, 0, count * 40 + 10],
+            },
+        )
+    )
+
+    listener.zha_send_event.assert_has_calls(calls)
+
+
+@pytest.mark.parametrize(
+    "button_presses, result_count",
+    (
+        (
+            [1],
+            1,
+        ),
+        (
+            [1, 1],
+            2,
+        ),
+        (
+            [1, 1, 3, 3, 3, 2, 2, 2, 2],
+            4,
+        ),
+    ),
+)
+def test_ButtonPressQueue_presses_without_pause(button_presses, result_count):
+    """Test ButtonPressQueue presses without pause in between presses."""
+
+    q = ButtonPressQueue()
+    q._ms_threshold = 50
+    cb = mock.MagicMock()
+    for btn in button_presses:
+        q.press(cb, btn)
+
+    # await cluster.button_press_queue._task
+    # Instead of awaiting the job, significantly extending the time
+    # these tests need, we just abort it and call the callback
+    # ourselves.
+    assert q._task is not None
+    q._task.cancel()
+    q._ms_last_click = 0
+    q._callback(q._click_counter)
+    cb.assert_called_once_with(result_count)
+
+
+@pytest.mark.parametrize(
+    "press_sequence, results",
+    (
+        (
+            # switch buttons within a sequence,
+            # new sequence start with different button
+            (
+                [1, 1, 3, 3],
+                [2, 2, 2],
+            ),
+            (2, 3),
+        ),
+        (
+            # no button switch within a sequence,
+            # new sequence with same button
+            (
+                [1, 1, 1],
+                [1],
+            ),
+            (3, 1),
+        ),
+    ),
+)
+async def test_ButtonPressQueue_presses_with_pause(press_sequence, results):
+    """Test ButtonPressQueue with pauses in between button press sequences."""
+
+    q = ButtonPressQueue()
+    q._ms_threshold = 50
+    cb = mock.MagicMock()
+
+    for seq in press_sequence:
+        for btn in seq:
+            q.press(cb, btn)
+        await q._task
+
+    assert cb.call_count == len(results)
+
+    calls = []
+    for res in results:
+        calls.append(mock.call(res))
+
+    cb.assert_has_calls(calls)
+
+
+def test_rdm002_triggers():
+    """Ensure RDM002 triggers won't break."""
+
+    buttons = [BUTTON_1, BUTTON_2, BUTTON_3, BUTTON_4]
+    actions = {
+        "remote_button_short_press": "press",
+        "remote_button_long_press": "hold",
+        "remote_button_short_release": "short_release",
+        "remote_button_long_release": "long_release",
+        "remote_button_double_press": "double_press",
+        "remote_button_triple_press": "triple_press",
+        "remote_button_quadruple_press": "quadruple_press",
+        "remote_button_quintuple_press": "quintuple_press",
+    }
+
+    expected_triggers = {}
+    for button in buttons:
+        for action, command in actions.items():
+            expected_triggers[(action, button)] = {COMMAND: f"{button}_{command}"}
+
+    expected_triggers.update(
+        {
+            ("remote_button_short_press", "dim_up"): {
+                COMMAND: "step_with_on_off",
+                CLUSTER_ID: 8,
+                ENDPOINT_ID: 1,
+                PARAMS: {"step_mode": 0},
+            },
+            ("remote_button_short_press", "dim_down"): {
+                COMMAND: "step_with_on_off",
+                CLUSTER_ID: 8,
+                ENDPOINT_ID: 1,
+                PARAMS: {"step_mode": 1},
+            },
+        }
+    )
+
+    actual_triggers = PhilipsRDM002.device_automation_triggers
+
+    assert actual_triggers == expected_triggers