Repository: zigpy/zha-device-handlers
Pull request #2340 (2023-04-14 21:56:30)

Add Philips RDM002 Tap Dial switch support, refactor Hue remotes
Add suport for [Philps Hue Tap Dial Switch](https://www.philips-hue.com/en-us/p/hue-tap-dial-switch/046677578800) (RDM002).
This PR also refactors all Hue remotes to "generate" the device triggers and removes a lot of duplicated code.
The device triggers changed are backwards-compatible (verified in tests).

codecov[bot]: ## [Codecov](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
Patch coverage: **`86.66`**% and project coverage change: **`+0.48`** :tada:
> Comparison is base [(`62062d3`)](https://codecov.io/gh/zigpy/zha-device-handlers/commit/62062d3b0d4e26169094a35d3c31fabf327108c6?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) 84.48% compared to head [(`f74c055`)](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) 84.97%.

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


```diff
@@            Coverage Diff             @@
##              dev    #2340      +/-   ##
==========================================
+ Coverage   84.48%   84.97%   +0.48%     
==========================================
  Files         265      267       +2     
  Lines        8478     8434      -44     
==========================================
+ Hits         7163     7167       +4     
+ Misses       1315     1267      -48     
```


| [Impacted Files](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) | Coverage Δ | |
|---|---|---|
| [zhaquirks/eurotronic/\_\_init\_\_.py](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL2V1cm90cm9uaWMvX19pbml0X18ucHk=) | `37.83% <ø> (ø)` | |
| [zhaquirks/ikea/\_\_init\_\_.py](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL2lrZWEvX19pbml0X18ucHk=) | `100.00% <ø> (+26.78%)` | :arrow_up: |
| [zhaquirks/ikea/fivebtnremote.py](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL2lrZWEvZml2ZWJ0bnJlbW90ZS5weQ==) | `100.00% <ø> (ø)` | |
| [zhaquirks/inovelli/\_\_init\_\_.py](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL2lub3ZlbGxpL19faW5pdF9fLnB5) | `100.00% <ø> (ø)` | |
| [zhaquirks/lds/\_\_init\_\_.py](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL2xkcy9fX2luaXRfXy5weQ==) | `100.00% <ø> (+61.11%)` | :arrow_up: |
| [zhaquirks/philips/\_\_init\_\_.py](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL3BoaWxpcHMvX19pbml0X18ucHk=) | `48.86% <0.00%> (-0.57%)` | :arrow_down: |
| [zhaquirks/samjin/\_\_init\_\_.py](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL3NhbWppbi9fX2luaXRfXy5weQ==) | `75.00% <0.00%> (ø)` | |
| [zhaquirks/smartwings/wm25lz.py](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL3NtYXJ0d2luZ3Mvd20yNWx6LnB5) | `100.00% <ø> (ø)` | |
| [zhaquirks/terncy/\_\_init\_\_.py](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL3Rlcm5jeS9fX2luaXRfXy5weQ==) | `50.56% <0.00%> (ø)` | |
| [zhaquirks/tuya/ts0601\_dimmer.py](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL3R1eWEvdHMwNjAxX2RpbW1lci5weQ==) | `100.00% <ø> (ø)` | |
| ... and [22 more](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/zigpy/zha-device-handlers/pull/2340?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).

MattWestb: The only test  is not liking is `zhaquirks/philips/__init__.py` and like 3 is not used `import logging` then the logging method is being changed.
MattWestb: Ops by the way:
Servus !!!
fholzer: lint issue fixed
TheJulianJES: Mostly just a note for me when I hopefully get back to this PR:
See if this fixes https://github.com/zigpy/zha-device-handlers/issues/2571 or if the fix can be easily made with this PR.
cod3gen: Dimming wheel acts as a switch with this quirk, would it be possible to use ROTATED LEFT/RIGHT instead? And maybe also include params like step_size?
fholzer: > Dimming wheel acts as a switch with this quirk, would it be possible to use ROTATED LEFT/RIGHT instead? And maybe also include params like step_size?

Will have a look this week.

Also, the RWL020 remote I got from Matt doesn't support the long press events, so will have to remove those as well. Not sure about RWL021 though.

> Breaking changes have to be in another PR

Will also look into changing my PR so that it keeps all existing PRESS_TYPES, commands and device_automation_triggers unchanged.
MattWestb: I think the RWL020 is running on the latest firmware (=old then its one early HUE device) https://github.com/dresden-elektronik/deconz-rest-plugin/wiki/OTA-Image-Types---Firmware-versions#philips-hue-signify was the latest upgrade i was doing on it if i remember right.
Perhaps Julian have "found" some newer version in his HUE system ?
fholzer: @cod3gen for me rotating the dial on my RDM002 using the code in this MR yields both, button press events, and "Steps with On Off" events, the latter includes direction & step size. I just pushed some changes. Can you check these?

@TheJulianJES wrt the dial, can you give guidance? In my last commit just now I suppose these duplicate button press events rotating the dial yields, and added device automation triggers. I see COMMAND_STEP_ON_OFF is used by a couple of quirks([1](https://github.com/zigpy/zha-device-handlers/blob/dev/zhaquirks/lutron/lzl4bwhl01remote.py#L111), [2](https://github.com/zigpy/zha-device-handlers/blob/dev/zhaquirks/lds/cctswitch.py#L113)), though always in combination with "press" events. That would make the UI suggest the wheel is something that is "pressed":
![image](https://github.com/zigpy/zha-device-handlers/assets/1814883/2d48d0d2-074b-4c03-8400-9389cc813254)

Is there some better alternative? I tried finding the strings available in the UI. Found [this](https://github.com/home-assistant/core/blob/dev/homeassistant/components/zha/strings.json#L228), but not sure if I am looking in the right place. Also, it doesn't list anything that's better suited.

P.S.: Regarding how to deal with breaking changes... I could make a version of this PR that only adds changes needed for RDM002, and keep all other devices unchanged. If you also want to merge those changes to other devices, these could be dealt with separately, as per breaking changes guideline. 
fholzer: With the requirement  to not break anything, device automation trigger keys and values, params, parameter names, button names, press types, I'm not sure this can be done in one generic class without having a lot of code just for handling these "exceptions". I'm leaning towards discarding all changes except those for RDM002.
SkywiperSolutions: > Dim up and dim down is now present, step_size can be taken from params. Nice 👍
> 
> Does the double click, triple click etc. work on your tap dial? Seems to have no function here, seems to be responding only to pressed, continuesly pressed, released and released after long press on my dial.

My RDM0002's only responds to pressed, continuously pressed, released and released after long press as well.
Any outlook on a release?
Thanks!
loicortola: Hi everyone, thanks @fholzer and all for working on this device!
Any updates on a release? Anyone working on it still? I see latest commits fail

Just so you know, I will completely make an extensive use of it as soon as available ^^

I haven't yet contributed to the custom quirks, but if you need assistance let me know, I will try to dig in.
casparz: Came here when looking into the workings of the RDM001, since it is not firing multiple button presses at the moment. I would love to see this refactor get merged, since is resolves this issue.
Alternative is copying more code from the init to the RDM001 implementation.
loicortola: @TheJulianJES @MattWestb , would you be able to take a final look at this PR?
From what I understand, all breaking changes have been reverted and it should be ready for merge?
fholzer: Sorry for the long absence. Just to clarify, at this point this PR is not backwards compatible, and also there's an issue (at least for me) where when turning the RDM002's dial e.g. up, it start dimming my light up, but in between jumps back to lower brightness before going back up. So that definitely needs to be fixed before considering merging this. Also someone reported issues, need to check what exactly that was.
Wrt which events are fired upon which button press behavior, I need to check what I had in mind originally, and post here to clarify, and collect feedback.
SkywiperSolutions: Ok. Thanks!
gtunes-dev: Is this still being actively worked on? Would be fantastic to see it completed. Thanks!
gtunes-dev: Hi, @fholzer. Thanks for your work on this. Do you have the time and interest in driving this to completion? If not for the entire effort, perhaps for a more focused set of changes to support the RDM002? Thanks!
fholzer: TBH rewriting the whole Philips integration doesn't make much sense if we can't break things in order to streamline them across devices. At the moment my time is quite limited. I could probably get around to looking into this again sometime in the next few weeks. If someone else is interested in driving this forward faster, they are welcome to do so, though please say so. (So we don't end up both putting time into this)
I would probably end up stripping this PR of most of the changes and focus only on the RDM002, as you suggested.
gtunes-dev: Speaking just for myself - my pressing issue is the state of the RDM002 support. It's a fantastic controller. I posted a Z2M blueprint that enables its use for media control with customization options for long/short presses for all buttons (https://community.home-assistant.io/t/z2m-philips-hue-tap-dial-media-controls/656669). I have a ZHA blueprint in the works but it's stalled because of the limited support though I do have a pretty creative solution for dealing with rotation speed.

So I would really appreciate it if you found the time to reduce this PR to RDM002 support and get it finalized. I'm not sure I have either the time or skill to do it though if you end up not being able to finish it, I might try.

In any case, it seems that the best approach here is to accept that the scope needs to be reduced and then figure out how to get it finished by you or someone else. Thanks!




gtunes-dev: Hi, @fholzer. Checking to see if this is still on your radar and if you have a plan to reduce the scope of the change to get the RDM002 working. Thanks.
Edivion: Any update here? The RDM002 would be the perfect solution for my current setup but with the very limited features I cannot really work with it. Properly supporting it would be greatly appreciated.
Sno88e: Is there any way to get this remote working properly?
I am happy to help in any way I can if testing is needed.
These remotes are awesome and there is not really an equivalent alternative out there.
I Know it's possible to switch to zigbee2mqtt but I'm not a fan of it and I want to keep my installation as native as possible...
fholzer: The regression that broke multi-press events should be fixed now. Also the single press issues. Though for the latter to work properly, I had to remove the "short release" event.
gtunes-dev: @fholzer Thank you for picking this up again! 

loicortola: A-MA-ZING !
Thanks for getting back to it 😊. Can't wait to use your code in the hopefully next release!
fholzer: Yeah, sorry for the long hiatus. Still have to change a couple of things to not break backwards compatibility for the other devices for those not using this branch. The downside to this for you will be that if you use this right now, and use other Philips devices like RDM001, ROM or RWL devices, the trigger names will be reverted to what they are on the dev branch. So in that case you'll have to update your automation triggers once that's done. Sorry in advance.
gtunes-dev: @fholzer - with your current changes, are long presses and/or releases supported on the Hue Tap Dial (rmd002)? 

They don't appear to be, but I might be missing something or there might be an issue with how I'm testing. Thanks.
fholzer: @gtunes-dev just pushed some more changes. Re-did the whole MR, but not finished yet. This supports single press/release, multi press and long press/release actions. Though it might break automation triggers for non-RDM002 devices. Will look into that next.

FYI, below are the test cases I want all devices I have to pass (RDM001, RDM002, RWL021, RWL022, ROM001). I know that on some devices that's broken (without this MR).

* On short press, should trigger a short press, followed by a short release event.
* On long hold, if supported, no short press/release events should be triggered.
* On long hold, if supported, a long press event should be triggered one or more times while the button is held, followed by a long release event once the button is released.
* On multi-press, if supported, no short press/release events should be triggered when pressing two or more times.

Also, are you testing by putting just the rdm002.py in your `custom_quirks` path? I haven't figured out how that should work if my rdm002.py file relies on changes to classes exported by `philips/__init__.py`. Personally, I test my changes by running homeassistant in a docker container based on `lscr.io/linuxserver/homeassistant:latest`, and I mount  `zha-device-handlers/zhaquirks` from my git repo clone onto the container's `/usr/local/lib/python3.12/site-packages/zhaquirks` path. For me, that looks like so: https://gist.github.com/fholzer/a2e876f9a06f9557cd5815c8272cbe4e
gtunes-dev: Thanks for the update @fholzer. I'll take a look at your latest changes tomorrow.

I'm testing by putting everything from your branch's version of the Philips folder into the root of my custom quirks folder. That seems to work though I've had to delete the contents of the __pycache__ folder in custom quirks and restart HA to otherwise it picks up old versions.

If there's an issue with this approach, I can do what you're suggesting with Docker but I'm not currently set up to attach a Zigbee antenna to my Docker server and that would take a bit of work.

Thanks again for doing this - I'll let you know what I see with the test cases you listed.
gtunes-dev: I understand the issue with __init__.py now - please disregard my previous message. I'm not sure there's an easy fix for this. I think I'll try to replicate your Docker-based environment tomorrow.
gtunes-dev: @fholzer - ok...I have your updates working now. Thanks for sharing your docker compose - the only way I was able to get it to work was to modify this volume mapping:

      - ./zha-device-handlers/zhaquirks:/usr/local/lib/python3.12/site-packages/zhaquirks

to map in just the "philips" directory. I don't think the enlistment I have is the right version but limiting the quirks changes to just philips does work.

I have rdm002 devices. No others. With respect to your test cases - I don't believe the rdm002 supports multi-press. If I'm wrong about that, please let me know.

All other cases behave as you describe.

A short press and release of buttons 1, 2, 3, 4 generates a short press followed by a short release and all events identify the correct button. A long press of any button generates a series of long press events and, when the button is released, a long release. All button identifiers are correct.

Dial rotation events appear unchanged, which I assume is intentional.

In addition to the new events, short presses generate the recall events that were present before your changes.

These changes are great. I can now complete a blueprint I've had in the works for a long time that enables the rdm002 to act as a media controller with extensibility and velocity sensitivity. Thank you for doing this and I hope you're able to get it into production builds!
gtunes-dev: I have a first pass at my media controller blueprint that relies on the changes from @fholzer.

It supports simple configuration of a dial as a media controller with a new approach for velocity-sensitive volume control.

It also supports custom actions for everything else this new update generates including double, triple, quadruple, and quintuple presses.

It also has logbook support so you can see the events the blueprint receives. To install, create a folder called "gTunes" in config\blueprints and drop this file in:

https://gist.github.com/gtunes-dev/b2636c6f1c4932cb15fa9b5a10ae820b

Thank you again, @fholzer - I hope you're able to get this out to production so I can post this publicly. I could also easily create one that is fully customizable without biasing towards media. I'll think about that.
fholzer: Glad you got this working, @gtunes-dev. Do note that my initial changes on this branch changed the automation triggers of other Philips devices (ROM*, RWL*, RDM01) and I a preparing a commit to fix that. Though that means if you are working on blueprints for any of these devices, the trigger will change back to the original ones before this can be merged. I will also share my updated blueprint for controlling my LG TV's volume using the dial :)

Also I will be adding a lot of unit tests soon, most of which I have implements locally already. This revealed a minor bug that caused args.args[2] and to have the wrong value for multi-press events, and args.press_type and args.args[2]  to have the wrong value for single-press release events, though i guess it's unlikely these values are used by anyone.
gtunes-dev: @fholzer - I'm working only with rdm002 controllers, but thanks for the warning.

I created an additional blueprint for the rdm002 that supports the dial and custom events for every event that I'm aware of....every press, release, long press, double, triple, quadruple, quintuple press. I suspect this might be helpful for you for debugging.

Couple of key features - it uses an approach to velocity sensing that turns the dial events into "small", "medium", "large" clockwise or counterclockwise actions. The user can configure the thresholds for differentiating small, medium, large. I think it's clever. We'll see if anyone else does.

It also emits actions to logbook and this can be toggled.

In any case, you might want to look at it to see if it helps with development. Again, I very much appreciate you doing this!

https://gist.github.com/gtunes-dev/b2636c6f1c4932cb15fa9b5a10ae820b#file-zha-philips-hue-tap-dial-yaml



fholzer: Had a brief look at the step_size related part of you blueprint. I see you use a much more comple approach than I do. The blueprint i currently use for my TV volume as well as dimming lights directly use the step_size and a customizable multiplier (i.e. the resulting change is step_size * multiplier). This worked well for me so far. Might have a closer look once I am done with the unit tests related to DIM_UP/DOWN events in the coming days.
gtunes-dev: > Had a brief look at the step_size related part of you blueprint. I see you use a much more comple approach than I do.

I have a bunch of these controlling various media endpoints driven by Roon - currently with Z2MQTT. What I've found is that, for me, what works best is adjusting specifically by 1, 3, or 5 increments based on the speed of rotation. I didn't want to hardcode that, though, so I made it possible for the user to choose their own "small, medium, large" detection windows and increments. With my media-specific blueprint, the defaults should work well. With the generic template, there are detection defaults but no opinion about 1,3,5...just the ability to put your own script in place.

Once your changes are on a path to release, I'll get my blueprints posted publicly. 
gtunes-dev: @fholzer - just confirming that all new events continue to work with an rdm002 with your latest changes.

What's the next step here? It looks like this is ready to go, right?
fholzer: Well, would be great if some more people that hat Philips remote could test this, though I am not sure how to reach out to them. Also this needs to be reviewed by maintainers, and if they have feedback, that needs to be incorporated. That is, if they're happy with this refactoring in the first place. Let see. Will mark it as "ready for review". Though note that i will be traveling for the next 2 weeks, so probably won't be able to work on this a lot until I'm back.
fholzer: I mentioned it already earlier on this thread, but just to recap: When I started working on RDM002 support, I found that the current implementation of many of the Philips remotes have issues. I decided to refactor code for all remotes, while adding RDM002 support. As part of that work I looked into all of those issues, and came up with 4 test cases that all remotes should pass. Here are those test cases, as well as a table showing which device implementations support which features, and pass/fail these test cases:

| Test Case                             | Behavior                                                                                                                                                                |
|---------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| test_PhilipsRemoteCluster_short_press | On short press, should send a short press, followed by a short release event.                                                                                           |
| test_PhilipsRemoteCluster_long_press  | On long hold, if supported, no short press/release events should be sent.                                                                                               |
| test_PhilipsRemoteCluster_long_press  | On long hold, if supported, a long press event should be sent one or more times while the button is held, followed by a long release event once the button is released. |
| test_PhilipsRemoteCluster_multi_press | On multi-press, if supported, no short press/release events should be sent when pressing two or more times.                                                             |

Here what the implementation supports without this MR. t1, t2, .. refere to above mentioned test cases; P=pass, F=fail
|        | press | released | multi | long press | released after long press | duration tracking | t1 | t2 | t3 | t4  |
| ------ | ----- | -------- | ----- | ---------- | ------------------------- | ----------------- | -- | -- | -- | --- |
| RDM001 | Y     | Y        | N     | Y          | broken                    | param5            | P  | F  | P  | N/A |
| ROM001 | Y     | Y        | Y     | Y          | Y                         | param5            | F  | F  | P  | F   |
| RWL022 | Y     | Y        | Y     | Y          | Y                         | param5            | F  | F  | P  | F   |
| RWL021 | Y     | Y        | Y     | Y          | Y                         | param5            | F  | F  | P  | F   |

I added a unit test that checks the device automation triggers before I made any other changes, so i can say with some level of certainty that my changes won't break existing automations. Additionally I added test cases for the desired behaviors outlined above. Before making my changes, I marked some things that the old implementation fails with "TODO"s in the unit tests, which I then removed in the second commit that include refactoring as well as adding support for RDM002.
bernhard-efler: > Well, would be great if some more people that hat Philips remote could test this, though I am not sure how to reach out to them. Also this needs to be reviewed by maintainers, and if they have feedback, that needs to be incorporated. That is, if they're happy with this refactoring in the first place. Let see. Will mark it as "ready for review". Though note that i will be traveling for the next 2 weeks, so probably won't be able to work on this a lot until I'm back.

I have a device and can test your changes. But how can I get them installed/copied to my home assistant?
fholzer: Thank you for offering to test. I am not entirely sure what the best way to "install" the files for testing is. I think usually files are self-sufficient, so can be put in the custom_quirks folder, though that won't work for this MR. I would suggest to copy the `zhaquirks/philips/` folder into the directory where the zhaquirks python dependency of your HA lives in your installation. That location varies depending on how you run home assistant. If you run it without docker, you could run `sudo find / -type d -name zhaquirks` to find the folder. If you run it inside a docker container, you'd have to run that (maybe without the `sudo`) inside the container, then mount the `philips` folder into that directory.

Personally I am using docker compose to run home assistant, and my `docker-compose.yml` looks like [this](https://gist.github.com/fholzer/a2e876f9a06f9557cd5815c8272cbe4e). This requires a clone of the zhaquirks repo, checked out to this branch, inside the folder where this `docker-compose.yml` file is located.
gtunes-dev: @TheJulianJES - I hope it's not an inconvenience for me to mention you. You're the identified assignee on this and it's now ready for review. @fholzer has done the work - I'm just an enthusiastic consumer.
TheJulianJES: All good. It's a huge PR, but hoping to review and test it soon. 
bernhard-efler: I tested the RDM002 with my own device. Works great and it is a great improvement. 
Only the multi press threshold could be longer (500ms) in my opinion. Sometimes I missed the time frame, especially when the dial was not positioned well for my fingers. But I'm not aware what other devices are using here.

gtunes-dev: > As a result, single press events are not immediately triggering automations, as we have to wait for this threshold to be able to tell it's not the start of a multi press sequence.

This is a very helpful explanation. Given this, my user feedback is that I would prefer to not see the detection window increased.

I suppose the more complex fix would be to extend the window once you're in accumulation mode. You could, for example, have the detection window increase non-linearly as you wait to see if there are third or fourth presses. That said, since the current behavior pre-exists your changes, I'd like to see them go to production without any changes. I'm sure there will be people that use triple and quadruple presses but I'm pretty happy with single press, long press release, and the rotation events.
gtunes-dev: I have updated blueprints available for use and testing. These versions include updated documentation, a couple of small bugfixes, and much improved organization including collapsible sections.

Media controls. This blueprint is for the use of the Tap Dial to control a media endpoint. It has built-in support for volume and transport controls while allowing additional customization:

Generic Controls. This is the second file in the gist. It doesn't assume a media controller but does allow the user to fully customize rotational actions as well as all button presses.

Both support generating logbook events.

https://gist.github.com/gtunes-dev/b2636c6f1c4932cb15fa9b5a10ae820b

I hope someone finds these useful. I'll post them to the community when these changes near release.


gtunes-dev: Thanks for looking at the blueprint, @tverlaan!

> @gtunes-dev I tested your general blueprint. I had to rename `button_2_double` to `button_2_double_press` and so forth for the other buttons and amount of presses. Is this a bug or did I load the patch from @fholzer 

I thought I fixed that in the update I posted a couple of weeks ago. Is it possible you're using the older version?

In addition to fixing the issue you found, the newer version has collapsible sections for each button - they start off collapsed and you need expand them to customize. Does the version you're using have these collapsed sections?

The latest version is the second file (you need to scroll down) in this gist:

https://gist.github.com/gtunes-dev/b2636c6f1c4932cb15fa9b5a10ae820b

I also hope this gets reviewed and approved soon! 
gtunes-dev: @TheJulianJES - can you possibly review this? Thanks.
gtunes-dev: Hi, @fholzer. I believe you and I are still running similar environments for testing - docker with your changes mapped into the container.

This has been working fine for me but is now broken with the latest Home Assistant container from linuxserver.io. There have been multiple 2024.10.2 images released. As of this post, the latest has the tag "2024.10.2-ls35". With this one, your changes prevent Home Assistant from starting. If I remove the mapping of your changes, it works fine.

If I revert to the previous 10.2 release, or anything earlier than that, your changes also work fine. So I'm back to the image with the tag "2024.10.2-ls34". 

In my case, this problem exists with the "latest" tag, these are just the version specific tags. For me, at least, something broke not in 10.2 but in something that is different in the .35 release.

If you can't repro this, I can get you logs. Thanks.
fholzer: Thanks for pointing this out. Will try to look into it this week.
gtunes-dev: One of those moments where you wish github had direct messages.

Some notes on the issue I'm experience. @TheJulianJES - this issue is very specific to my environment and I hope you don't defer your code review until it's root caused. 

@fholzer - I think the issue is related to specific changes the linuxserver team made to python package management. They went from pip to uv a few months ago but in this latest ls35 release, they made additional changes. My theory at the moment is that there's a permissions issue with the subfolder of quirks that I've mapped in and the consequence is that it can't write to it (might be trying to download a directory or to update __pycache__).

So this is specifically about the use of Docker, the use of this specific Docker image, and the fact that I'm trying to map just your changes, in a Philips directory) directly into the zhaquirks directory with this mapping:

      - /volume1/docker/homeassistant/quirks_philips_240908:/usr/local/lib/python3.12/site-packages/zhaquirks/philips

I believe that what's happening is that when uv tries to do this update, it's doing it as a user within the container called abc:abc, not using the user and group ids that I configured my container with. I don't think that user/group can write to my mapped in folder and I'm not sure how to make it possible. 

Here's a compare of the ls34 to ls35 that will help you see the changes they made:
      
 https://github.com/linuxserver/docker-homeassistant/compare/2024.10.2-ls34...2024.10.2-ls35
 
In any case, If I'm remotely right about this, this is very specific to my (our?) environments and what we're doing to try make zhaquirks work for testing purposes. This problem won't exist when the changes go public.
gtunes-dev: Thanks, @TheJulianJES! And another Thank You! to @fholzer for this work!!
TheJulianJES: Apparently, there's a new revision of the Hue wall switch module. Just another model string to add (`RDM004`) and maybe rename the class? Do you want to do it in this PR or do we want to rebase https://github.com/zigpy/zha-device-handlers/pull/3075/ after merging this PR and do it there (or in another PR)?
gtunes-dev: > Apparently, there's a new revision of the Hue wall switch module. Just another model string to add (`RDM004`) and maybe rename the class?

I'm just a cheerleader and somewhat helpful tester but I've been hoping to see the RDM002 supported for a very, very long time and would prefer to see this PR go forward without converging with the wall switch. I understand if that's not what you prefer.
TheJulianJES: To answer the second question myself, the "mode" options seems to configure how the wall switch modules act.
We should ideally expose that as an enum via quirks v2, as a lot of people use that apparently: https://community.home-assistant.io/t/how-to-configure-the-philips-hue-wall-module-to-use-push-button-momentary-type-wall-switches-zha/451125
TheJulianJES: To get back to the first question about the "philips" attribute, I only found this: https://github.com/zigpy/zha-device-handlers/pull/388/files#r450561439 ... which still isn't quite clear to me 😄 

Patch:
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
index 2efe774..86b7e4b 100644
--- a/zhaquirks/philips/rdm001.py
+++ b/zhaquirks/philips/rdm001.py
@@ -1,12 +1,8 @@
 """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,
@@ -18,33 +14,23 @@ from zigpy.zcl.clusters.general import (
 )
 
 from zhaquirks.const import (
-    ARGS,
-    BUTTON,
-    COMMAND,
-    COMMAND_ID,
     DEVICE_TYPE,
-    DOUBLE_PRESS,
     ENDPOINTS,
     INPUT_CLUSTERS,
+    LEFT,
     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
+from zhaquirks.philips import PHILIPS, SIGNIFY, Button, PhilipsRemoteCluster, PressType
 
 DEVICE_SPECIFIC_UNKNOWN = 64512
-_LOGGER = logging.getLogger(__name__)
 
 
 class PhilipsBasicCluster(CustomCluster, Basic):
@@ -67,66 +53,27 @@ class PhilipsBasicCluster(CustomCluster, Basic):
         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,
-        )
-    }
+class PhilipsRdm001RemoteCluster(PhilipsRemoteCluster):
+    """Philips RDM001 remote cluster."""
+
     BUTTONS = {
-        1: "left",
-        2: "right",
+        1: Button(LEFT, TURN_ON),
+        2: Button(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)
+    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 PhilipsROM001(CustomDevice):
-    """Philips ROM001 device."""
+class PhilipsRDM001(CustomDevice):
+    """Philips RDM001 device."""
 
     signature = {
         #  <SimpleDescriptor endpoint=1 profile=260 device_type=2080
@@ -164,7 +111,7 @@ class PhilipsROM001(CustomDevice):
                     PhilipsBasicCluster,
                     PowerConfiguration.cluster_id,
                     Identify.cluster_id,
-                    PhilipsRemoteCluster,
+                    PhilipsRdm001RemoteCluster,
                 ],
                 OUTPUT_CLUSTERS: [
                     Ota.cluster_id,
@@ -177,21 +124,6 @@ class PhilipsROM001(CustomDevice):
         }
     }
 
-    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"},
-    }
+    device_automation_triggers = (
+        PhilipsRdm001RemoteCluster.generate_device_automation_triggers()
+    )
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()
+    )


Test patch:
diff --git a/tests/test_philips.py b/tests/test_philips.py
new file mode 100644
index 0000000..5f08f7b
--- /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.rdm001 import PhilipsRDM001
+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
+
+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"},
+            },
+        ),
+        (
+            [PhilipsRDM001],
+            {
+                (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",
+    (
+        (
+            PhilipsRDM001,
+            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",
+        ),
+        (
+            PhilipsRDM001,
+            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",
+    (
+        (PhilipsRDM001, 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"),
+        (
+            PhilipsRDM001,
+            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